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:
commit
bfe439d990
55 files changed
+15596
-661
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user