Skip to content

Land the Quest integration on main (#239) - #326

Merged
thesprockee merged 259 commits into
mainfrom
claude-quest-land
Oct 10, 2026
Merged

thesprockee merged 259 commits into
mainfrom
claude-quest-land

Conversation

@thesprockee

@thesprockee thesprockee commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Lands the Quest work on main as one unit: claude-quest-integration-2 (tip 0074d35b, the code that passed smoke #4 on the Quest) merged with origin/main.

Closes #239
Supersedes #219 #220 #221 #222 #223 #226 #227 #229

Conflict resolutions (eae29b1b)

File Main Quest branch Kept
justfile build-android bounds the job count to ${CMAKE_BUILD_PARALLEL_LEVEL:-4} and tests that with tools/tests/test_build_android_jobs.py, which runs the recipe with a fake cmake ran tools/check_quest_static_init.sh in the same recipe main's line. The check moves to its own recipe, check-android-static-init, which test-android (and so .github/workflows/android.yml) runs. Left inside build-android, it fails main's test because the fake cmake produces no library.
justfile test-auth-unit removed test_winhttp_stub (23059c9e) run_test wrapper (timeout + 4G cap), added test_evr_codec the branch's wrapper and test_evr_codec, without test_winhttp_stub
justfile verify added python modules (test_version_reconfigure, test_vcpkg_pin, test_android_workflow, test_build_android_jobs, test_naming, test_server_hold) Quest suites (test-quest-*), timeout -k 10 600, test_check_quest_static_init, test_quest_standin_testonly all Quest suites and the timeout, with the union of both module lists
tools/tests/test_executable_scripts.py every tracked shebang file is 100755, no exceptions frozen exception list (#230) main's version, which supersedes the list. It passes on the merged tree.
src/core/auth_token.h comment fix (60s cap applies to the load path only) the save path calls SerializeCredentialsJson (core/auth_token_model.h) the branch's call. Main's sentence moves onto SerializeCredentialsJson.
src/modules/token-auth/src/token_auth.cpp, src/core/device_auth_flow.{h,cpp} #202: the device-code wait tolerates 5 consecutive poll errors, and an answered poll resets the run the loop moved into the shared nevr::auth::RunDeviceCodeFlow, which aborts on the first error the tolerance is ported into the core as DeviceFlowOps::max_consecutive_poll_errors, with main's log lines. The Windows module sets 5 (main's behaviour). Quest keeps the default 1; see "Quest poll-error tolerance" below.
src/runtime/compat/ws_bridge.cpp (3 hunks) #201 MoveCodeLineFirst; ConnLabel in the login-injected line; comment cleanups (#151) parsing and the platform/bearer helpers moved to EvrCodec; an empty-request guard on login injection MoveCodeLineFirst stays in the bridge, on EvrCodec::kHeaderSize / kSymLoginFailure. The helpers stay in EvrCodec. The login line keeps the guard and gains ConnLabel.
src/runtime/tests/test_token_auth.cpp #include "core/bounded_retry.h" #include "core/auth_refresh.h" both
tools/tests/test_naming.py (not a textual conflict) lists ws_bridge.cpp as holding legacy "nEVR" the comment holding it moved out the entry is removed, as the sensor requires

Later commits on the branch:

Quest poll-error tolerance (decided: Quest stays at 1)

The Windows module sets DeviceFlowOps::max_consecutive_poll_errors = 5 (#202). Quest keeps the default 1. Quest already has #202's outage tolerance, and more of it: its poll op (src/quest/auth/session.cpp, Session::RunDeviceCode) returns every transient failure as Pending, including 503, 429, 408, no connection, and a 200 the parser cannot read, and it polls on until the code's own deadline. The core only receives Error for the server's own refusal (an {"error":...} body or a non-transient 4xx), which is final. A limit of 5 would only re-poll a refused code. The new tests in src/quest/tests/auth_core_test.cpp pin this:

  • session_seven_transient_poll_failures_then_verified_end_in_a_login: 503, 429, no transport, an unparseable 200, 503, no transport, 429, then verified. Result: Ready, 8 polls, 1 code, 1 link shown.
  • session_a_refusal_after_transient_failures_ends_the_login_on_that_poll: 3×503 then {"error":"bad code"}, and 3×no-transport then 403. Both end Failed after exactly 4 polls.
  • Mutation proof (logs in /var/tmp/work-nevr-runtime/claude-quest-land/). Returning transient failures as Error (mutation-quest-M1-transient-as-error.log) fails the first test, plus the existing outage tests. Setting the Quest limit to 5 (mutation-quest-M2-limit-5.log) gives four CHECK_EQ failed lines. Log lines 6 and 7 are auth_core_test.cpp:1545: http.Count("poll") == 4, the new refusal test, once per case. Log line 5 is :1445: http.Count("poll") == 1 (session_a_refused_poll_ends_the_login_at_once_and_is_not_retried). Log line 62 is :2111: http.Count("poll") == 1 (the existing {"error":"bad code"} case); that line's FAIL label is spliced with another test's name because the test output is interleaved. With the source restored, all pass.

Evidence (final head 0f9e93ec)

  • just verify, capped (VCPKG_MAX_CONCURRENCY=6 CMAKE_BUILD_PARALLEL_LEVEL=6 systemd-run --user --scope -p MemoryMax=10G). Log: /var/tmp/work-nevr-runtime/claude-quest-land/verify-0f9e93ec-20261010T023711.log. First line: verify head: 0f9e93ec6b2239bd8c8c6d24e52250b5b70b5d9d. Last line: verify: OK (mingw-release). The GTest count is 698, and Python runs 178 then 353 tests, all OK. Two pre-existing hook-invariants: WARN [N83] known self-collision lines appear. Both new Quest tests show PASS.
  • PC client login test: ./launch-client.sh --dll <worktree>/build/mingw-release/bin/BugSplat64.dll --exit-after-login, capped, on Xephyr :101 (DISPLAY=:101, WAYLAND_DISPLAY unset). The DLL was built at 0f9e93ec, sha256 4613249becb05cd617ed540aead46baa79d06619bb6198e2b053c9906bb81d91. Script output: PASS: client reached logged in, original BugSplat64.dll restored and verified, rc 0 (/var/tmp/work-nevr-runtime/claude-quest-land/pc-login-0f9e93ec-20261010T023651.log). From the game's own log, in the game root the script uses (the main checkout, launch-client.sh --print-game-root → /home/andrew/src/nevr-runtime), /home/andrew/src/nevr-runtime/echovr/.wineprefix/drive_c/users/andrew/AppData/Local/EchoVR/logs/nevr-2026-10-10T07-36-52.463.jsonl:
    • :84 [NEVR.CONFIG] built-in defaults embedded in this build: nevr_socket_uri, nevr_http_uri, nevr_http_key, nevr_server_key
    • :168 [NEVR.WS] login injected xpid=OVR-ORG-1540343945437909062 platform=4 conn=1 (login) size=1096
    • :169 [NEVR.WS] LOGIN SUCCESS
    • :186 [EVR] [NETGAME] NetGame switching state (from logging in, to logged in)
    • The run used cached credentials (the script notes it), not a fresh device-code login.
  • Android: just test-android (capped) gives check_quest_static_init: OK (3 .init_array entries ..., 9 object files) and ok github.com/EchoTools/nevr-runtime/tests/quest (android-0f9e93ec-20261010T024145.log).
  • APK: built from 0f9e93ec with just android-repack-apk /mnt/games/cache/r15_goldmaster_store.apk ... <smoke-4 keystore>, signer SHA-256 bc4d88e4c523f2516de3c32be63084a4fb0fe8f368d773f71b3d4bdf45b8517a. File: /var/tmp/work-nevr-runtime/claude-quest-land/r15_nevr-sentinel_signed-0f9e93ec.apk, sha256 52b8ede1bec9866c157b7e9cd987dfeaffd78155484242de661b774e6d824dd2.
  • Mutation proof for the Windows port of Token refresh timeout at startup discards the cached login and falls into a device-code login that gives up on the first poll error #202 (on eae29b1b): with the Windows limit set to 1, DeviceAuthFlow.TransientPollErrorsAreRetriedUntilVerified, ConsecutivePollErrorsEndTheWaitAfterTheLimit and AnAnsweredPollResetsTheErrorRun fail (mutation-poll-errors.log, 3 FAILED TESTS). With the source restored, 3/3 pass (mutation-poll-errors-restored.log).

Not run

Branch lifecycle: deleted when this PR merges or closes.

🤖 Generated with Claude Code

thesprockee and others added 30 commits October 7, 2026 19:36
…refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…claude-quest-hooks' into claude-quest-router

* origin/claude-evr-codec:
  refactor(compat): extract the EVR frame codec into a platform-neutral evr_codec (refs #158)

* origin/claude-quest-hooks:
  test(quest): lifecycle gtests, pinned-ELF resolution, wire quest hooks into verify (refs #158)
  test(quest): host tests for the GOT backend and callback thunks (refs #158)
  feat(quest): exact GOT backend, shared hook lifecycle header, typed callback thunks (refs #158)
…k; tighten codec tests (refs #158)

- ws_bridge: the game->server diagnostic walk is now LogGameToServerFrame.
  It stops on a Truncated message before the PlayerSessionRequest hex dump
  and the invite decoders read the payload, reads the payload through the
  codec's message.payload, and prints the declared length unwrapped. A
  declared length of 2^64-24 made the old total wrap to 0, so the loop
  never advanced.
- evr_codec: remove ParseLoginRequest and LoginRequestStatusName (no
  production caller); the reader lives in tests/evr_codec_test_reader.h.
  Restore the platform-prefix and server-key bearer rationale as comments.
- Tests: call-site tests for the walk (truncated invite, truncated
  PlayerSessionRequest, wrapping length), a wrapping-length codec case in
  both test files, and a caught nlohmann::json::exception in the Quest test.
- test-quest-shared takes nlohmann/json.hpp from the build's vcpkg_installed
  tree and fails with a message when it is absent, instead of relying on a
  system package.
- ADR 0003 diagram column aligned.

Co-Authored-By: Andrew Bates <a@sprock.io>
…d of dying in the glob (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…quest-login

* origin/claude-quest-hooks:
  test(quest): lifecycle gtests, pinned-ELF resolution, wire quest hooks into verify (refs #158)
  test(quest): host tests for the GOT backend and callback thunks (refs #158)
  feat(quest): exact GOT backend, shared hook lifecycle header, typed callback thunks (refs #158)
…ig read, log structured

Review findings on the Quest config:
- The resolved config lived in a namespace-scope object with a dynamic
  initializer that ran after the ELF constructor and blanked it. It is now a
  function-local static; sentinel.cpp's dump directory string gets the same fix.
- The config read opens non-blocking and requires a regular file, so a FIFO at
  the path falls back to the embedded defaults instead of hanging startup.
- The disk log is one JSON object per line; open and write failures are each
  reported once and short writes are retried.
- Duplicate keys log a warning; an empty file value cannot clear a default.
- sentinel_host_test runs the constructor-then-main order, the non-regular-file
  paths, the log-failure paths and a run that captures every logcat and disk-log
  line and asserts no configured value appears in any of them.

Refs #158

Co-Authored-By: Andrew Bates <a@sprock.io>
…nsports (refs #158)

Owns the per-connection state machine the PC bridge keeps inside ws_bridge.cpp:
connection identity (config/login/matchmaker), once-per-session login injection
ahead of queued frames, remote end closing every game socket on the session and
resetting numbering, size limits, bounded pending/outbound queues and
backpressure. Transports, the login builder and the log sink are injected and
never called under the router lock. Host tests with fake transports run in
`just test-quest-router`, wired into `just verify`.

Co-Authored-By: Andrew Bates <a@sprock.io>
…refs #158)

The game-facing transport for the shared session router: a POSIX listener on
an ephemeral 127.0.0.1 port, the HTTP upgrade handshake, an incremental frame
decoder (partial reads, fragmentation, size limits checked on the header) and a
non-blocking, bounded write path that reports backpressure to the router.
Host tests drive it with a raw TCP client, including one-byte writes.

Co-Authored-By: Andrew Bates <a@sprock.io>
…nd make the rewrite atomic (#158)

Review of the first cut found it changed [CNSUser+0x88], which CNSOVRUser never
reads: the sender gets the account id from a virtual AccountID() that returns a
process global, and the platform nibble is already 4. The rewrite now writes
that global after checking the three instructions of CNSOVRUser::AccountID() and
proves the wire value by making the same virtual call.

- the JSON rewrite snapshots and restores every key it touched on any refusal or
  exception; the account id is restored with it
- no password in the login JSON: the game logs the outgoing login and only
  clears access_token, nonce, authticket, authcode, authtoken, userhash; the
  server reads the password from the upgrade URL, not the JSON
- CJson is resolved from libpnsovr.so (no DT_NEEDED on libr15, own CJson)
- the headset's real system_info values are kept
- installs through the merged GotHook/CallbackThunk (atomic original, mutex)
- structured key=value log lines through the sentinel sink
- ADR 0003: libpnsovr build id row, login interception section

Refs #158

Co-Authored-By: Andrew Bates <a@sprock.io>
… 20-digit literal (refs #158)

The N20 identity-literal sensor reads a 20-digit number in src/runtime as an account id.

Co-Authored-By: Andrew Bates <a@sprock.io>
…ption contract, JSON logs (refs #158)

Review of #222: one lock across WriteSlot (two handles on one RELRO page
crashed), Remove and Install are compare-and-swap, protection to restore is
read under the lock, thunk lets the original's exceptions through and never
re-calls the original, atomic publish, fixed-size registry and pthread mutex
(no throw from the ELF constructor), GotHook::Forget, JSON log lines, no
agent paths in the repo test.

Co-Authored-By: Andrew Bates <a@sprock.io>
…l TLS, session bridge (refs #158)

ConnectorRemoteTransport implements the router's RemoteTransport over an
abstract WsConnector: wss:// only, one connect attempt per session, no retry
and no downgrade after a failure, no URL or header value in any log line.
CurlWsConnector implements it with libcurl's WebSocket API over the OpenSSL the
Quest manifest already provides (adds the curl `websockets` feature): peer and
host verification pinned on, TLS 1.2+, wss-only protocol allow-list, no
redirects, trust from the Android system CA directory. SessionBridge wires
loopback server, router, transport and connector and reports the loopback port
for the shared redirect. Host tests: a fake connector under the real router,
and `just test-quest-tls` against real TLS servers (right chain, wrong CA,
wrong host, self-signed, empty trust store, non-TLS server, ws://) asserting
no plaintext fallback. A verify sensor pins the connector's TLS options.

Co-Authored-By: Andrew Bates <a@sprock.io>
… a wrong shape, tidy comments (refs #158)

- RefreshOutcome::Denied for HTTP 400/401/403 so callers can stop retrying a token the server refuses; other non-200 stays Rejected.
- The wider json::exception catch now logs 'valid JSON of an unexpected shape' for [] or {"access_token":1} instead of 'not valid JSON'. gameserver.cpp's RefreshAuthToken call picks up the same catch.
- HttpClient::Interrupt() so a shutdown can abort a request in flight.
- The device-flow log says open_result= (it was shellexecute_result= on every platform).
- RefreshRequestBody test now calls BuildRefreshBody; comments describe the current tree; the refresh lead / fallback relationship is stated once and matches the static_assert.

Co-Authored-By: Andrew Bates <a@sprock.io>
… handle dying credentials (refs #158)

- CA: read the Android CA directories into memory and pass them as CAINFO_BLOB; CAPATH cannot find Android's old-hash file names with an OpenSSL backend. Fails closed, loudly, with no certificate. Proxy variables ignored, response size capped, every setopt checked, requests interruptible. Loopback TLS tests with a generated CA and an Android-style directory.
- The refresh token goes to /data/data/<package>/files (derived from /proc/self/cmdline); only the login link stays on /sdcard. O_NOFOLLOW, exclusive temp file, directory fsync.
- A permanent poll error gives up after a bounded run of failures and logs the status. A denied or expired refresh token publishes Expired and restarts the device login.
- Session: Start/Stop serialised, no lock held across the log sink, Start does not throw, Stop does not wait out a request. Tests run under a per-test deadline.
- N94 sensor pins the core-to-EchoVR log level mapping.

Co-Authored-By: Andrew Bates <a@sprock.io>
…ude-quest-hooks' and 'origin/claude-quest-tokenauth' into claude-quest-router

* origin/claude-evr-codec:
  test(compat): build the expected wrapped length instead of spelling a 20-digit literal (refs #158)
  fix(just): test-quest-shared prints its missing-header message instead of dying in the glob (refs #158)
  fix(compat): check truncation before decoding in the game->server walk; tighten codec tests (refs #158)

* origin/claude-quest-hooks:
  fix(quest): serialize GOT writes process-wide, compare-and-swap, exception contract, JSON logs (refs #158)

* origin/claude-quest-tokenauth:
  docs(adr): record the shared token-auth core and the Quest adapters (refs #158)
  test(quest): let auth_core_test run a single test by name filter (refs #158)
  feat(quest): token auth session over the shared auth core, libcurl over OpenSSL on arm64-android (refs #158)
  refactor(token-auth): extract the platform-neutral auth core (refs #158)

# Conflicts:
#	docs/adr/0003-quest-networking-port.md
#	justfile
#	src/quest/vcpkg.json
…quest-login

* origin/claude-quest-hooks:
  fix(quest): serialize GOT writes process-wide, compare-and-swap, exception contract, JSON logs (refs #158)
…haviour (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
… type (#158)

Second review of the login rewrite:

- buildversion, appid, lobbyversion and publisher_lock are never written. The
  server classifies the client on them (IsPCVR() is buildversion != 630783, which
  selects the Quest encoder flag layout on lobby join); the PCVR constants would
  have made a Quest login a PCVR one. The Quest sends 630783 itself.
- the account id stays installed after the send (LogInSuccessCB compares it with
  the server reply); RewriteAndSend ties the send to the rewrite and a test
  fails if a restore-after-send is added
- the snapshot models absent, null, string, int and boolean and refuses to write
  over real, array or object; a null is restored as a null
- CJson functions are bound only if libpnsovr's export and its own PLT slot both
  equal the pinned address
- the CNSOVRUser check is the exact vtable pointer
- structured log records (event, outcome, fields) through the sentinel JSON sink;
  a missing identity source is logged
- tests use QCHECK and no longer abort on a missing key
- ADR 0003: the identity/client-class decision, the CJson type rules, who writes
  the account-id global and what reads it

Refs #158

Co-Authored-By: Andrew Bates <a@sprock.io>
…ude-quest-router

* origin/claude-quest-tokenauth:
  docs(adr): Quest token-auth trust, credential location and session behaviour (refs #158)
  fix(quest): trust Android's CA store, keep the refresh token private, handle dying credentials (refs #158)
  fix(token-auth): classify a refused refresh token, tell bad JSON from a wrong shape, tidy comments (refs #158)
The pre-write type check only matters on a build whose SetInt replaces a real
instead of refusing it. The fake can now model that, and the test fails if the
check is removed.

Refs #158

Co-Authored-By: Andrew Bates <a@sprock.io>
…hared loader, not CAPATH (refs #158)

Token auth (#223) found OpenSSL cannot use Android's /system/etc/security/cacerts
as a CApath (old MD5 subject-hash names). CurlWsConnector now loads the same
directories with quest/auth/ca_bundle into memory and passes CURLOPT_CAINFO_BLOB;
with no certificate loaded every Connect fails closed (TlsError,
kNoTrustAnchors). The host TLS test trusts directories through the same loader;
the verify sensor requires CAINFO_BLOB and forbids CAPATH.

Not built or run: the host is under the disk floor.

Co-Authored-By: Andrew Bates <a@sprock.io>
…quest-redirect

* origin/claude-quest-hooks:
  fix(quest): serialize GOT writes process-wide, compare-and-swap, exception contract, JSON logs (refs #158)
  test(quest): lifecycle gtests, pinned-ELF resolution, wire quest hooks into verify (refs #158)
  test(quest): host tests for the GOT backend and callback thunks (refs #158)
  feat(quest): exact GOT backend, shared hook lifecycle header, typed callback thunks (refs #158)
…-quest-redirect

* origin/claude-quest-config:
  fix(quest): keep the resolved config past static init, bound the config read, log structured
  fix(quest): satisfy the shared-source sensors for the Quest config
  feat(quest): embedded-default config, nevr-quest.json override and activation switches

# Conflicts:
#	src/quest/sentinel/CMakeLists.txt
…xports, one hook library (refs #158)

Second review of #222: handler that swallows the original's exception and
then throws is a handler fault (exception identity, not a sticky flag); the
entry uses one captured original per call and the rollback after a failed
re-protect keeps the original published; per-thread state moves from
emulated thread_local (allocates on first use) to pthread keys; streaming
relocation scan (no 8-slot cap); protect seam + tests for failed re-protect,
compare-and-swap Install and the 64-slot bound; -fvisibility=hidden with an
export allowlist test; got_hook compiled once into nevr_quest_got_hook with a
justfile sensor; first-call-only log in the clock hook.

Not built or run on this commit: the disk was under the 8 GiB floor.

Co-Authored-By: Andrew Bates <a@sprock.io>
…quest-login

* origin/claude-quest-hooks:
  fix(quest): thunk exception scoping, pthread-key call state, hidden exports, one hook library (refs #158)
… it again (#158)

nevr_quest_login compiled the backend sources itself; the backend now builds once
as nevr_quest_got_hook and test-quest-hooks fails if a second Quest CMake list
names the source. The login targets are defined after the sentinel and only when
that target exists.

Refs #158

Co-Authored-By: Andrew Bates <a@sprock.io>
…quest-router

* origin/claude-quest-hooks:
  fix(quest): thunk exception scoping, pthread-key call state, hidden exports, one hook library (refs #158)
…check, static-init sensor

Second-round review of the Quest config:
- The on-disk log opens non-blocking and must be a regular file, so a FIFO at
  nevr-sentinel.log cannot stall the constructor. A torn record leaves its
  fragment on its own line; a log at 1 MiB is renamed, never deleted.
- File warnings are capped at 32 lines plus one suppression line and repeated
  duplicate keys collapse per name. Key names that are not lowercase identifiers
  are not echoed.
- The resolution catch block allocates nothing (EmitFixed).
- sentinel_host_test checks every logcat line when it is emitted, over rejected,
  malformed and non-regular inputs too; its constructors use priorities so the
  static-init and timeout checks do not depend on link order.
- tools/check_quest_static_init.sh fails build-android when a sentinel object
  gains a dynamic initializer; activation.cpp and sentinel_log.cpp compile with
  -Wall -Wextra -Werror; ADR 0003 contract 4 states the constructor's I/O limits.

Refs #158

Co-Authored-By: Andrew Bates <a@sprock.io>
thesprockee and others added 17 commits October 9, 2026 12:30
The login socket is silent until the game's LogInRequestv2. Releasing the
hold restarted the first-frame idle clock, so a login socket was closed
1008 thirty seconds after the player signed in if the game had not yet
sent its login.

The router now tells the transport (GameTransport::SetIdleExempt, renamed
from SetHeld) that the login socket is exempt from the first-frame idle
close from the moment it is the login socket until it stops being it.
Config, matchmaker and unclassified sockets keep the idle close. The
release-restarts-the-clock logic and its test are replaced by a test that
a released login socket stays open past several windows while a silent
config socket is closed.

Co-Authored-By: claude-main <agents@sprock.io>
…ilure (#239)

The login gate mapped every Failed state to Refused, so a failure the
session retries every recovery period (a flaky network, a refused
refresh) closed the held login socket 1011 and the game's reconnects
failed fast into "service unavailable".

The session snapshot now says whether a Failed will be retried
(Snapshot::will_retry, set for the recoverable branch only). The gate
answers Awaiting for a recoverable Failed, for Expired (the token is being
replaced) and for a Ready whose token ran out (a refresh is under way);
only a final failure, a stopped session or a token without an account is
Refused.

Co-Authored-By: claude-main <agents@sprock.io>
…tstanding (#239)

An STcpConnectionUnrequireEvent lowers the request count of the connection
it arrives on. The service sends it as a frame of its own after the reply,
from concurrent goroutines, so frames interleave, and routing it to the
connection of the previous message could hand the profile update's
Unrequire to the matchmaker that a ping had just reached.

The router now counts the requests each connection sends on the shared
login session and delivers each Unrequire to a connection with a request
outstanding, the login connection first, never lowering a count below
zero. An Unrequire inside a batched frame lowers the count of the
connection the frame goes to. With nothing outstanding it follows the
message the service started itself (a LobbyPingRequest), else the last
message. The lobby requests are counted although it is not established
that each sets the require flag.

Co-Authored-By: claude-main <agents@sprock.io>
…prerequisites (#239)

The signed-in notice ("Select RETRY") was published at verification, but
the flag the page-enable hook used (awaiting the player) and the
prerequisites' Ready() flag turned over only on the 2 s token-auth poll,
and they were two flags. A RETRY in that window had its logging-in page
skipped while the login could still succeed, stranding the player on the
login-failed screen while logged in.

Both now read one word, QuestLogin::attempt_gate, which the token-auth
source publishes: the logging-in skip stops in the same instant the login
may proceed. An attempt whose logging-in page was skipped is poisoned and
fails its prerequisites until the game leaves logging in, even if the gate
turns ready first. The notice is applied to the block only once the login
may proceed (on a prompt screen and on one that shows the game's own text).
SetAwaitingPlayer and its seam are gone.

Co-Authored-By: claude-main <agents@sprock.io>
…239)

The latch flag is recomputed on the game thread only (Update, and right
after the error text hook), so a genuine error written by the game's
unhooked SetErrorMessage in the same frame as the page enable still looked
like the sentinel's text, and its error page was dropped.

The page-enable hook now hashes the live block (lock-free, relaxed byte
loads) against the latched hash before it skips anything. A sequence
counter around the sentinel's own block writes keeps a half-written block,
or a read that straddled a write, from reading as a genuine error.

Co-Authored-By: claude-main <agents@sprock.io>
…#239)

A local login failure makes the game send its own error event right after
SetDelimitedErrorMessage. A prompt published in the same frame was then
rewritten by the Update hook, which sent a second event with nothing to
space it from the game's: two events in one frame.

The error text hook now stamps the spacing clock when it follows a
failure, so the rewrite's event waits out the spacing.

Co-Authored-By: claude-main <agents@sprock.io>
…ing requests (#239)

The game's outstanding-request count is 8 bits; an Unrequire with none
outstanding wraps it to 255 without a sound, and the login connection's
later loss then raises "connection lost" (-95). The previous routing gave
an Unrequire to a connection that merely had a count, counted the wrong
set of requests, and sent a ping with no matchmaker to the login
connection.

The router now keeps the game's own count per connection from the flagged
requests (RE: every login send but LogOut, TelemetryEvent and
RemoteLogSetv3; every matchmaker send but MatchmakerStatusRequest;
SNSConfigRequestv2) and queues, per message the service pairs with an
Unrequire (ChannelInfoResponse, DocumentSuccess, UpdateProfileSuccess,
LobbyPingRequest), the connection it went to. Each standalone Unrequire
takes the queue's front and is dropped, and counted, if that connection has
nothing outstanding. An Unrequire inside the login reply frame lowers the
count of the connection the frame goes to. ChannelInfoResponse goes to the
login connection; a LobbyPingRequest with no matchmaker is dropped with its
Unrequire. The config connection's own remote follows the same rule.

Co-Authored-By: claude-main <agents@sprock.io>
Nakama sends every login failure as a LoginFailure frame and then a
standalone Unrequire. LoginFailure was missing from the messages the
router pairs with an Unrequire, so that Unrequire was dropped and the
login request stayed outstanding: a later Unrequire nothing was owed went
through and wrapped the game's count.

Co-Authored-By: claude-main <agents@sprock.io>
…ep (#239)

The page-enable hook decided to skip the logging-in page from a read of the
gate, then poisoned the attempt in a second step. If the gate turned ready
in between, the page was skipped and the attempt poisoned although the
login could proceed: the player stranded on the login-failed screen, then
failed by the attempt's own prerequisites.

The poison is now one compare-and-swap on the gate word that only succeeds
while the gate is not ready; if it fails the page goes through. The flip is
injected between the read and the swap in the test.

Co-Authored-By: claude-main <agents@sprock.io>
LatchHoldsLive hashes the error block with relaxed byte loads and then
re-reads the write sequence to tell its own write from a genuine one. On
arm64 the relaxed loads may be reordered after that sequence load, so a
read that straddled a write could still look genuine.

An acquire fence now sits between them. This is not testable on the x86
host (TSO); the existing worker-thread test still passes.

Co-Authored-By: claude-main <agents@sprock.io>
The router drops an Unrequire on purpose when its connection has nothing
outstanding, but only a rate-limited router line showed it. The token-auth
poll now logs router_unrequire_dropped (total and increase) each time
IntegratedBridge::DroppedUnrequires changed. The reporter's counter table
has 46 of 48 slots in use, so this is a structured line, not a counter.

Co-Authored-By: claude-main <agents@sprock.io>
An Unrequire cannot be taken out of a frame. When the connection a frame
goes to has nothing outstanding for the Unrequire inside it, it reaches the
game and wraps the game's count; the embedded-Unrequire loop stopped
without recording it. It is now counted (Stats::unmatchedEmbeddedUnrequires,
also for an Unrequire inside a frame the router drops) and reported in the
router_unrequire_dropped line. ADR 0003 states the limit: with nakama's
DisableLoginMessage branch (failure and Unrequire, then a login that still
succeeds) the success frame's embedded Unrequire arrives after the
failure's covered the login request.

Correction to 5dec746: before it, the Unrequire after a LoginFailure was
dropped and the login count stayed at 1. A later loss of the login socket
then took the "connection lost" (-95) path instead of a silent reconnect;
the count did not wrap.

Co-Authored-By: claude-main <agents@sprock.io>
#239)

ShutdownIntegration deleted the bridge, then stopped and joined the
token-auth thread, whose poll loop loads rt.bridge and calls into it every
2 s: a use after delete if a poll ran in between. The thread is now stopped
and joined first, then the bridge is stopped and deleted, then token auth.

Not covered by a host test: production_steps.cpp is built only into the
Android sentinel and ShutdownIntegration is not reachable from the host
tests; build-android compiles it.

Co-Authored-By: claude-main <agents@sprock.io>
Lands the eight Quest PRs (#219 #220 #221 #222 #223 #226 #227 #229) and the
#239 fix, as tested in smoke #4, on top of main.

Conflict resolutions:
- justfile build-android: main bounds the job count, the Quest branch ran the
  static-init check in the same recipe. Kept main's line; the check moved to
  its own recipe, check-android-static-init, which test-android (and so the
  android workflow) runs. A check inside build-android would fail main's
  test_build_android_jobs, which runs the recipe with a fake cmake.
- justfile test-auth-unit: the branch's run_test wrapper and test_evr_codec,
  without test_winhttp_stub, which main removed (23059c9).
- justfile verify: the branch's Quest suites and timeout, with the union of
  both sides' python sensor modules.
- tools/tests/test_executable_scripts.py: main's version (every tracked
  shebang file is 100755, no exception list) supersedes the branch's frozen
  list.
- src/core/auth_token.h: the branch's SerializeCredentialsJson call; main's
  comment about the 60s load-path cap goes on SerializeCredentialsJson.
- src/modules/token-auth/src/token_auth.cpp + src/core/device_auth_flow.*:
  main's #202 tolerance of 5 consecutive poll errors is ported into the
  shared nevr::auth core as DeviceFlowOps::max_consecutive_poll_errors; the
  Windows module sets 5, Quest keeps the default 1 (its tested behaviour).
- src/runtime/compat/ws_bridge.cpp: main's #201 MoveCodeLineFirst kept in
  the bridge, on EvrCodec's constants; the platform/bearer helpers stay in
  EvrCodec; the login-injected line keeps the branch's empty-request guard
  and gains main's ConnLabel.
- src/runtime/tests/test_token_auth.cpp: both includes.
- tools/tests/test_naming.py: the ws_bridge.cpp "nEVR" legacy entry is gone
  with the comment that held it.

Co-Authored-By: claude-main <agents@sprock.io>
)

Co-Authored-By: claude-main <agents@sprock.io>
Quest keeps DeviceFlowOps::max_consecutive_poll_errors at 1. Its poll op
returns every transient failure (503, 429, no connection, a 200 the parser
cannot read) as Pending and polls on to the code's deadline, so the core
only sees Error for the server's own refusal, which is final.

- seven transient failures of every kind, then verified: one code, a login
- three transient failures, then {"error":...} or a 403: the login ends on
  that poll (4 polls), not after further retries

Mutation: returning transient failures as Error fails the first; setting
the Quest limit to 5 fails the second (8 polls, not 4).

Co-Authored-By: claude-main <agents@sprock.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Quest: device-code login shows nothing in the headset (player cannot see URL or code)

1 participant