[PR #3531] feat(ghidra): persistent project cache with cross-session identity #5807

Open
opened 2026-10-31 01:09:35 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/3531
Author: @goncalompontes
Created: 10/14/2026
Status: 🔄 Open

Base: main ← Head: feat/ghidra-project-cache


📝 Commits (10+)

  • adeb127 feat(ghidra): open a pre-imported project with -process instead of re-importing
  • c3c3f0b fix(ghidra): reject existing projects without the fixed rea-project name
  • 000f379 feat(ghidra): select a pre-imported project with -process
  • 1394412 fix(ghidra): bound existing project copies to owned cancellable inputs
  • 676326e fix(ghidra): preserve missing project admission diagnostics
  • dd53a55 fix(ghidra): reconcile project copies with runtime configuration
  • be9c21f fix(ghidra): isolate saved-project snapshot identities
  • bd8633c fix(config): clarify paired Ghidra project settings
  • 4b7f7d8 Merge captured main into PR #2358
  • 63c4779 fix(ghidra): preserve existing project import mode

📊 Changes

130 files changed (+18302 additions, -764 deletions)

View changed files

📝 .agents/skills/reverse-engineer-anything/SKILL.md (+48 -0)
📝 docs/.vitepress/config.ts (+1 -0)
📝 docs/error-contract.schema.json (+126 -0)
➕ docs/ghidra-project-cache.md (+176 -0)
📝 docs/installation.md (+68 -0)
📝 docs/mcp-contracts.md (+68 -4)
➕ docs/specs/1048-ghidra-project-cache/spec.md (+322 -0)
➕ docs/specs/1048-ghidra-project-cache/tasks.md (+72 -0)
➕ docs/specs/analysis-jobs/spec.md (+232 -0)
➕ docs/specs/analysis-jobs/tasks.md (+55 -0)
➕ docs/specs/multi-target-sessions/spec.md (+155 -0)
➕ docs/specs/multi-target-sessions/tasks.md (+61 -0)
📝 docs/tool-design.md (+12 -0)
📝 docs/user-visible-errors.csv (+4 -1)
📝 src/application/AnalysisProvider.ts (+76 -0)
📝 src/application/DirectAnalysis.ts (+8 -6)
📝 src/application/DirectAnalysisCleanup.ts (+2 -2)
📝 src/application/DirectAnalysisDependencies.ts (+9 -9)
➕ src/application/binary/AdmittedEvidenceScope.test.ts (+126 -0)
➕ src/application/binary/BackgroundAnalysisJobs.test.ts (+255 -0)

...and 80 more files

📄 Description

Adds a managed, persistent Ghidra project cache plus an open-mode-independent analysis identity so prior analysis snapshots replay across sessions.

Related issue: #1048. The pre-imported project selection that issue describes is implemented by #2358; this PR adds the persistent cache layer on top.

NOTE: this branch is stacked on #2358 (feat/1048-existing-project-wiring), so until #2358 merges this PR's diff also contains its commits (existing-project selection, launcher wiring, and the related profile/config changes). A fork-only stacked copy exists at goncalompontes/rea#1.

Problem

Large Ghidra imports re-pay import and auto-analysis on every open_binary. A saved project was not reusable across sessions, and a cache miss and a later cache hit committed different analysis profiles, so retained analysis snapshots could not replay.

What

  • Config: REA_GHIDRA_PROJECT_CACHE=off|read|on (default off), _DIR, _MAX_BYTES, _MAX_AGE_DAYS.
  • Store: key over target + provider + JVM + platform + import-profile; canonical manifest; expanded-project admission; streaming digests; atomic publish under a per-key lock; byte/age-capped prune; integrity verification.
  • Session: a miss imports writable and publishes only after a confirmed shutdown and a natural process exit; a hit opens -process -noanalysis -readOnly.
  • Identity: the committed profile drops import-mode provenance and adds a stable project_cache commitment, so miss and hit share a profile digest.
  • CLI: rea cache-list|cache-verify|cache-prune|cache-remove.
  • Durability: a hit leases its entry through the runtime copy; pruning and removal respect that lease.
  • Security: the cache root is owner-only before project bytes are written. POSIX creates 0700 and tightens an existing widened root; Windows creates the root through the native protected-DACL boundary, and an existing root that does not pass that verification is rebuilt as a private directory that keeps the stored projects.
  • Diagnostics: a corrupt stored entry that is discarded is logged and reported in the session's Evidence limitations before the fresh import rebuilds it. A rebuilt cache root is logged and reported in the session's Evidence limitations in the same way.

Measured (Windows Ghidra P0)

engine2.dll: first open 256.8 s to cache hit 13.1 s.

Verification

Local Windows checkout at d4b8afbba:

  • npx tsc --noEmit ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass.
  • npm run format:check ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass.
  • npm run lint ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 0 errors (repository-wide warnings only).
  • npm run metadata:check, npm run jscpd, npm run scan:todos, npm run verify:architecture-guards ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass.
  • npm run test:local for the cache store, cache-root, key, manifest, provider behavior, config, and CLI logging suites ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass.
  • npm run test:focused -- tests/boundary/cli/jsonOutput.test.ts tests/acceptance/cacheVerifyCli.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 44 passed.
  • npm run test:fast ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 10,530 passed; the 63 failures are pre-existing POSIX/macOS-only suites on this Windows host (ADB paths, Hopper, WABT, Mach-O/Electron). No Ghidra or cache suite failed.
  • A real Windows probe created a durable cache root through the native DACL boundary and verified it.
  • A real Windows run rebuilt a cache root whose DACL had been reset to an inherited one, preserved the stored project, reused it in the same session, and reported the rebuild in the session Evidence.

The Linux-only ghidraClient boundary file could not run on this Windows host because its fixture rename hits EBUSY; Linux CI covers it. npm run knip reports the pre-existing unresolved #web-bundle-analysis-worker import.

Merge refresh (bf31ce2f9)

Merged upstream/main (fa3665725) into the branch. The only conflict was src/ghidra/GhidraSnapshotCache.test.ts: the branch's Windows describe.skipIf wrapper and main's new degraded-Evidence replay test were combined, so the existing relationship-coverage replay case and main's new case run in the same suite.

Validation on Linux, merged head:

  • npx tsc --noEmit ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 820 passed, 1 skipped; one GhidraProjectCacheRoot lane failed on the first run and passed on rerun (the flake already documented below).
  • npx vitest run src/ghidra/projectCache src/ghidra/GhidraProjectCache.behavior.test.ts src/ghidra/GhidraProvider.behavior.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 210 passed.
  • npx vitest run src/ghidra/GhidraSnapshotCache.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 17 passed.
  • npx oxfmt --check src/ghidra/GhidraSnapshotCache.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass; npx oxlint on the file ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó no findings.
  • npm run build:cached ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass.

User-visible CLI output

Linux, merged head, empty cache directory:

$ REA_GHIDRA_PROJECT_CACHE=on REA_GHIDRA_PROJECT_CACHE_DIR=/tmp/opencode/rea-cache-sample node scripts/rea.mjs cache-list --format json
{"cache_directory":"/tmp/opencode/rea-cache-sample","mode":"on","max_bytes":10737418240,"entry_count":0,"total_bytes":0,"entries":[]}

Security, privacy, process, containment

The store remains local and owner-only. Entries are derived data and are never executed. Publishing is gated on a confirmed shutdown, a natural process exit, completed non-timed-out analysis, and no client mutation. Interrupted or forced stops never publish. Admission rejects links/reparse points and malformed expanded projects.

A rebuilt Windows root copies the stored projects into the private replacement instead of moving them, so the preserved projects carry the replacement's owner-only DACL rather than the permissions the shared root gave them. The rebuild sets the previous root aside first and removes it only after every project is copied, so a rebuild that cannot complete leaves the stored projects where they were.

Author readiness

Human owner: @goncalompontes

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

Review follow-up

37efe1c2d answers the review finding that the Linux run had no passing test for "a changed session does not publish".

  • The fake bridge advertised the database mutations in its handshake but never answered them, so the publication test waited for a reply that never arrived and hit the 30 s test deadline. The fake bridge now answers the mutating operations the way the real bridge does: a Unix socket applies the mutation and returns the refreshed observation, and a read-only transport refuses it with method_unavailable. The publication test covers every mutating operation and asserts the bridge answered it.
  • The Windows cache-root admission decisions are now pinned portably: a root that is not a directory, a root a concurrent initializer created, a private leaf that could not become the root, and a root native verification does not confirm.
  • Validation on this Windows host: npx tsc --noEmit; npx vitest run src/ghidra/projectCache src/ghidra/GhidraProjectCache.behavior.test.ts src/ghidra/GhidraProvider.behavior.test.ts (186 passed); the fake bridge mutation surface probed over a loopback transport (applied on a writable transport, refused on a read-only one); the client boundary exercised end to end against the same fixture.

Not re-run here: the Linux-only ghidraClient socket tests still cannot run on this host: the fixture cannot publish a Windows endpoint without the native runtime, and AF_UNIX listen is denied here.

Windows cache root follow-up

d4b8afbba fixes two defects that kept the cache unusable on a real Windows host, both found by running the feature end to end there.

  • A cache root that native verification refused was rejected for the whole session, and the refusal had no supported remedy. The default root inherits its DACL from the user profile, so on an administrator account it is owned by BUILTIN\Administrators and always fails the owner-only check; the store then re-imported the target every session. Such a root is now rebuilt as a private directory: the previous root is set aside first, the stored children move into the replacement, and the replacement is verified before the previous root is removed. A rebuild that cannot complete restores the previous root and leaves the stored projects where they were.
  • The durable root was the native runtime root itself. The runtime root stays sealed by the handle that owns it, and Windows refuses a path-based rename into a sealed directory, so a sealed cache root refused the stored projects moved into it and the entries the store published into it. The durable root is now created as a child of the runtime root: the child carries the same protected, owner-only DACL and is not sealed, and the emptied runtime root is closed so the native handles are released deterministically.

The rebuild is reported through the store, the provider log, and the session Evidence, so a rebuilt root is stated rather than applied silently.

Validation on this Windows host (Ghidra 12.1.4, JDK 21):

  • npx tsc --noEmit; npx oxfmt --check; npx oxlint (0 errors, the repository-wide warnings only); npm run verify:module-boundaries.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 769 passed, 28 skipped.
  • npx vitest run src/ghidra/projectCache ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 51 passed, 3 skipped.
  • npm run test:fast ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 10,530 passed; the same 63 pre-existing POSIX/macOS-only failures (ADB paths, Hopper, WABT, Mach-O/Electron) and no Ghidra or cache failure.
  • A real run against a cache root whose DACL had been reset to an inherited one: the root was rebuilt, the stored project survived the rebuild, the same session reused it from the cache, and the session Evidence reported "The Ghidra project cache root was not owner-only and was rebuilt as a private directory; 4 stored item(s) were preserved."
  • The rebuilt root reads back as owned by the current user with a protected DACL that admits only that user and SYSTEM.

Not re-run here: the native artifact build lane. It requires Linux and MinGW-w64, so the native sources are unchanged by this commit; the change uses only operations the shipped artifact already provides (runtime_create, runtime_mkdir, runtime_close, cutter_bridge_verify_directory), each exercised against the real artifact on this host.

Windows cache root repair follow-up

26d6b1c54 answers the two review findings on the Windows root rebuild.

  • The rebuild moved the stored children into the private replacement, so a preserved project kept the permissions it was created with and stayed readable by the principals its DACL admits. Windows accounts hold SeChangeNotifyPrivilege, which bypasses the traverse check on the private parent, and a moved staging directory gave every project written into it the same shared permissions. The rebuild now copies the stored projects into the private replacement, so each copy is created inside it and inherits its owner-only DACL. A link is refused instead of carried into the private root, where it would keep naming the shared directory it points at.
  • A failed rebuild erased projects: the restoration move could fail partway and the replacement was then deleted with the projects it still held. The replacement now holds only copies, so it is removed without losing a stored project, and the set-aside root is put back with everything it held. When the set-aside root cannot be put back, both roots stay in place and the message names the one that holds the projects. When it cannot be removed after a successful rebuild, its path is reported, because it still holds a copy other principals can read.

Validation on this Windows host:

  • npx tsc --noEmit; npx oxfmt --check; npx oxlint (0 errors, the repository-wide warnings only); node scripts/check-doc-facts.mjs; npm run verify:module-boundaries.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 772 passed, 30 skipped.
  • npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 4 passed.
  • The new Windows cache root repair failures lanes cover a preserved project that cannot be copied, a replacement that cannot be removed, and a set-aside root that cannot be removed.

Not re-run here: the two POSIX-only cleanup lanes (a set-aside root that cannot be removed, and a replacement that cannot be removed) did not run on this Windows host; their expected outcomes were confirmed by forcing the removal to fail. The native artifact build lane was not re-run either: it requires Linux and MinGW-w64, and the native sources are unchanged by this commit.

Windows cache root publication follow-up

6b9c2e5b7 answers the review finding that a rebuilt root could publish unfinished projects.

  • The rebuild copied the preserved projects straight into their final paths, so a rebuild that stopped part-way left a half-copied project where a later session looks for it. The lookup admits an entry that is readable rather than complete, so the next session accepted the private root as ready and served that project as a cache hit, and another session could read or discard it while the rebuild was still running. Each preserved project is now copied into a staging directory inside the private root and published by an atomic rename, so a project is never visible at its final path while it is still being copied. A name a concurrent publisher already took keeps the published project and discards the copy, so a rebuild never replaces a project a live session stored.
  • One session rebuilds the root at a time. The rebuild holds a lock beside the root, and a session that finds the lock held reports the root as busy instead of changing a root another session is rebuilding. A lock left behind by a session that died is taken over once it has been silent for longer than any plausible rebuild, and the lock is kept alive while the projects are copied.

The new regression test fails against the previous implementation: it observed a published project holding 199 of its 200 files.

Validation on this Windows host:

  • npx tsc --noEmit; npx oxfmt --check; npx oxlint (0 errors, the repository-wide warnings only); node scripts/check-doc-facts.mjs; npm run verify:module-boundaries.
  • npx vitest run src/ghidra/projectCache src/ghidra/GhidraProjectCache.behavior.test.ts src/ghidra/GhidraProvider.behavior.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 197 passed, 5 skipped.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 776 passed, 30 skipped.
  • npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 4 passed.
  • The new Windows cache root repair coordination and Windows cache root repair locking lanes cover a project that is published only once it is copied, a project a concurrent publisher already stored, a root another session is rebuilding, a lock a dead session left behind, and the lock released when a rebuild cannot complete.

Not re-run here: the two POSIX-only cleanup lanes (a set-aside root that cannot be removed, and a replacement that cannot be removed) did not run on this Windows host; their expected outcomes were confirmed by forcing the removal to fail. The native artifact build lane was not re-run either: it requires Linux and MinGW-w64, and the native sources are unchanged by this commit.

Windows cache root repair reclamation follow-up

7f0c75843 answers the review findings on the reclamation of the copies an interrupted rebuild leaves behind.

  • The reclamation deleted any directory named <root>.repair-staging beside the cache root, even when REA never created it. The cache root is a path the caller chose, so a sibling of it can hold anything, and an ordinary open or cache-prune deleted its contents. The copies now live in a staging directory inside the cache root, which is the one directory REA owns, so the reclamation only removes state REA created. A stored entry is named by its digest, so the store never counts the copies, and a rebuild does not carry over the copies a previous interrupted rebuild left behind, which were never published either.
  • The reclamation checked the repair lock but never held it, so a rebuild that started while the reclamation measured the copies could have its live copies deleted. The reclamation now takes that lock before it checks, measures, and removes the copies: a rebuild that is running keeps the lock, so the reclamation steps aside, and a lock a dead session left behind is taken over, so the copies it left are reclaimed with it. The lock itself stays beside the root, because the rebuild replaces the root and a lock inside it would not survive the replacement.
  • The POSIX repair-failure lane created its read-only directory in every private directory the fake host made, so the repair could not remove the staging directory it created and the lane's cleanup failed with a permission error. Only the replacement root holds the read-only directory now.

The new regression tests fail against the previous implementation: the unrelated sibling directory was deleted, and the reclamation held no lock while it removed the copies.

Validation on this Windows host:

  • npx tsc --noEmit; npx oxfmt --check and npx oxlint on the changed files; node scripts/check-doc-facts.mjs; npm run verify:module-boundaries; npm run verify:architecture-guards; npm run scan:todos; npm run knip.
  • npx vitest run src/ghidra/projectCache ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 65 passed, 5 skipped.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 784 passed, 30 skipped.
  • npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 4 passed.
  • The real native boundary on this host: a root that native verification refuses was rebuilt, the rebuild created the staging directory for the preserved copies inside the private replacement through the native boundary, the stored project survived the rebuild, and the reclamation removed the copies of an interrupted rebuild from inside the root and reported the bytes they held. Nothing was left beside the root.

Not re-run here: the two POSIX-only cleanup lanes (a set-aside root that cannot be removed, and a replacement that cannot be removed) did not run on this Windows host; their expected outcomes were confirmed by forcing the removal to fail. The native artifact build lane was not re-run either: it requires Linux and MinGW-w64, and the native sources are unchanged by this commit.

Pre-existing on this host, not from this commit: the publishes a preserved project only after it is copied lane is flaky at roughly one run in thirty. A background scanner can hold the temporary cache root open, so the rename that sets the root aside fails with EPERM, and the lane then reports either a rejected rebuild or an empty publication. The same failure reproduces on the previous commit.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. No new target-execution or mutation authority is added: the cache stores derived Ghidra project data locally and never executes it.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above. References #1048; #2358 implements the issue's pre-imported project selection, and this PR is the cache layer stacked on it.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed. docs/ghidra-project-cache.md, the installation guide, and the configuration reference are updated.
  • User-visible CLI/MCP changes include representative output. The output above is from the merged head; MCP tool contracts are unchanged.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims. A pattern scan over the 107-file diff found only secret-shaped code identifiers and deliberate test fixtures; no credential was found.

Cache root ownership follow-up

d208cf012 answers the three review findings on the cache root, the repair
lock, and the store's working directories.

  • The store's staging, lock, and lease directories are never followed. A root
    that other principals could write could hold a link at the name of one of
    those directories, and the prune listed the link's target and removed what
    was older than an hour in it. The store removes the link and creates its
    own directory in its place, so a cleanup never reaches outside the cache
    root. The copy of a published project is made inside that directory too, so
    it cannot land in what the link pointed at.
  • The repair lock carries the token of the session that holds it. A lock that
    is taken over is moved aside and compared with the lock that was observed,
    so a takeover never removes the lock of a session that holds it, a session
    releases only the lock it holds, and a repair that lost its lock stops
    instead of changing a root another session is rebuilding. The lock protocol
    moves to src/ghidra/projectCache/GhidraProjectCacheLock.ts, because it
    owns the lock file.
  • A repair that cannot complete keeps the stored projects another session
    published while it ran. Those entries are moved into the set-aside root
    before the replacement is removed, so the rollback no longer discards a
    project another session stored.

The new regression tests fail against the previous implementation: the
published entry was discarded by the rollback, the repair removed the lock of
the session that had taken it over, two sessions could take over the same
abandoned lock, and a file at the name of a working directory was not
replaced.

Validation on this Windows host:

  • npx tsc --noEmit ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass.
  • npx oxfmt --check and npx oxlint on the changed files ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass (0 errors,
    the repository-wide warnings only).
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 794 passed, 34 skipped.
  • npx vitest run src/ghidra/projectCache ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 69 passed, 7 skipped.
  • npx vitest run tests/acceptance/cacheVerifyCli.test.ts ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 3 passed.
  • npx vitest run tests/acceptance/cachePruneCli.test.ts ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 1 passed.
  • npm run verify:module-boundaries; npm run verify:architecture-guards;
    npm run knip; npm run jscpd; npm run scan:todos;
    node scripts/check-doc-facts.mjs.

Cache root working directory and POSIX follow-up (06ef06c61, fd4428601, 1e0854488)

Answers the two follow-up review findings and runs the POSIX lanes the Windows
host could not run.

  • The store replaces a working directory by moving the entry it observed aside
    and comparing that entry's inode before removing it. A directory another
    session created after the observation is put back, or left beside the root
    when the name was taken again, so a repair never recursively removes a live
    staging copy or a publisher's lock.
  • The non-root POSIX repair-failure lane now makes the replacement read-only,
    which blocks the move the rollback performs instead of relying on removing a
    read-only child. Cleanup runs in a finally block, so a failed assertion no
    longer leaves a read-only tree behind.
  • The repair-lock takeover lane waits for the lock's owner token before handing
    the lock over; the file exists before its token is written, so the old wait
    could overwrite the repair's token and let the repair run to completion. The
    published-project lane pins its observation to one root inode, so the
    repair's quarantine of the root no longer looks like a zero-file
    publication.

Validation on Linux, head 1e0854488 (the two POSIX-only lanes now run):

  • npx tsc --noEmit ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass.
  • npx vitest run src/ghidra/projectCache ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 76 passed, no skips;
    GhidraProjectCacheRoot.test.ts passed 10 consecutive runs.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 827 passed, 1 skipped.
  • npx vitest run tests/boundary/mcp/sessionMcp.test.ts tests/boundary/mcp/optionalProviderIsolation.test.ts tests/composition/directAnalysisFactories.test.ts tests/composition/directAnalysisXrefs.test.ts ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 46 passed.
  • npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 4 passed.
  • npx oxfmt --check and npx oxlint on src/ghidra/projectCache ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass
    (0 errors, the repository-wide warnings only).
  • npm run verify:module-boundaries ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass.
  • npm run build:cached ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass; the acceptance lanes above ran against the
    build.

The merge at 1e0854488 also resolves the conflicts with main:
src/application/DirectAnalysis.ts keeps main's native dispatch profile
binding, and src/server/registerSessionStatusTool.ts keeps main's
probeSessionAvailability with this branch's target_id and wait_ms status
arguments.

The two POSIX-only lanes are now covered on Linux, so the earlier "not re-run
here" note for them no longer applies.

Lease and staging follow-up (a90a17584, aee75d863, 89e9685f5)

Answers the two cache findings from the latest automated review pass.

  • A lease is written beside its final name and renamed into place. The rename
    replaces the name atomically and never follows a link at the destination, so
    a concurrent remove or prune never sees the lease absent while a hit is
    using it, and a link planted at leases/<key>.lease before the root was
    tightened is replaced instead of followed. Regression: a hit with a linked
    lease leaves the link target unchanged and the lease a regular file.
  • Each client receives its own staging path even when two clients share a
    profile digest, so a failed publish in one client cannot discard another
    client's copy. Regression: two clients for one shared profile receive
    distinct staging slots.

Validation on Linux, head 89e9685f5:

  • npx tsc --noEmit ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass.
  • npx vitest run src/ghidra ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 829 passed, 1 skipped.
  • npx vitest run src/ghidra/projectCache ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 77 passed.
  • npx vitest run src/ghidra/projectCache/GhidraProjectStore.test.ts src/ghidra/GhidraProvider.behavior.test.ts ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 172 passed.
  • npx oxfmt --check and npx oxlint on the changed files ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass (0 errors,
    the repository-wide warnings only).

The latest automated review also raised findings in the multi-target session
and background-analysis work that this branch now carries
(src/application/binary/BinaryTargetRegistry.ts,
src/application/binary/BinarySession.ts, and
src/server/registerSessionTools.ts). Those are outside the cache change and
are left to that work's owners.

Multi-target session follow-up (4ec21a489)

Answers the two review findings on the multi-target session work that this
branch carries (src/application/binary/BinaryTargetRegistry.ts and
src/application/binary/BinarySession.ts).

  • A forced close that could not release the provider keeps the start permit
    that the retained cleanup owns, and the cleanup is retried inside the open.
    With REA_MAX_CONCURRENT_ANALYSES=1 the open therefore waited for a permit
    that only the open itself could give back, so a replacement open hung until
    another request retried the cleanup explicitly. The registry now retries the
    retained cleanup before it takes a new start permit; a successful retry
    gives the permit back and stops reporting the slot as retained. One retry
    runs at a time, so two overlapping opens do not close the same provider
    twice.
  • Every ordinary request raced the shared retirement promise of its epoch.
    Promise.race leaves its reaction attached to that promise, so every
    request that settled through the transition left a retained reaction behind
    until the epoch was retired, which a long-lived session never does. The
    retirement is now a removable listener: a request detaches itself when it
    settles or is aborted, and the epoch keeps nothing for it.
  • The client a cleanup closed is marked as released where the close happens,
    so a provider that a retry released is not closed a second time when the
    start that never unwound finally unwinds.

The new regression tests fail against the previous implementation: the
replacement open never started a second provider, the epoch kept a waiter for
a request that had already settled, and the released provider was closed
twice.

Validation on this Windows host:

  • npx tsc --noEmit ├ö├ç├ pass.
  • npx vitest run src/application/binary ├ö├ç├ 197 passed.
  • npx vitest run --project mcp-boundary ├ö├ç├ 967 passed; the 16 failures are
    the pre-existing POSIX-only failures this host already had without the
    change.
  • npm run test:fast ├ö├ç├ 12,210 passed; the 70 failures are the pre-existing
    POSIX/macOS-only suites on this host (Hopper, ADB, WABT, Mach-O, Electron),
    and no binary or session suite failed.
  • npx oxfmt --check and npx oxlint on the changed files ├ö├ç├ pass (0 errors,
    the repository-wide warnings only).
  • npm run verify:module-boundaries; npm run verify:architecture-guards;
    npm run knip; npm run scan:todos ├ö├ç├ pass.

Not re-run here: the POSIX-only cleanup lanes of the cache suites and the
native artifact build lane; they are unchanged by this commit.

Cancelled and closed-slot follow-up (794e3050d)

Answers the two review findings on the failed-start cleanup that the last
commit introduced.

  • An open that was cancelled while it waited for a start permit skipped the
    cleanup of its failed start. The admission reported the failure it could
    not acquire the permit for without running that cleanup, and the open
    assumed the cleanup had already run: the client the open created was never
    closed, and the target the open had replaced was closed and never
    restored.

    The session owns the cleanup of its failed start now. It runs once: the
    admission runs it under the shared start limit, and a failure the
    admission reported without running it runs it in the open. The admission
    runs the cleanup for a start it could not attempt as well, so the limit
    still counts a provider the cleanup could not release, and the slot that
    owns that provider stays registered as retained.

    The health probe of a client that starts the provider it selected runs
    inside the admitted start now, so a probe that fails runs the cleanup of
    the failed open under the limit that counts the provider the probe left
    behind. The probe of a composed client answers without touching a child,
    so the limit is not held for a provider start that never ran.

  • The cleanup of a failed start restored the target the open replaced before
    the open checked whether it had been retired. A forced close that
    released the start while it was in flight therefore left the closed slot
    holding a target again, and a target that opened in that slot afterwards
    could have its binding overwritten.

    The cleanup restores the target only while the slot still owns it. The
    lifecycle generation of the open is checked before the restoration and
    again after it waited for the restored client, so a slot that was closed
    while the restoration waited releases the restored client instead of
    binding it.

The new regression tests fail against the previous implementation: the client
of a cancelled open was never closed, the target it replaced was never
restored, and a closed slot held the target it had before the failed open.

Validation on this Windows host:

  • npx tsc --noEmit - pass.
  • npx vitest run src/application/binary src/server tests/composition/analysis-sessions - 434 passed.
  • npx vitest run tests/boundary/mcp - the 16 failures are the pre-existing POSIX-only failures this host already had without the change.
  • npx oxfmt --check and npx oxlint on the changed files - pass (0 errors, the repository-wide warnings only).
  • npm run check:fast; npm run verify:module-boundaries; npm run verify:architecture-guards; npm run scan:todos; npm run knip; node scripts/check-doc-facts.mjs - pass.

Restored start limit follow-up (1cd961008)

Answers the review finding that a restored target starts its provider outside
the shared start limit.

  • A cancelled replacement waits for a start permit, and the admission runs the
    cleanup of that failed open, which restores the target the open replaced. The
    restoration created its client without the start admission, so the provider
    start that client defers ran outside the shared limit: a query on the
    restored target launched an import beside a provider that was still
    starting, and the analysis cap that bounds the concurrent analyses was
    exceeded.

    The session keeps the admission the open carries and hands it to the restored
    client, so the restored target starts its provider under the same limit as
    every other start of the session. The restoration does not start the provider
    under that admission: the health probe of a composed client answers without
    touching a child, and a probe that waited for the limit would make a
    cancelled open wait for a permit it cannot use.

The new regression test fails against the previous implementation: the query on
the restored target did not run under the admission.

Validation on this Windows host:

  • npx tsc --noEmit - pass.
  • npx vitest run src/application/binary - 230 passed.
  • npx vitest run src/application/binary src/server tests/composition/analysis-sessions - 435 passed.
  • npx vitest run src/ghidra - 801 passed, 35 skipped.
  • npx vitest run --project mcp-boundary - the failing suites are the POSIX-only and the WABT/IDA suites this Windows host cannot run; the same suites fail without the change.
  • npx oxfmt --check and npx oxlint on the changed files - pass (0 errors, the repository-wide warnings only).
  • npm run check:fast; npm run verify:module-boundaries; npm run verify:architecture-guards; npm run scan:todos; npm run knip; node scripts/check-doc-facts.mjs - pass.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/morluto/rea/pull/3531 **Author:** [@goncalompontes](https://github.com/goncalompontes) **Created:** 10/14/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `feat/ghidra-project-cache` --- ### 📝 Commits (10+) - [`adeb127`](https://github.com/morluto/rea/commit/adeb1270b24db83d301ade1047786b48f0e67f84) feat(ghidra): open a pre-imported project with -process instead of re-importing - [`c3c3f0b`](https://github.com/morluto/rea/commit/c3c3f0b5967404e4669299846b44586d2b6bc722) fix(ghidra): reject existing projects without the fixed rea-project name - [`000f379`](https://github.com/morluto/rea/commit/000f379d59624ba3a5cb6cee2a83aab5905cecd6) feat(ghidra): select a pre-imported project with -process - [`1394412`](https://github.com/morluto/rea/commit/139441298aa2301a9cce90515bc6a3a84d23ff9c) fix(ghidra): bound existing project copies to owned cancellable inputs - [`676326e`](https://github.com/morluto/rea/commit/676326e9ce5934e5e8d9f11221f5f4ec566056a4) fix(ghidra): preserve missing project admission diagnostics - [`dd53a55`](https://github.com/morluto/rea/commit/dd53a554e7fbbe5d02c85d242685f65f99bacf9e) fix(ghidra): reconcile project copies with runtime configuration - [`be9c21f`](https://github.com/morluto/rea/commit/be9c21f5f9c812419a0db4c6d63bfe00290e6e33) fix(ghidra): isolate saved-project snapshot identities - [`bd8633c`](https://github.com/morluto/rea/commit/bd8633ca863a723339d206340bcc623676295fde) fix(config): clarify paired Ghidra project settings - [`4b7f7d8`](https://github.com/morluto/rea/commit/4b7f7d8f4ab409d976edb1e10ed9d6e889be81d8) Merge captured main into PR #2358 - [`63c4779`](https://github.com/morluto/rea/commit/63c4779a66cea39c6fccef6b4fd4feb8b35a4c9e) fix(ghidra): preserve existing project import mode ### 📊 Changes **130 files changed** (+18302 additions, -764 deletions) <details> <summary>View changed files</summary> 📝 `.agents/skills/reverse-engineer-anything/SKILL.md` (+48 -0) 📝 `docs/.vitepress/config.ts` (+1 -0) 📝 `docs/error-contract.schema.json` (+126 -0) ➕ `docs/ghidra-project-cache.md` (+176 -0) 📝 `docs/installation.md` (+68 -0) 📝 `docs/mcp-contracts.md` (+68 -4) ➕ `docs/specs/1048-ghidra-project-cache/spec.md` (+322 -0) ➕ `docs/specs/1048-ghidra-project-cache/tasks.md` (+72 -0) ➕ `docs/specs/analysis-jobs/spec.md` (+232 -0) ➕ `docs/specs/analysis-jobs/tasks.md` (+55 -0) ➕ `docs/specs/multi-target-sessions/spec.md` (+155 -0) ➕ `docs/specs/multi-target-sessions/tasks.md` (+61 -0) 📝 `docs/tool-design.md` (+12 -0) 📝 `docs/user-visible-errors.csv` (+4 -1) 📝 `src/application/AnalysisProvider.ts` (+76 -0) 📝 `src/application/DirectAnalysis.ts` (+8 -6) 📝 `src/application/DirectAnalysisCleanup.ts` (+2 -2) 📝 `src/application/DirectAnalysisDependencies.ts` (+9 -9) ➕ `src/application/binary/AdmittedEvidenceScope.test.ts` (+126 -0) ➕ `src/application/binary/BackgroundAnalysisJobs.test.ts` (+255 -0) _...and 80 more files_ </details> ### 📄 Description Adds a managed, persistent Ghidra project cache plus an open-mode-independent analysis identity so prior analysis snapshots replay across sessions. Related issue: #1048. The pre-imported project selection that issue describes is implemented by #2358; this PR adds the persistent cache layer on top. NOTE: this branch is stacked on #2358 (`feat/1048-existing-project-wiring`), so until #2358 merges this PR's diff also contains its commits (existing-project selection, launcher wiring, and the related profile/config changes). A fork-only stacked copy exists at goncalompontes/rea#1. ## Problem Large Ghidra imports re-pay import and auto-analysis on every `open_binary`. A saved project was not reusable across sessions, and a cache miss and a later cache hit committed different analysis profiles, so retained analysis snapshots could not replay. ## What - Config: `REA_GHIDRA_PROJECT_CACHE=off|read|on` (default off), `_DIR`, `_MAX_BYTES`, `_MAX_AGE_DAYS`. - Store: key over target + provider + JVM + platform + import-profile; canonical manifest; expanded-project admission; streaming digests; atomic publish under a per-key lock; byte/age-capped prune; integrity verification. - Session: a miss imports writable and publishes only after a confirmed shutdown and a natural process exit; a hit opens `-process -noanalysis -readOnly`. - Identity: the committed profile drops import-mode provenance and adds a stable `project_cache` commitment, so miss and hit share a profile digest. - CLI: `rea cache-list|cache-verify|cache-prune|cache-remove`. - Durability: a hit leases its entry through the runtime copy; pruning and removal respect that lease. - Security: the cache root is owner-only before project bytes are written. POSIX creates `0700` and tightens an existing widened root; Windows creates the root through the native protected-DACL boundary, and an existing root that does not pass that verification is rebuilt as a private directory that keeps the stored projects. - Diagnostics: a corrupt stored entry that is discarded is logged and reported in the session's Evidence limitations before the fresh import rebuilds it. A rebuilt cache root is logged and reported in the session's Evidence limitations in the same way. ## Measured (Windows Ghidra P0) `engine2.dll`: first open 256.8 s to cache hit 13.1 s. ## Verification Local Windows checkout at `d4b8afbba`: - `npx tsc --noEmit` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass. - `npm run format:check` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass. - `npm run lint` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 0 errors (repository-wide warnings only). - `npm run metadata:check`, `npm run jscpd`, `npm run scan:todos`, `npm run verify:architecture-guards` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass. - `npm run test:local` for the cache store, cache-root, key, manifest, provider behavior, config, and CLI logging suites ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass. - `npm run test:focused -- tests/boundary/cli/jsonOutput.test.ts tests/acceptance/cacheVerifyCli.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 44 passed. - `npm run test:fast` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 10,530 passed; the 63 failures are pre-existing POSIX/macOS-only suites on this Windows host (ADB paths, Hopper, WABT, Mach-O/Electron). No Ghidra or cache suite failed. - A real Windows probe created a durable cache root through the native DACL boundary and verified it. - A real Windows run rebuilt a cache root whose DACL had been reset to an inherited one, preserved the stored project, reused it in the same session, and reported the rebuild in the session Evidence. The Linux-only `ghidraClient` boundary file could not run on this Windows host because its fixture rename hits `EBUSY`; Linux CI covers it. `npm run knip` reports the pre-existing unresolved `#web-bundle-analysis-worker` import. ## Merge refresh (`bf31ce2f9`) Merged `upstream/main` (`fa3665725`) into the branch. The only conflict was `src/ghidra/GhidraSnapshotCache.test.ts`: the branch's Windows `describe.skipIf` wrapper and main's new degraded-Evidence replay test were combined, so the existing relationship-coverage replay case and main's new case run in the same suite. Validation on Linux, merged head: - `npx tsc --noEmit` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 820 passed, 1 skipped; one `GhidraProjectCacheRoot` lane failed on the first run and passed on rerun (the flake already documented below). - `npx vitest run src/ghidra/projectCache src/ghidra/GhidraProjectCache.behavior.test.ts src/ghidra/GhidraProvider.behavior.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 210 passed. - `npx vitest run src/ghidra/GhidraSnapshotCache.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 17 passed. - `npx oxfmt --check src/ghidra/GhidraSnapshotCache.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass; `npx oxlint` on the file ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó no findings. - `npm run build:cached` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó pass. ## User-visible CLI output Linux, merged head, empty cache directory: ```console $ REA_GHIDRA_PROJECT_CACHE=on REA_GHIDRA_PROJECT_CACHE_DIR=/tmp/opencode/rea-cache-sample node scripts/rea.mjs cache-list --format json {"cache_directory":"/tmp/opencode/rea-cache-sample","mode":"on","max_bytes":10737418240,"entry_count":0,"total_bytes":0,"entries":[]} ``` ## Security, privacy, process, containment The store remains local and owner-only. Entries are derived data and are never executed. Publishing is gated on a confirmed shutdown, a natural process exit, completed non-timed-out analysis, and no client mutation. Interrupted or forced stops never publish. Admission rejects links/reparse points and malformed expanded projects. A rebuilt Windows root copies the stored projects into the private replacement instead of moving them, so the preserved projects carry the replacement's owner-only DACL rather than the permissions the shared root gave them. The rebuild sets the previous root aside first and removes it only after every project is copied, so a rebuild that cannot complete leaves the stored projects where they were. ## Author readiness Human owner: @goncalompontes - [x] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work. - [x] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps. - [x] I declare this PR ready for initial maintainer review. ## Review follow-up `37efe1c2d` answers the review finding that the Linux run had no passing test for "a changed session does not publish". - The fake bridge advertised the database mutations in its handshake but never answered them, so the publication test waited for a reply that never arrived and hit the 30 s test deadline. The fake bridge now answers the mutating operations the way the real bridge does: a Unix socket applies the mutation and returns the refreshed observation, and a read-only transport refuses it with `method_unavailable`. The publication test covers every mutating operation and asserts the bridge answered it. - The Windows cache-root admission decisions are now pinned portably: a root that is not a directory, a root a concurrent initializer created, a private leaf that could not become the root, and a root native verification does not confirm. - Validation on this Windows host: `npx tsc --noEmit`; `npx vitest run src/ghidra/projectCache src/ghidra/GhidraProjectCache.behavior.test.ts src/ghidra/GhidraProvider.behavior.test.ts` (186 passed); the fake bridge mutation surface probed over a loopback transport (applied on a writable transport, refused on a read-only one); the client boundary exercised end to end against the same fixture. Not re-run here: the Linux-only `ghidraClient` socket tests still cannot run on this host: the fixture cannot publish a Windows endpoint without the native runtime, and AF_UNIX listen is denied here. ## Windows cache root follow-up `d4b8afbba` fixes two defects that kept the cache unusable on a real Windows host, both found by running the feature end to end there. - A cache root that native verification refused was rejected for the whole session, and the refusal had no supported remedy. The default root inherits its DACL from the user profile, so on an administrator account it is owned by `BUILTIN\Administrators` and always fails the owner-only check; the store then re-imported the target every session. Such a root is now rebuilt as a private directory: the previous root is set aside first, the stored children move into the replacement, and the replacement is verified before the previous root is removed. A rebuild that cannot complete restores the previous root and leaves the stored projects where they were. - The durable root was the native runtime root itself. The runtime root stays sealed by the handle that owns it, and Windows refuses a path-based rename into a sealed directory, so a sealed cache root refused the stored projects moved into it and the entries the store published into it. The durable root is now created as a child of the runtime root: the child carries the same protected, owner-only DACL and is not sealed, and the emptied runtime root is closed so the native handles are released deterministically. The rebuild is reported through the store, the provider log, and the session Evidence, so a rebuilt root is stated rather than applied silently. Validation on this Windows host (Ghidra 12.1.4, JDK 21): - `npx tsc --noEmit`; `npx oxfmt --check`; `npx oxlint` (0 errors, the repository-wide warnings only); `npm run verify:module-boundaries`. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 769 passed, 28 skipped. - `npx vitest run src/ghidra/projectCache` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 51 passed, 3 skipped. - `npm run test:fast` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 10,530 passed; the same 63 pre-existing POSIX/macOS-only failures (ADB paths, Hopper, WABT, Mach-O/Electron) and no Ghidra or cache failure. - A real run against a cache root whose DACL had been reset to an inherited one: the root was rebuilt, the stored project survived the rebuild, the same session reused it from the cache, and the session Evidence reported "The Ghidra project cache root was not owner-only and was rebuilt as a private directory; 4 stored item(s) were preserved." - The rebuilt root reads back as owned by the current user with a protected DACL that admits only that user and SYSTEM. Not re-run here: the native artifact build lane. It requires Linux and MinGW-w64, so the native sources are unchanged by this commit; the change uses only operations the shipped artifact already provides (`runtime_create`, `runtime_mkdir`, `runtime_close`, `cutter_bridge_verify_directory`), each exercised against the real artifact on this host. ## Windows cache root repair follow-up `26d6b1c54` answers the two review findings on the Windows root rebuild. - The rebuild moved the stored children into the private replacement, so a preserved project kept the permissions it was created with and stayed readable by the principals its DACL admits. Windows accounts hold `SeChangeNotifyPrivilege`, which bypasses the traverse check on the private parent, and a moved `staging` directory gave every project written into it the same shared permissions. The rebuild now copies the stored projects into the private replacement, so each copy is created inside it and inherits its owner-only DACL. A link is refused instead of carried into the private root, where it would keep naming the shared directory it points at. - A failed rebuild erased projects: the restoration move could fail partway and the replacement was then deleted with the projects it still held. The replacement now holds only copies, so it is removed without losing a stored project, and the set-aside root is put back with everything it held. When the set-aside root cannot be put back, both roots stay in place and the message names the one that holds the projects. When it cannot be removed after a successful rebuild, its path is reported, because it still holds a copy other principals can read. Validation on this Windows host: - `npx tsc --noEmit`; `npx oxfmt --check`; `npx oxlint` (0 errors, the repository-wide warnings only); `node scripts/check-doc-facts.mjs`; `npm run verify:module-boundaries`. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 772 passed, 30 skipped. - `npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 4 passed. - The new `Windows cache root repair failures` lanes cover a preserved project that cannot be copied, a replacement that cannot be removed, and a set-aside root that cannot be removed. Not re-run here: the two POSIX-only cleanup lanes (a set-aside root that cannot be removed, and a replacement that cannot be removed) did not run on this Windows host; their expected outcomes were confirmed by forcing the removal to fail. The native artifact build lane was not re-run either: it requires Linux and MinGW-w64, and the native sources are unchanged by this commit. ## Windows cache root publication follow-up `6b9c2e5b7` answers the review finding that a rebuilt root could publish unfinished projects. - The rebuild copied the preserved projects straight into their final paths, so a rebuild that stopped part-way left a half-copied project where a later session looks for it. The lookup admits an entry that is readable rather than complete, so the next session accepted the private root as ready and served that project as a cache hit, and another session could read or discard it while the rebuild was still running. Each preserved project is now copied into a staging directory inside the private root and published by an atomic rename, so a project is never visible at its final path while it is still being copied. A name a concurrent publisher already took keeps the published project and discards the copy, so a rebuild never replaces a project a live session stored. - One session rebuilds the root at a time. The rebuild holds a lock beside the root, and a session that finds the lock held reports the root as busy instead of changing a root another session is rebuilding. A lock left behind by a session that died is taken over once it has been silent for longer than any plausible rebuild, and the lock is kept alive while the projects are copied. The new regression test fails against the previous implementation: it observed a published project holding 199 of its 200 files. Validation on this Windows host: - `npx tsc --noEmit`; `npx oxfmt --check`; `npx oxlint` (0 errors, the repository-wide warnings only); `node scripts/check-doc-facts.mjs`; `npm run verify:module-boundaries`. - `npx vitest run src/ghidra/projectCache src/ghidra/GhidraProjectCache.behavior.test.ts src/ghidra/GhidraProvider.behavior.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 197 passed, 5 skipped. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 776 passed, 30 skipped. - `npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 4 passed. - The new `Windows cache root repair coordination` and `Windows cache root repair locking` lanes cover a project that is published only once it is copied, a project a concurrent publisher already stored, a root another session is rebuilding, a lock a dead session left behind, and the lock released when a rebuild cannot complete. Not re-run here: the two POSIX-only cleanup lanes (a set-aside root that cannot be removed, and a replacement that cannot be removed) did not run on this Windows host; their expected outcomes were confirmed by forcing the removal to fail. The native artifact build lane was not re-run either: it requires Linux and MinGW-w64, and the native sources are unchanged by this commit. ## Windows cache root repair reclamation follow-up `7f0c75843` answers the review findings on the reclamation of the copies an interrupted rebuild leaves behind. - The reclamation deleted any directory named `<root>.repair-staging` beside the cache root, even when REA never created it. The cache root is a path the caller chose, so a sibling of it can hold anything, and an ordinary open or `cache-prune` deleted its contents. The copies now live in a staging directory inside the cache root, which is the one directory REA owns, so the reclamation only removes state REA created. A stored entry is named by its digest, so the store never counts the copies, and a rebuild does not carry over the copies a previous interrupted rebuild left behind, which were never published either. - The reclamation checked the repair lock but never held it, so a rebuild that started while the reclamation measured the copies could have its live copies deleted. The reclamation now takes that lock before it checks, measures, and removes the copies: a rebuild that is running keeps the lock, so the reclamation steps aside, and a lock a dead session left behind is taken over, so the copies it left are reclaimed with it. The lock itself stays beside the root, because the rebuild replaces the root and a lock inside it would not survive the replacement. - The POSIX repair-failure lane created its read-only directory in every private directory the fake host made, so the repair could not remove the staging directory it created and the lane's cleanup failed with a permission error. Only the replacement root holds the read-only directory now. The new regression tests fail against the previous implementation: the unrelated sibling directory was deleted, and the reclamation held no lock while it removed the copies. Validation on this Windows host: - `npx tsc --noEmit`; `npx oxfmt --check` and `npx oxlint` on the changed files; `node scripts/check-doc-facts.mjs`; `npm run verify:module-boundaries`; `npm run verify:architecture-guards`; `npm run scan:todos`; `npm run knip`. - `npx vitest run src/ghidra/projectCache` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 65 passed, 5 skipped. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 784 passed, 30 skipped. - `npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts` ┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó+┬┤ÔöÉ┬ó 4 passed. - The real native boundary on this host: a root that native verification refuses was rebuilt, the rebuild created the staging directory for the preserved copies inside the private replacement through the native boundary, the stored project survived the rebuild, and the reclamation removed the copies of an interrupted rebuild from inside the root and reported the bytes they held. Nothing was left beside the root. Not re-run here: the two POSIX-only cleanup lanes (a set-aside root that cannot be removed, and a replacement that cannot be removed) did not run on this Windows host; their expected outcomes were confirmed by forcing the removal to fail. The native artifact build lane was not re-run either: it requires Linux and MinGW-w64, and the native sources are unchanged by this commit. Pre-existing on this host, not from this commit: the `publishes a preserved project only after it is copied` lane is flaky at roughly one run in thirty. A background scanner can hold the temporary cache root open, so the rename that sets the root aside fails with `EPERM`, and the lane then reports either a rejected rebuild or an empty publication. The same failure reproduces on the previous commit. ## Before merge - [x] This PR addresses a concrete problem or an agreed enhancement. - [x] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. No new target-execution or mutation authority is added: the cache stores derived Ghidra project data locally and never executes it. - [x] The PR has one focused outcome and the title follows `type(scope): outcome`. - [x] Related issue is linked, or the reason for not linking one is stated above. References #1048; #2358 implements the issue's pre-imported project selection, and this PR is the cache layer stacked on it. - [x] Tests cover changed observable behavior and meaningful failure paths. - [x] Owning docs, contracts, and generated metadata are updated where needed. `docs/ghidra-project-cache.md`, the installation guide, and the configuration reference are updated. - [x] User-visible CLI/MCP changes include representative output. The output above is from the merged head; MCP tool contracts are unchanged. - [x] I checked the final diff for secrets, unrelated cleanup, and unsupported claims. A pattern scan over the 107-file diff found only secret-shaped code identifiers and deliberate test fixtures; no credential was found. ## Cache root ownership follow-up `d208cf012` answers the three review findings on the cache root, the repair lock, and the store's working directories. - The store's staging, lock, and lease directories are never followed. A root that other principals could write could hold a link at the name of one of those directories, and the prune listed the link's target and removed what was older than an hour in it. The store removes the link and creates its own directory in its place, so a cleanup never reaches outside the cache root. The copy of a published project is made inside that directory too, so it cannot land in what the link pointed at. - The repair lock carries the token of the session that holds it. A lock that is taken over is moved aside and compared with the lock that was observed, so a takeover never removes the lock of a session that holds it, a session releases only the lock it holds, and a repair that lost its lock stops instead of changing a root another session is rebuilding. The lock protocol moves to `src/ghidra/projectCache/GhidraProjectCacheLock.ts`, because it owns the lock file. - A repair that cannot complete keeps the stored projects another session published while it ran. Those entries are moved into the set-aside root before the replacement is removed, so the rollback no longer discards a project another session stored. The new regression tests fail against the previous implementation: the published entry was discarded by the rollback, the repair removed the lock of the session that had taken it over, two sessions could take over the same abandoned lock, and a file at the name of a working directory was not replaced. Validation on this Windows host: - `npx tsc --noEmit` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass. - `npx oxfmt --check` and `npx oxlint` on the changed files ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass (0 errors, the repository-wide warnings only). - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 794 passed, 34 skipped. - `npx vitest run src/ghidra/projectCache` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 69 passed, 7 skipped. - `npx vitest run tests/acceptance/cacheVerifyCli.test.ts` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 3 passed. - `npx vitest run tests/acceptance/cachePruneCli.test.ts` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 1 passed. - `npm run verify:module-boundaries`; `npm run verify:architecture-guards`; `npm run knip`; `npm run jscpd`; `npm run scan:todos`; `node scripts/check-doc-facts.mjs`. ## Cache root working directory and POSIX follow-up (`06ef06c61`, `fd4428601`, `1e0854488`) Answers the two follow-up review findings and runs the POSIX lanes the Windows host could not run. - The store replaces a working directory by moving the entry it observed aside and comparing that entry's inode before removing it. A directory another session created after the observation is put back, or left beside the root when the name was taken again, so a repair never recursively removes a live staging copy or a publisher's lock. - The non-root POSIX repair-failure lane now makes the replacement read-only, which blocks the move the rollback performs instead of relying on removing a read-only child. Cleanup runs in a `finally` block, so a failed assertion no longer leaves a read-only tree behind. - The repair-lock takeover lane waits for the lock's owner token before handing the lock over; the file exists before its token is written, so the old wait could overwrite the repair's token and let the repair run to completion. The published-project lane pins its observation to one root inode, so the repair's quarantine of the root no longer looks like a zero-file publication. Validation on Linux, head `1e0854488` (the two POSIX-only lanes now run): - `npx tsc --noEmit` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass. - `npx vitest run src/ghidra/projectCache` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 76 passed, no skips; `GhidraProjectCacheRoot.test.ts` passed 10 consecutive runs. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 827 passed, 1 skipped. - `npx vitest run tests/boundary/mcp/sessionMcp.test.ts tests/boundary/mcp/optionalProviderIsolation.test.ts tests/composition/directAnalysisFactories.test.ts tests/composition/directAnalysisXrefs.test.ts` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 46 passed. - `npx vitest run tests/acceptance/cacheVerifyCli.test.ts tests/acceptance/cachePruneCli.test.ts` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 4 passed. - `npx oxfmt --check` and `npx oxlint` on `src/ghidra/projectCache` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass (0 errors, the repository-wide warnings only). - `npm run verify:module-boundaries` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass. - `npm run build:cached` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass; the acceptance lanes above ran against the build. The merge at `1e0854488` also resolves the conflicts with `main`: `src/application/DirectAnalysis.ts` keeps main's native dispatch profile binding, and `src/server/registerSessionStatusTool.ts` keeps main's `probeSessionAvailability` with this branch's `target_id` and `wait_ms` status arguments. The two POSIX-only lanes are now covered on Linux, so the earlier "not re-run here" note for them no longer applies. ## Lease and staging follow-up (`a90a17584`, `aee75d863`, `89e9685f5`) Answers the two cache findings from the latest automated review pass. - A lease is written beside its final name and renamed into place. The rename replaces the name atomically and never follows a link at the destination, so a concurrent remove or prune never sees the lease absent while a hit is using it, and a link planted at `leases/<key>.lease` before the root was tightened is replaced instead of followed. Regression: a hit with a linked lease leaves the link target unchanged and the lease a regular file. - Each client receives its own staging path even when two clients share a profile digest, so a failed publish in one client cannot discard another client's copy. Regression: two clients for one shared profile receive distinct staging slots. Validation on Linux, head `89e9685f5`: - `npx tsc --noEmit` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass. - `npx vitest run src/ghidra` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 829 passed, 1 skipped. - `npx vitest run src/ghidra/projectCache` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 77 passed. - `npx vitest run src/ghidra/projectCache/GhidraProjectStore.test.ts src/ghidra/GhidraProvider.behavior.test.ts` ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó 172 passed. - `npx oxfmt --check` and `npx oxlint` on the changed files ┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó┬┤ÔöÉ┬ó pass (0 errors, the repository-wide warnings only). The latest automated review also raised findings in the multi-target session and background-analysis work that this branch now carries (`src/application/binary/BinaryTargetRegistry.ts`, `src/application/binary/BinarySession.ts`, and `src/server/registerSessionTools.ts`). Those are outside the cache change and are left to that work's owners. ## Multi-target session follow-up (`4ec21a489`) Answers the two review findings on the multi-target session work that this branch carries (`src/application/binary/BinaryTargetRegistry.ts` and `src/application/binary/BinarySession.ts`). - A forced close that could not release the provider keeps the start permit that the retained cleanup owns, and the cleanup is retried inside the open. With `REA_MAX_CONCURRENT_ANALYSES=1` the open therefore waited for a permit that only the open itself could give back, so a replacement open hung until another request retried the cleanup explicitly. The registry now retries the retained cleanup before it takes a new start permit; a successful retry gives the permit back and stops reporting the slot as retained. One retry runs at a time, so two overlapping opens do not close the same provider twice. - Every ordinary request raced the shared retirement promise of its epoch. `Promise.race` leaves its reaction attached to that promise, so every request that settled through the transition left a retained reaction behind until the epoch was retired, which a long-lived session never does. The retirement is now a removable listener: a request detaches itself when it settles or is aborted, and the epoch keeps nothing for it. - The client a cleanup closed is marked as released where the close happens, so a provider that a retry released is not closed a second time when the start that never unwound finally unwinds. The new regression tests fail against the previous implementation: the replacement open never started a second provider, the epoch kept a waiter for a request that had already settled, and the released provider was closed twice. Validation on this Windows host: - `npx tsc --noEmit` ├ö├ç├ pass. - `npx vitest run src/application/binary` ├ö├ç├ 197 passed. - `npx vitest run --project mcp-boundary` ├ö├ç├ 967 passed; the 16 failures are the pre-existing POSIX-only failures this host already had without the change. - `npm run test:fast` ├ö├ç├ 12,210 passed; the 70 failures are the pre-existing POSIX/macOS-only suites on this host (Hopper, ADB, WABT, Mach-O, Electron), and no binary or session suite failed. - `npx oxfmt --check` and `npx oxlint` on the changed files ├ö├ç├ pass (0 errors, the repository-wide warnings only). - `npm run verify:module-boundaries`; `npm run verify:architecture-guards`; `npm run knip`; `npm run scan:todos` ├ö├ç├ pass. Not re-run here: the POSIX-only cleanup lanes of the cache suites and the native artifact build lane; they are unchanged by this commit. ## Cancelled and closed-slot follow-up (`794e3050d`) Answers the two review findings on the failed-start cleanup that the last commit introduced. - An open that was cancelled while it waited for a start permit skipped the cleanup of its failed start. The admission reported the failure it could not acquire the permit for without running that cleanup, and the open assumed the cleanup had already run: the client the open created was never closed, and the target the open had replaced was closed and never restored. The session owns the cleanup of its failed start now. It runs once: the admission runs it under the shared start limit, and a failure the admission reported without running it runs it in the open. The admission runs the cleanup for a start it could not attempt as well, so the limit still counts a provider the cleanup could not release, and the slot that owns that provider stays registered as retained. The health probe of a client that starts the provider it selected runs inside the admitted start now, so a probe that fails runs the cleanup of the failed open under the limit that counts the provider the probe left behind. The probe of a composed client answers without touching a child, so the limit is not held for a provider start that never ran. - The cleanup of a failed start restored the target the open replaced before the open checked whether it had been retired. A forced close that released the start while it was in flight therefore left the closed slot holding a target again, and a target that opened in that slot afterwards could have its binding overwritten. The cleanup restores the target only while the slot still owns it. The lifecycle generation of the open is checked before the restoration and again after it waited for the restored client, so a slot that was closed while the restoration waited releases the restored client instead of binding it. The new regression tests fail against the previous implementation: the client of a cancelled open was never closed, the target it replaced was never restored, and a closed slot held the target it had before the failed open. Validation on this Windows host: - `npx tsc --noEmit` - pass. - `npx vitest run src/application/binary src/server tests/composition/analysis-sessions` - 434 passed. - `npx vitest run tests/boundary/mcp` - the 16 failures are the pre-existing POSIX-only failures this host already had without the change. - `npx oxfmt --check` and `npx oxlint` on the changed files - pass (0 errors, the repository-wide warnings only). - `npm run check:fast`; `npm run verify:module-boundaries`; `npm run verify:architecture-guards`; `npm run scan:todos`; `npm run knip`; `node scripts/check-doc-facts.mjs` - pass. ## Restored start limit follow-up (`1cd961008`) Answers the review finding that a restored target starts its provider outside the shared start limit. - A cancelled replacement waits for a start permit, and the admission runs the cleanup of that failed open, which restores the target the open replaced. The restoration created its client without the start admission, so the provider start that client defers ran outside the shared limit: a query on the restored target launched an import beside a provider that was still starting, and the analysis cap that bounds the concurrent analyses was exceeded. The session keeps the admission the open carries and hands it to the restored client, so the restored target starts its provider under the same limit as every other start of the session. The restoration does not start the provider under that admission: the health probe of a composed client answers without touching a child, and a probe that waited for the limit would make a cancelled open wait for a permit it cannot use. The new regression test fails against the previous implementation: the query on the restored target did not run under the admission. Validation on this Windows host: - `npx tsc --noEmit` - pass. - `npx vitest run src/application/binary` - 230 passed. - `npx vitest run src/application/binary src/server tests/composition/analysis-sessions` - 435 passed. - `npx vitest run src/ghidra` - 801 passed, 35 skipped. - `npx vitest run --project mcp-boundary` - the failing suites are the POSIX-only and the WABT/IDA suites this Windows host cannot run; the same suites fail without the change. - `npx oxfmt --check` and `npx oxlint` on the changed files - pass (0 errors, the repository-wide warnings only). - `npm run check:fast`; `npm run verify:module-boundaries`; `npm run verify:architecture-guards`; `npm run scan:todos`; `npm run knip`; `node scripts/check-doc-facts.mjs` - pass. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#5807
No description provided.