chore(experiment): refresh main before storage-description exploration

Co-authored-by: gensokyo-dev <[email protected]>
Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
committed 2026-10-16 20:55:19 +00:00
commit bfe439d990
55 files changed
+15596 -661

No files matched your search

+4 -2
View File
@@ -153,8 +153,10 @@ and during shutdown.
- **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and
`uninstall_adb_package` declare `mutatesTarget` and never run implicitly:
pushes check for existing device files before transfer and refuse them
unless `overwrite` is set. The check is not atomic, so a different process
can create the path before `adb push` writes it. When available, the device's
unless `overwrite` is set. A failed or unexpected existence probe stops
transfer even with `overwrite`; only a clean `test -e` exit of 1 with no
output is interpreted as absence. The check is not atomic, so a different
process can create the path before `adb push` writes it. When available, the device's
`sha256sum` verifies the transfer; installs preserve the
device's `Failure [...]` reason verbatim.
- **Producer identity stays attached.** Every result embeds the producing
+40
View File
@@ -127,6 +127,46 @@ If cleanup cannot be verified, the error retains collected observations in
`details.partial_observation` and marks their application cleanup `unverified`.
Cleanup uncertainty does not authorize signaling unrelated processes.
Main-process IPC recording keeps original-function removal for `on` and `once`
registrations and records a fired `once` callback once. Native Node.js owns
removal order and traversal, including listeners registered by removal observers;
the hook translates only its own recording wrappers at the removal boundary.
Wrapper metadata uses weak keys and does not retain removed registrations.
Wrapper metadata operations, private collection methods and traversal constructors are captured
before target startup; later replacement of their global bindings does not
change listener registration or removal.
An `on` listener's application-defined `.apply` receives the original argument-list
object unchanged. Borrowing the exposed method preserves its dynamic receiver.
Because that method owns argument interpretation and may not
invoke the callback, the hook records a bounded `ipc-observation-gap` timeline
entry with the registered channel rather than an IPC invocation. Its empty
`argument_shapes` means unobserved arguments, not a zero-argument callback;
callback execution and result remain unknown. Coverage and retention include
these gaps even when their individual records are dropped. Normal native
dispatch continues to record actual invocation frames. Evidence snapshots copy
retained arrays and counters without invoking application iterators or Array
species.
Listener-chain recognition distinguishes owned, unowned and unknown metadata.
Data links may be inherited; absence is established only after completing the
bounded prototype lookup. Exhausting either walk, encountering a getter or seeing a Proxy leaves
ownership unknown. The hook preserves native registration without adding another
recording wrapper and retains a `listener-chain` observation gap after successful
registration. This gap does not prove IPC delivery. An existing inner recording
wrapper can still report its actual invocation; otherwise callback activity
remains unobserved. Setter-only properties have a known undefined read.
Data-descriptor parsing is shared with removal normalization.
This does not establish complete JavaScript function introspection equivalence.
Caller-defined `.listener` properties can change the identity exposed by
`listeners()` and meta-events; accessors and Proxy metadata are not followed for
wrapper recognition. Re-registering a raw recording wrapper on another channel
retains the original registration's channel attribution.
Mutating `.listener` on a raw recording wrapper can also prevent removal through
application decorators; the reproduced identity conflict is tracked in
[#4513](https://github.com/morluto/rea/issues/4513).
### Target fuse prerequisites
Active scenario capture requires that the target Electron executable honors Node CLI inspector flags (`--inspect` or `--remote-debugging-port`) so that Playwright can establish its DevTools protocol session.
+7
View File
@@ -145,6 +145,13 @@ New attempts wait for that cleanup; failed cleanup retains its original error
and owned directories until a successful close. Closing the provider reports a
provider-state failure to live callers rather than cancelling their signals.
Configured analysis extensions and seeds use owned file reads during profile
resolution and launch snapshots. A changed read extent or descriptor state
refuses the commitment. Failed source or snapshot descriptor closes remain
owned and block new admission until cleanup succeeds; the physical failure
and exact cancellation reason remain available. Provider shutdown joins all
profile stages before retrying their retained descriptors.
| Concern | Ghidra contract |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. |
+8
View File
@@ -132,6 +132,14 @@ providers need not expose identical state machines.
while it remains unresolved. Link the originating cleanup error to its exact
owner so confirmed release resolves its cleanup marker while preserving the
earlier diagnostic. Shared CLI/MCP adapters cannot infer that confirmation.
- Provider profile selection owns every asynchronous candidate through its
terminal result and finalization. The registry owns profile-file cleanup,
joins all automatic-selection children, and closes those file owners before
reporting candidate shutdown. Final caller cancellation joins the selection;
an adapter that ignores cancellation remains active and owned until it settles.
Block new admission during shutdown and while cleanup remains unresolved;
reopen after confirmed retry. Preserve failed results before considering a
later signal, and retain every child's attributed cleanup in mixed outcomes.
- Release child descriptors before removing their containing root. Retain a
composite owner when either stage fails so retry preserves that ordering.
A Windows command leader's exit does not establish Job Object closure. Keep
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,592 @@
# Electron IPC listener verification, 2026-10-16
[#4390](https://github.com/morluto/rea/issues/4390), existing
[#4389](https://github.com/morluto/rea/pull/4389), umbrella
[#4027](https://github.com/morluto/rea/issues/4027).
All 73 IPC boundary regressions pass, alongside shared projection/actions and
converted MCP schema checks. Real draw.io operation phases succeed.
Every real capture still **fails overall provider cleanup**. These are distinct
results; neither completed-task acceleration nor token savings is established.
## Producing boundary and regressions
The existing PR `efd452948` was integrated with main `180ba03bf` in `5c3b02468`.
It fixes removal through the caller's original function and duplicate recording
of `once` invocations. Independent review found two further producing-boundary
problems: wrapper recognition evaluated caller `.listener` getters, and a copied
`removeAllListeners` snapshot loop differed from native live-array traversal when
removal observers prepended a listener.
Both new differential cases failed on that implementation (14 passed, two
failed). Fix `3c330f259` delegates traversal to Node, translating only owned `on`
wrappers at `removeListener`, and preserves the `off` alias. Descriptor reads
avoid evaluating getters. A further Proxy case exposed that descriptor reflection
can itself run a trap (16 passed, one failed); `2fb7f5523` uses Node's Proxy
identity first and removes the superseded catch. Those initial **17 cases** pass;
the later decorator finding and final 23-case validation are recorded below.
The native EventEmitter controls are semantic regressions, not real-app proof.
The change uses weak wrapper keys, removes the copied traversal and its snapshot
allocation, and adds no tool, cache, schema or normal reply field. Related
compiled provider/service modules and catalog metadata were byte-identical to
baseline main `180ba03bf` at the initial source freeze. This does not prove
arbitrary function introspection equivalence: custom
`.listener` properties and raw wrapper re-registration retain the limitations
documented in [Electron observation](../electron-observation.md).
## Unchanged real application and observed scenario
Official [draw.io Desktop 32.4.1](https://github.com/jgraph/drawio-desktop/releases/tag/v32.4.1)
AppImage SHA-256 is
`c687a9f1ddcaa146b78547452d264c03c2afcf125639cba5deeb65f3e9554c9e`;
both the release asset digest and published checksum file match. Its unchanged
62,941,673-byte ASAR is
`e64b9650c0b8241ca9a7d33b56386a385bceee63270cec701d3a467347b697e6`.
Complete accompanying resources were extracted; no application source was edited.
Public fuse inspection reports the official executable's Inspector fuse disabled.
No vendor Inspector attempt was made. The following trials explicitly select
generic Electron **44.7.0**, Xvfb and fresh profiles; they establish no packaged
runtime compatibility.
The real renderer initially displays an **Invalid file data** dialog. A public
CLI startup smoke and independent DOM/argv probes preserve this failure, tracked
separately in [#4494](https://github.com/morluto/rea/issues/4494). Its exact cause
is not proven. Two attempts to click a palette item without handling the dialog
timed out and were cancelled; they remain failed diagnostics. An initial helper
also incorrectly queried `application.process()` after transport closure; the
corrected helper retains the original ChildProcess handle instead.
The final scenario uses declared REA clicks to dismiss that actual error and
insert a visible palette item, then waits for rendering. Independent DOM reads
confirm SVG elements **0 → 2**, disappearance of the error and the real unsaved
changes indicator. The verifier exercises the real app callbacks and channels;
the production hook wraps their registration as described above.
The source also registers `app-load-finished` through both `once` and `on`;
recording its once callback twice adds a spurious event.
## Initial fixed-source ABBA observations
Verifier/source revision is `d02cc934c`; its hashes were recorded before all four
trials. Baseline is main `180ba03bf`. The same script, runtime, artifact and
declared actions run in the order baseline, candidate, candidate, baseline.
| Variant/run | IPC events | `app-load-finished` | SVG change | Pipeline wall s | Family CPU s | Peak cgroup bytes |
| ----------- | ---------- | ------------------- | ---------- | --------------- | ------------ | ----------------- |
| Baseline 1 | 8 | 3 | 0 → 2 | 11.084 | 6.270053 | 560,615,424 |
| Candidate 1 | 7 | 2 | 0 → 2 | 11.135 | 6.395461 | 550,383,616 |
| Candidate 2 | 7 | 2 | 0 → 2 | 11.898 | 6.907123 | 525,631,488 |
| Baseline 2 | 8 | 3 | 0 → 2 | 11.469 | 6.526674 | 558,456,832 |
Every declared action completed, every insertion oracle passed, and no verifier
deadline fired. Every provider returned `cleanup_failed`; the captures remain
partial/failed, including possible dirty-editor teardown and the known Linux
ownership gap [#4282](https://github.com/morluto/rea/issues/4282). Baseline 2 also
reports Xvfb temporary-directory cleanup failure (payload exit 5). Its directory
identity was not captured; no broad path-based deletion was attempted.
Runs are serial on CPU 10 with one-CPU quota, 8 GiB hard memory, 6 GiB early
stop, no swap and 192 tasks. Counters cover the cgroup family at payload exit
while the observer remains alive; they are **not final whole-scope counters**.
Memory includes file cache, not RSS. Some scopes still contain Xvfb at that
point; all recorded scopes were later independently confirmed inactive/dead.
External recovery does not establish provider cleanup.
OS/build caches are uncontrolled; these are first/repeat fresh-process runs,
not verified cold/warm system pairs. Two samples per variant and a cleanup-bound
failed workflow cannot establish a speedup. Token billing, peak agent context,
complete schema/call costs and actual MCP-wire parity are unmeasured. Fewer
events alone do not establish lower token cost.
Full static checks passed (two tasks, no cache hits): 89.441 s wall,
88.630288 family CPU s and 4,337,172,480 peak cgroup bytes, with no OOM.
Documentation checks also passed (6 tasks plus five README consistency cases).
The [selected JSON record](electron-ipc-listeners-2026-10-16.json) retains exact
source/input identities, failed attempts, coverage, IPC and resource receipts.
Historical diagnostic helper hashes were not captured and remain explicitly
unknown; the frozen hash is not assigned to them retrospectively.
## Reproduction
Build both pinned checkouts. Under the existing resource observer and a private
Xvfb instance, use the committed helper for each runtime root in ABBA order:
```sh
node experiments/analysis-efficiency/drawio-electron-lifecycle.mjs \
--runtime-root /absolute/pinned-checkout \
--executable /absolute/electron-44.7.0/electron \
--application /absolute/drawio/resources/app.asar \
--profile-root /absolute/temporary-profiles --dismiss-error \
--selector '.geSidebar a.geItem:visible >> nth=0'
```
The helper returns failure on incomplete provider cleanup. This shared-service
probe does not establish successful public CLI/MCP workflows. App images,
extractions/profiles and full Result files are regenerable and excluded from Git.
## Review follow-up: normalization before app decorators
Fresh review of `f3bb9b9ed` found that translation inside a patched
`removeListener` happened after an application's outer decorator had already
seen the recording wrapper. Two native controls reproduced this with stable and
replaced decorators (17 passed, two failed).
`c8c494b5c` delegates the original Node traversal through a private forwarding
receiver. It shares the real emitter's live state and translates owned `on`
entries **before** invoking the current application removal method. App methods
and getters receive the real emitter; read-only method properties remain valid.
Native once entries pass unchanged. The public `removeListener`/`off` patches
are removed. Five decorator variants pass: stable, replaced, read-only, getter
and Proxy.
An additional mutation case proved that reading a changed public `.listener`
getter before delegation also changed deletion timing (22 passed, one failed).
Final source `5a094a0df` retains canonical removal targets in the weak owner
registry. Only unchanged data links are translated; changed getters run when
Node performs removal, preserving the exception and state transition. All
**23 cases** pass, including the original retention/fatal-error coverage.
The same frozen draw.io verifier was reused. The follow-up ABBA attempt ran
its first baseline, then the resource launcher refused the other three requests
because 8 GiB plus its 4 GiB host reserve was unavailable. Those are **not
executed**, with no performance measurements. The final source was separately
admitted and completed dialog recovery and insertion with seven IPC events;
overall cleanup still failed, and Xvfb teardown also returned an error. No new
completed-task speedup or cold/warm pair is claimed.
Final static checks passed: two uncached tasks, 135.173 s wall, 133.786166 family
CPU s and 4,274,634,752 peak cgroup bytes, no OOM. The JSON record appends exact
source pins, denial logs, final real-app/public cleanup diagnostics and checks
under `review_followup`; the initial records were not overwritten.
## Callback invocation and remaining raw-metadata limitation
Source `514b73b78` invokes `handle` callbacks through captured `Reflect.apply`,
preserving the receiver and actual arguments without reading an app-defined
`.call` property. The added direct-call control and hook case pass; all **24
IPC regressions** pass (3.661 s wall, 3.404 family CPU s, 318,291,968 peak cgroup
bytes, no OOM). This is a Node boundary check, not a real Electron handle claim.
An initial three-case experiment incorrectly assumed native EventEmitter avoided
callable method-property reads. Its `on` and `once` controls themselves failed;
they do not demonstrate hook regressions. The attempted intrinsic event-call
change was reverted and those invalid cases removed. Both failed runs remain
in `callable_followup`; no successful result is assigned to them.
The same unchanged draw.io verifier at this source completes actual dialog
recovery and shape insertion, with seven IPC events, SVG elements 0 → 2 and an
unsaved-changes indicator. Overall provider cleanup still fails. Pipeline wall
time is 12.610 s, family CPU 7.062834 s and peak cgroup memory 634,187,776 bytes;
there is no paired performance or token claim. All recorded scopes were later
independently inactive/dead, which does not establish provider cleanup.
Fresh review also found a distinct remaining metadata case: changing a raw
recording wrapper's writable `.listener` property can make an app decorator
reject removal. A production-hook/native-Node probe reproduces this (plain:
removed; hook: decorator throws and one listener remains). Exact reproduction
and the ownership conflict are tracked in [#4513](https://github.com/morluto/rea/issues/4513).
No affected real app has been identified. Removing the descriptor guard alone
does not make native removal match the mutated wrapper; temporarily rewriting
public app metadata would add further visible mutations. This limitation is
explicit in the observation guide and is not represented as fixed by this PR.
Current-source static checks also pass: two uncached tasks, 92.912 s wall,
92.031383 family CPU s and 4,340,375,552 peak cgroup bytes, no OOM.
## Native single/array dispatch follow-up
Fresh review of `6fc2d2769` identified that the earlier native controls covered
only one listener. Node 24.18.0 reads `.apply` during single-listener dispatch,
but invokes each callback intrinsically during array dispatch. Native `once`
uses `.call` for zero arguments and `.apply` otherwise. Unconditional `.apply`
in the observation helper changed two of those paths: a multi-listener callback
could throw before later callbacks ran, and zero-argument once read the wrong
property. Four explicit native controls reproduce two failures (26 passed,
two failed), rather than assuming all callable-property reads should disappear.
Source `e8d7e8f20` keeps one observation boundary. An `on` recording wrapper
delegates direct calls intrinsically and forwards native `.apply` calls through
its own nonenumerable method. Node selects the route; the hook does not inspect
or copy listener storage. `once` preserves its native zero/nonzero-argument
invocation. All **28 cases** pass, including the prior callback identity,
retention, reentrant-removal and fatal-error coverage. `handle` remains intrinsic.
The unchanged real draw.io verifier again completes dialog recovery and shape
insertion, with seven IPC events, SVG elements 0 → 2 and the unsaved indicator.
Overall provider cleanup still fails; no deadline fires. Its 12.290 s pipeline
run is a validation observation, not a paired performance comparison. Exact
source hashes, failures, resource measurements and post-scope states are appended
under `native_dispatch_followup`; all previous records remain unchanged.
The separate raw metadata limitation [#4513](https://github.com/morluto/rea/issues/4513)
remains open and is not represented as fixed.
Final-source static checks pass: two uncached tasks, 171.430 s wall,
169.139239 family CPU s and 4,091,392,000 peak cgroup bytes, no OOM.
## Argument-list ownership follow-up
Review of `23700fd2c` found two related representation errors. A raw callback's
`.apply` accepts Arguments objects, other array-like objects, null or omitted
argument lists; the recording helper assumed an Array. Native once also passes
its actual Arguments object to a callback-defined `.apply`, whereas the hook
passed a rest Array. Seven explicit native controls reproduce six failures
(29 passed, six failed of 35).
Source `a82d935ba` binds captured native Function apply to the method-forwarding
function. JavaScript itself converts argument lists, treats null/undefined as
empty and rejects invalid primitives before recording reads values. The
array-like getter control is read exactly once. Once retains its actual
Arguments object for invocation; recording alone takes an array slice. The
captured slice helper is bound once, avoiding an extra helper-argument array
on each invocation. No custom array-like parser or exception repair is added.
All **35 cases** pass. The same frozen real draw.io verifier again completes
dialog recovery and shape insertion with seven IPC events; no deadline fires,
and overall provider cleanup still fails. Exact source identities, both failed
boundaries, intermediate/final tests, resource costs and post-scope states are
appended under `argument_list_followup`; previous records remain unchanged.
Current-source static checks pass: two uncached tasks, 122.454 s wall,
121.287057 family CPU s and 4,185,915,392 peak cgroup bytes, no OOM.
The unchanged ASAR digest was checked again after the real-app trial.
## Owned metadata and successful-Evidence verifier follow-up
Fresh review of `41a513d99` found that `on` wrapper metadata used strict
assignment. Native on does not write such metadata, so inherited Function or
Object prototype setters/read-only fields could prevent registration only under
capture. Four native controls fail before `5dcf79f29` (35 passed, four failed).
The recording wrapper now defines its own data property directly; native once
assignment behavior is retained. All **39 cases** pass.
The verifier also incorrectly treated a successful Evidence envelope as a raw
capture. Real cleanup failures had exercised only the separate partial path.
`4a9a01f7a` reads successful `value.normalized_result` and preserves failed
`error.partialObservation.capture`. The new
`experiments/analysis-efficiency/drawio-electron-lifecycle-oracles.py` exercises
the actual verifier with controlled success, partial cleanup, action failure,
insertion failure and absent capture. The old verifier rejects valid successful
Evidence before producing a summary; all five corrected controls pass.
**These are controlled reply/exit checks, not real-provider evidence.**
The corrected verifier was separately frozen before a real unchanged draw.io
trial. Actual dialog recovery and shape insertion again complete with seven IPC
events, no deadline and failed overall provider cleanup (pipeline 12.777 s).
Success with real provider cleanup remains unverified. Earlier trials retain
their earlier verifier hashes; the corrected hash is not assigned to them.
Exact source pins, failed controls, resource costs and post-scope states are
appended under `owned_metadata_and_verifier_followup` without replacing history.
Final-source static checks pass: two uncached tasks, 99.992 s wall,
98.956939 family CPU s and 4,237,795,328 peak cgroup bytes, no OOM.
## Species-free recording and snapshot ownership
Fresh review of `e2cf2d89b` found that slicing a rest Array invokes application
`Array[Symbol.species]` before dispatch. The first control reproduced failed on
dispatch (40 passed, one failed); once dispatch already used Arguments.
Extending the check to retrieve the retained snapshot while the throwing getter
remains installed exposed the same representation problem in snapshot maps
(39 passed, two failed).
Source `3141ac3f4` records from actual invocation frames while preserving native
callback argument-list types: the method-based on path still passes an Array,
and once passes Arguments. Group validation uses the existing recording loops
without a filtered intermediate Array. Captured `Array.from` produces snapshots
without species lookup, reading dropped-counter Maps directly rather than
spreading them into another Array first. The initial **41 cases** pass, including both
live snapshots under the throwing getter with zero getter reads.
The frozen-source real unchanged draw.io operation again completes recovery and
insertion with seven IPC events, no deadline and failed overall cleanup
(pipeline 12.779 s). This does not establish that draw.io customizes species.
The corrected verifier and its five previously passing controlled oracles are
byte-identical to `4a9a01f7a`, so those unchanged oracle sources were reused.
Exact hashes, failures, resource receipts and scope states are appended under
`array_species_followup`; earlier records remain unchanged. There is no new
completed-task performance or token/context claim.
The native multiple-listener route was then included in the same live-species
control: all **42 cases** pass. Only test coverage expanded; production hook
and verifier bytes remain identical to `3141ac3f4`. Exact validation identities
and the additional result are recorded under `all_native_dispatch_paths`.
A fresh-profile real negative control omits `--selector` while retaining dialog
recovery and the same 500 ms wait. SVG elements remain **0 → 0** and unsaved
changes remain false; insertion gives **0 → 2** and unsaved true. This supports
the operation oracle rather than attributing startup rendering to the click.
The control also returns seven IPC events and failed provider cleanup. These
are different tasks, not a performance pair or a model-cell-count proof.
Final 42-case source validation includes two uncached static tasks passing in
94.234 s wall, 93.308800 family CPU s and 4,406,538,240 peak cgroup bytes,
with no OOM. These are verification costs, not an application speedup.
## Opaque apply inputs and iterator-free snapshots
Fresh review of `84d9cf5db` found two producing-boundary regressions: native
array-like conversion replaced the argument-list object before a custom
listener `.apply` received it, and `Array.from` snapshots consulted application
iterators on retained arrays/Maps. Four exact-input native controls and two
snapshot controls fail before correction (42 passed, six failed of 48).
Source `e442ca27f` forwards custom `.apply` the original receiver and method
arguments without interpreting its opaque list. Custom apply may transform
arguments or skip callback execution, so the hook emits a bounded
`ipc-observation-gap` timeline record with the known registration channel. It
does not fabricate a main-event invocation, argument shapes or callback result.
The shared result projection retains explicit uncertainty and counts dropped
gaps through existing retention. The canonical timeline enum adds that kind;
the captured-IPC subset does not. Native apply still performs its own conversion
before an actual callback frame is observed.
Snapshots now copy arrays through private null-prototype indexed views and
counters through captured native Map traversal. Neither retained buffers nor
counter Maps are exposed to application iterators; returned records and shape
arrays remain independent copies. No cache, tool or normal-dispatch metadata
is added. The raw-wrapper metadata limitation in #4513 remains unfixed.
The focused hook/projection/actions gate passes **68 cases** (48 + 4 + 16).
The separate MCP/schema gate passes **23 cases**: an actual SDK client receives
the gap alongside ordinary IPC Evidence, and Ajv2020 validates the advertised
converted output; all canonical tool schemas also pass their existing checks.
These tests use controlled providers and establish no real-provider success.
The verifier and its five controlled exit/summary oracles are hash-identical to
the earlier passing source and are reused without another redundant run.
## Unrelated CI baseline failure
CI run `38935598884` tested synthetic merge `eedae92d406015895a6d0eced5035da1b9ead07a`
(head `84d9cf5db`, base `9d940c2f2`). Two firmware cleanup tests failed; another
shard was cancelled by fail-fast. A fresh, unmodified detached checkout of that
exact base reproduces the same failures: **33 passed, two failed** in
`tests/boundary/firmware/firmwareIntegration.test.ts`. Evidence is posted on
[the PR](https://github.com/morluto/rea/pull/4389#issuecomment-6221054273) and
[existing #4475](https://github.com/morluto/rea/issues/4475#issuecomment-6221054775).
This establishes that these failures predate the Electron changes; it does not
resolve whether the firmware producer or its expectations need correction. No
firmware code or assertions are changed here, and required CI is not bypassed.
The first final-source real request fails before application launch because the
previous `/tmp` runtime is absent. It is retained as an input-resolution failure,
not a provider measurement. The cached Electron 44.7.0 archive is verified against
the official release SHASUMS256 and extracted into a new owned task directory;
its executable/archive digests are pinned before the next request. No target
source, vendor fuse or scenario success requirement is changed.
The recovered-runtime real operation completes all three declared actions:
SVG elements **0 → 2**, unsaved changes true, seven IPC events, no deadline.
Overall provider cleanup still fails. Current-source static validation passes
two uncached tasks in **94.646 s wall, 93.878644 family CPU s and 4,300,570,624
peak cgroup bytes**, no OOM. The initial static attempt's test-only SDK/Ajv type
error is retained; parsing that boundary fixes it, and 12 affected client and
projection cases pass again. `opaque_apply_and_iterator_followup` preserves all
source pins, failures, resource receipts and independently checked scope states
without replacing earlier measurements. Draw.io does not establish that real
applications exercise the synthetic custom-apply/iterator cases.
The final documentation gate also passes six tasks and five README consistency
cases. Source and unchanged ASAR hashes are checked again after validation.
## Borrowed apply receivers
Fresh review of `7541a900c` supplied the borrowed custom-method case
`raw.apply.call(g, emitter, list)`: the plain emitter runs `g`, while the hook
hard-coded its registered callback as the method receiver. The same hard-coded
receiver also affected borrowed native apply. Both native differential controls
fail before correction (48 passed, two failed of 50).
Source `da04465fe` maps only the hook wrapper itself to the original listener
when forwarding custom apply; other method receivers pass unchanged. Native
apply uses its actual receiver, preserving both ordinary wrapper recording and
borrowed-call dispatch. The hook does not fabricate a registered callback
invocation when another callable is selected. The method-forwarding boundary is
corrected directly; no new helper, state, tool or schema is added. All **70**
hook/projection/actions cases pass (50 + 4 + 16). Shared schema/client, projection
and verifier/oracle sources are byte-identical to the prior passing versions,
so their checks are reused by verified hash.
CI run `38943919946` is confirmed by the successful build checkout log and Git
parents to use merge `98c14dfc01fe4698a50eedc8b1cd1ac9cd03267d`
(head `7541a900c`, main `4838f4449`). Its fifth shard fails the same two firmware
cleanup tests as the unmodified `9d940c2f2` base control. The relevant firmware
source/test and package paths have no changes between these main revisions.
The REST PR `base.sha` remains stale at `2c7583599`; it is not used as the CI
identity. No firmware code/assertion or required-check bypass is included.
The frozen-source unchanged draw.io operation completes recovery/insertion
again with seven IPC events, SVG **0 → 2**, unsaved changes true and failed
overall provider cleanup. Pipeline: **12.038 s wall, 6.662295 family CPU s,
598,835,200 peak cgroup bytes**. This remains a partial workflow, not a speedup
claim. Final static checks pass two uncached tasks in **108.067 s wall,
106.996233 family CPU s and 4,430,647,296 peak cgroup bytes**, no OOM.
All prior measurements remain unchanged under `borrowed_apply_followup`.
The final documentation gate also passes six tasks and five README consistency
cases. Current source digests are checked again after validation.
## Listener-owner intrinsic lifetime
Fresh review of `f5b489c1b` found that late application replacement of
`Object.defineProperty` prevents the hook's new wrapper properties from being
created, while a plain native emitter still registers/invokes its callbacks.
Inspection of this same new registration/removal owner found its other late
property/constructor lookups. Seven independent native controls replace
`Object.defineProperty`, `Object.getOwnPropertyDescriptor`, `Object.create`,
`Reflect.get`, `Reflect.set`, global `Set` or global `Proxy`; each fails before
correction (50 passed, seven failed of 57). The controls include both on/once
execution and complete removal, with globals restored after the scenario.
Source `a9c5611d0` captures the owner's descriptor/property operations and private
constructors before application startup. The private receiver uses a direct
null-prototype object literal instead of looking up Object.create. All **77**
hook/projection/actions cases pass (57 + 4 + 16). Application-owned callback
methods and actual receivers retain their existing semantics. No tool, schema,
state, target authority or general-purpose hardening layer is introduced; this
is not complete JavaScript introspection equivalence. Unchanged schema/client,
projection and verifier/oracle checks are reused by verified source hashes.
The preceding CI run `38946503183` tests confirmed merge
`fcd7a9d92a2e64d6eb96d4fabfed7fc7f44b0d45` (head `f5b489c1b`, main `4838f4449`).
Its 36 jobs finish with 29 successes, the same two firmware failures causing
three failed jobs including summaries, and four fail-fast cancellations. The
fifth-shard log again names the two cases reproduced on untouched main. No
firmware code/assertions or required checks are altered.
Frozen-source real unchanged draw.io actions complete again with seven IPC
events, SVG **0 → 2**, unsaved changes true, no deadline and failed overall
provider cleanup. Pipeline wall is **11.429 s**. Observed payload-exit cgroup
counters are 6.441029 CPU s and 550,363,136 peak bytes, but the observer still
sees one additional scope descendant: **family resource observation is
unverified**, not a complete-family performance measurement. The scope is later
inactive/dead after external recovery; this does not verify provider cleanup.
No identity or cleanup cause is inferred from the remaining PID.
Final static checks pass two uncached tasks in **90.980 s wall, 90.119351 family
CPU s and 4,338,749,440 peak cgroup bytes**, no OOM. Source identities, all seven
failures and resource limitations are appended under `listener_intrinsics_followup`
without replacing any previous measurements.
The final docs gate passes six tasks and five README consistency cases; source
digests are checked again after validation.
## Private collection method lifetime
Fresh review of `e6c373d0` found that replacing `WeakMap.prototype.has` after
hook initialization still intercepts its private wrapper registry. Inspecting
the same owner found analogous get/set and visited-Set has/add lookups. All five
independent native controls fail before correction (57 passed, five failed of
62); plain EventEmitter registration, on/once calls and complete removal succeed.
Source `774ed6aa6` binds these private WeakMap/Set methods before application
startup and reuses captured Set membership for event-family classification.
One initialization-only binding helper replaces duplicated binding construction
for existing Map traversal/argument slicing too. It adds no helper argument
arrays per invocation, cache, schema, tool or target authority. Application-owned
callback methods/receivers remain dynamic. All **82** hook/projection/actions
cases pass (62 + 4 + 16). The previous seven global-binding controls also remain
green, and unchanged schema/client/projection/verifier/oracle checks are reused
by verified source hashes. Arbitrary introspection and #4513 remain unsupported.
Frozen-source unchanged draw.io actions complete again with seven IPC events,
SVG **0 → 2**, unsaved changes true and failed overall provider cleanup. Pipeline
wall **11.238 s**; observed payload-exit cgroup CPU **6.285321 s**, peak
**556,285,952 bytes**. Another scope descendant remains at payload exit, so complete-family resource observation is unverified. Later external scope teardown does not prove provider cleanup.
Final static checks pass two uncached tasks in **92.999 s wall, 92.227652 family
CPU s and 4,371,951,616 peak cgroup bytes**, no OOM. Preceding CI38949129899 checks
confirmed e6c3 + main4838, repeats the same two firmware failures and finishes
31 successful /3 failed jobs including summaries /2 fail-fast cancellations.
Sources, failures and resource limitations are appended under
`private_collection_methods_followup`; prior fields remain unchanged.
The final docs gate passes six tasks and five README consistency cases; source
digests are verified again afterward.
## Unknown listener-chain ownership
Fresh review of `0b4f2e81` found that the bounded Boolean chain test treats a
still-unexamined function as fresh when 17 app decorators hide an owned once
wrapper. Two emits then produce three invocation records for one actual once
callback. The same unknown-as-fresh error affects accessor/Proxy links. Four
native controls fail before correction (63 passed, four failed of 67); the
16-decorator boundary already passes. A deep ordinary on control confirms
missing ownership cannot justify fabricated captured IPC either.
Source `ef46b8f0b` classifies metadata as owned, unowned or unknown. Bounded-walk
exhaustion, accessor links and Proxies preserve native registration without
adding another recording wrapper. Successful unknown registrations retain a
bounded `listener-chain` gap; this is not proof of IPC delivery. An existing
inner wrapper may still report its actual once invocation. Shared native
data-descriptor parsing preserves accessor uncertainty at recognition/removal
and avoids inherited descriptor-value interpretation. The superseded Boolean
classification is removed.
The shared projection explains both gap boundaries and retention/coverage, and
an actual MCP SDK client receives custom-apply and listener-chain gaps alongside
ordinary IPC Evidence, validated by its converted advertised schema. All **96**
hook/projection/actions/MCP cases pass (67 + 5 + 16 + 8). A test-only lint length
failure is corrected by generating repeated fixture rows from one table without
relaxing rules; all **13** affected client/projection cases pass again. The
canonical schema and unchanged verifier/five controlled oracle sources retain
their earlier passing checks by verified hashes; the changed projection/client
are freshly tested rather than claimed unchanged.
Prior CI run `38951509761` uses confirmed merge
`fa090a6bde3a5dadeb71066824477292520fb43b` (0b4f + mainf31b231a). All **36 jobs
succeed**. The earlier unrelated firmware failures do not recur in this
combination; their cause is not inferred or claimed fixed here. Current-source
CI/review remain required. No firmware code/assertions were modified.
Frozen-source unchanged draw.io recovery/insertion again completes with seven
IPC events, SVG **0 → 2**, unsaved true, no deadline and failed overall provider
cleanup. Pipeline wall **11.163 s**; observed payload-exit cgroup CPU **6.203177 s**,
peak **547,418,112 bytes**. An extra scope descendant leaves complete-family
resource observation unverified; later inactive/dead scope after external
recovery does not establish provider cleanup.
Final static checks pass two uncached tasks in **88.383 s wall, 87.726306 family
CPU s and 4,399,632,384 peak cgroup bytes**, no OOM. All source identities, failed
controls, corrected fixture-only lint failure and resource caveats are appended
under `chain_ownership_followup`; historical fields are unchanged.
Final docs validation passes six tasks and five README consistency cases;
current source digests are verified again afterward.
## Inherited listener-link coverage
Fresh review of `ae2cd8d4` found that a missing own descriptor is not absence:
an app decorator can inherit its data link to an owned once wrapper. Native
removal follows that link and removes the decorator; the hook hid it under
another recorder, left a listener installed and produced three invocation
records for one callback. Five native inherited-link cases fail before correction
(68 passed, five failed of 73): data/getter/Proxy prototypes and prototype
depths16/17. A late Object.getPrototypeOf replacement control already passes.
Source `e22ce9aaf` completes bounded native prototype lookup at the descriptor
owner, capturing getPrototypeOf before startup. Data links retain their actual
source value. A setter-only property has a known undefined read that shadows
ancestors; a getter, Proxy or incomplete walk remains unknown. Missing metadata becomes absent only after the prototype chain ends; found
properties retain native read/shadowing semantics. Registration and removal consume
the same parsed descriptor, and redundant outer Proxy interpretation is removed.
Neither existing resource bound is raised. All **102** hook/projection/actions/
MCP cases pass (73 + 5 + 16 + 8). Projection/client are freshly tested unchanged
sources; canonical schema and verifier/oracles retain prior checks by hash.
Prior CI run38955237255 tests confirmed merge9c0ceb152c902c1339806608f8b4a010c3d22fc6
(ae2c + main8116dc06). All36 jobs succeed; current-head CI/review remain required.
Frozen-source unchanged draw.io recovery/insertion again completes with seven
IPC events, SVG **0 → 2**, unsaved true and failed overall provider cleanup.
Pipeline: **11.264 s wall /6.376574 family CPU s /557,068,288 peak cgroup bytes**.
Payload-exit observation is verified at that phase while the observer lives;
these are not final whole-scope counters or provider cleanup proof.
Final static checks pass two uncached tasks in **90.797 s wall, 90.096799 family
CPU s and 4,335,267,840 peak cgroup bytes**, no OOM. All source identities, native
failures and current results are appended under `inherited_listener_links_followup`;
previous fields and earlier resource caveats remain unchanged.
Final docs validation passes six tasks and five README consistency cases;
current source digests are verified again afterward.
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Check verifier result/exit semantics with controlled replies, not real apps."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import textwrap
def write(root, name, source):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(source))
def check(arguments, root, case):
shutil.copyfile(arguments.verifier, root / "probe.mjs")
write(root, "package.json", '{"type":"module"}')
write(root, "node_modules/playwright-core/package.json",
'{"type":"module","exports":"./index.js"}')
write(root, "node_modules/playwright-core/index.js", """
let reads = 0;
const page = {
locator: () => ({ waitFor: async () => {} }),
getByText: () => ({ waitFor: async () => {} }),
async evaluate() {
const after = reads++ > 0;
return { title: 'controlled oracle', invalid_file_dialog: !after,
unsaved_changes: after,
diagram_svg_elements: after && process.env.REA_DRAWIO_ORACLE_CASE !== 'insertion-failure' ? 2 : 0,
cells: 0 };
},
};
export const _electron = { async launch() {
return { process: () => ({exitCode: 0, signalCode: null}),
firstWindow: async () => page, close: async () => {} };
}};
""")
write(root, "dist/browser/PlaywrightElectronActiveProvider.js", """
export class PlaywrightElectronActiveProvider {
constructor(environment, launch) { this.launch = launch; }
}
""")
write(root, "dist/browser/PlaywrightElectronActiveActions.js", """
export const runWithExecutionLimits = operation => operation;
""")
write(root, "dist/application/javascript/ElectronActiveObservationService.js", """
export async function captureElectronScenario(provider, input) {
const app = await provider.launch({});
await app.close();
const name = process.env.REA_DRAWIO_ORACLE_CASE;
const capture = { actions: input.actions.map(action => ({...action,
status: name === 'action-failure' ? 'failed' : 'completed'})),
coverage: {}, retention: {}, ipc: {events: [], observed: 0, dropped: 0} };
if (name === 'no-capture') return {ok: false, error: {reason: 'startup_failed'}};
if (name === 'partial') return {ok: false, error: {reason: 'cleanup_failed',
partialObservation: {capture}}};
return {ok: true, value: {evidence_id: 'ev_controlled',
normalized_result: capture}};
}
""")
result = subprocess.run(
[arguments.node, str(root / "probe.mjs"), "--runtime-root", str(root),
"--executable", arguments.node, "--application", str(root),
"--profile-root", str(root), "--dismiss-error", "--selector", "controlled"],
env={**os.environ, "REA_DRAWIO_ORACLE_CASE": case},
capture_output=True, text=True, timeout=15,
)
replies = []
for line in result.stdout.splitlines():
try:
replies.append(json.loads(line))
except ValueError:
pass
receipt = next((value for value in replies if "workflow_status" in value), None)
expected_success = case == "complete"
expected_provider = case not in ("partial", "no-capture")
expected_actions = case not in ("action-failure", "no-capture")
expected_inserted = case != "insertion-failure"
matched = (
(result.returncode == 0) is expected_success
and receipt is not None
and receipt["provider_ok"] is expected_provider
and receipt["cleanup_verified"] is expected_provider
and receipt["actions_completed"] is expected_actions
and receipt["shape_inserted"] is expected_inserted
and (receipt["workflow_status"] == "verified") is expected_success
)
return {"case": case, "exit_code": result.returncode,
"expected_success": expected_success, "summary_present": receipt is not None,
"matched_expected": matched}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", required=True)
parser.add_argument("--verifier", type=Path,
default=Path(__file__).with_name("drawio-electron-lifecycle.mjs"))
arguments = parser.parse_args()
receipts = []
with tempfile.TemporaryDirectory(prefix="rea-drawio-oracles-") as directory:
for case in ("complete", "partial", "action-failure", "insertion-failure", "no-capture"):
root = Path(directory) / case
root.mkdir()
receipts.append(check(arguments, root, case))
print(json.dumps({"oracle_cases": receipts, "real_provider_verified": False}))
assert all(receipt["matched_expected"] for receipt in receipts), receipts
if __name__ == "__main__":
main()
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// Real draw.io ASAR, renderer and declared actions; DOM reads are independent
// verification probes. No fabricated IPC channels or replacement app callbacks.
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { _electron } from "playwright-core";
const { values } = parseArgs({
options: {
"runtime-root": { type: "string" },
executable: { type: "string" },
application: { type: "string" },
"profile-root": { type: "string" },
selector: { type: "string" },
"dismiss-error": { type: "boolean", default: false },
},
});
for (const key of ["runtime-root", "executable", "application", "profile-root"])
assert.ok(values[key], `Missing --${key}`);
const runtime = resolve(values["runtime-root"]);
const { PlaywrightElectronActiveProvider } = await import(
pathToFileURL(
join(runtime, "dist/browser/PlaywrightElectronActiveProvider.js"),
)
);
const { captureElectronScenario } = await import(
pathToFileURL(
join(
runtime,
"dist/application/javascript/ElectronActiveObservationService.js",
),
)
);
const { runWithExecutionLimits } = await import(
pathToFileURL(
join(runtime, "dist/browser/PlaywrightElectronActiveActions.js"),
)
);
const profile = await mkdtemp(
join(resolve(values["profile-root"]), "drawio-ui-"),
);
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error("Verifier deadline")),
40_000,
);
let application;
let child;
let page;
let before;
let after;
let checkedFinalState = false;
const inspect = () =>
page.evaluate(() => {
const diagram = document.querySelector(".geDiagramContainer svg");
return {
title: document.title,
invalid_file_dialog:
document.body.innerText.includes("Invalid file data"),
unsaved_changes: document.body.innerText.includes("Unsaved changes"),
diagram_svg_elements:
diagram?.querySelectorAll("rect,path,ellipse,text").length ?? null,
cells: document.querySelectorAll(".geDiagramContainer [data-cell-id]")
.length,
};
});
const started = performance.now();
try {
const actions = values["dismiss-error"]
? [
{
step_id: "dismiss-invalid-file",
kind: "click",
window_index: 0,
selector: 'button:has-text("OK"):visible',
},
]
: [];
if (values.selector !== undefined)
actions.push({
step_id: "insert-shape",
kind: "click",
window_index: 0,
selector: values.selector,
});
actions.push({
step_id: "settle-renderer",
kind: "wait",
window_index: 0,
duration_ms: 500,
});
const result = await captureElectronScenario(
new PlaywrightElectronActiveProvider(process.env, async (options) => {
application = await _electron.launch(options);
child = application.process();
page = await application.firstWindow({ timeout: 15_000 });
await page
.locator(".geDiagramContainer")
.waitFor({ state: "visible", timeout: 15_000 });
if (values["dismiss-error"])
await page
.getByText("Invalid file data", { exact: true })
.waitFor({ state: "visible", timeout: 15_000 });
before = await inspect();
return new Proxy(application, {
get(target, key) {
if (key === "close")
return async () => {
if (!checkedFinalState) {
checkedFinalState = true;
after = await inspect();
}
return target.close();
};
const value = Reflect.get(target, key, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
}),
{
executable_path: resolve(values.executable),
application_path: resolve(values.application),
args: ["--no-sandbox", "--disable-gpu", `--user-data-dir=${profile}`],
actions,
},
{ signal: controller.signal },
);
const capture = result.ok
? result.value.normalized_result
: result.error.partialObservation?.capture;
await writeFile(join(profile, "result.json"), JSON.stringify(result));
const actionsCompleted =
capture?.actions.every((action) => action.status === "completed") === true;
const inserted =
values.selector === undefined
? null
: after?.diagram_svg_elements > before?.diagram_svg_elements;
console.log(
JSON.stringify({
profile,
runtime,
elapsed_ms: performance.now() - started,
deadline_triggered: controller.signal.aborted,
selected_shape: values.selector ?? null,
before,
after,
actions_completed: actionsCompleted,
shape_inserted: inserted,
provider_ok: result.ok,
cleanup_verified: result.ok,
reason: result.ok ? null : result.error.reason,
coverage: capture?.coverage,
retention: capture?.retention,
ipc: capture?.ipc,
actions: capture?.actions,
workflow_status:
result.ok && actionsCompleted && inserted !== false
? "verified"
: "partial-or-failed",
}),
);
assert.ok(actionsCompleted, "Declared real-app actions did not complete");
assert.ok(before && after, "Real renderer state was not verified");
assert.notEqual(inserted, false, "Click did not insert a diagram shape");
if (values["dismiss-error"]) assert.equal(after.invalid_file_dialog, false);
assert.equal(
controller.signal.aborted,
false,
"Verifier deadline was reached",
);
assert.ok(result.ok, "Provider cleanup remains unverified");
} finally {
clearTimeout(timer);
if (
application !== undefined &&
child?.exitCode === null &&
child?.signalCode === null
)
await runWithExecutionLimits(
application.close(),
undefined,
performance.now() + 5_000,
).catch(() => {
process.exitCode = 1;
});
// The resource scope owns any unresolved descendants; no PID/path-only kill.
if (child?.exitCode === null && child?.signalCode === null) process.exit(1);
}
+298 -49
View File
@@ -1,10 +1,34 @@
"use strict";
const applyFunction = Reflect.apply;
const applyArguments = Function.prototype.apply;
const bindFunction = Function.prototype.bind;
const callFunction = Function.prototype.call;
const arrayFrom = Array.from;
const defineProperty = Object.defineProperty;
const ownPropertyDescriptor = Object.getOwnPropertyDescriptor;
const getPrototypeOf = Object.getPrototypeOf;
const getProperty = Reflect.get;
const setProperty = Reflect.set;
const SetConstructor = Set;
const ProxyConstructor = Proxy;
const bindBuiltinMethod = (method) =>
applyFunction(bindFunction, callFunction, [method]);
const forEachMapEntry = bindBuiltinMethod(Map.prototype.forEach);
const sliceArguments = bindBuiltinMethod(Array.prototype.slice);
const weakMapHas = bindBuiltinMethod(WeakMap.prototype.has);
const weakMapGet = bindBuiltinMethod(WeakMap.prototype.get);
const weakMapSet = bindBuiltinMethod(WeakMap.prototype.set);
const setHas = bindBuiltinMethod(Set.prototype.has);
const setAdd = bindBuiltinMethod(Set.prototype.add);
const hasOwnProperty = bindBuiltinMethod(Object.prototype.hasOwnProperty);
const { isProxy } = require("node:util").types;
const {
createElectronActiveBoundaryPatches,
} = require("./electron-active-hook-boundaries.cjs");
const ipcEventKinds = new Set([
const ipcEventKinds = new SetConstructor([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
@@ -98,7 +122,7 @@ const eventFamily = (kind, processTypeValue) => {
return "preload-configuration";
if (kind.startsWith("ipc-renderer"))
return processTypeValue === "renderer" ? "renderer-ipc" : "ipc";
return ipcEventKinds.has(kind) ? "ipc" : kind;
return setHas(ipcEventKinds, kind) ? "ipc" : kind;
};
const noteDropped = (kind, processTypeValue, isIpc) => {
@@ -163,7 +187,7 @@ const frameIdentity = (event) => {
const record = (event) => {
observed += 1;
const isIpc = ipcEventKinds.has(event.kind);
const isIpc = setHas(ipcEventKinds, event.kind);
if (isIpc) observedIpc += 1;
else observedRuntime += 1;
const raw = {
@@ -191,7 +215,7 @@ const record = (event) => {
...event,
};
const shapeGroups = Array.isArray(raw.argument_shape_value_groups)
? raw.argument_shape_value_groups.filter(Array.isArray)
? raw.argument_shape_value_groups
: Array.isArray(raw.argument_shape_values)
? [raw.argument_shape_values]
: [];
@@ -242,11 +266,13 @@ const record = (event) => {
eventBytes + estimatedRetainedBytes + snapshotEnvelopeReserveBytes >
retentionBudgetBytes;
};
for (const group of shapeGroups)
for (const group of shapeGroups) {
if (!Array.isArray(group)) continue;
for (const argument of group) {
countShape(argument);
if (shapeOverflow) break;
}
}
if (shapeOverflow) {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
@@ -290,9 +316,11 @@ const record = (event) => {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
}
for (const group of shapeGroups)
for (const group of shapeGroups) {
if (!Array.isArray(group)) continue;
for (const argument of group)
retainedEvent.argument_shapes.push(shape(argument));
}
for (const valueShape of existingShapes)
retainedEvent.argument_shapes.push(
typeof valueShape === "string" ? valueShape : "unknown",
@@ -317,6 +345,14 @@ const recordIpc = (kind, channel, args, details = {}) =>
});
})();
const recordIpcObservationGap = (channel, event, error = false) =>
recordRuntime("ipc-observation-gap", event, error ? "failed" : "observed", {
channel,
target: "ipcMain.on",
process_type: "main",
error,
});
const patchEmitter = (emitter, observeEvent) => {
if (emitter === null || emitter === undefined) return;
const original = emitter.emit;
@@ -343,43 +379,248 @@ const recordInvocation = (kind, channel, event, args) =>
error: false,
});
const invokeObservedEventListener = (
thisArg,
channel,
listener,
args,
nativeMethod,
) => {
// Recording slices an actual invocation frame, avoiding Array species;
// callback dispatch keeps its native Array/Arguments representation.
const recorded = recordInvocation(
"main-event-invocation",
channel,
args[0],
sliceArguments(args, 1),
);
try {
if (nativeMethod === "call") return listener.call(thisArg);
if (nativeMethod === "apply") return listener.apply(thisArg, args);
return applyFunction(listener, thisArg, args);
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
// Recording wrappers follow EventEmitter's own wrapper convention: `.listener`
// names the caller's function so native removal can match it. Node retains
// ownership of listener traversal and meta-events; hook-created "on" entries
// are translated before calling the application's removal method/decorators.
// Only hook-created wrappers are tracked, by the registration they stand in
// for: they already record, so patched on() registers them as is. An "on"
// wrapper stands in for the caller's raw function and a "once" wrapper for
// EventEmitter's once wrapper, which removeAllListeners reports accordingly.
// The value is the native removal argument: the app function for an "on"
// wrapper, or the wrapper itself for "once". Public .listener is mutable;
// it is not the authority for the original function's identity.
const hookListenerRemovalTargets = new WeakMap();
const MAX_LISTENER_CHAIN_HOPS = 16;
// Follow native property lookup without invoking application getters or Proxy
// traps. Undefined means the readable link is absent; null means unknown.
const listenerDataDescriptor = (listener) => {
let candidate = listener;
for (
let hops = 0;
hops <= MAX_LISTENER_CHAIN_HOPS && candidate !== null;
hops += 1
) {
if (isProxy(candidate)) return null;
const descriptor = ownPropertyDescriptor(candidate, "listener");
if (descriptor !== undefined) {
if (hasOwnProperty(descriptor, "value")) return descriptor;
// A setter-only property shadows ancestors with a known undefined read.
return descriptor.get === undefined ? undefined : null;
}
candidate = getPrototypeOf(candidate);
}
return candidate === null ? undefined : null;
};
// A decorator that wraps once's hook wrapper and names it through `.listener`
// already records through that wrapper. Registering the decorator as is keeps
// native self-removal working: the once wrapper removes itself by identity, and
// removeListener matches the decorator through its `.listener`.
const classifyHookListenerWrapper = (listener) => {
const visited = new SetConstructor();
let candidate = listener;
for (
let hops = 0;
hops <= MAX_LISTENER_CHAIN_HOPS && typeof candidate === "function";
hops += 1
) {
if (weakMapHas(hookListenerRemovalTargets, candidate)) return "owned";
if (setHas(visited, candidate)) return "unowned";
setAdd(visited, candidate);
const descriptor = listenerDataDescriptor(candidate);
if (descriptor === null) return "unknown";
candidate = descriptor?.value;
}
return typeof candidate === "function" ? "unknown" : "unowned";
};
const patchIpcMain = (ipcMain) => {
if (ipcMain === null || ipcMain === undefined) return;
for (const [method, kind] of [
["handle", "main-handler-invocation"],
["on", "main-event-invocation"],
["once", "main-event-invocation"],
]) {
const original = ipcMain[method];
if (typeof original !== "function") continue;
ipcMain[method] = function patchedIpcMain(channel, listener) {
const { handle, on, once, removeAllListeners } = ipcMain;
if (typeof handle === "function")
ipcMain.handle = function patchedIpcMainHandle(channel, listener) {
if (typeof listener !== "function")
return original.call(this, channel, listener);
const wrapped =
kind === "main-handler-invocation"
? async function wrappedHandler(event, ...args) {
const recorded = recordInvocation(kind, channel, event, args);
try {
const result = await listener.call(this, event, ...args);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
}
: function wrappedEventListener(event, ...args) {
const recorded = recordInvocation(kind, channel, event, args);
try {
return listener.call(this, event, ...args);
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
return original.call(this, channel, wrapped);
return handle.call(this, channel, listener);
return handle.call(
this,
channel,
async function wrappedHandler(event, ...args) {
const recorded = recordInvocation(
"main-handler-invocation",
channel,
event,
args,
);
try {
const result = await applyFunction(listener, this, arguments);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
},
);
};
}
if (typeof on !== "function") return;
ipcMain.on = function patchedIpcMainOn(channel, listener) {
if (typeof listener !== "function") return on.call(this, channel, listener);
const wrapperStatus = classifyHookListenerWrapper(listener);
if (wrapperStatus !== "unowned") {
const result = on.call(this, channel, listener);
if (wrapperStatus === "unknown")
recordIpcObservationGap(channel, "listener-chain");
return result;
}
const wrapped = function wrappedEventListener() {
return invokeObservedEventListener(this, channel, listener, arguments);
};
// Native single-listener dispatch reads .apply; array dispatch invokes the
// function directly. Let Node select that path without inspecting or
// copying the emitter's listener storage, including during reentrant emits.
defineProperty(wrapped, "apply", {
configurable: true,
writable: true,
value: function nativeApply(...methodArgs) {
let applicationApply;
try {
applicationApply = listener.apply;
} catch (cause) {
recordIpcObservationGap(channel, "custom-apply", true);
throw cause;
}
if (applicationApply !== applyArguments) {
// Custom apply owns the original list's interpretation. Forward it
// unchanged rather than executing getters to invent callback facts.
const gap = recordIpcObservationGap(channel, "custom-apply");
try {
return applyFunction(
applicationApply,
this === wrapped ? listener : this,
methodArgs,
);
} catch (cause) {
if (gap !== null) {
gap.error = true;
gap.phase = "failed";
}
throw cause;
}
}
// Native Function.apply performs its own list conversion before the
// recording wrapper observes an actual invocation frame.
// Borrowing the exposed method keeps its dynamic receiver.
return applyFunction(applyArguments, this, methodArgs);
},
});
defineProperty(wrapped, "listener", {
configurable: true,
enumerable: true,
writable: true,
value: listener,
});
weakMapSet(hookListenerRemovalTargets, wrapped, listener);
return on.call(this, channel, wrapped);
};
// Mirror EventEmitter#once and _onceWrap: resolve this.on at registration
// and target.removeListener at firing, so later decorators observe both.
if (typeof once === "function")
ipcMain.once = function patchedIpcMainOnce(channel, listener) {
if (typeof listener !== "function")
return once.call(this, channel, listener);
const target = this;
let fired = false;
const wrapped = function wrappedOnceEventListener() {
if (fired) return undefined;
target.removeListener(channel, wrapped);
fired = true;
return invokeObservedEventListener(
target,
channel,
listener,
arguments,
arguments.length === 0 ? "call" : "apply",
);
};
wrapped.listener = listener;
weakMapSet(hookListenerRemovalTargets, wrapped, wrapped);
this.on(channel, wrapped);
return this;
};
if (typeof removeAllListeners !== "function") return;
// Node owns its live-array traversal. A forwarding receiver translates only
// our "on" entries before calling the current application removal method,
// including decorators installed/replaced during traversal. Application
// getters and methods always receive the real emitter; its public methods
// are never temporarily replaced. Native once entries remain unchanged.
ipcMain.removeAllListeners = function patchedIpcMainRemoveAllListeners(
...args
) {
const emitter = this;
// A separate empty target also permits forwarding a non-writable app
// method; a Proxy of the emitter itself would violate get invariants.
const receiver = new ProxyConstructor(
{ __proto__: null },
{
get(_target, key) {
const value = getProperty(emitter, key, emitter);
if (
(key !== "removeListener" && key !== "removeAllListeners") ||
typeof value !== "function"
)
return value;
return (...methodArgs) => {
if (key === "removeListener") {
const entry = methodArgs[1];
const removalTarget = weakMapGet(
hookListenerRemovalTargets,
entry,
);
// Translate only a still-valid data link. If app code changed it
// to an accessor, native removal owns when that getter executes.
if (
removalTarget !== undefined &&
removalTarget !== entry &&
listenerDataDescriptor(entry)?.value === removalTarget
)
methodArgs[1] = removalTarget;
}
return applyFunction(value, emitter, methodArgs);
};
},
set: (_target, key, value) => setProperty(emitter, key, value, emitter),
},
);
const result = applyFunction(removeAllListeners, receiver, args);
return result === receiver ? emitter : result;
};
};
const patchNavigationMethods = (webContents, contentsId) => {
@@ -649,10 +890,24 @@ try {
);
}
// Never expose retained buffers to application iterators or array constructors.
const snapshotArray = (values, project) =>
arrayFrom({ __proto__: null, length: values.length }, (_unused, index) =>
project === undefined ? values[index] : project(values[index]),
);
const snapshotCounts = (counts, field) => {
const values = { __proto__: null, length: 0 };
forEachMapEntry(counts, (count, value) => {
values[values.length++] = { [field]: value, count };
});
return arrayFrom(values);
};
globalThis.__reaElectronActiveSnapshot = () => ({
events: events.map((event) => ({
events: snapshotArray(events, (event) => ({
...event,
argument_shapes: [...event.argument_shapes],
argument_shapes: snapshotArray(event.argument_shapes),
})),
retention_budget_bytes: retentionBudgetBytes,
estimated_retained_bytes: estimatedRetainedBytes,
@@ -661,14 +916,8 @@ globalThis.__reaElectronActiveSnapshot = () => ({
dropped,
dropped_ipc: droppedIpc,
dropped_runtime: droppedRuntime,
dropped_event_families: [...droppedFamilies].map(([family, count]) => ({
family,
count,
})),
dropped_event_roles: [...droppedRoles].map(([role, count]) => ({
role,
count,
})),
dropped_event_families: snapshotCounts(droppedFamilies, "family"),
dropped_event_roles: snapshotCounts(droppedRoles, "role"),
observed,
observed_ipc: observedIpc,
observed_runtime: observedRuntime,
+112
View File
@@ -0,0 +1,112 @@
import { expect, it, vi } from "vitest";
import { execFileBytes } from "../process/ExecFileOutput.js";
import { probeAdbPathExistence } from "./AdbCommand.js";
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
execFileBytes: vi.fn(),
}));
const probe = (signal?: AbortSignal) =>
probeAdbPathExistence(
"push_adb_file",
"device",
"/device/path",
"/host/adb",
{
timeoutMs: 100,
maxOutputBytes: 1024,
},
signal,
);
it("recognizes only the completed empty false result as absence", async () => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("test false"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
}),
);
await expect(probe()).resolves.toEqual({ ok: true, value: false });
});
it.each([
{ code: 2 },
{ code: "ENOENT" },
{ signal: "SIGTERM" },
{ collectionFailure: true },
{ stdout: "unexpected" },
{ stderr: " " },
])("keeps nonstandard false completion unknown: %j", async (extra) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("probe failed"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
...extra,
}),
);
const result = await probe();
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisProtocolError",
diagnostics: { destination_existence: "unknown" },
},
});
});
it.each([
[{ killed: true }, "AnalysisTimeoutError"],
[{ outputTruncated: true }, "AnalysisResourceConstraintError"],
[{ code: "ABORT_ERR" }, "AnalysisCancelledError"],
] as const)(
"preserves a stopped or incomplete probe: %j",
async (extra, tag) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("probe failed"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
...extra,
}),
);
await expect(probe()).resolves.toMatchObject({
ok: false,
error: { _tag: tag },
});
},
);
it("recognizes an empty successful probe as existence", async () => {
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
});
await expect(probe()).resolves.toEqual({ ok: true, value: true });
});
it.each(["stdout", "stderr"] as const)(
"retains unexpected successful %s as unknown",
async (stream) => {
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
[stream]: Buffer.from("unexpected"),
});
await expect(probe()).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisProtocolError",
capturedOutput: { [stream]: "unexpected", exit_code: 0 },
},
});
},
);
+244
View File
@@ -0,0 +1,244 @@
import {
AnalysisCancelledError,
AnalysisProtocolError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import {
execFileBytes,
execFileOutputFailure,
type ExecFileOutputFailure,
} from "../process/ExecFileOutput.js";
import { packageChangeFailureReason } from "./AdbShellOutput.js";
const STDERR_EXCERPT_BYTES = 512;
/** Parsed command failure owned by this adapter, separate from its public tag. */
class AdbProtocolError extends AnalysisProtocolError {
constructor(
message: string,
readonly commandFailure: ExecFileOutputFailure,
cause: unknown,
) {
super(message, { cause });
}
}
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
const quoteShellOperand = (value: string): string =>
/^[A-Za-z0-9_./:=@+,-]+$/u.test(value)
? value
: "'" + value.replaceAll("'", "'\\''") + "'";
export const adbArguments = (
serial: string | undefined,
arguments_: readonly string[],
): readonly string[] => {
const operands =
arguments_[0] === "shell"
? ["shell", ...arguments_.slice(1).map(quoteShellOperand)]
: [...arguments_];
return serial === undefined ? operands : ["-s", serial, ...operands];
};
export interface AdbRunLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
}
/** Run one bounded adb invocation and keep its exact failure shape. */
export const runAdbCapture = async (
operation: string,
targetPath: string,
binary: string,
arguments_: readonly string[],
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<{ stdout: Buffer; stderr: Buffer }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation, { cause: signal.reason }));
try {
return ok(
await execFileBytes(binary, arguments_, {
timeout: limits.timeoutMs,
maxBuffer: limits.maxOutputBytes,
stopSignal: "SIGTERM",
...(signal === undefined ? {} : { signal }),
}),
);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
return err(new AnalysisCancelledError(operation, { cause }));
if (failure === undefined)
return err(
new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
);
if (failure.outputTruncated)
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{ cause },
),
);
const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES);
if (/unauthorized/iu.test(excerpt))
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
);
if (
/device[^\n]*not found|device offline|still authorizing|more than one device/iu.test(
excerpt,
)
)
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
);
if (
/No such file or directory|Permission denied|read: Permission denied/iu.test(
excerpt,
)
)
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
);
if (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
);
return err(
new AdbProtocolError(
excerpt === ""
? `adb exited with code ${String(failure.code)} for ${operation}`
: excerpt,
failure,
cause,
),
);
}
};
/** Decode textual adb protocols after the shared byte capture and error boundary. */
export const runAdb = async (
...arguments_: Parameters<typeof runAdbCapture>
): Promise<
Result<{ stdout: string; stderr: string; stdoutBytes: number }, AnalysisError>
> => {
const captured = await runAdbCapture(...arguments_);
if (!captured.ok) return captured;
return ok({
stdout: captured.value.stdout.toString("utf8"),
stderr: captured.value.stderr.toString("utf8"),
// Byte count of the captured output before UTF-8 decoding, so malformed
// sequences replaced by U+FFFD still report their true captured size.
stdoutBytes: captured.value.stdout.byteLength,
});
};
/**
* adb exits nonzero when the device refuses a package change, printing the
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
* refusal from that failed run so it is not reported as unreadable output.
*/
export const packageChangeRefusal = (
operation: string,
serial: string,
error: AnalysisError,
): Result<never, AnalysisError> | undefined => {
if (!(error instanceof AdbProtocolError)) return undefined;
const failure = error.commandFailure;
const reason = packageChangeFailureReason(
`${failure.stdout}\n${failure.stderr}`,
);
return reason === null
? undefined
: err(
new AnalysisUnsupportedTargetError(operation, serial, reason, {
cause: error,
}),
);
};
/** Only the clean false result of test -e establishes absence. */
export const probeAdbPathExistence = async (
operation: string,
serial: string,
path: string,
binary: string,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> => {
const probed = await runAdbCapture(
operation,
path,
binary,
adbArguments(serial, ["shell", "test", "-e", path]),
limits,
signal,
);
if (probed.ok) {
if (probed.value.stdout.length === 0 && probed.value.stderr.length === 0)
return ok(true);
return err(
new AnalysisProtocolError(
"The ADB path existence probe returned unexpected output; destination existence is unknown",
{
diagnostics: { destination_existence: "unknown" },
capturedOutput: {
stdout: probed.value.stdout.toString("utf8"),
stderr: probed.value.stderr.toString("utf8"),
stdout_bytes: probed.value.stdout.length,
stderr_bytes: probed.value.stderr.length,
exit_code: 0,
signal: null,
truncated: false,
},
},
),
);
}
const error = probed.error;
if (!(error instanceof AdbProtocolError)) return probed;
const failure = error.commandFailure;
if (
failure.code === 1 &&
failure.signal === null &&
!failure.killed &&
!failure.outputTruncated &&
failure.collectionFailure !== true &&
failure.stdout === "" &&
failure.stderr === ""
)
return ok(false);
return err(
new AnalysisProtocolError(error.message, {
cause: error.cause,
diagnostics: { destination_existence: "unknown" },
capturedOutput: {
stdout: failure.stdout,
stderr: failure.stderr,
...(failure.stdoutBytes === undefined
? {}
: { stdout_bytes: failure.stdoutBytes }),
...(failure.stderrBytes === undefined
? {}
: { stderr_bytes: failure.stderrBytes }),
exit_code: typeof failure.code === "number" ? failure.code : null,
signal: failure.signal,
truncated: failure.outputTruncated,
},
}),
);
};
+20 -168
View File
@@ -70,9 +70,13 @@ import {
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import {
execFileBytes,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
adbArguments,
runAdb,
runAdbCapture,
packageChangeRefusal,
probeAdbPathExistence,
type AdbRunLimits,
} from "./AdbCommand.js";
import {
AdbConfigurationFailure,
adbConfigurationAvailability,
@@ -102,7 +106,6 @@ import {
parseBugreportCopiedPath,
parseDumpsysPackageOutput,
parseFeatureListOutput,
packageChangeFailureReason,
parsePackageChangeOutput,
parseProcessListOutput,
parsePngDimensions,
@@ -144,7 +147,6 @@ const SCREEN_TIMEOUT_MS = 60_000;
const SCREEN_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
const BUGREPORT_TIMEOUT_MS = 600_000;
const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024;
const STDERR_EXCERPT_BYTES = 512;
const UNPARSED_LINES_REPORTED = 10;
/** Carry the exact digest owner through caller-specific partial-result mapping. */
@@ -170,7 +172,7 @@ const hashAdbFile = async (
return await hashStableFile(path, {
...(signal === undefined ? {} : { signal }),
retainFailedClose: (handle) => {
closeLease = new AnalysisCleanupLease(() => handle.close());
closeLease = new AnalysisCleanupLease(() => handle.close(), [path]);
resources.retain({
kind: "lease",
resource: `${path} (ADB digest handle)`,
@@ -326,7 +328,10 @@ const syncStagedAdbFile = async (
}
const resource = `${path} (ADB staged-output handle)`;
const stagedOwner = owner;
const closeLease = new AnalysisCleanupLease(() => stagedOwner.close());
const closeLease = new AnalysisCleanupLease(
() => stagedOwner.close(),
[resource],
);
const released = await resources.release({
kind: "lease",
resource,
@@ -361,150 +366,6 @@ const syncStagedAdbFile = async (
if (primary !== undefined) throw primary;
};
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
const quoteShellOperand = (value: string): string =>
/^[A-Za-z0-9_./:=@+,-]+$/u.test(value)
? value
: "'" + value.replaceAll("'", "'\\''") + "'";
const adbArguments = (
serial: string | undefined,
arguments_: readonly string[],
): readonly string[] => {
const operands =
arguments_[0] === "shell"
? ["shell", ...arguments_.slice(1).map(quoteShellOperand)]
: [...arguments_];
return serial === undefined ? operands : ["-s", serial, ...operands];
};
interface AdbRunLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
}
/** Run one bounded adb invocation and keep its exact failure shape. */
const runAdbCapture = async (
operation: string,
targetPath: string,
binary: string,
arguments_: readonly string[],
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<{ stdout: Buffer; stderr: Buffer }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation, { cause: signal.reason }));
try {
return ok(
await execFileBytes(binary, arguments_, {
timeout: limits.timeoutMs,
maxBuffer: limits.maxOutputBytes,
stopSignal: "SIGTERM",
...(signal === undefined ? {} : { signal }),
}),
);
} catch (cause) {
if (signal?.aborted || execFileOutputFailure(cause)?.code === "ABORT_ERR")
return err(new AnalysisCancelledError(operation, { cause }));
const failure = execFileOutputFailure(cause);
if (failure === undefined)
return err(
new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
);
if (failure.outputTruncated)
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{ cause },
),
);
const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES);
if (/unauthorized/iu.test(excerpt))
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
);
if (
/device[^\n]*not found|device offline|still authorizing|more than one device/iu.test(
excerpt,
)
)
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
);
if (
/No such file or directory|Permission denied|read: Permission denied/iu.test(
excerpt,
)
)
return err(
new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, {
cause,
}),
);
if (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
);
return err(
new AnalysisProtocolError(
excerpt === ""
? `adb exited with code ${String(failure.code)} for ${operation}`
: excerpt,
{ cause },
),
);
}
};
/** Decode textual adb protocols after the shared byte capture and error boundary. */
const runAdb = async (
...arguments_: Parameters<typeof runAdbCapture>
): Promise<
Result<{ stdout: string; stderr: string; stdoutBytes: number }, AnalysisError>
> => {
const captured = await runAdbCapture(...arguments_);
if (!captured.ok) return captured;
return ok({
stdout: captured.value.stdout.toString("utf8"),
stderr: captured.value.stderr.toString("utf8"),
// Byte count of the captured output before UTF-8 decoding, so malformed
// sequences replaced by U+FFFD still report their true captured size.
stdoutBytes: captured.value.stdout.byteLength,
});
};
/**
* adb exits nonzero when the device refuses a package change, printing the
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
* refusal from that failed run so it is not reported as unreadable output.
*/
const packageChangeRefusal = (
operation: string,
serial: string,
error: AnalysisError,
): Result<never, AnalysisError> | undefined => {
if (!(error instanceof AnalysisProtocolError)) return undefined;
const failure = execFileOutputFailure(error.cause);
if (failure === undefined) return undefined;
const reason = packageChangeFailureReason(
`${failure.stdout}\n${failure.stderr}`,
);
return reason === null
? undefined
: err(
new AnalysisUnsupportedTargetError(operation, serial, reason, {
cause: error,
}),
);
};
const clientIdentityResult = (
selection: AdbBinarySelection,
identity: AdbClientIdentity,
@@ -605,7 +466,10 @@ const publishScreenExclusively = async (
// the visible file despite later cancellation so its receipt remains known.
const ownedOutput = new OwnedFileHandle(output);
const handleResource = `${outputPath} (ADB publication handle)`;
const closeOnly = new AnalysisCleanupLease(() => ownedOutput.close());
const closeOnly = new AnalysisCleanupLease(
() => ownedOutput.close(),
[handleResource],
);
const removeOutput = async (
expected: Awaited<ReturnType<typeof output.stat>>,
): Promise<void> => {
@@ -2529,28 +2393,16 @@ export class AdbProvider {
operation,
signal,
);
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
// Any other probe failure leaves existence unknown, so it is reported as
// such rather than treated as absence or followed by a push.
const existsProbe = await runAdb(
const existsProbe = await probeAdbPathExistence(
operation,
input.serial,
input.device_path,
selection.binary,
adbArguments(input.serial, [
"shell",
"test",
"-e",
input.device_path,
]),
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
if (
!existsProbe.ok &&
!(existsProbe.error instanceof AnalysisProtocolError)
)
return existsProbe;
const deviceExists = existsProbe.ok;
if (!existsProbe.ok) return existsProbe;
const deviceExists = existsProbe.value;
if (deviceExists && !input.overwrite)
return err(
new AnalysisOutputError(
+6
View File
@@ -290,6 +290,12 @@ export interface AnalysisProviderReadinessSnapshot {
/** Deep provider candidate discoverable before one target-bound route is chosen. */
export interface AnalysisProviderCandidate extends AnalysisProvider {
/**
* Own inspection through final reads and cleanup. Observe the supplied owner
* signal cooperatively; resolve only after finalization settles, retaining any
* failed cleanup capability. Registry cancellation and shutdown join this
* completion rather than abandoning the promise.
*/
resolveAnalysisProfile(
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
@@ -7,12 +7,12 @@ import {
AnalysisCancelledError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
/** Profile-resolution truth for one discovered provider candidate. */
@@ -34,34 +34,29 @@ export const evaluateAnalysisProviderCandidate = async (
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
> => {
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
let resolved: Awaited<ReturnType<typeof candidate.resolveAnalysisProfile>>;
try {
const resolution = await waitForAbortable(
candidate.resolveAnalysisProfile(
target,
signal === undefined && fileCloseResources === undefined
? undefined
: {
...(signal === undefined ? {} : { signal }),
...(fileCloseResources === undefined
? {}
: { fileCloseResources }),
},
),
signal,
);
if (resolution === ABORTED)
return err(new AnalysisCancelledError("open_binary"));
resolved = resolution;
resolved = await candidate.resolveAnalysisProfile(target, {
...(signal === undefined ? {} : { signal }),
...(fileCloseResources === undefined ? {} : { fileCloseResources }),
});
} catch (cause: unknown) {
void cause;
return ok(
rejectedProfile(candidate, status, "Provider profile resolution threw"),
resolved = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
status.provider.id,
"resolve_analysis_profile",
{
cause,
diagnostics: { reason: "Provider profile resolution threw" },
},
),
);
}
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
if (!resolved.ok && resolved.error instanceof AnalysisCancelledError)
return err(resolved.error);
if (!resolved.ok) {
@@ -85,6 +80,10 @@ export const evaluateAnalysisProviderCandidate = async (
});
return ok({ ...rejection, profileError: error });
}
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
try {
if (resolved.value.profile === null)
return ok(
@@ -16,6 +16,7 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ProviderSelectionError } from "../../domain/providerSelectionError.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
const DATABASE_TARGET: BinaryTarget = {
@@ -395,29 +396,63 @@ describe("analysis provider registry: profile and cleanup failures", () => {
expect(alternate.created).toEqual([]);
});
it("cancels a pending profile probe even when the adapter ignores its signal", async () => {
it("owns a signal-ignoring profile probe until final completion and joins shutdown", async () => {
const alpha = candidate("alpha");
let observedSignal: AbortSignal | undefined;
let finish: (() => void) | undefined;
let started: (() => void) | undefined;
const pending = new Promise<void>((resolve) => {
started = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const ignoresCancellation: AnalysisProviderCandidate = {
...alpha.provider,
resolveAnalysisProfile: (_target, options) => {
resolveAnalysisProfile: async (target, options) => {
observedSignal = options?.signal;
return new Promise<never>(() => undefined);
started?.();
await gate;
return alpha.provider.resolveAnalysisProfile(target);
},
};
const registry = new AnalysisProviderRegistry([ignoresCancellation]);
const controller = new AbortController();
const selecting = new AnalysisProviderRegistry([
ignoresCancellation,
]).select(DATABASE_TARGET, "alpha", { signal: controller.signal });
expect(observedSignal).toBe(controller.signal);
controller.abort();
await expect(selecting).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
const reason = { caller: "profile finalization" };
let selected = false;
const selecting = registry
.select(DATABASE_TARGET, "alpha", { signal: controller.signal })
.then((result) => {
selected = true;
return result;
});
await pending;
controller.abort(reason);
let closed = false;
const closing = registry.closeOwnedResources().then((result) => {
closed = true;
return result;
});
expect(alpha.created).toEqual([]);
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(selected).toBe(false);
expect(closed).toBe(false);
expect(registry.hasPendingOwnedResources()).toBe(true);
expect(observedSignal?.aborted).toBe(true);
expect(observedSignal?.reason).toBe(reason);
finish?.();
const result = await selecting;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
expect(await closing).toEqual(ok(null));
expect(registry.hasPendingOwnedResources()).toBe(false);
expect(alpha.created).toEqual([]);
} finally {
finish?.();
await Promise.all([selecting, closing]);
}
});
});
@@ -524,3 +559,207 @@ const capability = (provider: ProviderIdentity): CapabilityDescriptor => ({
},
limitations: [],
});
describe("profile resolution terminal facts", () => {
it.each([false, true])(
"preserves a physical profile failure across late caller abort (throws: %s)",
async (throws) => {
const alpha = candidate("alpha");
const controller = new AbortController();
const primary = new ProviderAdapterError(
"alpha",
"resolve_analysis_profile",
{ cause: new Error("physical profile failure") },
);
const registry = new AnalysisProviderRegistry([
{
...alpha.provider,
resolveAnalysisProfile: async () => {
controller.abort({ caller: "after physical failure" });
if (throws) throw primary;
return err(primary);
},
},
]);
const result = await registry.select(DATABASE_TARGET, "alpha", {
signal: controller.signal,
});
expect(result).toEqual(err(primary));
expect(registry.hasPendingOwnedResources()).toBe(false);
expect(await registry.closeOwnedResources()).toEqual(ok(null));
},
);
it("preserves exact cancellation with cleanup and blocks admission until its owner confirms release", async () => {
const alpha = candidate("alpha");
const controller = new AbortController();
const reason = { caller: "final cleanup" };
const primary = new AnalysisCancelledError("resolve_analysis_profile", {
cause: reason,
cleanup: { reason: "file close rejected", resources: ["profile-file"] },
});
let attempts = 0;
let allowClose = false;
const registry = new AnalysisProviderRegistry([
{
...alpha.provider,
resolveAnalysisProfile: async () => {
attempts++;
controller.abort(reason);
return err(primary);
},
closeOwnedResources: async () => {
if (!allowClose) return err(primary);
primary.resolveCleanup();
return ok(null);
},
},
]);
const result = await registry.select(DATABASE_TARGET, "alpha", {
signal: controller.signal,
});
expect(result).toEqual(err(primary));
expect(primary.cause).toBe(reason);
expect(registry.hasPendingOwnedResources()).toBe(true);
expect(await registry.select(DATABASE_TARGET, "alpha")).toEqual(
err(primary),
);
expect(attempts).toBe(1);
allowClose = true;
expect(await registry.closeOwnedResources()).toEqual(ok(null));
expect(primary.cleanupIncomplete).toBe(false);
expect(registry.hasPendingOwnedResources()).toBe(false);
});
});
describe("automatic profile cleanup ownership", () => {
it.each(["alpha", "beta"])(
"keeps automatic mixed-child cleanup conjunctive when %s releases first",
async (first) => {
const alpha = candidate("alpha");
const beta = candidate("beta");
const controller = new AbortController();
const reason = { caller: "mixed profile finalization" };
const failures = {
alpha: new AnalysisCancelledError("resolve_analysis_profile", {
cause: reason,
cleanup: { reason: "alpha descriptor", resources: ["alpha-file"] },
}),
beta: new ProviderCleanupError("beta", ["beta-file"], {
reason: "beta descriptor",
}),
};
let entered = 0;
let admitChildren: (() => void) | undefined;
const admitted = new Promise<void>((resolve) => {
admitChildren = resolve;
});
const released = new Set<string>();
const registry = new AnalysisProviderRegistry(
[alpha, beta].map((fixture) => {
const id = fixture.provider.identity().id as "alpha" | "beta";
return {
...fixture.provider,
resolveAnalysisProfile: async () => {
entered++;
if (entered === 2) {
controller.abort(reason);
admitChildren?.();
}
await admitted;
return err(failures[id]);
},
closeOwnedResources: async () => {
if (!released.has(id)) return err(failures[id]);
failures[id].resolveCleanup();
return ok(null);
},
};
}),
);
const result = await registry.select(DATABASE_TARGET, "auto", {
signal: controller.signal,
});
if (result.ok) throw new Error("Mixed failed profiles admitted");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([
"alpha-file",
"beta-file",
]);
expect(result.error.diagnostics?.profile_errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ provider_id: "alpha" }),
expect.objectContaining({ provider_id: "beta" }),
]),
);
const close1 = await registry.closeOwnedResources();
expect(close1.ok).toBe(false);
released.add(first);
const close2 = await registry.closeOwnedResources();
expect(close2.ok).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([
first === "alpha" ? "beta-file" : "alpha-file",
]);
expect(close1.ok ? [] : close1.error.cleanupResources).toEqual([
first === "alpha" ? "beta-file" : "alpha-file",
]);
released.add(first === "alpha" ? "beta" : "alpha");
expect(await registry.closeOwnedResources()).toEqual(ok(null));
expect(result.error.cleanupIncomplete).toBe(false);
if (close1.ok || close2.ok)
throw new Error("Cleanup unexpectedly succeeded");
expect(close1.error.cleanupIncomplete).toBe(false);
expect(close2.error.cleanupIncomplete).toBe(false);
expect(registry.hasPendingOwnedResources()).toBe(false);
},
);
});
it("registers before production and gates fresh selections until shutdown has joined finalization", async () => {
const alpha = candidate("alpha");
let calls = 0;
let enter: (() => void) | undefined;
let finish: (() => void) | undefined;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const registry = new AnalysisProviderRegistry([
{
...alpha.provider,
resolveAnalysisProfile: async (target) => {
calls++;
if (calls === 1) {
enter?.();
await gate;
}
return alpha.provider.resolveAnalysisProfile(target);
},
},
]);
const selecting = registry.select(DATABASE_TARGET, "alpha");
await entered;
const closing = registry.closeOwnedResources();
const next = registry.select(DATABASE_TARGET, "alpha");
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(calls).toBe(1);
expect(registry.hasPendingOwnedResources()).toBe(true);
finish?.();
expect(await selecting).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(await closing).toEqual(ok(null));
expect((await next).ok).toBe(true);
expect(calls).toBe(2);
expect(registry.hasPendingOwnedResources()).toBe(false);
} finally {
finish?.();
await Promise.all([selecting, closing, next]);
await registry.closeOwnedResources();
}
});
@@ -1,3 +1,10 @@
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { bindArtifactFileCleanup } from "../../artifacts/ArtifactFileOperation.js";
import { SharedStartupAttempt } from "../../process/SharedStartupAttempt.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import {
analysisProviderIdSchema,
@@ -67,9 +74,10 @@ interface TargetSelectionContext {
readonly target: BinaryTarget;
readonly baseline: readonly AnalysisProviderCandidateStatus[];
readonly signal: AbortSignal | undefined;
readonly fileCloseResources?: FileCloseResourceOwner;
}
type SelectionResult = Result<AnalysisProviderSelection, AnalysisError>;
/**
* Discover and select overlapping deep providers without starting their clients.
* Registration order never affects selection or caller-visible output.
@@ -80,6 +88,20 @@ export class AnalysisProviderRegistry {
readonly #declaredOperations: readonly string[];
readonly #defaultSelector: AnalysisProviderSelector;
readonly #selections = new Set<SharedStartupAttempt<SelectionResult>>();
readonly #selectionCleanup = new Set<AnalysisError>();
readonly #profileFiles = new ArtifactResourceScope();
readonly #profileFileOwner: FileCloseResourceOwner = {
retain: (owner, resource) =>
this.#profileFiles.retain({
kind: "lease",
resource,
lease: new AnalysisCleanupLease(() => owner.close(), [resource]),
}),
run: (operation) => this.#profileFiles.run(operation),
};
#closing: Promise<Result<null, AnalysisError>> | undefined;
constructor(
providers: readonly AnalysisProviderCandidate[],
defaultSelector: AnalysisProviderSelector = "auto",
@@ -143,29 +165,67 @@ export class AnalysisProviderRegistry {
/** Report retained candidate resources without retrying cleanup. */
hasPendingOwnedResources(): boolean {
return this.#providers.some(
(provider) => provider.hasPendingOwnedResources?.() === true,
return (
this.#selections.size > 0 ||
this.#profileFiles.hasPending ||
[...this.#selectionCleanup].some((error) => error.cleanupIncomplete) ||
this.#providers.some(
(provider) => provider.hasPendingOwnedResources?.() === true,
)
);
}
/** Close candidate-owned resources even when no target client was opened. */
async closeOwnedResources(): Promise<Result<null, AnalysisError>> {
closeOwnedResources(): Promise<Result<null, AnalysisError>> {
this.#closing ??= Promise.resolve()
.then(() => this.#closeOwnedResources())
.finally(() => {
this.#closing = undefined;
});
return this.#closing;
}
async #closeOwnedResources(): Promise<Result<null, AnalysisError>> {
const selections = [...this.#selections];
for (const selection of selections) selection.close();
await Promise.allSettled(selections.map((selection) => selection.promise));
const failures: AnalysisError[] = [];
try {
await this.#profileFiles.retryCleanup();
} catch (cause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cause,
"provider profile files",
);
const failure = new ProviderCleanupError(
"rea",
cleanup.resources,
{ reason: cleanup.reason },
{ cause },
);
bindArtifactFileCleanup(cause, failure);
failures.push(failure);
}
for (const provider of this.#providers) {
if (provider.closeOwnedResources === undefined) continue;
const result = await provider.closeOwnedResources();
if (!result.ok) failures.push(result.error);
}
if (failures.length === 0) return ok(null);
if (failures.length === 1) return err(failures[0]!);
for (const failure of this.#selectionCleanup) {
if (failure.cleanupIncomplete) failures.push(failure);
else this.#selectionCleanup.delete(failure);
}
const distinct = [...new Set(failures)];
if (distinct.length === 0) return ok(null);
if (distinct.length === 1) return err(distinct[0]!);
return err(
new ProviderCleanupError(
"rea",
failures.flatMap((failure) => failure.cleanupResources),
{
reason: failures.map((failure) => failure.message).join("; "),
},
{ cause: new AggregateError(failures) },
[...new Set(distinct.flatMap((failure) => failure.cleanupResources))],
{ reason: distinct.map((failure) => failure.message).join("; ") },
{ cause: new AggregateError(distinct) },
).bindCleanupDependencies(
distinct.filter((failure) => failure.cleanupIncomplete),
),
);
}
@@ -186,13 +246,84 @@ export class AnalysisProviderRegistry {
async select(
target: BinaryTarget,
requestedSelector?: AnalysisProviderSelector,
options: {
readonly signal?: AbortSignal;
readonly fileCloseResources?: FileCloseResourceOwner;
} = {},
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
options: { readonly signal?: AbortSignal } = {},
): Promise<SelectionResult> {
if (signalIsAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
if (this.#closing !== undefined) {
const closed = await this.#closing;
if (!closed.ok) return closed;
}
for (const failure of this.#selectionCleanup)
if (!failure.cleanupIncomplete) this.#selectionCleanup.delete(failure);
if (
[...this.#selectionCleanup].some((failure) => failure.cleanupIncomplete)
) {
// A retry may release retained owners; never stop another live selection
// merely because a new caller wants admission.
if (this.#selections.size > 0)
return err(
[...this.#selectionCleanup].find(
(failure) => failure.cleanupIncomplete,
)!,
);
const closed = await this.closeOwnedResources();
if (!closed.ok) return closed;
}
if (signalIsAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const selection = new SharedStartupAttempt<SelectionResult>(
(signal) =>
Promise.resolve().then(() =>
this.#selectTarget(target, requestedSelector, signal),
),
options.signal,
);
this.#selections.add(selection);
void selection.promise.then(
(result) => {
this.#selections.delete(selection);
if (!result.ok && result.error.cleanupIncomplete)
this.#selectionCleanup.add(result.error);
},
() => {
this.#selections.delete(selection);
},
);
const waited = await selection.wait(options.signal);
if (waited.status !== "cancelled") {
if (!waited.value.ok) return waited.value;
if (!selection.signal.aborted) return waited.value;
return err(
new AnalysisCancelledError("open_binary", {
cause: selection.signal.reason,
}),
);
}
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
}
async #selectTarget(
target: BinaryTarget,
requestedSelector: AnalysisProviderSelector | undefined,
signal: AbortSignal,
): Promise<SelectionResult> {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const parsedRequest =
requestedSelector === undefined
? undefined
@@ -210,8 +341,10 @@ export class AnalysisProviderRegistry {
const request = parsedRequest?.data;
const selector = request ?? this.#defaultSelector;
const baseline = this.candidates(target);
if (signalIsAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
return ok({
requestedProviderId: selector,
@@ -236,10 +369,7 @@ export class AnalysisProviderRegistry {
{
target,
baseline,
signal: options.signal,
...(options.fileCloseResources === undefined
? {}
: { fileCloseResources: options.fileCloseResources }),
signal: signal,
},
selector,
request === undefined ? "environment" : "request",
@@ -247,10 +377,7 @@ export class AnalysisProviderRegistry {
return this.#selectAutomatically({
target,
baseline,
signal: options.signal,
...(options.fileCloseResources === undefined
? {}
: { fileCloseResources: options.fileCloseResources }),
signal: signal,
});
}
@@ -299,7 +426,7 @@ export class AnalysisProviderRegistry {
initial,
target,
signal,
context.fileCloseResources,
this.#profileFileOwner,
);
if (!evaluation.ok) return evaluation;
const evaluated = evaluation.value;
@@ -346,7 +473,7 @@ export class AnalysisProviderRegistry {
status,
target,
signal,
context.fileCloseResources,
this.#profileFileOwner,
)
: ok({
candidate: provider,
@@ -355,17 +482,21 @@ export class AnalysisProviderRegistry {
},
),
);
const cancelled = results.find((result) => !result.ok);
if (cancelled !== undefined && !cancelled.ok) return cancelled;
const evaluations = results.map((result) => {
if (!result.ok)
throw new TypeError("Cancelled candidate evaluation disappeared");
return result.value;
const evaluations = results.flatMap((result) =>
result.ok ? [result.value] : [],
);
const failures = results.flatMap((result, index) => {
const error = result.ok ? result.value.profileError : result.error;
return error === undefined
? []
: [{ providerId: this.#providers[index]!.identity().id, error }];
});
const cleanupFailure = evaluations.find(
({ profileError }) => profileError?.cleanupIncomplete === true,
)?.profileError;
if (cleanupFailure !== undefined) return err(cleanupFailure);
if (
results.some((result) => !result.ok) ||
failures.some(({ error }) => error.cleanupIncomplete) ||
signalIsAborted(signal)
)
return err(profileSelectionFailure(failures, signal));
const statuses = evaluations.map(({ status }) => status);
const usable = evaluations.filter(
(
@@ -507,3 +638,64 @@ const cloneTargetSupport = (
...support,
diagnostics: jsonObjectSchema.parse(support.diagnostics),
});
/** Every terminal child remains attributed; cleanup is conjunctive across owners. */
const profileSelectionFailure = (
failures: readonly {
readonly providerId: string;
readonly error: AnalysisError;
}[],
signal?: AbortSignal,
): AnalysisError => {
const primary =
failures.find(({ error }) => !(error instanceof AnalysisCancelledError)) ??
failures[0];
if (primary === undefined)
return new AnalysisCancelledError("open_binary", { cause: signal?.reason });
if (failures.length === 1) return primary.error;
const cleanupFailures = failures
.filter(({ error }) => error.cleanupIncomplete)
.map(({ error }) => error);
const options = {
diagnostics: {
profile_errors: failures.map(({ providerId, error }) => ({
provider_id: providerId,
error: projectAnalysisError(error),
})),
},
...(primary.error.capturedOutput === undefined
? {}
: { capturedOutput: primary.error.capturedOutput }),
...(primary.error.partialObservation === undefined
? {}
: { partialObservation: primary.error.partialObservation }),
...(cleanupFailures.length === 0
? {}
: {
cleanup: {
reason: cleanupFailures.map((error) => error.message).join("; "),
resources: [
...new Set(
cleanupFailures.flatMap((error) => error.cleanupResources),
),
],
},
}),
};
const failure =
primary.error instanceof AnalysisCancelledError
? new AnalysisCancelledError("open_binary", {
...options,
cause: primary.error.cause,
})
: new ProviderAdapterError("rea", "resolve_analysis_profile", {
...options,
userMessage: projectAnalysisError(primary.error).message,
cause: new AggregateError(
failures.map(({ error }) => error),
"Provider profile resolutions failed",
{ cause: primary.error },
),
});
return failure.bindCleanupDependencies(cleanupFailures);
};
+6 -6
View File
@@ -200,12 +200,15 @@ export class BinarySession
current: this.#active,
target,
options,
fileCloseResources: this.fileCloseResourceOwner,
stagedSnapshotMatches: (target, profile) =>
this.matchesSnapshot(target, profile),
}).then((resolved) =>
isAborted(options.signal)
? err(new AnalysisCancelledError("open_binary"))
resolved.ok && isAborted(options.signal)
? err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
)
: resolved,
);
}
@@ -225,7 +228,6 @@ export class BinarySession
current: this.#active,
path,
options: admitted,
fileCloseResources: this.fileCloseResourceOwner,
stagedSnapshotMatches: (target, profile) =>
this.matchesSnapshot(target, profile),
}),
@@ -503,11 +505,9 @@ export class BinarySession
if (clearRecords) this.clearSessionRecords();
this.#clearRuntimeAvailability();
const fileResources = await this.closeWriteResources();
const hashResources = await this.closeFailedFileCloses();
const candidateResources = await this.#providerRouter.closeOwnedResources();
const resourceFailures = [
...(fileResources.ok ? [] : [fileResources.error]),
...(hashResources.ok ? [] : [hashResources.error]),
...(candidateResources.ok ? [] : [candidateResources.error]),
];
let resourceFailure = resourceFailures[0];
+1 -9
View File
@@ -15,7 +15,6 @@ import {
AnalysisSnapshotMismatchError,
} from "../../domain/evidenceErrors.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
import { err, type Result } from "../../domain/result.js";
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
@@ -61,7 +60,6 @@ interface ResolveSessionOpenInput {
target: BinaryTarget,
profile: AnalysisProfileCommitment | null,
) => boolean;
readonly fileCloseResources?: FileCloseResourceOwner;
}
interface ResolveSessionTargetInput {
@@ -73,7 +71,6 @@ interface ResolveSessionTargetInput {
target: BinaryTarget,
profile: AnalysisProfileCommitment | null,
) => boolean;
readonly fileCloseResources?: FileCloseResourceOwner;
}
/** Parse a target, resolve its provider route, and validate snapshot binding. */
@@ -109,12 +106,7 @@ export const resolveSessionTarget = async (
const resolvedRoute =
sameTarget && options.providerId === undefined && current !== undefined
? ({ ok: true, value: current.route } as const)
: await router.resolve(
target,
options.providerId,
options.signal,
input.fileCloseResources,
);
: await router.resolve(target, options.providerId, options.signal);
if (!resolvedRoute.ok) return resolvedRoute;
return validateResolvedSessionTarget({
...input,
+1 -36
View File
@@ -32,8 +32,6 @@ import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
import { writeAnalysisSnapshot as writeSnapshotFile } from "./AnalysisSnapshotFiles.js";
const SNAPSHOT_MUTATION_RECOVERY =
@@ -57,21 +55,9 @@ export interface WorkflowSnapshotRecordInput {
export abstract class BinarySessionRecords {
readonly #records: InvestigationRecords;
readonly #writeResources = new ArtifactResourceScope();
readonly #fileCloseResources = new ArtifactResourceScope();
protected readonly fileCloseResourceOwner: FileCloseResourceOwner = {
retain: (owner, resource) =>
this.#fileCloseResources.retain({
kind: "lease",
resource,
lease: { release: () => owner.close() },
}),
run: (operation) => this.#fileCloseResources.run(operation),
};
protected get hasPendingFileResources(): boolean {
return (
this.#writeResources.hasPending || this.#fileCloseResources.hasPending
);
return this.#writeResources.hasPending;
}
readonly #snapshot = new AnalysisSnapshotCache();
#snapshotInvalidated = false;
@@ -150,27 +136,6 @@ export abstract class BinarySessionRecords {
}
}
/** Retry retained descriptors owned by this composed binary session. */
async closeFailedFileCloses(): Promise<Result<null, AnalysisError>> {
try {
await this.#fileCloseResources.retryCleanup();
return ok(null);
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"provider file descriptor",
);
return err(
new ProviderCleanupError(
"rea",
observation.resources,
{ reason: observation.reason },
{ cause, operation: "close_binary" },
),
);
}
}
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | AnalysisInputError> {
@@ -12,7 +12,6 @@ import type {
CapabilityDescriptor,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
import {
AnalysisProviderRegistry,
type AnalysisProviderBinding,
@@ -86,15 +85,8 @@ export class SessionProviderRouter {
target: BinaryTarget,
providerId?: AnalysisProviderSelector,
signal?: AbortSignal,
fileCloseResources?: FileCloseResourceOwner,
): Promise<Result<SessionProviderRoute, AnalysisError>> {
const resolutionOptions =
signal === undefined && fileCloseResources === undefined
? undefined
: {
...(signal === undefined ? {} : { signal }),
...(fileCloseResources === undefined ? {} : { fileCloseResources }),
};
const resolutionOptions = signal === undefined ? undefined : { signal };
const selected = await this.registry.select(
target,
providerId,
+9 -2
View File
@@ -51,12 +51,19 @@ it("resolves an aggregate only after every bound owner releases", async () => {
["first resource", "second resource"],
{},
);
const first = new AnalysisCleanupLease(async () => undefined);
const second = new AnalysisCleanupLease(async () => undefined);
const first = new AnalysisCleanupLease(
async () => undefined,
["first resource"],
);
const second = new AnalysisCleanupLease(
async () => undefined,
["second resource"],
);
new AnalysisCleanupLeaseGroup(error, [first, second]);
await first.release();
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources).toEqual(["second resource"]);
await second.release();
expect(error.cleanupIncomplete).toBe(false);
});
+23 -1
View File
@@ -6,7 +6,15 @@ export class AnalysisCleanupLease {
#errors: WeakRef<AnalysisError>[] = [];
readonly #groups = new Set<AnalysisCleanupLeaseGroup>();
constructor(private readonly cleanup: () => Promise<void>) {}
readonly cleanupResources: readonly string[] | undefined;
constructor(
private readonly cleanup: () => Promise<void>,
resources?: readonly string[],
) {
this.cleanupResources =
resources === undefined ? undefined : [...resources];
}
async release(): Promise<void> {
if (this.#released) return;
@@ -55,9 +63,23 @@ export class AnalysisCleanupLeaseGroup {
this.#error = new WeakRef(error);
this.#pending = new Set(owners);
if (this.#pending.size === 0) return;
error.bindCleanupResourceOwner(this);
for (const owner of this.#pending) owner.bindCleanupGroup(this);
}
/** Unlabelled owners keep attribution unknown; never match resources by text. */
get cleanupResources(): readonly string[] | undefined {
if (
[...this.#pending].some((owner) => owner.cleanupResources === undefined)
)
return undefined;
return [
...new Set(
[...this.#pending].flatMap((owner) => owner.cleanupResources ?? []),
),
];
}
get hasLiveError(): boolean {
return this.#error.deref() !== undefined;
}
+2 -2
View File
@@ -176,7 +176,7 @@ export const withArtifactRegularFile = async <Value>(
{
symlinks,
retainFailedClose: (owner) => {
cleanupLease = new AnalysisCleanupLease(() => owner.close());
cleanupLease = new AnalysisCleanupLease(() => owner.close(), [path]);
resources.retain({
kind: "lease",
resource: path,
@@ -218,7 +218,7 @@ export const withArtifactFileHandle = async <Value>(
operation: (file: FileHandle) => Promise<Value>,
): Promise<Value> => {
const owner = new OwnedFileHandle(file);
const cleanupLease = new AnalysisCleanupLease(() => owner.close());
const cleanupLease = new AnalysisCleanupLease(() => owner.close(), [path]);
try {
return await withOwnedFileHandle(
path,
+4 -1
View File
@@ -144,7 +144,10 @@ export class CdpTargetSessionOwner {
connection,
operation,
resource,
cleanupLease: new AnalysisCleanupLease(() => connection.close()),
cleanupLease: new AnalysisCleanupLease(
() => connection.close(),
[resource],
),
active: true,
settled,
settle,
@@ -2,28 +2,87 @@ import { expect, it } from "vitest";
import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js";
const event = {
sequence: 1,
correlation_id: "capture:123:1",
kind: "main-handler-invocation" as const,
event: null,
phase: "observed" as const,
channel: "readiness:echo",
direction: "renderer-to-main" as const,
sender: null,
receiver: "main",
frame: null,
target: null,
argument_shapes: [],
result_shape: null,
process_type: "main",
source: "electron-active-hook",
capture_method: "api-wrapper" as const,
artifact_path: null,
artifact_sha256: null,
error: false,
};
it.each(["custom-apply", "listener-chain"])(
"keeps %s gaps distinct from captured IPC and retention loss",
(boundary) => {
const result = projectElectronActiveCapture(
{ executable: "/opt/electron", application: "/opt/app/main.js" },
[],
{
windows: [],
metrics: [],
electronVersion: "test-electron",
hookSnapshot: {
events: [
{
...event,
kind: "ipc-observation-gap",
event: boundary,
channel: "custom",
direction: null,
receiver: null,
target: "ipcMain.on",
},
],
retention_budget_bytes: 16 * 1024 * 1024,
estimated_retained_bytes: 1_024,
event_serialized_byte_upper_bound: 512,
retained: 1,
dropped: 2,
dropped_ipc: 0,
dropped_runtime: 2,
dropped_event_families: [{ family: "ipc-observation-gap", count: 2 }],
dropped_event_roles: [{ role: "main", count: 2 }],
observed: 3,
observed_ipc: 0,
observed_runtime: 3,
hook_error: false,
},
},
);
expect(result.ipc).toEqual({ events: [], observed: 0, dropped: 0 });
expect(result.timeline.events[0]).toMatchObject({
kind: "ipc-observation-gap",
channel: "custom",
});
expect(result.coverage.status).toBe("partial_attach");
expect(result.coverage.observed_event_families).toContain(
"ipc-observation-gap",
);
expect(result.coverage.unavailable_event_families).toContain("ipc");
expect(result.retention.dropped).toBe(2);
expect(result.limitations).toContainEqual(
expect.stringContaining("unobserved callback arguments and execution"),
);
expect(result.limitations).not.toContain(
"The active IPC hook could not be installed.",
);
},
);
it("projects hook retention and dropped event families through the result schema", () => {
const event = {
sequence: 1,
correlation_id: "capture:123:1",
kind: "main-handler-invocation" as const,
event: null,
phase: "observed" as const,
channel: "readiness:echo",
direction: "renderer-to-main" as const,
sender: null,
receiver: "main",
frame: null,
target: null,
argument_shapes: [],
result_shape: null,
process_type: "main",
source: "electron-active-hook",
capture_method: "api-wrapper" as const,
artifact_path: null,
artifact_sha256: null,
error: false,
};
const result = projectElectronActiveCapture(
{
executable: "/opt/electron",
@@ -184,6 +184,11 @@ export const projectElectronActiveCapture = (
coverage: createCoverage(hookSnapshot, observations),
limitations: [
"IPC payloads are represented by value shapes; payload values are never retained.",
...(observations.observedEventFamilies.includes("ipc-observation-gap")
? [
"IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the custom-apply or listener-chain boundary and registered channel; dropped gap records are counted by retention. Listener-chain gaps describe registration uncertainty, not proof of IPC delivery.",
]
: []),
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
+35 -2
View File
@@ -60,6 +60,11 @@ export interface AnalysisErrorOptions extends ErrorOptions {
readonly partialObservation?: AnalysisPartialObservation | undefined;
}
/** Exact cleanup owner that can attribute its currently unresolved resources. */
export interface AnalysisCleanupResourceOwner {
readonly cleanupResources: readonly string[] | undefined;
}
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
readonly #cleanupDependencies = new Set<AnalysisError>();
@@ -68,7 +73,8 @@ export abstract class AnalysisError extends Error {
readonly capturedOutput: AnalysisCapturedOutput | undefined;
readonly cleanup: AnalysisCleanupObservation | undefined;
cleanupIncomplete: boolean;
readonly cleanupResources: readonly string[];
#ownedCleanupResources: readonly string[];
#cleanupResourceOwner: AnalysisCleanupResourceOwner | undefined;
constructor(message: string, options?: AnalysisErrorOptions) {
super(message, options);
this.diagnostics =
@@ -81,7 +87,7 @@ export abstract class AnalysisError extends Error {
: { ...options.capturedOutput };
this.cleanup = options?.cleanup;
this.cleanupIncomplete = options?.cleanup !== undefined;
this.cleanupResources = options?.cleanup?.resources ?? [];
this.#ownedCleanupResources = [...(options?.cleanup?.resources ?? [])];
this.partialObservation = options?.partialObservation;
}
abstract readonly _tag: AnalysisErrorTag;
@@ -91,6 +97,33 @@ export abstract class AnalysisError extends Error {
partialObservation: AnalysisPartialObservation | undefined;
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
/** Live dependencies own current resource attribution; cleanup keeps history. */
get cleanupResources(): readonly string[] {
if (this.#cleanupDependencies.size === 0)
return (
this.#cleanupResourceOwner?.cleanupResources ??
this.#ownedCleanupResources
);
return [
...new Set(
[...this.#cleanupDependencies]
.filter((dependency) => dependency.cleanupIncomplete)
.flatMap((dependency) => dependency.cleanupResources),
),
];
}
/** Bind attribution only to the producer that owns the complete cleanup set. */
bindCleanupResourceOwner(owner: AnalysisCleanupResourceOwner): this {
this.#cleanupResourceOwner = owner;
return this;
}
/** Initialize resource facts supplied by a more specific error producer. */
protected setCleanupResources(resources: readonly string[]): void {
this.#ownedCleanupResources = [...resources];
}
/** Retain a later lifecycle failure without replacing the original typed error. */
retainSecondaryFailure(
failure: AnalysisError,
+3 -3
View File
@@ -12,7 +12,6 @@ import { AnalysisCancelledError } from "./analysisErrorCore.js";
export class BrowserObservationError extends AnalysisError {
readonly _tag = "BrowserObservationError";
override cleanupIncomplete: boolean;
override readonly cleanupResources: readonly string[];
override readonly userCategory: "cancelled" | undefined;
override readonly userMessage: string | undefined;
@@ -26,9 +25,10 @@ export class BrowserObservationError extends AnalysisError {
options?.detail ?? observationFailureMessage(operation, reason);
this.cleanupIncomplete =
this.cleanup !== undefined || reason === "cleanup_failed";
this.cleanupResources =
this.setCleanupResources(
this.cleanup?.resources ??
(reason === "cleanup_failed" ? ["browser_transport"] : []);
(reason === "cleanup_failed" ? ["browser_transport"] : []),
);
this.userCategory =
reason === "cancelled" ||
options?.cause instanceof AnalysisCancelledError ||
@@ -97,6 +97,7 @@ export const electronActiveTimelineEventSchema = z.strictObject({
kind: z.enum([
"main-handler-invocation",
"main-event-invocation",
"ipc-observation-gap",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
+37
View File
@@ -59,3 +59,40 @@ it.each([false, true])(
]);
},
);
it.each(["alpha", "beta"])(
"projects only unresolved resources through nested cleanup groups when %s closes first",
(first) => {
const alpha = new ProviderCleanupError("alpha", ["alpha-file"], {});
const beta = new ProviderCleanupError("beta", ["beta-file"], {});
const third = new ProviderCleanupError("third", ["third-file"], {});
const children = new ProviderCleanupError(
"rea",
["alpha-file", "beta-file"],
{},
).bindCleanupDependencies([alpha, beta]);
const outer = new ProviderCleanupError(
"rea",
["alpha-file", "beta-file", "third-file"],
{},
).bindCleanupDependencies([children, third]);
const released = first === "alpha" ? alpha : beta;
const remaining = first === "alpha" ? beta : alpha;
released.resolveCleanup();
expect(outer.cleanupIncomplete).toBe(true);
expect(outer.cleanupResources).toEqual([
...remaining.cleanupResources,
"third-file",
]);
expect(projectAnalysisError(outer)).toMatchObject({
code: "cleanup_incomplete",
details: { resources: [...remaining.cleanupResources, "third-file"] },
});
remaining.resolveCleanup();
expect(outer.cleanupIncomplete).toBe(true);
expect(outer.cleanupResources).toEqual(["third-file"]);
third.resolveCleanup();
expect(outer.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(outer).code).not.toBe("cleanup_incomplete");
},
);
+1 -2
View File
@@ -5,7 +5,6 @@ import type { AnalysisPartialObservation } from "./analysisErrorBase.js";
/** Provider resources could not be proven closed after bounded cleanup. */
export class ProviderCleanupError extends ProviderAdapterError {
override cleanupIncomplete = true;
override readonly cleanupResources: readonly string[];
override readonly userMessage =
"Provider cleanup could not be fully confirmed. Review the reported local resources before opening another target.";
@@ -30,6 +29,6 @@ export class ProviderCleanupError extends ProviderAdapterError {
...(partialObservation === undefined ? {} : { partialObservation }),
diagnostics,
});
this.cleanupResources = [...resources];
this.setCleanupResources(resources);
}
}
+2 -3
View File
@@ -5,7 +5,6 @@ import type { AnalysisErrorOptions } from "./analysisErrorBase.js";
export class WebScriptExportError extends ArtifactOperationError {
override readonly userMessage: string;
override cleanupIncomplete: boolean;
override readonly cleanupResources: readonly string[];
constructor(
reason: ArtifactOperationError["reason"],
@@ -17,12 +16,12 @@ export class WebScriptExportError extends ArtifactOperationError {
) {
super("export_web_scripts", reason, undefined, undefined, options);
this.userMessage = `Script export failed for ${target}: ${diagnostic}`;
this.cleanupResources = [
this.setCleanupResources([
...new Set([
...(options.cleanup?.resources ?? []),
...(options.residualPaths ?? []),
]),
];
]);
this.cleanupIncomplete =
options.cleanup !== undefined || (options.residualPaths?.length ?? 0) > 0;
}
+23 -7
View File
@@ -1,3 +1,4 @@
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { createHash } from "node:crypto";
import {
mkdtemp,
@@ -27,7 +28,10 @@ import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixt
import { GhidraProvider } from "./GhidraProvider.js";
const directories: string[] = [];
let resources = new ArtifactResourceScope();
afterEach(async () => {
await resources.close();
resources = new ArtifactResourceScope();
for (const directory of directories.splice(0))
await rm(directory, { recursive: true, force: true });
});
@@ -92,7 +96,7 @@ describe("Ghidra seed commitment", () => {
const directory = await workspace();
const path = join(directory, "seeds.tsv");
await writeFile(path, SEEDS);
const resolved = await resolveGhidraAnalysisSeeds(path);
const resolved = await resolveGhidraAnalysisSeeds(path, resources);
if (!resolved.ok) throw resolved.error;
const commitment = resolved.value;
if (commitment === undefined) throw new Error("Expected a commitment");
@@ -103,12 +107,16 @@ describe("Ghidra seed commitment", () => {
entries: { function: 2, code: 1, label: 1 },
});
const runtime = await workspace();
const snapshot = await snapshotGhidraAnalysisSeeds(commitment, runtime);
const snapshot = await snapshotGhidraAnalysisSeeds(
commitment,
runtime,
resources,
);
expect(await readFile(snapshot, "utf8")).toBe(CANONICAL);
});
it("is absent when no seed file is configured", async () => {
const resolved = await resolveGhidraAnalysisSeeds(undefined);
const resolved = await resolveGhidraAnalysisSeeds(undefined, resources);
expect(resolved.ok && resolved.value).toBeUndefined();
});
@@ -116,13 +124,20 @@ describe("Ghidra seed commitment", () => {
const directory = await workspace();
const path = join(directory, "seeds.tsv");
await writeFile(path, SEEDS);
const resolved = await resolveGhidraAnalysisSeeds(path);
const resolved = await resolveGhidraAnalysisSeeds(path, resources);
if (!resolved.ok || resolved.value === undefined)
throw new Error("Expected a commitment");
await writeFile(path, `${SEEDS}0x401100\tcode\n`);
await expect(
snapshotGhidraAnalysisSeeds(resolved.value, await workspace()),
).rejects.toThrow(/changed after the analysis profile was committed/u);
snapshotGhidraAnalysisSeeds(resolved.value, await workspace(), resources),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
reason: expect.stringMatching(
/changed after the analysis profile was committed/u,
),
},
});
});
it("commits the resolved path behind a configured symbolic link", async () => {
@@ -131,7 +146,7 @@ describe("Ghidra seed commitment", () => {
await writeFile(path, SEEDS);
const link = join(directory, "link.tsv");
await symlink(path, link);
const resolved = await resolveGhidraAnalysisSeeds(link);
const resolved = await resolveGhidraAnalysisSeeds(link, resources);
if (!resolved.ok || resolved.value === undefined)
throw new Error("Expected a commitment");
expect(resolved.value.path).toBe(await realpath(path));
@@ -140,6 +155,7 @@ describe("Ghidra seed commitment", () => {
it("reports an unreadable seed file as a provider error", async () => {
const resolved = await resolveGhidraAnalysisSeeds(
join(await workspace(), "absent.tsv"),
resources,
);
if (resolved.ok) throw new Error("Expected an error");
expect(projectAnalysisError(resolved.error)).toMatchObject({
+94 -96
View File
@@ -1,12 +1,18 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { mkdir, open, realpath, writeFile } from "node:fs/promises";
import { mkdir, realpath } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
import type { AnalysisOperation } from "../application/AnalysisProvider.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
readGhidraProfileArtifact,
writeGhidraProfileArtifact,
ghidraProfileArtifactError,
} from "./GhidraProfileArtifacts.js";
import { err, ok, type Result } from "../domain/result.js";
/** Seed file format admitted by this adapter. */
@@ -131,114 +137,106 @@ const normalizedSeeds = (entries: readonly SeedEntry[]): string =>
)
.join("");
/** Read a bounded regular file through one handle without following links. */
const readSeedFile = async (path: string): Promise<Buffer> => {
const file = await open(
path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > MAX_GHIDRA_SEED_BYTES)
throw new Error(
`Ghidra seed file must be a regular file of at most ${MAX_GHIDRA_SEED_BYTES} bytes: ${path}`,
);
const bytes = Buffer.alloc(stat.size);
let offset = 0;
while (offset < bytes.length) {
const read = await file.read(
bytes,
offset,
bytes.length - offset,
offset,
);
if (read.bytesRead === 0)
throw new Error(`Incomplete Ghidra seed file read: ${path}`);
offset += read.bytesRead;
}
return bytes;
} finally {
await file.close();
}
};
const sha256 = (bytes: Buffer | string): string =>
createHash("sha256").update(bytes).digest("hex");
const decodeSeeds = (bytes: Buffer) => {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return parseGhidraSeeds(text);
};
/** Parse seed facts before final close so later cleanup cannot replace invalid input. */
const readSeeds = (
path: string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<readonly SeedEntry[]> =>
readGhidraProfileArtifact(
path,
MAX_GHIDRA_SEED_BYTES,
resources,
signal,
(bytes) => {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw seedLineError(0, "file is not valid UTF-8");
}
const parsed = parseGhidraSeeds(text);
if (!parsed.ok) throw parsed.error;
return parsed.value;
},
);
/** Resolve the configured seed file into an immutable profile commitment. */
export const resolveGhidraAnalysisSeeds = async (
configured: string | undefined,
): Promise<
Result<
GhidraSeedCommitment | undefined,
ConfigurationError | ProviderAdapterError
>
> => {
if (configured === undefined) return ok(undefined);
let bytes: Buffer;
let path: string;
try {
path = await realpath(configured);
bytes = await readSeedFile(path);
} catch (cause: unknown) {
return err(
new ProviderAdapterError("ghidra", "resolve_analysis_profile", {
cause,
diagnostics: {
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<Result<GhidraSeedCommitment | undefined, AnalysisError>> =>
resources
.run(async () => {
if (configured === undefined) return ok(undefined);
let entries: readonly SeedEntry[];
let path: string;
try {
path = await realpath(configured);
entries = await readSeeds(path, resources, signal);
} catch (cause: unknown) {
return err(
ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
setting: "REA_GHIDRA_SEED_FILE",
path: configured,
}),
);
}
const normalized = normalizedSeeds(entries);
const tally = { function: 0, code: 0, label: 0 };
for (const entry of entries) tally[entry.kind] += 1;
return ok<GhidraSeedCommitment>({
format: GHIDRA_SEED_FORMAT,
path,
configured_path: configured,
sha256: sha256(normalized),
entries: tally,
});
})
.catch((cause: unknown) =>
err(
ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
setting: "REA_GHIDRA_SEED_FILE",
path: configured,
reason: cause instanceof Error ? cause.message : String(cause),
},
}),
}),
),
);
}
let parsed: ReturnType<typeof decodeSeeds>;
try {
parsed = decodeSeeds(bytes);
} catch {
return err(seedLineError(0, "file is not valid UTF-8"));
}
if (!parsed.ok) return parsed;
const normalized = normalizedSeeds(parsed.value);
const tally = { function: 0, code: 0, label: 0 };
for (const entry of parsed.value) tally[entry.kind] += 1;
return ok({
format: GHIDRA_SEED_FORMAT,
path,
configured_path: configured,
sha256: sha256(normalized),
entries: tally,
});
};
/** Re-read the committed seeds and write the canonical copy the script applies. */
export const snapshotGhidraAnalysisSeeds = async (
commitment: GhidraSeedCommitment,
runtimeRoot: string,
): Promise<string> => {
ghidraSeedCommitmentSchema.parse(commitment);
if ((await realpath(commitment.configured_path)) !== commitment.path)
throw new Error(
"Ghidra seed file resolved path differs from explicit configuration",
);
const parsed = decodeSeeds(await readSeedFile(commitment.path));
if (!parsed.ok) throw parsed.error;
const normalized = normalizedSeeds(parsed.value);
if (sha256(normalized) !== commitment.sha256)
throw new Error(
`Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`,
);
const directory = join(runtimeRoot, "seeds");
await mkdir(directory, { mode: 0o700 });
const path = join(directory, "seeds.tsv");
await writeFile(path, normalized, { flag: "wx", mode: 0o600 });
return path;
};
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<string> =>
resources
.run(async () => {
ghidraSeedCommitmentSchema.parse(commitment);
if ((await realpath(commitment.configured_path)) !== commitment.path)
throw new Error(
"Ghidra seed file resolved path differs from explicit configuration",
);
const entries = await readSeeds(commitment.path, resources, signal);
const normalized = normalizedSeeds(entries);
if (sha256(normalized) !== commitment.sha256)
throw new Error(
`Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`,
);
const directory = join(runtimeRoot, "seeds");
await mkdir(directory, { mode: 0o700 });
const path = join(directory, "seeds.tsv");
await writeGhidraProfileArtifact(path, normalized, resources, signal);
return path;
})
.catch((cause: unknown) => {
throw ghidraProfileArtifactError(cause, "open_binary", {
setting: "REA_GHIDRA_SEED_FILE",
path: commitment.path,
});
});
const totalEntries = (commitment: GhidraSeedCommitment): number =>
commitment.entries.function +
+24 -11
View File
@@ -309,17 +309,30 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
platform,
this.options.configurationDirectoryName,
);
const seedPath =
this.options.analysisSeeds === undefined
? undefined
: await snapshotGhidraAnalysisSeeds(
this.options.analysisSeeds,
session.runtimeRoot,
);
const extensions = await snapshotGhidraExtensions(
this.options.analysisExtensions ?? [],
session.runtimeRoot,
);
let seedPath: string | undefined;
let extensions: readonly GhidraExtension[] = [];
if (
this.options.analysisSeeds !== undefined ||
(this.options.analysisExtensions?.length ?? 0) > 0
) {
if (options.resourceScope === undefined)
throw new GhidraLaunchError(
"Configured Ghidra artifacts require a caller-owned resource scope.",
);
if (this.options.analysisSeeds !== undefined)
seedPath = await snapshotGhidraAnalysisSeeds(
this.options.analysisSeeds,
session.runtimeRoot,
options.resourceScope,
options.signal,
);
extensions = await snapshotGhidraExtensions(
this.options.analysisExtensions ?? [],
session.runtimeRoot,
options.resourceScope,
options.signal,
);
}
await writeGhidraRuntimeFile(
paths.descriptorPath,
`${JSON.stringify({
+120
View File
@@ -0,0 +1,120 @@
import { lstat, open } from "node:fs/promises";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
assertArtifactReadActive,
artifactFileCancellationCause,
artifactFilePrimaryFailure,
bindArtifactFileCleanup,
withArtifactFileHandle,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
import { sameRegularFileState } from "../filesystem/RegularFile.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Bounded serial read; the caller owns admission and final descriptor cleanup. */
export const readGhidraProfileArtifact = <Value>(
path: string,
maximumBytes: number,
resources: ArtifactResourceScope,
signal: AbortSignal | undefined,
interpret: (bytes: Buffer) => Value,
): Promise<Value> =>
withArtifactRegularFile(path, resources, signal, async (file) => {
const before = await file.stat({ bigint: true });
if (before.size > BigInt(maximumBytes))
throw new Error(
`Ghidra profile artifact must be at most ${String(maximumBytes)} bytes: ${path}`,
);
const bytes = Buffer.alloc(Number(before.size));
let offset = 0;
while (offset < bytes.length) {
assertArtifactReadActive(signal);
const read = await file.read(
bytes,
offset,
bytes.length - offset,
offset,
);
if (read.bytesRead === 0)
throw new Error(`Incomplete Ghidra profile artifact read: ${path}`);
offset += read.bytesRead;
}
assertArtifactReadActive(signal);
const extra = Buffer.alloc(1);
if (
(await file.read(extra, 0, 1, offset)).bytesRead !== 0 ||
!sameRegularFileState(before, await file.stat({ bigint: true })) ||
!sameRegularFileState(before, await lstat(path, { bigint: true }))
)
throw new Error(`Ghidra profile artifact changed during read: ${path}`);
assertArtifactReadActive(signal);
return interpret(bytes);
});
/** Own the new private snapshot descriptor through write and final close. */
export const writeGhidraProfileArtifact = async (
path: string,
bytes: Buffer | string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<void> => {
assertArtifactReadActive(signal);
const file = await open(path, "wx", 0o600);
await withArtifactFileHandle(path, file, resources, signal, (handle) =>
handle.writeFile(bytes),
);
};
/** Preserve physical failure, exact cancellation, and all retained file leases. */
export const ghidraProfileArtifactError = (
cause: unknown,
operation: string,
diagnostics: Readonly<Record<string, JsonValue>> = {},
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
const primary = artifactFilePrimaryFailure(cause);
const reason = primary instanceof Error ? primary.message : String(primary);
const cleanup =
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
if (primary instanceof AnalysisError) {
if (cleanup === undefined) return primary;
const failure = new ProviderCleanupError(
"ghidra",
cleanup.resources,
{
reason: cleanup.reason,
},
{ cause },
);
bindArtifactFileCleanup(cause, failure);
return analysisErrorWithCleanupFailure(
primary,
failure,
operation,
).bindCleanupDependencies([
...(primary.cleanupIncomplete ? [primary] : []),
failure,
]);
}
const error =
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
? new AnalysisCancelledError(operation, {
cause: artifactFileCancellationCause(cause),
cleanup,
diagnostics,
})
: new ProviderAdapterError("ghidra", operation, {
cause,
cleanup,
diagnostics: { ...diagnostics, reason },
userMessage: reason,
});
bindArtifactFileCleanup(cause, error);
return error;
};
+51 -5
View File
@@ -6,6 +6,7 @@ import type {
AnalysisClient,
AnalysisClientContext,
AnalysisProfileResolutionOptions,
AnalysisProfileResolution,
AnalysisProviderCandidate,
AnalysisProviderReadinessSnapshot,
CapabilityDescriptor,
@@ -27,7 +28,8 @@ import {
jsonValueSchema,
type JsonValue,
} from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Logger } from "pino";
@@ -82,6 +84,10 @@ const SUPPORTED_ARCHITECTURES = new Set([
/** Ghidra candidate backed by an isolated ephemeral headless import. */
export class GhidraProvider implements AnalysisProviderCandidate {
readonly #inspectionResources = new ArtifactResourceScope();
readonly #profileWork = new Set<
Promise<Result<AnalysisProfileResolution, AnalysisError>>
>();
#closing: Promise<Result<null, AnalysisError>> | undefined;
#installation: GhidraInstallationInspection | undefined;
private readonly environment: NodeJS.ProcessEnv;
@@ -228,10 +234,22 @@ export class GhidraProvider implements AnalysisProviderCandidate {
}
hasPendingOwnedResources(): boolean {
return this.#inspectionResources.hasPending;
return this.#profileWork.size > 0 || this.#inspectionResources.hasPending;
}
async closeOwnedResources() {
closeOwnedResources() {
this.#closing ??= Promise.resolve()
.then(async () => {
await Promise.allSettled(this.#profileWork);
return this.#closeInspectionResources();
})
.finally(() => {
this.#closing = undefined;
});
return this.#closing;
}
async #closeInspectionResources() {
try {
await this.#inspectionResources.retryCleanup();
return ok(null);
@@ -252,6 +270,25 @@ export class GhidraProvider implements AnalysisProviderCandidate {
async resolveAnalysisProfile(
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>> {
if (this.#closing !== undefined) {
const closed = await this.#closing;
if (!closed.ok) return closed;
}
const work = Promise.resolve().then(() =>
this.#resolveProfile(target, options),
);
this.#profileWork.add(work);
try {
return await work;
} finally {
this.#profileWork.delete(work);
}
}
async #resolveProfile(
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
) {
const installation = this.#inspectInstallation();
const resolved = await resolveGhidraAnalysisProfile(
@@ -307,12 +344,21 @@ export class GhidraProvider implements AnalysisProviderCandidate {
this.config,
target,
installation.platform,
this.#inspectionResources,
options?.signal,
);
if (!extensions.ok) return extensions;
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError("open_binary"));
const seeds = await resolveGhidraAnalysisSeeds(this.config.ghidraSeedFile);
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal.reason,
}),
);
const seeds = await resolveGhidraAnalysisSeeds(
this.config.ghidraSeedFile,
this.#inspectionResources,
options?.signal,
);
if (!seeds.ok) return seeds;
if (extensions.value.length === 0 && seeds.value === undefined)
return resolved;
+1 -1
View File
@@ -287,7 +287,7 @@ const readChildren = async (
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
const lease = new AnalysisCleanupLease(() => owner.close());
const lease = new AnalysisCleanupLease(() => owner.close(), [path]);
const attempt = await resources.release({
kind: "lease",
resource: path,
+50 -11
View File
@@ -1,3 +1,4 @@
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { promisify } from "node:util";
@@ -19,7 +20,10 @@ import {
} from "./GhidraExtensions.js";
const roots: string[] = [];
let resources = new ArtifactResourceScope();
afterEach(async () => {
await resources.close();
resources = new ArtifactResourceScope();
await Promise.all(
roots.splice(0).map((path) => rm(path, { recursive: true, force: true })),
);
@@ -49,7 +53,12 @@ const artifact = async () => {
const path = join(directory, "fixture.jar");
const bytes = Buffer.from([0x50, 0x4b, 3, 4, 0, 1, 2, 3]);
await writeFile(path, bytes);
const resolved = await resolveGhidraExtensions(config(path), target, "linux");
const resolved = await resolveGhidraExtensions(
config(path),
target,
"linux",
resources,
);
if (!resolved.ok || resolved.value[0] === undefined)
throw new Error("expected fixture artifact");
return { extension: resolved.value[0], bytes, path };
@@ -77,6 +86,7 @@ describe("optional Ghidra extension refusals", () => {
config("/absent.jar"),
target,
"darwin",
resources,
);
expect(host.ok).toBe(false);
if (!host.ok)
@@ -101,6 +111,7 @@ describe("optional Ghidra extension refusals", () => {
availableArchitectures: ["arm64"],
},
"linux",
resources,
);
expect(architecture.ok).toBe(false);
if (!architecture.ok)
@@ -120,7 +131,9 @@ describe("optional Ghidra extension refusals", () => {
describe("optional Ghidra extension boundary", () => {
it("keeps ordinary profiles free of unconfigured extensions", async () => {
expect(await resolveGhidraExtensions(config(), target, "linux")).toEqual({
expect(
await resolveGhidraExtensions(config(), target, "linux", resources),
).toEqual({
ok: true,
value: [],
});
@@ -142,21 +155,31 @@ describe("optional Ghidra extension boundary", () => {
it("snapshots identical bytes exclusively into the private runtime", async () => {
const { extension, bytes } = await artifact();
const runtime = await root();
const snapshots = await snapshotGhidraExtensions([extension], runtime);
const snapshots = await snapshotGhidraExtensions(
[extension],
runtime,
resources,
);
expect(snapshots[0]?.path).toBe(join(runtime, "extensions/nativeaot.jar"));
expect(await readFile(join(runtime, "extensions/nativeaot.jar"))).toEqual(
bytes,
);
await expect(
snapshotGhidraExtensions([extension], runtime),
).rejects.toMatchObject({ code: "EEXIST" });
snapshotGhidraExtensions([extension], runtime, resources),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
cause: { code: "EEXIST" },
});
});
it("rejects artifact changes after the profile commitment", async () => {
const { extension, path } = await artifact();
await writeFile(path, Buffer.from([0x50, 0x4b, 3, 4, 9, 9, 9, 9]));
await expect(
snapshotGhidraExtensions([extension], await root()),
).rejects.toThrow("digest differs");
snapshotGhidraExtensions([extension], await root(), resources),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("digest differs") },
});
});
it("rejects nonexistent, empty and non-JAR artifacts with useful reasons", async () => {
const directory = await root();
@@ -167,6 +190,7 @@ describe("optional Ghidra extension boundary", () => {
config(path),
target,
"linux",
resources,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
@@ -179,6 +203,7 @@ describe("optional Ghidra extension boundary", () => {
config(absentPath),
target,
"linux",
resources,
);
expect(absent.ok).toBe(false);
if (!absent.ok)
@@ -212,11 +237,12 @@ describe("optional Ghidra extension boundary", () => {
config(path),
target,
"linux",
resources,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
expect(resolved.error).toMatchObject({
diagnostics: { reason: expect.stringContaining("regular JAR") },
diagnostics: { reason: expect.stringContaining("regular file") },
});
expect(writerRequired).toBe(false);
} finally {
@@ -232,6 +258,7 @@ describe("optional Ghidra extension boundary", () => {
config("/absent.jar"),
target,
"linux",
resources,
controller.signal,
);
expect(resolved.ok).toBe(false);
@@ -332,6 +359,7 @@ describe("Ghidra extension profile and producer validation", () => {
config("/absent.jar"),
{ ...target, format: "pe", managed: true, executableRole: "application" },
"linux",
resources,
);
expect(resolved.ok).toBe(false);
if (!resolved.ok)
@@ -349,15 +377,26 @@ describe("Ghidra extension profile and producer validation", () => {
snapshotGhidraExtensions(
[{ ...second.extension, configured_path: first.path }],
await root(),
resources,
),
).rejects.toThrow("resolved path differs");
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("resolved path differs") },
});
});
it("rejects unregistered descriptors before snapshot filesystem writes", async () => {
const { extension } = await artifact();
const runtime = await root();
await expect(
snapshotGhidraExtensions([{ ...extension, id: "unknown" }], runtime),
).rejects.toThrow("Unregistered");
snapshotGhidraExtensions(
[{ ...extension, id: "unknown" }],
runtime,
resources,
),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("Unregistered") },
});
});
});
+109 -103
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open, realpath, writeFile, mkdir } from "node:fs/promises";
import { realpath, mkdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
import type { AppConfig } from "../../config/types.js";
@@ -10,7 +9,13 @@ import {
AnalysisCapabilityUnavailableError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
import {
readGhidraProfileArtifact,
writeGhidraProfileArtifact,
ghidraProfileArtifactError,
} from "../GhidraProfileArtifacts.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -68,86 +73,74 @@ export interface GhidraExtensionAdapter {
const adapters: readonly GhidraExtensionAdapter[] = [nativeAotAdapter];
const MAX_EXTENSION_BYTES = 8 * 1024 * 1024;
/** Read a bounded regular artifact through its open handle, without ambient state changes. */
const readJar = async (path: string): Promise<Buffer> => {
const file = await open(
/** JAR framing belongs to the extension adapter; file lifecycle is shared. */
const readJar = (
path: string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<Buffer> =>
readGhidraProfileArtifact(
path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
MAX_EXTENSION_BYTES,
resources,
signal,
(bytes) => {
if (bytes.length < 4 || bytes.readUInt32LE(0) !== 0x04034b50)
throw new Error(`Ghidra extension is not a JAR: ${path}`);
return bytes;
},
);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size < 4 || stat.size > MAX_EXTENSION_BYTES)
throw new Error(
`Ghidra extension must be a regular JAR of 4..${MAX_EXTENSION_BYTES} bytes: ${path}`,
);
const bytes = Buffer.alloc(stat.size);
let offset = 0;
while (offset < bytes.length) {
const read = await file.read(
bytes,
offset,
bytes.length - offset,
offset,
);
if (read.bytesRead === 0)
throw new Error(`Incomplete Ghidra extension read: ${path}`);
offset += read.bytesRead;
}
const extra = Buffer.alloc(1);
if (
(await file.read(extra, 0, 1, offset)).bytesRead !== 0 ||
bytes.readUInt32LE(0) !== 0x04034b50
)
throw new Error(
`Ghidra extension changed during read or is not a JAR: ${path}`,
);
return bytes;
} finally {
await file.close();
}
};
/** Resolve explicitly configured optional extensions into the immutable analysis profile. */
export const resolveGhidraExtensions = async (
config: AppConfig,
target: BinaryTarget,
platform: NodeJS.Platform,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<Result<readonly GhidraExtension[], AnalysisError>> => {
const extensions: GhidraExtension[] = [];
for (const adapter of adapters) {
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
const configured = adapter.configuredPath(config);
if (configured === undefined) continue;
const refusal = adapter.unsupportedReason(target, platform);
if (refusal !== null) return err(extensionRefusalError(refusal, target));
try {
const path = await realpath(configured);
const bytes = await readJar(path);
extensions.push({
id: adapter.id,
path,
configured_path: configured,
sha256: createHash("sha256").update(bytes).digest("hex"),
entry_class: adapter.entryClass,
integration_api: 1,
});
} catch (cause: unknown) {
return err(
new ProviderAdapterError("ghidra", "resolve_analysis_profile", {
cause,
diagnostics: {
extension: adapter.id,
path: configured,
reason: cause instanceof Error ? cause.message : String(cause),
},
}),
);
}
}
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
return ok(extensions);
};
): Promise<Result<readonly GhidraExtension[], AnalysisError>> =>
resources
.run(async () => {
const extensions: GhidraExtension[] = [];
for (const adapter of adapters) {
if (signal?.aborted)
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const configured = adapter.configuredPath(config);
if (configured === undefined) continue;
const refusal = adapter.unsupportedReason(target, platform);
if (refusal !== null)
return err(extensionRefusalError(refusal, target));
try {
const path = await realpath(configured);
const bytes = await readJar(path, resources, signal);
extensions.push({
id: adapter.id,
path,
configured_path: configured,
sha256: createHash("sha256").update(bytes).digest("hex"),
entry_class: adapter.entryClass,
integration_api: 1,
});
} catch (cause: unknown) {
return err(
ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
extension: adapter.id,
path: configured,
}),
);
}
}
if (signal?.aborted)
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
return ok(extensions);
})
.catch((cause: unknown) =>
err(ghidraProfileArtifactError(cause, "resolve_analysis_profile")),
);
/** Require explicit local configuration before admitting executable code from a profile. */
export const validateGhidraExtensionProfile = (
@@ -206,35 +199,48 @@ const extensionRefusalError = (
export const snapshotGhidraExtensions = async (
extensions: readonly GhidraExtension[],
runtimeRoot: string,
): Promise<readonly GhidraExtension[]> => {
if (extensions.length === 0) return [];
for (const extension of extensions) {
ghidraExtensionSchema.parse(extension);
const adapter = adapters.find((value) => value.id === extension.id);
if (adapter === undefined || adapter.entryClass !== extension.entry_class)
throw new Error(
`Unregistered Ghidra extension identity: ${extension.id}`,
);
if ((await realpath(extension.configured_path)) !== extension.path)
throw new Error(
`Ghidra extension resolved path differs from explicit configuration: ${extension.id}`,
);
}
const directory = join(runtimeRoot, "extensions");
await mkdir(directory, { mode: 0o700 });
const result: GhidraExtension[] = [];
for (const extension of extensions) {
const bytes = await readJar(extension.path);
if (createHash("sha256").update(bytes).digest("hex") !== extension.sha256)
throw new Error(
`Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`,
);
const path = join(directory, `${extension.id}.jar`);
await writeFile(path, bytes, { flag: "wx", mode: 0o600 });
result.push({ ...extension, path });
}
return result;
};
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<readonly GhidraExtension[]> =>
resources
.run(async () => {
assertArtifactReadActive(signal);
if (extensions.length === 0) return [];
for (const extension of extensions) {
ghidraExtensionSchema.parse(extension);
const adapter = adapters.find((value) => value.id === extension.id);
if (
adapter === undefined ||
adapter.entryClass !== extension.entry_class
)
throw new Error(
`Unregistered Ghidra extension identity: ${extension.id}`,
);
if ((await realpath(extension.configured_path)) !== extension.path)
throw new Error(
`Ghidra extension resolved path differs from explicit configuration: ${extension.id}`,
);
}
const directory = join(runtimeRoot, "extensions");
await mkdir(directory, { mode: 0o700 });
const result: GhidraExtension[] = [];
for (const extension of extensions) {
const bytes = await readJar(extension.path, resources, signal);
if (
createHash("sha256").update(bytes).digest("hex") !== extension.sha256
)
throw new Error(
`Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`,
);
const path = join(directory, `${extension.id}.jar`);
await writeGhidraProfileArtifact(path, bytes, resources, signal);
result.push({ ...extension, path });
}
return result;
})
.catch((cause: unknown) => {
throw ghidraProfileArtifactError(cause, "open_binary");
});
/** Require every configured producer result to match its artifact and registered ABI. */
export const validateGhidraExtensionResults = (
+1 -2
View File
@@ -105,7 +105,6 @@ interface UpstreamSession {
/** A failed cleanup retains its private workspace and reports the remaining resource. */
class IdaCleanupError extends ProviderAdapterError {
override readonly cleanupIncomplete = true;
override readonly cleanupResources: readonly string[];
constructor(
path: string,
cause: unknown,
@@ -146,7 +145,7 @@ class IdaCleanupError extends ProviderAdapterError {
}),
},
});
this.cleanupResources = [path];
this.setCleanupResources([path]);
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const createKunaFileCloseScope = (
return {
owner: {
retain: (owner, resource) => {
const lease = new AnalysisCleanupLease(() => owner.close());
const lease = new AnalysisCleanupLease(() => owner.close(), [resource]);
leases.set(owner, lease);
parent.retain({ close: () => lease.release() }, resource);
},
+1 -1
View File
@@ -1048,7 +1048,7 @@ export class KunaProjectCache {
key,
staging,
resource,
lease: new AnalysisCleanupLease(async () => owner.close()),
lease: new AnalysisCleanupLease(async () => owner.close(), [resource]),
};
this.#hashRecords.set(owner, retained);
this.#retainedHashFiles.set(owner, retained);
+4 -1
View File
@@ -118,7 +118,10 @@ export class KunaTargetCommands {
reportedTimeoutMs: request.timeoutMs,
launcher: this.lifetime.launcher,
retainFailedFileClose: (owner, resource) => {
const lease = new AnalysisCleanupLease(() => owner.close());
const lease = new AnalysisCleanupLease(
() => owner.close(),
[resource],
);
this.#resources.retain({ kind: "lease", resource, lease });
failedHashClose = { owner, lease };
},
+32
View File
@@ -70,3 +70,35 @@ it("does not lose an unexpected producer rejection after its final caller cancel
reject?.(cause);
await expect(waiting).rejects.toBe(cause);
});
it.each(["caller", "owner"])(
"preserves %s interruption when owning cancellation and closure occur together",
async (first) => {
const completed = createDeferred<string>();
const controller = new AbortController();
const reason = { caller: "operation owner" };
const attempt = new SharedStartupAttempt(
() => completed.promise,
controller.signal,
);
const waiting = attempt.wait();
if (first === "caller") {
controller.abort(reason);
attempt.close();
} else {
attempt.close();
expect(attempt.signal.aborted).toBe(true);
expect(attempt.signal.reason).toBe(STARTUP_OWNER_CLOSED);
controller.abort(reason);
}
expect(attempt.signal.reason).toBe(
first === "caller" ? reason : STARTUP_OWNER_CLOSED,
);
expect(attempt.settled).toBe(false);
completed.resolve("finalized");
await expect(waiting).resolves.toEqual({
status: "completed",
value: "finalized",
});
},
);
+12 -3
View File
@@ -18,11 +18,20 @@ type StartupSettlement<Value> =
export class SharedStartupAttempt<Value> {
readonly #controller = new AbortController();
readonly promise: Promise<Value>;
readonly #signal: AbortSignal;
#waiters = 0;
#settlement: StartupSettlement<Value> = { status: "pending" };
constructor(start: (signal: AbortSignal) => Promise<Value>) {
this.promise = start(this.#controller.signal);
constructor(
start: (signal: AbortSignal) => Promise<Value>,
/** An owning operation's cancellation is independent of individual waiters. */
ownerSignal?: AbortSignal,
) {
this.#signal =
ownerSignal === undefined
? this.#controller.signal
: AbortSignal.any([ownerSignal, this.#controller.signal]);
this.promise = start(this.#signal);
void this.promise.then(
(value) => {
this.#settlement = { status: "completed", value };
@@ -35,7 +44,7 @@ export class SharedStartupAttempt<Value> {
/** Signal consumed by the startup producer, never by an individual waiter. */
get signal(): AbortSignal {
return this.#controller.signal;
return this.#signal;
}
/** Completion includes the producer's final cleanup, not just its abort. */
File diff suppressed because it is too large. Load diff
+69
View File
@@ -5,6 +5,10 @@ import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import {
execFileOutput,
execFileOutputFailure,
} from "../../../src/process/ExecFileOutput.js";
import {
expectConfigurationRefusal,
serveCli as serve,
@@ -14,6 +18,71 @@ import {
removeTestWorkspace,
} from "../../support/workspace/workspaceFixture.js";
it.skipIf(process.platform === "win32").each([
{ exit: 1, stdout: "", stderr: "adb: error: closed", overwrite: false },
{ exit: 1, stdout: "", stderr: "adb: error: closed", overwrite: true },
{ exit: 1, stdout: "unexpected probe output", stderr: "", overwrite: false },
{ exit: 2, stdout: "", stderr: "", overwrite: false },
])("refuses an ambiguous destination probe before push: %j", async (probe) => {
const workspace = await createTestWorkspace("rea-adb-probe-cli-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const source = await workspace.write("payload.bin", "replacement");
await workspace.write("device-file", "original");
const adb = await workspace.write(
"adb",
`#!/bin/sh
root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
printf '%s\\n' "$3" >> "$root/calls"
if [ "$1" = version ]; then
printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-cli\\nInstalled as %s\\n' "$0"
exit 0
fi
if [ "$3" = shell ] && [ "$4" = test ]; then
printf '%s' '${probe.stdout}'
printf '%s' '${probe.stderr}' >&2
exit ${String(probe.exit)}
fi
if [ "$3" = push ]; then cp "$4" "$root/device-file"; exit 0; fi
exit 1
`,
);
await chmod(adb, 0o755);
const result = await execFileOutput(
process.execPath,
[
"scripts/rea.mjs",
"push-adb-file",
"device",
source,
"/data/local/tmp/payload.bin",
...(probe.overwrite ? ["--overwrite"] : []),
"--json",
],
{
env: { ...process.env, REA_ADB_PATH: adb },
timeout: 20_000,
stopSignal: "SIGTERM",
maxBuffer: 1024 * 1024,
},
).then(
(output) => ({ exitCode: 0, stdout: output.stdout }),
(cause: unknown) => {
const failure = execFileOutputFailure(cause);
if (failure === undefined) throw cause;
return { exitCode: failure.code, stdout: failure.stdout };
},
);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "unreadable_output",
});
if (probe.stderr !== "") expect(result.stdout).toContain(probe.stderr);
expect(await readFile(workspace.path("device-file"), "utf8")).toBe(
"original",
);
expect(await readFile(workspace.path("calls"), "utf8")).toBe("\nshell\n");
});
it.each([
[
["inspect-adb-client", "--json"],
@@ -0,0 +1,565 @@
import * as extensionModule from "../../../src/ghidra/extensions/GhidraExtensions.js";
import { createHash } from "node:crypto";
import { basename } from "node:path";
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
import { GhidraHeadlessLauncher } from "../../../src/ghidra/GhidraLauncher.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js";
import { silentLogger } from "../../../src/logger.js";
import { createDeferred } from "../../fixtures/binarySession.js";
import { readFile, rename, writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import {
resolveGhidraAnalysisSeeds,
snapshotGhidraAnalysisSeeds,
} from "../../../src/ghidra/GhidraAnalysisSeeds.js";
import {
resolveGhidraExtensions,
snapshotGhidraExtensions,
} from "../../../src/ghidra/extensions/GhidraExtensions.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const controls = vi.hoisted(() => ({
realpath: undefined as ((path: string) => Promise<void>) | undefined,
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
realpath: async (...args: Parameters<typeof actual.realpath>) => {
await controls.realpath?.(String(args[0]));
return actual.realpath(...args);
},
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
controls.file?.(handle, String(args[0]));
return handle;
},
};
});
afterEach(() => {
controls.file = undefined;
controls.realpath = undefined;
vi.restoreAllMocks();
});
const target = {
path: "/tmp/native",
kind: "executable",
format: "elf",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
sha256: "a".repeat(64),
} as const;
const configured = (
path: string,
extra: Readonly<Record<string, string>> = {},
) => {
const result = parseConfig({ REA_GHIDRA_NATIVEAOT_JAR: path, ...extra });
if (!result.ok) throw result.error;
return result.value;
};
it.each(
(["seed", "extension"] as const).flatMap((kind) =>
[false, true].map((lateAbort) => ({ kind, lateAbort })),
),
)(
"retains $kind read failure and exact close owner through retry (late abort: $lateAbort)",
async ({ kind, lateAbort }) => {
const root = await createTestTempDirectory("rea-profile-file-");
const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar");
await writeFile(
path,
kind === "seed" ? "0x1000\tcode\n" : Buffer.from([0x50, 0x4b, 3, 4, 0]),
);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const physical = new Error("controlled profile read failure");
let allowClose = false;
let closeActual: (() => Promise<void>) | undefined;
let closes = 0;
controls.file = (handle, opened) => {
if (opened !== path) return;
controls.file = undefined;
closeActual = handle.close.bind(handle);
vi.spyOn(handle, "read").mockRejectedValue(physical);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
if (lateAbort)
controller.abort({ caller: "after physical profile failure" });
if (!allowClose) throw new Error("controlled profile close failure");
await closeActual?.();
});
};
try {
const result =
kind === "seed"
? await resolveGhidraAnalysisSeeds(path, resources, controller.signal)
: await resolveGhidraExtensions(
configured(path),
target,
"linux",
resources,
controller.signal,
);
if (result.ok) throw new Error("Expected profile read failure");
expect(
result.error.message + JSON.stringify(result.error.diagnostics),
).toContain(physical.message);
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([path]);
expect(resources.hasPending).toBe(true);
await expect(resources.retryCleanup()).rejects.toThrow();
expect(closes).toBe(2);
let extraOpens = 0;
controls.file = () => {
extraOpens++;
};
const refused =
kind === "seed"
? await resolveGhidraAnalysisSeeds(path, resources)
: await resolveGhidraExtensions(
configured(path),
target,
"linux",
resources,
);
expect(refused).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(extraOpens).toBe(0);
expect(closes).toBe(3);
allowClose = true;
await resources.retryCleanup();
expect(closes).toBe(4);
expect(result.error.cleanupIncomplete).toBe(false);
if (refused.ok) throw new Error("Unexpected admission");
expect(refused.error.cleanupIncomplete).toBe(false);
expect(resources.hasPending).toBe(false);
} finally {
allowClose = true;
await resources.close();
await closeActual?.();
}
},
);
it.each(["grow", "replace"])(
"refuses seeds that %s during the admitted read",
async (mode) => {
const root = await createTestTempDirectory("rea-seed-growth-");
const path = join(root, "seeds.tsv");
await writeFile(path, "0x1000\tcode\n");
const resources = new ArtifactResourceScope();
controls.file = (handle, opened) => {
if (opened !== path) return;
controls.file = undefined;
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementationOnce(
async (...args: Parameters<typeof handle.read>) => {
const result = await read(...args);
if (mode === "grow")
await writeFile(path, "0x1000\tcode\n0x2000\tcode\n");
else {
const next = join(root, "replacement.tsv");
await writeFile(next, "0x1000\tcode\n");
await rename(next, path);
}
return result;
},
);
};
try {
expect((await resolveGhidraAnalysisSeeds(path, resources)).ok).toBe(
false,
);
} finally {
await resources.close();
}
},
);
it.each(["seed", "extension"] as const)(
"retains %s cleanup and exact cancellation during final close",
async (kind) => {
const root = await createTestTempDirectory("rea-profile-cancel-close-");
const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar");
await writeFile(
path,
kind === "seed" ? "0x1000\tcode\n" : Buffer.from([0x50, 0x4b, 3, 4, 0]),
);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const reason = { caller: "final profile close" };
let allowClose = false;
controls.file = (handle, opened) => {
if (opened !== path) return;
controls.file = undefined;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
controller.abort(reason);
if (!allowClose)
throw new Error("controlled final profile close failure");
await close();
});
};
try {
const result =
kind === "seed"
? await resolveGhidraAnalysisSeeds(path, resources, controller.signal)
: await resolveGhidraExtensions(
configured(path),
target,
"linux",
resources,
controller.signal,
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
cause: reason,
cleanupIncomplete: true,
cleanupResources: [path],
},
});
if (result.ok) throw new Error("Expected cancelled finalization");
allowClose = true;
await resources.retryCleanup();
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
allowClose = true;
await resources.close();
}
},
);
it.each(["seed", "extension"] as const)(
"retains %s snapshot output owner after cancellation during close",
async (kind) => {
const root = await createTestTempDirectory("rea-profile-output-close-");
const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar");
const bytes =
kind === "seed"
? Buffer.from("0x1000\tcode\n")
: Buffer.from([0x50, 0x4b, 3, 4, 0]);
await writeFile(path, bytes);
const resources = new ArtifactResourceScope();
const seeds = await resolveGhidraAnalysisSeeds(
kind === "seed" ? path : undefined,
resources,
);
const extensions =
kind === "extension"
? await resolveGhidraExtensions(
configured(path),
target,
"linux",
resources,
)
: ({ ok: true, value: [] } as const);
if (!seeds.ok || !extensions.ok)
throw new Error("Failed fixture commitment");
const output = join(
root,
kind === "seed" ? "seeds/seeds.tsv" : "extensions/nativeaot.jar",
);
const controller = new AbortController();
const reason = { caller: "snapshot output close" };
let allowClose = false;
controls.file = (handle, opened) => {
if (opened !== output) return;
controls.file = undefined;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
controller.abort(reason);
if (!allowClose)
throw new Error("controlled snapshot output close failure");
await close();
});
};
try {
const error = await (
kind === "seed" && seeds.value !== undefined
? snapshotGhidraAnalysisSeeds(
seeds.value,
root,
resources,
controller.signal,
)
: snapshotGhidraExtensions(
extensions.value,
root,
resources,
controller.signal,
)
).then(
() => {
throw new Error("Expected snapshot failure");
},
(cause: unknown) => cause,
);
expect(error).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
cleanupIncomplete: true,
cleanupResources: [output],
});
expect(await readFile(output)).toEqual(bytes);
allowClose = true;
await resources.retryCleanup();
expect(error).toMatchObject({ cleanupIncomplete: false });
} finally {
allowClose = true;
await resources.close();
}
},
);
it("joins whole provider resolution before shutdown and gates the next admission", async () => {
const root = await createTestTempDirectory("rea-profile-completion-");
const jar = join(root, "extension.jar");
const seed = join(root, "seeds.tsv");
await writeFile(jar, Buffer.from([0x50, 0x4b, 3, 4, 0]));
await writeFile(seed, "0x1000\tcode\n");
const provider = new GhidraProvider(
configured(jar, {
GHIDRA_INSTALL_DIR: "/ghidra",
REA_GHIDRA_SEED_FILE: seed,
}),
silentLogger,
{},
ghidraProfileInstallationHost,
);
const entered = createDeferred<void>();
const release = createDeferred<void>();
controls.realpath = async (path) => {
if (path !== seed) return;
controls.realpath = undefined;
entered.resolve();
await release.promise;
};
let opens = 0;
controls.file = () => {
opens++;
};
const resolving = provider.resolveAnalysisProfile(target);
await entered.promise;
const count = opens;
let closed = false;
const closing = provider.closeOwnedResources().then((result) => {
closed = true;
return result;
});
const next = provider.resolveAnalysisProfile(target);
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closed).toBe(false);
expect(opens).toBe(count);
expect(provider.hasPendingOwnedResources()).toBe(true);
release.resolve();
expect((await resolving).ok).toBe(true);
expect(await closing).toEqual({ ok: true, value: null });
expect((await next).ok).toBe(true);
expect(provider.hasPendingOwnedResources()).toBe(false);
} finally {
release.resolve();
await Promise.all([resolving, closing, next]);
await provider.closeOwnedResources();
}
});
it.each(["seed", "extension"] as const)(
"preserves %s snapshot write failure through client cancellation and cleanup retry",
async (kind) => {
const root = await createTestTempDirectory("rea-profile-client-write-");
const source = join(root, kind === "seed" ? "input.tsv" : "input.jar");
await writeFile(
source,
kind === "seed" ? "0x1000\tcode\n" : Buffer.from([0x50, 0x4b, 3, 4, 0]),
);
const scope = new ArtifactResourceScope();
const seeds = await resolveGhidraAnalysisSeeds(
kind === "seed" ? source : undefined,
scope,
);
const extensions =
kind === "extension"
? await resolveGhidraExtensions(
configured(source),
target,
"linux",
scope,
)
: ({ ok: true, value: [] } as const);
if (!seeds.ok || !extensions.ok)
throw new Error("Failed fixture commitment");
const targetPath = join(root, "target.bin");
await writeFile(targetPath, "synthetic target");
const spawn = vi.fn();
const launcher = new GhidraHeadlessLauncher({
environment: {},
analyzeHeadlessPath: join(root, "support/analyzeHeadless"),
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
...(seeds.value === undefined ? {} : { analysisSeeds: seeds.value }),
analysisExtensions: extensions.value,
spawnProcess: spawn,
});
const client = new GhidraClient({
launcher,
targetPath,
targetSha256: createHash("sha256")
.update("synthetic target")
.digest("hex"),
providerVersion: "12.1.4",
profileDigest: "b".repeat(64),
});
const controller = new AbortController();
let allowClose = false;
let closes = 0;
let outputPath = "";
const physical = new Error("controlled snapshot physical write failure");
controls.file = (handle, opened) => {
if (
basename(opened) !== (kind === "seed" ? "seeds.tsv" : "nativeaot.jar")
)
return;
controls.file = undefined;
outputPath = opened;
const close = handle.close.bind(handle);
vi.spyOn(handle, "writeFile").mockRejectedValue(physical);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
controller.abort({ caller: "after snapshot write failure" });
if (!allowClose) throw new Error("controlled snapshot close failure");
await close();
});
};
try {
const result = await client.start(controller.signal);
if (result.ok) throw new Error("Failed snapshot admitted");
expect(result.error.kind).toBe("start");
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
expect(result.error.cleanupFailure?.cleanupResources).toContain(
outputPath,
);
expect(result.error.cause).toMatchObject({
primaryFailure: { diagnostics: { reason: physical.message } },
});
expect(spawn).not.toHaveBeenCalled();
expect(closes).toBe(2);
allowClose = true;
await client.close();
expect(closes).toBe(3);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
} finally {
allowClose = true;
await client.close();
await scope.close();
}
},
);
it("preserves the exact cancellation reason between extension completion and provider continuation", async () => {
const root = await createTestTempDirectory("rea-profile-handoff-");
const jar = join(root, "extension.jar");
await writeFile(jar, Buffer.from([0x50, 0x4b, 3, 4, 0]));
const controller = new AbortController();
const reason = { caller: "profile continuation" };
const resolve = extensionModule.resolveGhidraExtensions;
vi.spyOn(extensionModule, "resolveGhidraExtensions").mockImplementationOnce(
async (...args) => {
const result = await resolve(...args);
queueMicrotask(() => controller.abort(reason));
return result;
},
);
const provider = new GhidraProvider(
configured(jar, { GHIDRA_INSTALL_DIR: "/ghidra" }),
silentLogger,
{},
ghidraProfileInstallationHost,
);
try {
const result = await provider.resolveAnalysisProfile(target, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (result.ok) throw new Error("Cancelled handoff admitted");
expect(result.error.cause).toBe(reason);
} finally {
await provider.closeOwnedResources();
}
});
it.each(["seed", "extension"] as const)(
"preserves malformed %s facts before final close failure and late cancellation",
async (kind) => {
const root = await createTestTempDirectory("rea-profile-format-close-");
const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar");
await writeFile(
path,
kind === "seed" ? "0x1000\tinvalid\n" : "invalid jar",
);
const scope = new ArtifactResourceScope();
const controller = new AbortController();
let allowClose = false;
controls.file = (handle, opened) => {
if (opened !== path) return;
controls.file = undefined;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
controller.abort({ caller: "after format interpretation" });
if (!allowClose)
throw new Error("controlled malformed artifact close failure");
await close();
});
};
try {
const result =
kind === "seed"
? await resolveGhidraAnalysisSeeds(path, scope, controller.signal)
: await resolveGhidraExtensions(
configured(path),
target,
"linux",
scope,
controller.signal,
);
if (result.ok) throw new Error("Malformed artifact admitted");
expect(result.error._tag).toBe("ProviderAdapterError");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual([path]);
if (kind === "seed")
expect(result.error.diagnostics?.primary_error).toMatchObject({
code: "configuration_invalid",
details: {
settings: [
{
setting: "REA_GHIDRA_SEED_FILE",
constraint: expect.stringContaining("kind must be"),
},
],
},
});
else expect(result.error.diagnostics?.reason).toContain("not a JAR");
allowClose = true;
await scope.retryCleanup();
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
allowClose = true;
await scope.close();
}
},
);
@@ -1,3 +1,5 @@
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { createHash } from "node:crypto";
import type { Dir } from "node:fs";
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
@@ -970,3 +972,79 @@ describe.skipIf(process.platform === "win32")(
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP profile selection completion through composed sessions",
() => {
it.each(["preview", "open"] as const)(
"preserves physical failure and live cleanup after late cancellation during %s",
async (operation) => {
const { directory, root, provider } = await installation();
const path = join(directory, "controlled-psp.elf");
await writeFile(path, pspElfBytes());
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) throw parsed.error;
const controller = new AbortController();
const readFailure = new Error(
"controlled session profile read failure",
);
let allowClose = false;
let closes = 0;
controls.file = (handle, openedPath) => {
if (openedPath !== join(root, "Module.manifest")) return;
controls.file = undefined;
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
controller.abort({ caller: "profile inspection finalization" });
if (!allowClose)
throw new Error("controlled session profile close failure");
await close();
});
};
const createClient = vi
.spyOn(provider, "createClient")
.mockImplementation(() => {
throw new Error("Failed profile must not start an engine");
});
const binary = composeBinarySession(
new AnalysisProviderRegistry([provider]),
);
try {
const result =
operation === "preview"
? await binary.previewTarget(parsed.value, {
providerId: "ghidra",
signal: controller.signal,
})
: await binary.open(path, {
providerId: "ghidra",
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed profile admitted");
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(result.error.message).toContain(readFailure.message);
expect(result.error.cleanupIncomplete).toBe(true);
expect(binary.status()).toMatchObject({ cleanup_pending: true });
expect(createClient).not.toHaveBeenCalled();
const failedClose = await binary.close();
expect(failedClose.ok).toBe(false);
expect(closes).toBe(2);
expect(result.error.cleanupIncomplete).toBe(true);
allowClose = true;
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(closes).toBe(3);
expect(result.error.cleanupIncomplete).toBe(false);
if (failedClose.ok) throw new Error("Close unexpectedly succeeded");
expect(failedClose.error.cleanupIncomplete).toBe(false);
expect(binary.status()).toMatchObject({ cleanup_pending: false });
} finally {
allowClose = true;
await binary.close();
}
},
);
},
);
+41
View File
@@ -5,6 +5,7 @@ import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { afterEach, expect, it, vi } from "vitest";
import { z } from "zod";
@@ -450,6 +451,26 @@ it("runs active Electron scenarios with selected paths and inferred working dire
const capturedInputs: unknown[] = [];
const activeResult =
createElectronActiveObservationFixtureResult(applicationPath);
const capturedEvent = activeResult.timeline.events[0];
if (capturedEvent === undefined) throw new Error("IPC fixture was empty");
const gaps = ["custom-apply", "listener-chain"].map((event, index) => ({
...capturedEvent,
sequence: index + 2,
kind: "ipc-observation-gap" as const,
event,
channel: event === "custom-apply" ? "custom" : "opaque-chain",
direction: null,
receiver: null,
target: "ipcMain.on",
argument_shapes: [],
result_shape: null,
}));
activeResult.timeline.events.push(...gaps);
activeResult.timeline.observed = 3;
activeResult.retention.retained = 3;
activeResult.retention.estimated_retained_bytes = 1_536;
activeResult.retention.observed_runtime = 2;
activeResult.coverage.observed_event_families.push("ipc-observation-gap");
const provider: ElectronActiveObservationPort = {
identity: () => ({
id: "test-electron-active",
@@ -482,6 +503,15 @@ it("runs active Electron scenarios with selected paths and inferred working dire
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
({ name }) => name === "capture_electron_scenario",
);
if (advertised?.outputSchema === undefined)
throw new Error("Active Electron output schema was not advertised");
const validateOutput = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(z.record(z.string(), z.unknown()).parse(advertised.outputSchema));
const captured = await client.callTool({
name: "capture_electron_scenario",
arguments: {
@@ -495,10 +525,21 @@ it("runs active Electron scenarios with selected paths and inferred working dire
},
});
expect(captured.isError, JSON.stringify(captured)).not.toBe(true);
expect(
validateOutput(captured.structuredContent),
JSON.stringify(validateOutput.errors),
).toBe(true);
expect(captured.structuredContent).toMatchObject({
normalized_result: {
application: { process_ownership: "provider-owned" },
ipc: { events: [{ channel: "readiness:echo" }] },
timeline: {
events: [
{ kind: "main-handler-invocation" },
{ kind: "ipc-observation-gap", channel: "custom" },
{ kind: "ipc-observation-gap", channel: "opaque-chain" },
],
},
coverage: {
status: "partial_attach",
pre_capture_activity: "unavailable",
@@ -18,7 +18,9 @@ import {
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { ok } from "../../../src/domain/result.js";
import { err, ok } from "../../../src/domain/result.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
@@ -235,3 +237,81 @@ const structured = (result: CallToolResult): Record<string, unknown> =>
result.isError === true
? parseMcpToolError(result)
: z.record(z.string(), z.unknown()).parse(result.structuredContent);
it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => {
directory = await createTestTempDirectory("rea-profile-completion-mcp-");
const path = join(directory, "fixture.hop");
await writeFile(path, "fixture");
const starts: string[] = [];
const released = new Set<string>();
const failures = {
alpha: new AnalysisCancelledError("resolve_analysis_profile", {
cause: { caller: "controlled producer cancellation" },
cleanup: { reason: "alpha close failed", resources: ["alpha-file"] },
}),
beta: new ProviderCleanupError("beta", ["beta-file"], {
reason: "beta close failed",
}),
};
const providers = (["alpha", "beta"] as const).map((id) => ({
...candidate(id, starts),
resolveAnalysisProfile: async () => err(failures[id]),
closeOwnedResources: async () => {
if (!released.has(id)) return err(failures[id]);
failures[id].resolveCleanup();
return ok(null);
},
}));
const session = composeBinarySession(new AnalysisProviderRegistry(providers));
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "profile-completion", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
try {
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const opening = parseMcpToolError(
await mcp.callTool({
name: "open_binary",
arguments: { path, provider_id: "auto" },
}),
);
expect(opening.error).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: ["alpha-file", "beta-file"],
diagnostics: {
profile_errors: expect.arrayContaining([
expect.objectContaining({ provider_id: "alpha" }),
expect.objectContaining({ provider_id: "beta" }),
]),
},
},
});
expect(starts).toEqual([]);
released.add("alpha");
const closing = parseMcpToolError(
await mcp.callTool({ name: "close_binary", arguments: {} }),
);
expect(closing.error).toMatchObject({
code: "cleanup_incomplete",
details: { resources: ["beta-file"] },
});
expect(session.status()).toMatchObject({ cleanup_pending: true });
released.add("beta");
expect(
(await mcp.callTool({ name: "close_binary", arguments: {} })).isError,
).not.toBe(true);
expect(session.status()).toMatchObject({ cleanup_pending: false });
expect(failures.alpha.cleanupIncomplete).toBe(false);
expect(failures.beta.cleanupIncomplete).toBe(false);
} finally {
released.add("alpha");
released.add("beta");
await session.close();
}
});
@@ -129,19 +129,33 @@ it("waits for an aborted pre-session hash before retrying its late close owner",
});
try {
const controller = new AbortController();
const previewWork = binary.previewTarget(fixture.target, {
signal: controller.signal,
});
let previewSettled = false;
const previewWork = binary
.previewTarget(fixture.target, {
signal: controller.signal,
})
.then((result) => {
previewSettled = true;
return result;
});
await closeEntered;
controller.abort();
let closeSettled = false;
const closing = binary.close().then((result) => {
closeSettled = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(previewSettled).toBe(false);
expect(closeSettled).toBe(false);
expect(binary.status()).toMatchObject({ cleanup_pending: true });
releaseClose();
const preview = await previewWork;
expect(preview).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
error: { _tag: "ProviderAdapterError", cleanupIncomplete: true },
});
const closing = binary.close();
releaseClose();
const firstClose = await closing;
expect(firstClose).toMatchObject({
ok: false,
@@ -152,6 +166,10 @@ it("waits for an aborted pre-session hash before retrying its late close owner",
close.mockRestore();
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(binary.status()).toMatchObject({ cleanup_pending: false });
if (preview.ok || firstClose.ok)
throw new Error("Cleanup unexpectedly succeeded");
expect(preview.error.cleanupIncomplete).toBe(false);
expect(firstClose.error.cleanupIncomplete).toBe(false);
} finally {
releaseClose();
close.mockRestore();
@@ -1327,9 +1327,10 @@ describe("cancellation during profile resolution", () => {
const [first] = await createBinarySessionTargets();
const provider = createCacheProvider([]);
const controller = new AbortController();
const failure = new Error("Profile resolution failed before cancellation");
provider.resolveAnalysisProfile = () => {
controller.abort();
return Promise.reject(new Error("Profile resolution cancelled"));
controller.abort({ caller: "late profile rejection" });
return Promise.reject(failure);
};
const session = createTestBinarySession(provider);
try {
@@ -1337,7 +1338,11 @@ describe("cancellation during profile resolution", () => {
session.open(first, { signal: controller.signal }),
).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
error: {
_tag: "ProviderAdapterError",
operation: "resolve_analysis_profile",
cause: failure,
},
});
// Allow Node to report any rejection abandoned by the cancellation path.
await new Promise<void>((resolve) => setImmediate(resolve));
@@ -1347,35 +1352,66 @@ describe("cancellation during profile resolution", () => {
}
});
it("cancels profile resolution even when the provider ignores its signal", async () => {
it("joins a signal-ignoring profile producer before cancellation and session close settle", async () => {
const [first] = await createBinarySessionTargets();
const provider = createCacheProvider([]);
if (provider.resolveAnalysisProfile === undefined)
throw new Error("Expected the fixture profile resolver");
const resolveProfile = provider.resolveAnalysisProfile.bind(provider);
const admitted = createDeferred<void>();
const release = createDeferred<void>();
let observedSignal: AbortSignal | undefined;
let created = 0;
provider.resolveAnalysisProfile = (_target, options) => {
provider.resolveAnalysisProfile = async (target, options) => {
observedSignal = options?.signal;
return new Promise<never>(() => undefined);
admitted.resolve();
await release.promise;
return resolveProfile(target);
};
provider.createClient = () => {
created += 1;
created++;
return new ControllableAnalysisClient();
};
const session = createTestBinarySession(provider);
const controller = new AbortController();
const opening = session.open(first, { signal: controller.signal });
while (observedSignal === undefined)
await new Promise<void>((resolve) => setImmediate(resolve));
expect(observedSignal).toBe(controller.signal);
controller.abort();
await expect(opening).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
const reason = { caller: "profile cancellation" };
let openSettled = false;
const opening = session
.open(first, { signal: controller.signal })
.then((result) => {
openSettled = true;
return result;
});
await admitted.promise;
controller.abort(reason);
let closeSettled = false;
const closing = session.close().then((result) => {
closeSettled = true;
return result;
});
expect(created).toBe(0);
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(observedSignal?.reason).toBe(reason);
expect(openSettled).toBe(false);
expect(closeSettled).toBe(false);
expect(session.status()).toMatchObject({ cleanup_pending: true });
release.resolve();
await expect(opening).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
expect(await closing).toEqual(resultOk(null));
expect(created).toBe(0);
expect(session.status()).toMatchObject({ cleanup_pending: false });
} finally {
release.resolve();
await Promise.all([opening, closing]);
await session.close();
}
});
});
describe("cancellation during client transitions", () => {
it("cancels a call while it waits for a transition", async () => {
const [first] = await createBinarySessionTargets();
const health = createDeferred<ReturnType<typeof ok>>();
@@ -242,6 +242,7 @@ it("waits for mixed finalization and resolves cleanup only after both exact desc
fault.blocked.delete(fault.destinations[0]!);
await destination.cleanupLease.release();
expect(projected.cleanupIncomplete).toBe(true);
expect(projected.cleanupResources).toEqual([path]);
fault.blocked.clear();
await resources.close();
expect(projected.cleanupIncomplete).toBe(false);