Skip to content

feat(quest): exact GOT backend, shared hook lifecycle, typed thunks (#158 package A) - #222

Merged
thesprockee merged 12 commits into
mainfrom
claude-quest-hooks
Oct 10, 2026
Merged

thesprockee merged 12 commits into
mainfrom
claude-quest-hooks

Conversation

@thesprockee

@thesprockee thesprockee commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Package A of #158 (tranches 1e, 1d, 1c). Design: docs/adr/0003, 0004. Tip d96bb82. Earlier rounds are in the commit history; this body states the tree as it is now and what the last two rounds added.

Follow-up round (one commit, d96bb82, on top of the pushed fd02052)

  • TestBackendBuiltWithoutExceptions matches nevr_quest::ApplyEmbedded through the substring 8LogEvent #231 backend symbol match. TestBackendBuiltWithoutExceptions matched backend functions by the substring 8LogEvent, which also matched nevr_quest::{anon}::ApplyEmbedded (PR feat(quest): embedded-default config, nevr-quest.json override and activation switches (#158 C) #220, built with exceptions, takes a std::vector<LogEvent>). It now anchors on _ZN8sentinel<len><name> for the backend's own functions (GotHook, ResolveSlot, LogFields, LogEvent, HexString, SetLogSink, StartReporter, RegisterReportCounter, StopReporter, ReporterRunning, ReporterMain). Fixture: a scratch worktree with origin/claude-quest-hooks + origin/claude-quest-config (81e446e) merged, conflicts resolved in the working tree only (the CMake source list takes feat(quest): embedded-default config, nevr-quest.json override and activation switches (#158 C) #220's sentinel_log.cpp/activation.cpp and drops its own got_hook.cpp so the backend is compiled once), built for Android; the old test fails on it and the new one passes (merge-fixture.log, excerpt below). The scratch worktree is removed. Mutation R7 (backend library built with exceptions) still fails the new matcher.
  • Counter table 32. RegisterReportCounter holds 32 counters (static storage, no heap); the 33rd and any registration after StartReporter are refused and logged (register_refused). ADR 0003 states the contract: all RegisterReportCounter calls come before StartReporter. Test registers 32, refuses the 33rd, refuses one after start, and accepts again after Stop; mutation K1 (back to 8) fails it.
  • Compile-fail gate is strict. Each snippet must produce errors that are ALL the rule's own: the recipe counts error: lines and requires every one to match the rule-specific pattern (the static_assert text; HookRecord.*is private within this context; invalid conversion from .*::Handler) and at least one. An unrelated extra error, or a failure for another reason, fails the gate (mutations H2, H3).
  • Include scan. TestRawInstallOnlyInTests walks src/quest/tests recursively and resolves each include of a file outside a tests/ directory relative to the including file, to src/ and to src/quest/sentinel (each path.Cleaned) before testing membership of the test tree. It reads files with the extensions .cpp .cc .cxx .h .hpp .inc under src/. ADR 0003 now says exactly that. Mutations T1r (#include "../tests/compile_fail/common.h") and T1s (quest/tests/compile_fail/common.h) fail it.
  • Small fixes. The duplicated relroReadOnly comment is one, sitting above WriteSlot's block; the Go test header and src/quest/CMakeLists.txt cite docs/design/2026-07-13-quest-crash-reporter-injection.md (the file that exists) and the ADR.

What the tree provides

  • 1e src/quest/sentinel/got_hook.{h,cpp}: GotHook resolves a slot by module + symbol + relocation type with optional build ID, pinned link-time address and expected original; refuses with one JSON log line and no write on any mismatch; writes are under one process-wide lock and compare-and-swap; the protection to restore is read from /proc/self/maps under the lock; a failed install that may leave our entry reachable poisons the slot.
  • 1d src/core/hook_lifecycle.h: platform-free create / publish / enable ordering and rollback, shared by core/hooking.h (MinHook) and the GOT backend.
  • 1c callback_thunk.h (thunk, NEVR_HOOK_RECORD), hook_install.h (InstallThunk), pinned_targets.h, hook_report.{h,cpp} (counter reporter), hook_log.{h,cpp} (JSON lines). Only clock_gettime is installed (entry.cpp).

Sixth round (commits 8291d46, fd02052)

  1. N1 record-only arming, by type and at run time. HookRecord has a private constructor (friend HookRecordAccess, used by NEVR_HOOK_RECORD); Thunk::Arm refuses and logs (arm_refused) a record whose address is not inside the nevr_hook_records section (checked against the linker's __start_/__stop_ symbols). Tests: a run-time record is refused and the thunk stays unarmed; the legitimate record arms. compile_fail/direct_record.cpp must not compile ("is private within this context").
  2. N3 install by type. InstallThunk<T> and ThunkInstaller::Install<T> static_assert that T is a CallbackThunk instantiation. just test-quest-hooks now compiles src/quest/tests/compile_fail/: control.cpp must compile; fake_thunk.cpp ("InstallThunk requires a CallbackThunk"), direct_record.cpp and plain_handler.cpp ("invalid conversion", a non-noexcept handler) must fail with the message that names the rule, not for some other reason.
  3. N4/N6 cheap hardening, limits stated. TestRawInstallOnlyInTests scans .cpp .cc .cxx .h .hpp .inc and also fails if any file outside a tests/ directory includes a header from src/quest/tests. Remaining limit (macro token pasting, a macro forwarding to the access class) is written in callback_thunk.h and ADR 0003, with the advisory nature of the #error (#undef __cpp_exceptions defeats it; the frame sensor on the built library is the real check).
  4. IsMapped fails closed. One complete pass over the maps file (data read, then end of file) is required to say "unmapped". A failed open, a read error, or an end of file before any data is "unknown" and keeps the reservation. ReleasePoisonedSlotsIn logs released, still_mapped or mapping_unknown. A test seam (SetMapsOpener) feeds it a directory descriptor (read error), /dev/null (empty) and a failed open; mutations Q6/Q7/Q8 each make the retry succeed and fail the test.
  5. Reporter Start/Stop race. StartReporter, StopReporter and RegisterReportCounter share one control lock held across thread creation, naming and join (the thread loop uses a separate mutex; order is control then state; the atfork handlers take both). ReporterRunning() added. Test: two threads loop Start/Stop 100 times each; without the control lock it hangs (mutation S1, 3 of 3 runs). The recipe now runs the host test under timeout 300, so a hang fails the gate.
  6. never_fired. The wording says "the hook was not called in that window; a failed install looks the same (the reporter starts before the install), so read it with the install line". RegisterReportCounter(..., ReportKind::kFaults) marks a counter whose zero is healthy; it never reports never_fired (the clock hook's fault counter is registered that way).
  7. Docs. The example in callback_thunk.h now matches the API; the dated PR feat(quest): embedded-default config, nevr-quest.json override and activation switches (#158 C) #220 narrative is gone from hook_log.h; the orphaned comment above IsMapped is fixed; the logging rationale is "needs no heap" (the earlier claim that nlohmann throws was contradicted by the host test, which parses with allow_exceptions=false). The #error guard is documented as advisory.

For dependants (#221, #226, #227, #229 and the router/social/redirect branches)

Everything from the previous round still applies (records, InstallThunk, -fno-exceptions, three-argument StartReporter). New in this round:

  • Thunk::Arm(sentinel::HookRecord<Thunk>{...}) and any other run-time record no longer compile or are refused at run time. Only a namespace-scope NEVR_HOOK_RECORD is accepted.
  • A handler chosen at run time (claude-quest-redirect hook_adapter.cpp 140-141 conditional re-arm; claude-quest-social social_install_test.cpp 86 Arm(SocialHandler())): define one record per candidate handler at namespace scope, and arm the chosen one: Thunk::Arm(useA ? kHookA : kHookB); (both are const HookRecord<Thunk>&). A handler that is a pointer computed at run time cannot be recorded; enumerate the candidates as handlers, or make the handler a single recorded function that branches on state it reads. Each candidate handler is a root for the frame sensor.
  • Test code that wants a handler not in production (e.g. social_install_test.cpp): define its own NEVR_HOOK_RECORD in the test translation unit; records in test binaries are fine, the section check is per program.
  • RegisterReportCounter takes an optional third ReportKind; a counter whose zero is healthy should be kFaults.
  • Call sites using the old API (Thunk::Arm(&H), hook.Install(target, Thunk::EntryAddress(), Thunk::OriginalOut()), Arm(nullptr)): listed in the review for claude-quest-redirect, claude-quest-social, claude-quest-router; each becomes a record + Arm(record), InstallThunk<Thunk>(hook, target), Disarm().
  • New GotStatus::kSlotPoisoned (previous round) and the maps-unknown behaviour of ReleasePoisonedSlotsIn need no change in callers.

Gates on tip d96bb82 (flock build lock, 6 jobs; first line of every log is tip d96bb8253dbda583ec1d663a5d81bdc1e84142c4; one verify run)

just verify          -> verify exit=0 ... got_hook_test: 834 log lines parsed with nlohmann::json
                        got_hook_test: all checks passed / verify: OK (mingw-release)      (verify9.log)
just build-android   -> exit=0, sentinel objects and library deleted first and rebuilt (7 ninja steps)  (android10.log)
                        artifact build/android-arm64/sentinel/libovrplatformloader.so
                        sha256 5997a8d9b68e04ec1c03df0f48fa405e29cdb3eaa4d333288e8204df3569009f, mtime 2026-10-08 00:40:20 -0500
just test-android    -> exit=0 on that artifact (sha256 before and after identical);
                        BAC1-5, ExportHygiene, ExportAllowlist, NoEmulatedTLSInHookPath,
                        HookFramesCarryNoPersonality (hooks=1, roots=2, reachable checked=3),
                        BackendBuiltWithoutExceptions, RawInstallOnlyInTests, BackendCompiledOnce,
                        StlContract all PASS                                              (testandroid10.log)
just test-quest-hooks          -> exit=0 (includes the compile-fail snippets)             (hosttest10.log)
just test-quest-hooks-pinned <apk> -> exit=0, both libraries OK                           (pinned10.log)

The mutation runs for this commit's content ran before it was committed (against the working tree that became d96bb82) and rebuilt the library only for R7; the artifact above was rebuilt from the committed tree after them.

Mutation proofs for the follow-up (each applied alone, source restored, restored run passes; mutations18/19.log)

== K1 counter table back to 8 slots: MUTATED exit=1
    src/quest/tests/got_hook_test.cpp:1337 CHECK failed: accepted == 32
    src/quest/tests/got_hook_test.cpp:1338 CHECK failed: Errors() == 0
    got_hook_test: 2 check(s) failed
    error: recipe `test-quest-hooks` failed with exit code 1
    got_hook_test: 858 log lines parsed with nlohmann::json
   RESTORED exit=0: got_hook_test: all checks passed
== R7 backend library built with exceptions: MUTATED exit=1
        elf_groundtruth_test.go:392: hooks=1 (roots=2), reachable sentinel functions checked=3
        elf_groundtruth_test.go:425: backend function _ZN8sentinel7GotHook7InstallERKNS_9GotTargetEPvPS4_PFbPKcPNS_8ElfImageEE sits under CIE augmentation "zPLR", want "zR"
    --- FAIL: TestBackendBuiltWithoutExceptions (0.03s)
    FAIL
    FAIL	github.com/EchoTools/nevr-runtime/tests/quest	0.263s
    ... (6 lines)
   RESTORED exit=0: ok  	github.com/EchoTools/nevr-runtime/tests/quest	0.240s
== H2 an unrelated error next to the expected one in fake_thunk.cpp: MUTATED exit=1
          |                                         ^~~~~
      • ‘std::integral_constant<bool, false>::value’ evaluates to false
    cc1plus: all warnings being treated as errors
    error: recipe `test-quest-hooks` failed with exit code 1
   RESTORED exit=0: got_hook_test: all checks passed
== H3 snippet that fails for a different reason (no static_assert text): MUTATED exit=1
       10 | sentinel::GotStatus Use(sentinel::GotHook& hook, const sentinel::GotTarget& target) {
          |                                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~
    cc1plus: all warnings being treated as errors
    error: recipe `test-quest-hooks` failed with exit code 1
   RESTORED exit=0: got_hook_test: all checks passed
== T1r production file includes a tests header by relative path: MUTATED exit=1
    --- FAIL: TestRawInstallOnlyInTests (0.02s)
    FAIL
    FAIL	github.com/EchoTools/nevr-runtime/tests/quest	0.021s
    FAIL
   RESTORED exit=0: ok  	github.com/EchoTools/nevr-runtime/tests/quest	0.019s
== T1s production file includes a nested tests header by src-relative path: MUTATED exit=1
    --- FAIL: TestRawInstallOnlyInTests (0.02s)
    FAIL
    FAIL	github.com/EchoTools/nevr-runtime/tests/quest	0.018s
    FAIL
   RESTORED exit=0: ok  	github.com/EchoTools/nevr-runtime/tests/quest	0.019s

Scratch-merge evidence for #231 (merge-fixture.log)

== OLD test (fd02052) on the merged artifact
    elf_groundtruth_test.go:425: backend function _ZN10nevr_quest12_GLOBAL__N_113ApplyEmbeddedERNS_14ResolvedConfigERKNS_16EmbeddedDefaultsEPNSt6__ndk16vectorINS_8LogEventENS6_9allocatorIS8_EEEE sits under CIE augmentation "zPLR", want "zR"
--- FAIL: TestBackendBuiltWithoutExceptions (0.04s)
FAIL
FAIL	github.com/EchoTools/nevr-runtime/tests/quest	0.044s
FAIL
== NEW test (this tip) on the merged artifact
--- PASS: TestBAC1_ELFShapeArm64 (0.00s)
--- PASS: TestBAC2_SentinelMarkerExported (0.01s)
--- PASS: TestBAC3_ForwardsToRenamedOriginal (0.01s)
--- PASS: TestBAC4_SonameHijacksLoader (0.01s)
--- PASS: TestBAC5_EarlyArmHooks (0.02s)
--- PASS: TestExportHygiene_NoBreakpadLeak (0.01s)
--- PASS: TestExportAllowlist (0.01s)
--- PASS: TestNoEmulatedTLSInHookPath (0.02s)
    elf_groundtruth_test.go:392: hooks=1 (roots=2), reachable sentinel functions checked=3
--- PASS: TestHookFramesCarryNoPersonality (0.16s)
--- PASS: TestBackendBuiltWithoutExceptions (0.04s)
--- PASS: TestRawInstallOnlyInTests (0.01s)
--- PASS: TestBackendCompiledOnce (0.00s)
--- PASS: TestStlContract (0.01s)
PASS
ok  	github.com/EchoTools/nevr-runtime/tests/quest	0.320s

Not verified

  • Nothing ran on Bionic, a headset or qemu-aarch64. The unwinder-crossing crash is inferred from measured layouts; the reporter thread, pthread_atfork and pthread_setname_np were never exercised on a device.
  • Sensors catch honest mistakes only (decision recorded in ADR 0003): indirect calls are not followed; a macro that forwards to HookRecordAccess, or a test-directory wrapper reached through a path the scan does not match, gets around the type-level gates.
  • CNSUser::SendLogInRequest and the engine imports CJson::TString calls were not shown exception-free.
  • /proc/self/maps readability under SELinux on the headset; a concurrent dlclose between Remove's revalidation and its store.
  • No ThreadSanitizer run of the reporter after the lock change (the test shows no hang and no leak by ReporterRunning(), TSan was not re-run).
  • The 1 s / 10 s / 60 s reporter cadence is unmeasured against a real game's call rate.

refs #158

🤖 Generated with Claude Code

…allback thunks (refs #158)

WIP: tests and just recipes follow in the next commits.

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

Real shared objects (JUMP_SLOT, GLOB_DAT, RELRO, writable GOT, lazy) and
in-memory images (ambiguous, addend, malformed, vaddr-relative pointers,
read-only slot page) through the production Install/Remove.

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

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>
…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>
…aths; ELF sensors (refs #158)

Third review of #222. The game uses libc++_shared's libgcc-style unwinder and
the sentinel a static LLVM one, so no exception may unwind through a sentinel
frame that has a personality. callback_thunk.h is now built -fno-exceptions
(enforced by #error), handlers are noexcept, the proxy / pthread-key /
exception_ptr machinery is gone. A kWriteVerifyFailed or rollback keeps the
original published, a failed re-protect no longer hides an earlier failure,
and a failed rollback compare-and-swap is logged. tests/quest pins the hook
frames to the personality-free CIE, rejects any non-libc++abi emulated TLS,
and counts the backend's compile rules in build.ninja; test-android runs with
-count=1 (Go cached a result across a changed .so).

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

Co-Authored-By: Andrew Bates <a@sprock.io>
…ned slots, counter reporter (refs #158)

Fourth review of #222. The frame sensor now starts from every thunk entry and
every NEVR_HOOK_HANDLER function (own output section) and follows direct
edges, so a handler that passes the original to an exception-enabled helper
fails it. A slot where our entry may still be reachable through a foreign hook
stays reserved (retry would publish that hook as the original and loop).
Hooks only count; a reporter thread logs the counters (first change, then at
most once a minute). Thunk contract text states what noexcept does and does not
do under -fno-exceptions. The backend library is built -fno-exceptions like the
host tests. Host tests also parse every log line with nlohmann::json.

Co-Authored-By: Andrew Bates <a@sprock.io>
thesprockee added a commit that referenced this pull request Oct 8, 2026
…rocess-wide facade is never destroyed (refs #158)

The thunk contract (callback_thunk.h after #222 round 3): no exception crosses between game frames and
sentinel frames, the Social handler is noexcept, and every TU that includes callback_thunk.h is built
-fno-exceptions.

- The facade's slots that call the game (Update delivering the party callbacks, JoinInternal and
  AcceptInvite asking the accept gate) move to social_game_calls.cpp, built -fno-exceptions. They call
  social_internal.h functions in social_facade.cpp that contain their own failures and return before
  the game is called; events are copied into a fixed batch so no heap object is alive while the game
  runs (more than 32 in one frame are dropped and counted).
- social_install.cpp is built -fno-exceptions; the handler is noexcept and reads the facade object
  published at install instead of constructing anything.
- tools/check_quest_social_frames.sh (100755) pins that those objects reference no personality and carry
  no LSDA, with a negative control on the object that does.
- Facade::Instance() is a leaked, never-destroyed object (a function-local static was destroyed at exit
  while network threads still ran); a test registers an atexit check before first use.
- Tests: a game exception thrown by a callback and by the accept gate reaches the game's caller through
  Update, JoinInternal and AcceptInvite and is neither caught nor counted by the facade; event batch
  overflow.

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

Fifth review of #222. Hooks are defined by NEVR_HOOK_RECORD ({entry, handler} in
nevr_hook_records) and Arm takes only a record; the frame sensor requires one
record per thunk Entry and walks from each record's entry and handler, with no
allowlist. GotHook::Install is private; production installs through
InstallThunk (hook_install.h) and only GotHookTestAccess (tests) reaches the
raw install. The reporter no longer stays in fast mode because a counter never
moved: grace window, one never_fired line per idle counter, then one pass per
steady interval; reporter_started line, thread name, spurious-wakeup loop,
logging outside the lock, pthread_atfork. A retry on a poisoned slot reports
slot_poisoned and ReleasePoisonedSlotsIn only releases unmapped addresses.
hook_log.h states its real reasons and that logcat is not a durable log.

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

Sixth check of #222. HookRecord has no public constructor and Thunk::Arm refuses a
record outside the nevr_hook_records section; InstallThunk and ThunkInstaller
static_assert a CallbackThunk instantiation; just test-quest-hooks compiles
snippets that break each rule and requires the naming error. The raw-install
scan covers every C++ extension and production includes of anything under
src/quest/tests. IsMapped treats a failed open, a read error and an empty file as
unknown and ReleasePoisonedSlotsIn then keeps the reservation (logged).
StartReporter/StopReporter/Register share one control lock; never_fired text
corrected and fault-only counters never report it. Doc fixes (example, narrative,
logging rationale, advisory #error).

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

Co-Authored-By: Andrew Bates <a@sprock.io>
thesprockee added a commit that referenced this pull request Oct 8, 2026
…(refs #158)

- The hook is a NEVR_HOOK_RECORD (kSocialHook) armed with SocialThunk::Arm / Disarm and installed with
  InstallThunk<SocialThunk>; GotHook::Install is no longer called directly.
- The handler no longer logs: SelectSocialObject counts (selected, null result, pnsovr unavailable,
  foreign object) and RegisterSocialReportCounters hands the counters, with the thunk's calls and
  faults, to the sentinel's reporter (6 of its 8 slots).
- Tests define their own record for the production handler; the handler path is checked to log nothing.
- The ADR records the registration order and the counters; the recipes build and link hook_report.cpp
  and the backend without exceptions.

Co-Authored-By: Andrew Bates <a@sprock.io>
…e-fail and include gates (refs #158, #231)

- TestBackendBuiltWithoutExceptions matches the hook backend's own symbols by exact mangled
  prefix; the substring 8LogEvent also matched an exception-enabled function that takes a
  std::vector<LogEvent> (#220's ApplyEmbedded). Shown on a scratch merge with #220.
- The reporter holds 32 counters (static storage); the 33rd and any registration after
  StartReporter are refused and logged; the ADR states that registration precedes start.
- Compile-fail snippets must produce ONLY the rule's own errors, each matched against a
  rule-specific diagnostic.
- TestRawInstallOnlyInTests walks src/quest/tests recursively and resolves includes relative to
  the including file, src/ and src/quest/sentinel before the prefix test.
- One relroReadOnly comment, in the right place; ADR wording matches the scan; the stale
  docs citation in the Go test header and src/quest/CMakeLists.txt points at the real design doc.

Co-Authored-By: Andrew Bates <a@sprock.io>
@thesprockee

Copy link
Copy Markdown
Member Author

Landed via #326.

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.

1 participant