Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd62f5d0c5 | ||
|
|
a040533470 | ||
|
|
abe49808b2 | ||
|
|
70de702109 | ||
|
|
4e14452ddb | ||
|
|
de9371b85d | ||
|
|
0cd4eafd61 | ||
|
|
509d4c8f56 | ||
|
|
79d2b71896 | ||
|
|
9be493880c | ||
|
|
748ac7f0df | ||
|
|
77c8a1ba32 | ||
|
|
8a2a1a2b8b | ||
|
|
c7e9e096d3 | ||
|
|
8c62a4aa36 | ||
|
|
3cfa00a29c | ||
|
|
623339a7ab | ||
|
|
c0b3cd14b1 | ||
|
|
195cf18818 | ||
|
|
e26bb49a4b | ||
|
|
326c073880 | ||
|
|
c6b62754b0 | ||
|
|
264799b0ff | ||
|
|
5b24399387 | ||
|
|
5927937179 | ||
|
|
988494abdb | ||
|
|
3c9b5d6d02 | ||
|
|
54c71313ae | ||
|
|
6eb55be4e8 | ||
|
|
5717ad24aa | ||
|
|
d09be9cfa2 | ||
|
|
7086768a27 | ||
|
|
be0b2d138c | ||
|
|
c360cfcd6d | ||
|
|
df2fc251e3 | ||
|
|
0ec4826633 | ||
|
|
d694aef227 | ||
|
|
5853089885 | ||
|
|
4d980d0176 | ||
|
|
556504edcb | ||
|
|
1e36e707b1 | ||
|
|
e31aa72c9d | ||
|
|
5112fe07ae | ||
|
|
c72786f289 | ||
|
|
cc2e18a265 | ||
|
|
fb146bf40b | ||
|
|
870dcb1fda | ||
|
|
e567033a71 | ||
|
|
150e996a86 | ||
|
|
4087048270 | ||
|
|
71929ed37c | ||
|
|
199396f61c | ||
|
|
5c0472c571 | ||
|
|
8f65d7f256 | ||
|
|
3e8236639a | ||
|
|
0a51b246c6 | ||
|
|
8451d28392 | ||
|
|
3d028dbc74 | ||
|
|
bfb7ed0bd4 | ||
|
|
c3e42385e7 | ||
|
|
746a044e42 | ||
|
|
3bd9aa3484 | ||
|
|
0c7b6d834a | ||
|
|
d5be402fb3 | ||
|
|
8267edc0ef | ||
|
|
360626434d | ||
|
|
d74e72406a | ||
|
|
a6e8f5269f | ||
|
|
1b56237cae | ||
|
|
67384b23e5 | ||
|
|
ece277b30b | ||
|
|
cd9252bc63 | ||
|
|
e65c3955f9 | ||
|
|
0d22474280 | ||
|
|
c4475d4a67 | ||
|
|
709b5cbdc1 | ||
|
|
9a95db6819 | ||
|
|
eba64e9386 | ||
|
|
967a8bc279 | ||
|
|
c15c2062ad | ||
|
|
f9ccacca2c | ||
|
|
61e688070f | ||
|
|
f26973a529 | ||
|
|
bf7fad390c | ||
|
|
50dbd875cf | ||
|
|
bf3475867f | ||
|
|
f22e8e7268 | ||
|
|
b0ddb58e27 | ||
|
|
ae07d15170 | ||
|
|
c75cf827e7 | ||
|
|
8116dc0651 | ||
|
|
060587b456 | ||
|
|
a70c42e03d | ||
|
|
f31b231a92 | ||
|
|
5eaef92e5b | ||
|
|
0f61261e01 | ||
|
|
4838f44493 | ||
|
|
60bf102459 |
No files matched your search
@@ -124,6 +124,26 @@ operation's full ordered list. Candidate capabilities describe possible
|
||||
providers. Top-level session capabilities describe the current route, and
|
||||
`analysis_provider_binding` identifies the selected provider.
|
||||
|
||||
## Static memory and original file bytes
|
||||
|
||||
For Ghidra, `read_bytes` reads initialized analysis memory, including loader
|
||||
fixups; it does not read a running process. Check `returned_bytes` and `complete`
|
||||
before decoding a table. An incomplete read is missing coverage, not a zero-filled
|
||||
tail. Use `list_segments` to inspect the selected block's initialization and address-space
|
||||
facts, and retain unavailable facts as unknown.
|
||||
|
||||
Use `address_to_file_offset` to anchor one observed address to original file
|
||||
bytes. A mapping for the first address does not establish a contiguous mapping
|
||||
for an entire table, and loader fixups can make analysis bytes differ from source
|
||||
bytes. Keep source ranges and analysis-memory observations separate.
|
||||
|
||||
A pointer table can refer to writable slots populated by a constructor rather
|
||||
than directly to immutable resources. An uninitialized block or an observed zero
|
||||
does not establish the slot's later runtime value. Follow `xrefs` and the relevant
|
||||
writer's assembly or pseudocode; distinguish recorded assignments from inferred
|
||||
execution order. Do not replace unreadable bytes with zeros or conclude that a
|
||||
resource is absent without the necessary initialization evidence.
|
||||
|
||||
## DOS MZ with Ghidra
|
||||
|
||||
For an admitted DOS MZ target, use `inspect_native_load_image` to check measured
|
||||
@@ -133,10 +153,8 @@ reported static image, not runtime DOS or PC-98 hardware behavior.
|
||||
|
||||
For Ghidra decompilation, `procedure_pseudo_code` also accepts `procedure: "file-offset:0x..."`; CLI `rea decompile <path> --file-offset=0x... --provider ghidra` resolves the source byte offset internally. An unresolved 0x-prefixed numeric procedure selector also falls back to a unique file mapping. Existing symbols and analysis addresses win; multiple mappings fail as ambiguous. Preserve the returned file-offset and address locations and raw selection observations. ELF virtual addresses are distinct from file offsets; never assume an arbitrary offset plus image base is an analysis address.
|
||||
|
||||
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
|
||||
anchor one observed address to original file bytes. Loader fixups can change a
|
||||
word; a source offset does not imply byte equality. Complete function body ranges
|
||||
use inclusive ends and can contain gaps. Do not treat the enclosing span as code.
|
||||
Complete function body ranges use inclusive ends and can contain gaps. Do not
|
||||
treat the enclosing span as code.
|
||||
For packed targets, retain the original and separately derived artifact identities;
|
||||
decompiling an unpacking stub does not recover the unpacked program.
|
||||
|
||||
|
||||
@@ -258,6 +258,23 @@ The separate GitHub Pages host uses only the manual
|
||||
**Settings → Pages → Build and deployment → Source** must be set to
|
||||
**GitHub Actions** before deploying there.
|
||||
|
||||
### Translation corrections
|
||||
|
||||
README editions and website editions have separate translation sources:
|
||||
|
||||
- Find the language's `readme` path in the
|
||||
[language inventory](website/locales/languages.json) and edit that file.
|
||||
Locale codes and README suffixes can differ. Use the
|
||||
[English README](README.md) to check commands, requirements, and analysis claims.
|
||||
- Edit `msgstr` values in `website/locales/<language>/messages.po` for website
|
||||
text. Follow the [website translation guide](website/locales/README.md) for
|
||||
the Python environment, catalog validation, and local preview. Preserve source
|
||||
strings, contexts, placeholders, commands, paths, and recorded evidence.
|
||||
|
||||
Before submitting a website translation, run the catalog and website checks in
|
||||
that guide. Translated HTML in `website/_site/` is generated; edit the PO catalog
|
||||
rather than the generated pages.
|
||||
|
||||
## Development feedback and PR verification
|
||||
|
||||
For routine merges, wait for the required checks to pass for the current PR
|
||||
|
||||
@@ -783,14 +783,22 @@ def _within_response_limit(value: Any) -> bool:
|
||||
current = pending.pop()
|
||||
if isinstance(current, str):
|
||||
size += 2
|
||||
if len(current) > MAX_FRAME_BYTES - size:
|
||||
return False
|
||||
if current.isascii():
|
||||
size += len(current)
|
||||
size += current.count('"') + current.count("\\")
|
||||
size += sum(5 for character in current if ord(character) < 0x20)
|
||||
else:
|
||||
size += 4 * len(current)
|
||||
size += current.count('"') + current.count("\\")
|
||||
size += sum(5 for character in current if ord(character) < 0x20)
|
||||
# Count UTF-8 bytes without allocating a copy of the whole output.
|
||||
try:
|
||||
size += sum(
|
||||
len(current[start:start + 65536].encode("utf-8"))
|
||||
for start in range(0, len(current), 65536)
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
# Unencodable output also needs the signed omission fallback.
|
||||
return False
|
||||
size += current.count('"') + current.count("\\")
|
||||
size += sum(5 for character in current if ord(character) < 0x20)
|
||||
elif isinstance(current, dict):
|
||||
identity = id(current)
|
||||
if identity in seen:
|
||||
|
||||
@@ -98,11 +98,25 @@ existing analysis families directly, for example
|
||||
| `collect_adb_bugreport` | read | Device-generated bugreport zip, digested |
|
||||
| `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); checks for existing targets and refuses them without explicit overwrite (the check is not atomic); compares digests when device `sha256sum` is available |
|
||||
| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves its Success/Failure verdict, records `replace_requested`, and leaves actual replacement `null` because prior package state is not observed |
|
||||
| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 |
|
||||
| `uninstall_adb_package` | **mutates device** | Requests package removal for user 0 via `adb shell pm uninstall --user 0`, without data retention |
|
||||
| `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) |
|
||||
| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict |
|
||||
| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run |
|
||||
|
||||
For `inspect_adb_window`, `focus_contexts` retains each display header, its exact
|
||||
display ID when recognized, and every focus declaration with its original line.
|
||||
Declarations without a display header are explicitly `unscoped`. A missing
|
||||
declaration differs from a declaration whose `value` is JSON `null`.
|
||||
`source_section` identifies the selected dumpsys section; `observed_lines`
|
||||
preserves its focus declarations and display headers in source order.
|
||||
|
||||
`focus_summary` is `single_context` only for one context with no repeated focus
|
||||
field. The singular `focused_window` and `focused_app` summarize that context;
|
||||
they do not establish device-wide focus. Multiple contexts or repeated fields
|
||||
produce `ambiguous` with null singular fields and all declarations retained.
|
||||
When no focus field was declared, the status is `unavailable`. REA reads the
|
||||
displays section only when the windows section declares neither focus field.
|
||||
|
||||
For `install_adb_package`, `replace_requested` records whether `-r` was sent.
|
||||
The `replaced` result is `null`: the successful install response does not say
|
||||
whether the package was installed fresh or replaced, and REA does not query the
|
||||
@@ -153,8 +167,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
|
||||
@@ -167,8 +183,10 @@ and during shutdown.
|
||||
The app/activity lifecycle tools use `am start -W` and `am force-stop`.
|
||||
- Per-invocation budgets: client/devices 10-20 s, shell reads 20-60 s with
|
||||
64 KiB-8 MiB capture caps (logcat 4 MiB, screen 32 MiB), transfers and
|
||||
bugreports 600 s. Exceeded budgets report `resource_constraint`; stopped
|
||||
commands report `provider_timeout`.
|
||||
bugreports 600 s. Output capture overflow reports `resource_constraint`;
|
||||
deadline expiration, including the client-version probe, reports
|
||||
`provider_timeout`. Command cancellation preserves the caller's exact reason;
|
||||
a later abort does not replace an already completed command failure.
|
||||
- Pull and capture outputs are additive: the destination must not already
|
||||
exist. Re-running a completed pull refuses the existing directory instead
|
||||
of overwriting evidence.
|
||||
|
||||
@@ -26,6 +26,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "Provider Layers"
|
||||
PROVIDER_OWNERS["src/providers/<br/>shared deferred client and inspection owners<br/>exact cleanup retry and attributed failures"]
|
||||
PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/<br/>BYO offline ELF/core adapters<br/>owned snapshots/Python and optional core-only GDB<br/>unchanged upstream parsers, ABI structures and mapping API"]
|
||||
WASM["src/wasm/<br/>caller-supplied WABT; immutable artifact inspection<br/>owned process supervision and retryable workspace cleanup"]
|
||||
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
|
||||
@@ -138,6 +139,11 @@ graph TD
|
||||
GHIDRA --> GHIDRA_BRIDGE
|
||||
HOPPER --> PROCESS
|
||||
GHIDRA --> PROCESS
|
||||
GHIDRA --> PROVIDER_OWNERS
|
||||
APP --> PROVIDER_OWNERS
|
||||
PROVIDER_OWNERS --> PROCESS
|
||||
PROVIDER_OWNERS --> AUX
|
||||
PROVIDER_OWNERS --> DOMAIN
|
||||
GHIDRA --> WINDOWS
|
||||
PROCESS --> WINDOWS
|
||||
WINDOWS --> WINDOWS_NATIVE
|
||||
|
||||
@@ -285,6 +285,14 @@ REA establishes a main-frame origin boundary before enabling Runtime, Debugger,
|
||||
|
||||
## Data minimization
|
||||
|
||||
The Link projection parses repeated CDP header field lines independently and
|
||||
retains their declarations in order, including derived untrusted agent hints.
|
||||
Commas inside URI targets or quoted parameters remain part of those values.
|
||||
COOP and COEP projections retain the exact declared token after validating the
|
||||
complete Structured Field Item, including parameters such as `report-to`.
|
||||
These are declarations, not an assessment of the document's effective policy;
|
||||
absent, malformed or non-token items project as `null`.
|
||||
|
||||
REA retains local observation data and removes only URL userinfo credentials, explicitly declared scenario secrets, and values from structured credential/storage fields:
|
||||
|
||||
- URL observations preserve the complete local URL, including query values, fragments, duplicate parameters, and their order. Only username/password userinfo is removed; malformed original URL strings remain available with `origin: null`.
|
||||
@@ -320,7 +328,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
|
||||
an omitted type leaves equality unknown rather than proving a modification.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Script metadata admission bounds retained identity and source-map strings to 8 MiB and 50,000 records. When a source-map URL cannot fit, the script identity remains and source-map coverage reports `resource_budget_exhausted`; rejected script observations are counted without retaining their IDs. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
|
||||
|
||||
|
||||
@@ -144,6 +144,18 @@ distinction: it closes and deletes provider-owned profiles, but only disconnects
|
||||
from an external CDP browser. Real-browser verification checks both outcomes and
|
||||
confirms that an attached external browser remains alive.
|
||||
|
||||
That cleanup requires REA to remain running. Abrupt termination, including
|
||||
Windows process-tree termination or POSIX `SIGKILL`, can leave the launch
|
||||
profile and Playwright artifacts behind. A profile can retain cookies, history,
|
||||
cache and origin storage. Later REA runs currently do not reclaim these paths.
|
||||
Playwright 1.58.2 can also leave an artifacts directory when launch preparation
|
||||
fails before a browser process exists, such as for a missing executable.
|
||||
POSIX wrappers can request awaited cancellation with `SIGINT` or `SIGTERM`;
|
||||
Windows process termination is not an equivalent graceful-cancellation channel.
|
||||
Do not identify owned artifacts by directory prefix or creation time alone.
|
||||
See [#4659](https://github.com/morluto/rea/issues/4659) for the remaining recovery
|
||||
and wrapper-cancellation work.
|
||||
|
||||
Keeping that browser alive does not guarantee its emulation state survives.
|
||||
Chromium can clear caller-owned `navigator.platform` emulation when a page CDP
|
||||
session disconnects, including sessions that sent no emulation commands. REA
|
||||
|
||||
@@ -56,8 +56,15 @@ rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
|
||||
|
||||
The normalized result contains canonical local paths, frame and DOM
|
||||
structure, resource metadata, stable script/resource identities, explicit
|
||||
completeness, and content-addressed script artifacts. Script metadata
|
||||
retains its execution-context frame ID when CDP supplies one. The capture also
|
||||
completeness, and content-addressed script artifacts. Resource sizes are unknown
|
||||
when omitted or invalid; supplied invalid sizes also produce an explicit
|
||||
completeness diagnostic.
|
||||
|
||||
DOM node indices retain their snapshot positions. Invalid rows are omitted with
|
||||
completeness diagnostics, so `total_nodes` can exceed the retained node count
|
||||
and a parent index can refer to an omitted source node.
|
||||
|
||||
Script metadata retains its execution-context frame ID when CDP supplies one. The capture also
|
||||
inventories worker, service-worker, and shared-worker targets with
|
||||
validated opener-target and parent-frame IDs. Worker discovery uses passive
|
||||
target metadata; REA does not attach to or execute code in those targets.
|
||||
@@ -114,6 +121,11 @@ window, remains pending until cancelled. After cancellation, the final state
|
||||
read has 5 seconds before REA keeps the initial snapshot instead; the
|
||||
cancellation keeps the caller's reason and records that expiry in its
|
||||
diagnostics.
|
||||
Blocked synchronous shell APIs keep their return contracts:
|
||||
`showItemInFolder` returns `undefined` and `writeShortcutLink` returns `false`.
|
||||
Their `shell-attempt` events record the blocked phase and argument shapes;
|
||||
neither result means the requested host operation occurred. The existing
|
||||
`openExternal` and `openPath` blocks continue to reject their promises.
|
||||
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
|
||||
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
|
||||
so a crashed renderer does not require a successful renderer-side title query.
|
||||
@@ -127,6 +139,63 @@ 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
|
||||
`once` registration, firing and callable dispatch as well as 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.
|
||||
|
||||
Native runtimes differ in whether `once` consults callback `.call` or `.apply`.
|
||||
The hook observes actual callback entry without imposing another runtime's
|
||||
property reads or argument-list representation. Application methods selected by
|
||||
native dispatch keep their original receiver and argument-list object; opaque
|
||||
methods produce a `custom-call` or `custom-apply` observation gap when callback
|
||||
execution cannot be established.
|
||||
|
||||
If native wrapper creation or registration-method lookup could expose private
|
||||
instrumentation through an application getter, setter or Proxy, registration
|
||||
stays native and unwrapped. The `once-metadata` observation gap identifies that
|
||||
unobserved registration attempt; it does not prove registration or delivery. The original callback,
|
||||
metadata writes and native method receivers are preserved, including when
|
||||
registration fails. Later registrations resume normal observation. Non-callable
|
||||
`call`/`apply` properties produce failed observation gaps when native dispatch reads
|
||||
them; callback entry is not fabricated.
|
||||
|
||||
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.
|
||||
|
||||
@@ -153,7 +153,9 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture.
|
||||
## Resource and lifecycle policy
|
||||
|
||||
- Nonempty regular firmware inputs: at most **128 MiB**, copied and hashed into
|
||||
a private workspace before analysis. No in-place source modification.
|
||||
a private workspace before analysis. Growth beyond that allowance during
|
||||
copying receives the same typed input refusal as an already oversized file.
|
||||
Supply a stable copy within the allowance. No in-place source modification.
|
||||
- One queued heavy operation per provider instance; Binwalk one thread and
|
||||
Unblob one worker. No automatic parallel extraction engines.
|
||||
- **120-second** deadline; inherited **1 GiB per-process address-space** limit,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -91,6 +91,10 @@ host permission denial, changed artifacts and cancellation retain distinct
|
||||
errors. The selected file is never modified; this inspection creates no
|
||||
temporary files, starts no subprocesses and accesses no network endpoint.
|
||||
|
||||
An input above 256 MiB returns `resource_constraint` with resource `file-size`
|
||||
and `reported_limits.maximum_input_bytes`. Select a suitable input within that
|
||||
fixed allowance; changing an output-write or transport budget does not admit it.
|
||||
|
||||
Resource guards bound the selected file and cumulative header searches to 256 MiB
|
||||
each, aggregate embedded strings
|
||||
to 1 MiB, structural table decoding to 16 MiB and the complete normalized metadata
|
||||
|
||||
@@ -8,6 +8,8 @@ Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP
|
||||
|
||||
Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. The attached profile requires the legacy GUI tool names; upstream 2.0.0 renamed those tools, and REA has not established an equivalent attached-mode lifecycle and ownership contract for that catalog. It therefore fails closed with the missing tool names in the error diagnostics.
|
||||
|
||||
Startup failures identify the accepted REA analysis operation. Missing-profile and typed startup transport failures retain the original stage in `details.diagnostics.startup_operation`; existing diagnostics, timeout budgets and resource limits remain available. Startup remains shared within a session; each caller receives its own operation attribution. Cancellation retains live cleanup ownership, so a successful retry releases the originating cancellation outcome as well.
|
||||
|
||||
The headless profile is defined by the database-supervisor API, not by an upstream version string. The upstream source snapshot [`c133c385`](https://github.com/mrexodia/ida-pro-mcp/tree/c133c3853faa111a9b00ee615c013b720d0c4acd) includes `idb_close` (introduced by [upstream PR #495](https://github.com/mrexodia/ida-pro-mcp/pull/495)) alongside `idb_open` and `idb_list`. Earlier builds may report the same `2.0.0` version while lacking that tool, so REA checks the live catalog rather than treating a version number or branch name as a compatibility guarantee.
|
||||
|
||||
| Mode | Upstream profile | Known working |
|
||||
|
||||
+53
-4
@@ -59,10 +59,12 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
|
||||
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||
create backups, use atomic replacement, and verify their result. Setup and
|
||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
||||
If that snapshot differs from the current configuration, setup previews and
|
||||
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
|
||||
Both snapshots remain available; a failed rollback reports the current recovery
|
||||
copy's path and the restore failure.
|
||||
If that snapshot differs from the current configuration, setup and uninstall
|
||||
preserve a verified `.rea.backup.<sha256>.recovery` copy of the current bytes
|
||||
before editing; setup also previews it. Both snapshots remain available.
|
||||
When an edit made a backup, a failed uninstall update or restore, and a failed
|
||||
setup readback or rollback, name the backup path that holds the bytes from just
|
||||
before that edit.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE`, `REA_MCP_OUTPUT_SCHEMA_PROFILE`, and
|
||||
`REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
@@ -833,6 +835,16 @@ rea doctor --json
|
||||
rea setup
|
||||
```
|
||||
|
||||
On macOS, [Homebrew OpenJDK is keg-only](https://formulae.brew.sh/formula/openjdk)
|
||||
and may be unavailable to the system `java` wrapper. Set `JAVA_HOME` to the
|
||||
selected formula's `libexec/openjdk.jdk/Contents/Home`, for example
|
||||
`/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home` or
|
||||
`/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home`. Use a JDK inside the
|
||||
Java version bounds declared by your Ghidra installation; REA does not select
|
||||
another installed JDK automatically. Doctor distinguishes an absent Java
|
||||
command from a command that runs and fails, and retains bounded probe output
|
||||
and any parsed properties when the selected runtime cannot be admitted.
|
||||
|
||||
On Windows, configure the existing installation in PowerShell:
|
||||
|
||||
```powershell
|
||||
@@ -882,6 +894,19 @@ the launcher fails before preparing its home/cache/config/temp directories or
|
||||
starting Java. The client may already have allocated private runtime/endpoint
|
||||
resources and a target snapshot; existing startup-failure cleanup still applies.
|
||||
Restore the selected release's properties instead of renaming its installation folder.
|
||||
Inspection follows selected properties symlinks, admits only a regular file,
|
||||
and caps the metadata payload at 1 MiB under REA's resource budget. A final
|
||||
one-byte probe checks for growth. This is not a Java
|
||||
Properties format limit. Reads preserve Ghidra's Latin-1 byte mapping and check
|
||||
the descriptor and selected name for changes. Permission failures and resource
|
||||
refusals remain explicit; failed descriptor cleanup must succeed before that
|
||||
owner admits another inspection.
|
||||
Native decompiler compatibility inspection follows the selected executable symlink
|
||||
and reads at most the first 4096 bytes through the same descriptor owner. It
|
||||
checks the full extent and selected name for changes without reading the remainder.
|
||||
An unreadable or unrecognized header leaves compatibility unknown; diagnostics
|
||||
retain read failures. Cancellation and incomplete cleanup propagate as failures,
|
||||
and failed cleanup retains the exact descriptor for retry.
|
||||
This coordinate selects only REA's session-private configuration directory, not
|
||||
the installation or an existing user configuration directory.
|
||||
|
||||
@@ -900,6 +925,10 @@ ownership inspection, so retaining those wrappers would prevent verified
|
||||
process-group cancellation during startup.
|
||||
If ownership remains unverifiable, it reports the reason and retains the process
|
||||
supervisor and private runtime instead of removing files beneath a live provider.
|
||||
Startup preserves an observed launch failure when cancellation arrives during
|
||||
rollback. Producer cancellation retains the caller's exact reason separately
|
||||
from LaunchSupport warnings and cleanup failures. Failed rollback keeps the
|
||||
original process owner for a later cleanup retry.
|
||||
The experimental Windows transport uses authenticated IPv4 loopback with a
|
||||
private native-owned bearer descriptor and Job Object process ownership.
|
||||
|
||||
@@ -911,6 +940,9 @@ bridge, and health readiness. Large binaries can need more: set
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS` to an integer between 1 and 2,147,483,647
|
||||
milliseconds in the server environment. An absent setting uses the default;
|
||||
empty, invalid or out-of-range supplied values fail configuration validation.
|
||||
When the deadline elapses, the `provider_timeout` error names
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS` with the deadline that was applied and suggests
|
||||
`REA_GHIDRA_DISABLED_ANALYZERS` for analyzers that dominate the time.
|
||||
REA parses the supplied configuration environment and
|
||||
passes the deadline to each provider client; a startup failure is returned by the query that triggered it,
|
||||
without exposing partial analysis. This deadline is separate from MCP transport
|
||||
@@ -992,6 +1024,8 @@ export REA_GHIDRA_COMPILER_SPEC_ID="borlandcpp" # optional; requires a languag
|
||||
|
||||
REA passes `-processor`/`-cspec`, checks both against the installation's `.ldefs`, and records them in the analysis profile. DOS imports reject an override.
|
||||
|
||||
Language definitions must be well-formed XML encoded as UTF-8 or BOM-marked UTF-16, with any encoding declaration matching the bytes. Literal Unicode replacement characters (U+FFFD) are accepted; malformed source bytes are rejected.
|
||||
|
||||
To seed code that auto-analysis cannot reach (computed jumps, pointers in data):
|
||||
|
||||
```bash
|
||||
@@ -1202,6 +1236,21 @@ on the shell `PATH`. Use the setup command above for the guided setup
|
||||
journey, `npx -y rea-agents@latest` for unattended one-off commands, or install globally
|
||||
with `npm install --global rea-agents` for a shell-visible `rea` command.
|
||||
|
||||
For many concurrent agent sessions, run setup directly from a global installation
|
||||
to avoid keeping a separate npm launcher process for each MCP connection:
|
||||
|
||||
```bash
|
||||
npm install --global rea-agents
|
||||
rea setup
|
||||
```
|
||||
|
||||
Review the plan, approve the selected client changes, then restart those clients.
|
||||
This registers the installed entrypoint directly; installing the global package
|
||||
alone does not replace existing `npx` registrations. Each client still has its own
|
||||
REA process and analysis state. Direct registrations follow the installed CLI;
|
||||
use `rea update` to update it. Running setup through `npx` again selects the pinned
|
||||
package-runner command. Do not register paths inside npm's temporary package cache.
|
||||
|
||||
The optional curl wrapper installs only the global npm package:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -288,6 +288,27 @@ and the result includes its basis, candidates, changed dimensions, Evidence
|
||||
links, limitations, and a `changed_from` graph containing all compared nodes,
|
||||
their observations, and relationships.
|
||||
|
||||
A matched pair whose only differing dimension is `location` counts as
|
||||
`unchanged` when both input graphs have complete coverage and its neighboring
|
||||
relationship identities are resolved. This includes an
|
||||
identical module shifted by an earlier bundle edit and a named IPC channel
|
||||
observed at a new offset. The item still reports `dimensions: ["location"]`
|
||||
and both node IDs; the graph retains both original observations and coordinates.
|
||||
Content, property, and complete relationship differences still count as
|
||||
`changed`. With incomplete coverage, relocation alone remains `unknown` and
|
||||
retains the location and coverage dimensions. Matching confidence is unchanged:
|
||||
an inferred pair is not promoted to exact identity by this classification.
|
||||
|
||||
Complete inventory does not resolve ambiguous or unmatched relationship targets.
|
||||
A matched module with identical bytes can therefore remain `unknown` when a
|
||||
neighbor, including its containing chunk, has no cross-version match. The item
|
||||
retains its exact content match, coordinates, candidate inventory, and an explicit
|
||||
relationship-identity limitation. `coverage` is added to its dimensions only for
|
||||
incomplete inventory. Definite content, property, or complete topology differences
|
||||
remain `changed` while retaining any separate relationship uncertainty.
|
||||
An unknown item with no observed content, property, or relationship difference
|
||||
does not emit a `changed_from` edge; its pairing and uncertainty remain in the item.
|
||||
|
||||
One-sided absence is `added` or `removed` only when the opposite input graph has
|
||||
complete coverage. With partial, unavailable, or truncated input, the same
|
||||
condition is `unknown`. The comparison retains every classified item and
|
||||
|
||||
@@ -52,6 +52,7 @@ All four are read-only with respect to the target file; only `open_jeb_project`
|
||||
- The engine caps `list_units` pages at 100; a full page is reported as `coverage: "partial"` because more units may exist. An empty remainder is `complete` only when fewer units than requested were returned.
|
||||
- When `decompile_jeb_item` omits `unit_path`, the engine implicitly selects the project's first code unit; REA reports `unit_path: null` and a limitation instead of guessing the effective unit.
|
||||
- Engine refusals (`success: false` envelopes) are preserved as unsupported-target errors carrying the engine's own message, for example a nonexistent item address.
|
||||
- Successfully parsed client information is reused for the lifetime of the MCP connection, including when the engine omits its version. Explicit client inspection and availability probes refresh it.
|
||||
- An unreachable endpoint is a provider-availability failure with recovery guidance; REA does not retry or spawn the engine.
|
||||
|
||||
Real-engine verification lives in the JEB lane described in [testing.md](testing.md); mock transport tests do not establish real-engine compatibility.
|
||||
+21
-1
@@ -22,6 +22,8 @@ Select it explicitly with `--provider kuna`, MCP's `open_binary.provider_id: "ku
|
||||
| `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. |
|
||||
| `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. |
|
||||
|
||||
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
|
||||
|
||||
Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason.
|
||||
|
||||
## What Kuna answers
|
||||
@@ -68,7 +70,7 @@ Single-function subprocess output and each cached source facet share a 16 MiB bu
|
||||
|
||||
Results keep exact u64 virtual addresses, decoded text, native encoding, terminator status and byte length including any terminator. Kuna scans mapped sections or segments for runs of at least five characters; unterminated fragments can include instruction bytes. `all` includes ASCII, UTF-8 and the engine's Latin-range UTF-16LE reading. Other text, including short strings, non-Latin wide text and UTF-16BE, can remain undiscovered. An empty result does not prove absence.
|
||||
|
||||
Use `search_strings` for the matching rows or `list_strings` with an exact address for that location. Literal and regex matching run in REA, preserving the existing case and ECMAScript Unicode semantics instead of substituting Kuna's different regex dialect. Procedure and string regex queries share one worker slot per session. Input projections are materialized only after admission, so queued queries retain no per-query inventory arrays. Close cancels and drains matching. Native metadata queries also share one admission slot, including references; a failed stop retains admission until cleanup is proven.
|
||||
Use `search_strings` for the matching rows or `list_strings` with an exact address for that location. The validated inventory is sorted by numeric address and encoding. Once cached, exact-address reads use binary search and return every encoding at that address without scanning the full inventory or retaining another lookup index. Literal and regex matching run in REA, preserving the existing case and ECMAScript Unicode semantics instead of substituting Kuna's different regex dialect. Procedure and string regex queries share one worker slot per session. Input projections are materialized only after admission, so queued queries retain no per-query inventory arrays. Close cancels and drains matching. Native metadata queries also share one admission slot, including references; a failed stop retains admission until cleanup is proven.
|
||||
|
||||
`raw_result.strings` reports the source JSON digest/size, scanned scope, extraction policy, complete inventory count and selected count without copying unrequested text into the answer. Omit the address from `list_strings` to retrieve the complete inventory as Evidence; normal session retention and bundle export apply, including transport-capacity errors for oversized inline replies. These operations preserve their complete-result contracts and do not add paging. Function-referenced string facets in `analyze_function` remain unavailable; call `xrefs` explicitly when references to one returned string are needed.
|
||||
|
||||
@@ -82,6 +84,24 @@ REA parses the graph as Kuna writes it, like the string scan, so the graph has n
|
||||
|
||||
Evidence includes only selected addresses or dossier neighbors, the producing JSON's SHA-256/byte count, coverage and relevant function facts; unrequested program-wide JSON and instruction text are omitted. Reference caches are in memory and expire on close: a fresh CLI process or MCP server rebuilds them. A first call query therefore costs a whole-program reference walk even when a C export is warm. These operations do not establish full Ghidra/IDA feature parity.
|
||||
|
||||
Failures from shared installation or inventory work identify the operation the
|
||||
caller requested. `diagnostics.producer_operation` retains the internal producer
|
||||
name when it differs. Caller-specific failures keep the producer's typed outcome,
|
||||
captured output, partial facts and live cleanup dependencies; cleanup retry
|
||||
updates every projection rather than preserving an independent stale marker.
|
||||
Installation acquisition is shared, with independently cancellable caller waits.
|
||||
The final departing caller stops the probe and joins its cleanup; session close
|
||||
also stops and joins acquisition. A cleanly cancelled acquisition can be retried,
|
||||
while a retained cleanup failure blocks replacement until its owner is closed.
|
||||
Shared string and reference queries preserve caller cancellation reasons and
|
||||
join the final departing caller's command cleanup. A command cleanup error keeps
|
||||
its typed primary failure and captured output. Its live resource list follows
|
||||
both process and workspace ownership: proving process exit does not prove that
|
||||
the private workspace was removed, and retry updates earlier caller errors.
|
||||
If workspace setup fails, an unsuccessful rollback retains the original target
|
||||
or read failure together with its cleanup owner. Replacement stays blocked until
|
||||
that rollback is resolved; a clean rollback permits another setup attempt.
|
||||
|
||||
## Provenance and limitations
|
||||
|
||||
- Addresses and bytes are Kuna's observations of the image. Procedure entries and extents are its derivations: extents are upper bounds clipped at the next entry, and Kuna can split one function at an entry its own analysis infers. Pseudocode is its reconstruction, never original source.
|
||||
|
||||
@@ -435,6 +435,9 @@ Provider calls receive the request cancellation signal. Artifact traversal,
|
||||
hashing, version comparisons, Hopper requests, and process capture
|
||||
check the same signal. Cancellation is distinct from timeout. A cleanup failure
|
||||
uses `cleanup_incomplete` and lists only the owned resource kinds that remain.
|
||||
Once those owners confirm cleanup, composed failures recover their original
|
||||
typed primary outcome and recovery guidance. Historical composition diagnostics
|
||||
remain in `details.cleanup_history`, separate from the primary diagnostics.
|
||||
An atomic Evidence bundle or snapshot write can publish its complete output
|
||||
before staging cleanup fails. In that case, `details.published: true`,
|
||||
`details.published_path`, and `details.bytes` retain the publication receipt.
|
||||
@@ -521,6 +524,12 @@ analysis runtime. These failures are reported at the operation that needs
|
||||
them. A configured provider or an endpoint alone does not establish that a
|
||||
target is supported.
|
||||
|
||||
Composite Hopper regex searches report failures against the accepted
|
||||
`search_strings` or `search_procedures` operation. The underlying inventory
|
||||
method and request ID remain in `details.diagnostics.provider_request`, with
|
||||
the bridge error code and diagnostic type when available. Reattribution
|
||||
preserves the failure class, captured output, and live cleanup ownership.
|
||||
|
||||
Evidence bundles, snapshots, and extraction use the paths and output behavior
|
||||
declared by their tools. Artifact extraction materializes the selected regular
|
||||
files into a fresh REA-chosen temporary directory and reports its path.
|
||||
@@ -531,6 +540,25 @@ temporary source files are declared as `launchesProcess` and `writesFilesystem`,
|
||||
including for a single JavaScript source. This internal analysis does not execute
|
||||
the supplied application code.
|
||||
|
||||
## Static Ghidra bytes and source mappings
|
||||
|
||||
`read_bytes` returns initialized analysis memory, including loader fixups, rather
|
||||
than live process memory. Check `returned_bytes` and `complete` before decoding:
|
||||
reads stop at unmapped or uninitialized bytes. Missing bytes are unknown; do not
|
||||
pad an incomplete read with zeros and treat the result as an observation.
|
||||
|
||||
`list_segments` reports memory-block initialization and address-space facts.
|
||||
`address_to_file_offset` maps one address through the provider's source-byte
|
||||
mapping. A successful start-address mapping does not establish that an entire
|
||||
range is contiguous in the original file, or that its bytes equal analysis
|
||||
memory after loader fixups. Uninitialized, missing, and ambiguous mappings fail
|
||||
explicitly; preserve the reported failure rather than guessing an offset.
|
||||
|
||||
For constructor-populated pointer or resource slots, a static zero is not proof
|
||||
of a later null value. Inspect the slot's `xrefs` and the writer's assembly or
|
||||
pseudocode, retaining uncertainty about execution order and runtime values.
|
||||
These operations inspect static evidence and do not execute the target.
|
||||
|
||||
## Ghidra first-query deadlines and recovery
|
||||
|
||||
A successful MCP initialize handshake establishes the REA connection.
|
||||
|
||||
@@ -261,6 +261,13 @@ Mach-O fixtures check each case against source-owned return values.
|
||||
|
||||
## Static inspection
|
||||
|
||||
`inspect_plist.format` describes the selected source, not plutil's conversion
|
||||
format. Sources explicitly identified as XML, binary or JSON retain those
|
||||
labels. For generic text, a bounded source-prefix probe recognizes leading XML
|
||||
markup, including UTF-8 and BOM-marked UTF-16 sources. Unidentified text and
|
||||
OpenStep plists, including a data-only value such as `<deadbeef>`, report
|
||||
`unknown` even when plutil decodes their values through an XML conversion.
|
||||
|
||||
`inspect_macho.selected_architecture` identifies the exact selected lipo slice
|
||||
and its `container_file_offset` in the input file. A thin Mach-O has a known
|
||||
offset of zero; an unknown universal-slice offset stays null. The Evidence
|
||||
@@ -398,6 +405,10 @@ complete observation.
|
||||
coroutine tables, and inherited overrides, have explicit unsupported or
|
||||
partial coverage. Other providers retain the
|
||||
existing symbol-based inventory with its narrower coverage.
|
||||
Symbols without a reported location remain in that inventory: Swift symbol
|
||||
`address` and Objective-C `implementation_address` are null. An empty provider
|
||||
address is retained as `reported_address` in the symbol's evidence, whose
|
||||
location also remains unknown. Nonempty provider address syntax is preserved.
|
||||
|
||||
## Native desktop observation
|
||||
|
||||
|
||||
@@ -309,6 +309,10 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/
|
||||
|
||||
개발 환경과 검사 항목은 [CONTRIBUTING.md](../../CONTRIBUTING.md), 검증 절차는 [테스트 가이드](../testing.md), 프로젝트 구조는 [아키텍처 지도](../architecture.mermaid)를 참고하세요.
|
||||
|
||||
한국어 번역도 개선할 수 있습니다. 이 README를 수정할 때는 [영문 README](../../README.md)의 명령어, 요구 사항, 분석 범위와 일치하는지 확인하세요. 웹사이트 번역은 `website/locales/ko/messages.po`의 `msgstr` 값을 수정하세요.
|
||||
|
||||
웹사이트 번역의 수정 방법, 검사 명령, 로컬 미리보기는 `website/locales/README.md`를 따르세요. 위 기여 문서의 **Translation corrections** 절에서 이 가이드로 이동할 수 있습니다. 명령어, 경로, 자리표시자, 기록된 근거는 그대로 보존하세요. `website/_site/`의 한국어 HTML은 자동 생성되므로 직접 수정하지 마세요.
|
||||
|
||||
## 라이선스
|
||||
|
||||
[MIT](../../LICENSE)
|
||||
|
||||
@@ -73,7 +73,12 @@ invocation. A signed status response also confirms completion of older bridge
|
||||
connections after its UI dispatch, preserving recovery proof for a caller whose
|
||||
receipt another caller has acknowledged. Neither receipt recovery nor a status
|
||||
probe retries the original command. Failed acknowledgements remain pending and
|
||||
block new commands until they can be retried successfully.
|
||||
block new commands until they can be retried successfully. Status proof failures
|
||||
identify `cutter_command` and retain `status` in
|
||||
`details.diagnostics.auxiliary_operation`; malformed proof still blocks dispatch.
|
||||
Descriptor failures likewise retain the requesting operation through cleanup.
|
||||
Command admission reports typed cancellation and treats the caller's abort reason
|
||||
as opaque metadata, without inferring cleanup ownership from it.
|
||||
|
||||
## Configuration and coverage
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ triggers or arbitrary SQL.
|
||||
Rows are optional. `table` selects one exact ordinary-table name. `columns`
|
||||
(CLI repeated `--column`) selects exact visible column names in the requested
|
||||
order; omission selects all visible columns, including generated columns.
|
||||
Unselected cells are not evaluated or projected. Column selection requires a
|
||||
Unselected cells are not evaluated or projected. Malformed column-name bytes
|
||||
cause a format refusal when all columns are requested. In inventory scope, an explicit valid subset can
|
||||
still be sampled without decoding unrelated malformed column names.
|
||||
Column selection requires a
|
||||
table and at least one name; empty-string identifiers are valid. `row_limit`
|
||||
requires `table`, defaults to 20 and accepts positive safe integers. Samples
|
||||
remain subject to the byte and worker resource limits below, regardless of row count. The result reports
|
||||
|
||||
@@ -83,6 +83,9 @@ providers need not expose identical state machines.
|
||||
recovery handshakes. Bound abandoned unauthenticated listeners where the
|
||||
protocol permits it; that deadline must not expire authenticated idle sessions
|
||||
or long-running native work. Listener expiry does not prove target closure.
|
||||
- Readiness observations may await owned prerequisite commands. Status must
|
||||
remain readable during owner finalization without starting replacement work;
|
||||
report readiness as unknown when it cannot be re-observed.
|
||||
- Shared startup has one producer and independently cancellable caller waits.
|
||||
Keep it running while any caller still needs it. The final departing caller
|
||||
waits for owned cleanup and retains its failure. Provider closure is distinct
|
||||
@@ -95,6 +98,9 @@ providers need not expose identical state machines.
|
||||
primary failure and let incomplete cleanup retain its own classification.
|
||||
Preserve the caller's abort reason as the typed cancellation error's cause,
|
||||
including when cancellation is observed only after provider completion.
|
||||
Shared command owners must retain the observed cancellation reason separately
|
||||
from physical failures and cleanup ownership. Carry that fact through failure
|
||||
wrapping so adapters can preserve the exact reason without interpreting it.
|
||||
Client cancellation alone does not prove native work stopped.
|
||||
For atomic publication, document the cancellation cutoff and preserve any
|
||||
committed output's publication receipt.
|
||||
@@ -132,6 +138,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
|
||||
|
||||
@@ -43,7 +43,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
31,src/application/ReferenceSourceImportTypes.ts:limit,Import reports terminal limit failure,No reachable constructor found,Declared state may be unreachable; false verification risk,Remove unreachable top-level limit variant; per-entry limit guidance remains caller-visible.,removed,"verified by typecheck, exhaustive projection test, and source search"
|
||||
32,src/application/Setup.ts:configureDetectedClients(backup),Agent configuration backup fails,"{clientName} configuration backup verification failed; no successful configuration was reported.",Exposes machine client key and obscures no-write guarantee,"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",implemented,focused assertions passed
|
||||
33,src/application/Setup.ts:configureDetectedClients(write),Agent configuration write fails,"{clientName} configuration write verification failed; no successful configuration was reported.",Exposes machine client key and gives no recovery,"Agent configuration could not be updated. Check file permissions, then rerun setup.",implemented,focused assertions passed
|
||||
34,src/application/Setup.ts:configureDetectedClients(readback),Written agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",implemented,focused assertions passed
|
||||
34,src/application/Setup.ts:configureDetectedClients(readback),Written agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Agent configuration could not be verified after writing. The original was restored and its backup at {backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup. Without a backup: Agent configuration could not be verified. Repair the configuration file, then rerun setup.",implemented,focused assertions passed
|
||||
35,src/application/Setup.ts:installSkill,Skill install or semantic readback fails,Agent skill installation or readback failed.,No useful recovery action,"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",implemented,focused assertions passed
|
||||
36,src/main.ts:serveStdio.onerror,Established MCP client connection reports an error,MCP stdio transport error,Exposes transport implementation and gives no recovery,REA lost its MCP client connection. Restart REA from your MCP client.,implemented,verified by injected transport onerror runtime test
|
||||
37,src/main.ts:serveStdio throw,MCP connection cannot be created,Failed to start MCP stdio transport,Exposes transport implementation and gives no recovery,REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.,implemented,verified by injected transport startup-throw runtime test
|
||||
@@ -55,7 +55,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
43,src/application/Uninstall.ts:removeClient parse,Agent configuration is malformed,Malformed configuration was preserved without mutation.,Does not name required repair format or retry,"Configuration is not valid JSON/TOML and was not changed. Repair it, then rerun uninstall.",implemented,focused JSON assertion passed; TOML path uses same branch
|
||||
44,src/application/Uninstall.ts:removeClient backup,Configuration backup copy fails,Configuration backup failed; no mutation was attempted.,No recovery step,"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",implemented,verified by injected filesystem backup failure test
|
||||
45,src/application/Uninstall.ts:removeClient update,Write or semantic readback fails,Configuration update failed; the original was restored and {backupPath} was retained.,Exposes local path and lacks clear next sequence,"Report whether the original was restored; if restoration also fails, direct the user to the retained backup.",implemented,verified by injected update and restoration failure tests
|
||||
45.5,src/application/Uninstall.ts:removeClient restore,Configuration update fails and restoring the original also fails,"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.","High risk; persistent configuration may be damaged, but retained backup is authoritative",Keep new recovery copy,implemented,verified by injected restoration failure test
|
||||
45.5,src/application/Uninstall.ts:removeClient restore,Configuration update fails and restoring the original also fails,"Configuration could not be updated or restored. Restore the backup at {backupPath} manually, then rerun uninstall.","High risk; persistent configuration may be damaged, but retained backup is authoritative",Keep new recovery copy,implemented,verified by injected restoration failure test
|
||||
46,src/application/Uninstall.ts:removeManagedPath symlink,Managed cache/state/skill path is a symbolic link,Refused to follow symlink {path}.,Exposes local path and does not explain manual safety check,"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",implemented,focused assertion passed
|
||||
47,src/application/Uninstall.ts:removeManagedPath failure,Managed item removal fails,{path} could not be removed.,Exposes local path and gives no recovery,"This item could not be removed. Check file permissions, then rerun uninstall.",implemented,verified by injected managed-path removal failure test
|
||||
48,src/application/DoctorDiagnostics.ts:node,Runtime version is outside supported ranges,"Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",Low risk; exact dependency recovery,Keep current copy,unchanged,verified by doctor unit test
|
||||
|
||||
|
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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
||||
# Native Electron once dispatch — 2026-10-16
|
||||
|
||||
#4593 was reported by review of #4562, but its source was already in main from
|
||||
#4389. The initial report was explicitly source-only; subsequent paired controls
|
||||
confirmed the bug. This change delegates `once` creation, firing and callable
|
||||
selection to the actual runtime and observes only established callback entry.
|
||||
CLI and MCP continue to share the same hook, event model, projection and retained
|
||||
Evidence. No new tool, schema, execution authority or analysis cache is introduced.
|
||||
|
||||
## Paired native controls
|
||||
|
||||
The baseline is `709b5cbdc15aa757f89c38ecd12e5a47710747a6`. Final source is
|
||||
`4ebd0f60e860daa57a7033c6e77ea7f5f4b8ac97`, including the two review-gap fixes below. Earlier source cohort
|
||||
`c0b52b5408fa97013798e6ecee885ccac08e7356` contains the fix; the final cleanup
|
||||
commit is `e60c4a1098321ed2a7f564ae35909a31d707694f`, which removes the obsolete fifth helper argument and
|
||||
unreachable copied call/apply dispatch branches after inspecting every caller.
|
||||
The pre-review gap-owner follow-up is `7f05f476c52498adba41d943ead1a86e7216ca76`. The adjacent JSON preserves
|
||||
source hashes and separate measurement cohorts, including the initial 93-row prefix.
|
||||
The final review JSONL contains 117 paired rows (39 inputs × 3 runtimes). The
|
||||
earlier 96-row JSONL remains unchanged, including its initial 93-row prefix. Both
|
||||
comparisons have identical native
|
||||
control facts in each before/after pair. Regenerable binaries, apps, profiles
|
||||
and multi-MB raw captures are excluded from Git.
|
||||
|
||||
| Actual engine | Cases | Baseline behavioral differences | Fixed differences |
|
||||
| ---------------------------------------------------------- | ----: | ------------------------------: | ----------------: |
|
||||
| Node 24.18.0 (pinned) | 39 | 4 | 0 |
|
||||
| Official Node 24.21.0 | 39 | 16 | 0 |
|
||||
| Generic Electron 44.7.0 embedded Node 24.21.0, run as Node | 39 | 16 | 0 |
|
||||
|
||||
The original 21-case reproduction found 0/9/9 differences respectively. The
|
||||
pre-review 32-case cohort found 1/13/13. The final 39-case suite adds readonly
|
||||
callback methods, emitter method invariants, native metadata writes, failure
|
||||
recovery and non-callable callback methods. The extra pinned-runtime difference is registration
|
||||
metadata ordering. On the newer runtimes, opaque callback methods can suppress
|
||||
callbacks under the old hook although native dispatch executes them. The pinned
|
||||
runtime also catches false invocation records when an opaque method legitimately
|
||||
skips the callback. The added regression fails on the baseline for that false fact.
|
||||
|
||||
These are **controlled producer cases with a minimal Electron module**, including
|
||||
the actual embedded Node binary, not a real GUI application's natural callback
|
||||
loss. Native traces, receiver checks, failures and remaining listeners are compared
|
||||
exactly. Expected property reads are derived from each runtime rather than copied
|
||||
from one Node implementation. The custom-apply guard still checks its original
|
||||
Arguments representation when the runtime actually invokes that method.
|
||||
|
||||
## Ownership and uncertainty
|
||||
|
||||
Native once wrappers keep their public callback identity and descriptor attributes.
|
||||
The registration receiver forwards methods/getters to the original emitter and
|
||||
supports readonly method descriptors. A private callable receives an actual
|
||||
Arguments frame, avoiding application Array species/iterators when recording.
|
||||
Metadata recognition and admission share one bounded descriptor lookup.
|
||||
|
||||
An application-selected opaque call/apply can bypass observation; a bounded
|
||||
`custom-call`/`custom-apply` gap replaces invented invocation facts. Opaque native
|
||||
metadata writes or registration lookups keep the exact original callback/native
|
||||
operation and emit `once-metadata` gaps. The temporary unobserved scope is restored
|
||||
even if registration throws. The 39-case suite checks this recovery, including a native alias that bypasses
|
||||
patched `on`; the once owner retains the observation gap for that path. #4513 raw
|
||||
recording-wrapper mutation remains explicitly unfixed.
|
||||
|
||||
Review found two missing failure facts: a decorator can register through an
|
||||
unhooked alias and then throw, and a runtime can read a non-callable call/apply
|
||||
property before dispatch fails. Both new regressions fail on the pre-review
|
||||
source. The once owner now finalizes its missing gap in `finally`, after restoring
|
||||
the scope; the callable metadata owner records a failed gap while preserving the
|
||||
exact non-callable value. Error identity and native callback behavior are retained.
|
||||
|
||||
Two short-lived proxies are used for an observable once registration. Metadata
|
||||
remains weakly retained, and existing event/serialization budgets remain in force.
|
||||
The added registration cost was not isolated; no speed or token improvement is
|
||||
claimed. The saved producer comparison is a correctness experiment, not a whole
|
||||
application performance benchmark. Its runner reports differences instead of
|
||||
exiting unsuccessfully for an expected bad baseline; the regression tests assert
|
||||
that fixed behavior matches native behavior and observation claims are truthful.
|
||||
|
||||
## Real application verification
|
||||
|
||||
Unchanged Joplin Desktop 3.7.21 ASAR and explicit generic Electron 44.7.0 run the
|
||||
shipped welcome note/1075×666 PNG → actual resource context menu → app bridge →
|
||||
blocked reveal workflow. The image stays visible, page errors remain empty,
|
||||
reveal events increase 0→1, and the shared retained capture contains the exact
|
||||
event. Both staged hook files are hashed against the selected source before launch.
|
||||
No synthetic application IPC channels or replacement app callbacks are added.
|
||||
|
||||
The argv shim is an explicit test adapter for the still-failing standard startup
|
||||
#2974. **Overall provider cleanup remains failed (#4282)** and the verifier exits
|
||||
nonzero. External scope teardown is not provider cleanup. The first frozen real
|
||||
pipeline takes 17.680 s; observed payload-exit CPU 16.272793 s and peak 1,395,253,248 B
|
||||
have an extra descendant, so complete-family observation is **unverified**. The
|
||||
cleanup cohort is recorded separately. The pre-review 32-case source repeats the real
|
||||
operation successfully, but overall cleanup still fails: 18.074 s pipeline, observed
|
||||
CPU 17.011774 s / peak 1,422,839,808 B; complete-family measurement remains unverified. No completed public CLI/MCP workflow,
|
||||
real Windows/macOS run, natural app callback-loss reproduction or full check:pr
|
||||
is claimed.
|
||||
|
||||
The final review source repeats the same real operation: 18.353 s pipeline,
|
||||
observed CPU 17.098599 s / peak 1,387,556,864 B; complete-family measurement remains
|
||||
unverified and overall provider cleanup fails. An earlier trial failed to initialize
|
||||
the display because the provider environment omits XAUTHORITY. That launch is
|
||||
preserved separately; the fresh final trial uses local Xvfb with `-nolisten tcp -ac`,
|
||||
not a production provider change.
|
||||
|
||||
Frozen local validation: 75 hook cases on both Node versions; 5 projection + 8 MCP
|
||||
cases on the unchanged shared contracts; static checks pass (final 118.946 s wall / 117.752622 s CPU / 4,282,826,752 B
|
||||
cgroup peak, verified payload-exit family observation). The pinned final run uses
|
||||
`test:focused`; an attempted `test:local` invocation was rejected before tests ran
|
||||
and is recorded as not executed. See JSON for exact
|
||||
commands/cohorts, failed candidate attempts, source pins and measured resources.
|
||||
Failures include the Array-species frame, readonly receiver invariants, metadata
|
||||
setter identity, on-getter ordering, a lint/interoperability correction and helper
|
||||
preparation failures. They are not relabeled as results from the final source.
|
||||
|
||||
All heavy work runs serially with CPU 10/quota 1, 8 GiB hard/6 GiB early/no swap/192 tasks.
|
||||
Counters are captured at payload exit with the observer alive, not final whole-scope
|
||||
counters; cgroup peak includes file cache. Each final Joplin run has a new private
|
||||
configuration mount. Filesystem/runtime/build caches are warm; cold-system and
|
||||
warm-repeat costs are unmeasured. Agent/billed tokens and public reply/context
|
||||
costs are unmeasured. The broad internal error capture size is not an agent-token
|
||||
measurement. All owned scopes are checked inactive/dead independently.
|
||||
|
||||
## Reproduce the producer comparison
|
||||
|
||||
Use the pinned project toolchain and explicitly provided alternate runtimes.
|
||||
The alternate Node 24.21 archive is checked against [official checksums](https://nodejs.org/download/release/v24.21.0/SHASUMS256.txt);
|
||||
its archive/binary hashes are recorded. No global toolchain is upgraded.
|
||||
|
||||
```sh
|
||||
python3 experiments/analysis-efficiency/electron-once-runtimes.py \
|
||||
--hook-root /absolute/checkout-to-measure \
|
||||
--node /absolute/pinned-node \
|
||||
--node /absolute/node24.21 \
|
||||
--electron /absolute/generic-electron44.7 \
|
||||
--output /absolute/owned-output
|
||||
```
|
||||
|
||||
Run this under the recorded resource wrapper. For a baseline comparison, use a
|
||||
clean checkout of 709b5cbd as hook-root while keeping the same current oracle/runner.
|
||||
The runner retains full JSON and prints a compact summary. The real Joplin recipe
|
||||
is in [the shell verifier record](electron-shell-sync-2026-10-16.md); select the
|
||||
current runtime-root. That workflow must remain nonzero while cleanup fails.
|
||||
@@ -0,0 +1,117 @@
|
||||
{"runtime":0,"mode":"plain","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"plain","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"plain","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-opaque","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":false}]}
|
||||
{"runtime":0,"mode":"call-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-opaque","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"apply-opaque","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"call-readonly","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":false}]}
|
||||
{"runtime":0,"mode":"call-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-readonly","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"apply-readonly","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"call-getter","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":false}]}
|
||||
{"runtime":0,"mode":"call-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-getter","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"apply-getter","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"call-throwing-getter","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":true}]}
|
||||
{"runtime":0,"mode":"call-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-throwing-getter","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":true}]}
|
||||
{"runtime":0,"mode":"apply-throwing-getter","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":true}]}
|
||||
{"runtime":0,"mode":"call-non-callable","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: this.listener.call is not a function","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.call is not a function","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: this.listener.call is not a function","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":true}]}
|
||||
{"runtime":0,"mode":"call-non-callable","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-non-callable","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-non-callable","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-non-callable","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: this.listener.apply is not a function","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.apply is not a function","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: this.listener.apply is not a function","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":true}]}
|
||||
{"runtime":0,"mode":"apply-non-callable","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: this.listener.apply is not a function","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.apply is not a function","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: this.listener.apply is not a function","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":true}]}
|
||||
{"runtime":0,"mode":"inherited-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"candidate_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":0,"mode":"on-getter-listener-setter","arguments":0,"native":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"reads":1,"writes":[],"public_identity":true,"error":null},"candidate_hooked":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":0,"mode":"on-getter-native-alias","arguments":0,"native":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"candidate_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":0,"mode":"on-getter-native-alias-throws","arguments":0,"native":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"baseline_hooked":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"candidate_hooked":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":true}]}
|
||||
{"runtime":0,"mode":"throwing-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"candidate_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":true},{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"readonly-emitter-methods","arguments":0,"native":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"candidate_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"plain","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"plain","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"plain","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-non-callable","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.call is not a function","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-non-callable","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-non-callable","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-non-callable","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-non-callable","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.apply is not a function","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-non-callable","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.apply is not a function","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"inherited-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"candidate_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":1,"mode":"on-getter-listener-setter","arguments":0,"native":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"reads":1,"writes":[],"public_identity":true,"error":null},"candidate_hooked":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":1,"mode":"on-getter-native-alias","arguments":0,"native":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"candidate_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":1,"mode":"on-getter-native-alias-throws","arguments":0,"native":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"baseline_hooked":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"candidate_hooked":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":true}]}
|
||||
{"runtime":1,"mode":"throwing-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"candidate_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":true},{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"readonly-emitter-methods","arguments":0,"native":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"candidate_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"plain","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"plain","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"plain","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-non-callable","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.call is not a function","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-non-callable","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-non-callable","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-non-callable","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-non-callable","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.apply is not a function","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-non-callable","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":[],"error":"TypeError: listener.apply is not a function","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"inherited-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"candidate_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":2,"mode":"on-getter-listener-setter","arguments":0,"native":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"reads":1,"writes":[],"public_identity":true,"error":null},"candidate_hooked":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":2,"mode":"on-getter-native-alias","arguments":0,"native":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"candidate_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":2,"mode":"on-getter-native-alias-throws","arguments":0,"native":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"baseline_hooked":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"candidate_hooked":{"body_calls":1,"same_error":true,"public_identity":true,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":true}]}
|
||||
{"runtime":2,"mode":"throwing-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"candidate_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":true},{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"readonly-emitter-methods","arguments":0,"native":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"candidate_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
@@ -0,0 +1,96 @@
|
||||
{"runtime":0,"mode":"plain","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"plain","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"plain","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-opaque","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":false}]}
|
||||
{"runtime":0,"mode":"call-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-opaque","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"apply-opaque","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"call-readonly","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":false}]}
|
||||
{"runtime":0,"mode":"call-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-readonly","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"apply-readonly","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"call-getter","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":false}]}
|
||||
{"runtime":0,"mode":"call-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-getter","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"apply-getter","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":false}]}
|
||||
{"runtime":0,"mode":"call-throwing-getter","arguments":0,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-call","error":true}]}
|
||||
{"runtime":0,"mode":"call-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"call-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"apply-throwing-getter","arguments":1,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":true}]}
|
||||
{"runtime":0,"mode":"apply-throwing-getter","arguments":3,"native":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"custom-apply","error":true}]}
|
||||
{"runtime":0,"mode":"inherited-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"candidate_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":0,"mode":"on-getter-listener-setter","arguments":0,"native":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"reads":1,"writes":[],"public_identity":true,"error":null},"candidate_hooked":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":0,"mode":"throwing-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"candidate_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"readonly-emitter-methods","arguments":0,"native":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"candidate_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"plain","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"plain","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"plain","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"call-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"apply-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"inherited-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"candidate_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":1,"mode":"on-getter-listener-setter","arguments":0,"native":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"reads":1,"writes":[],"public_identity":true,"error":null},"candidate_hooked":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":1,"mode":"throwing-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"candidate_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":1,"mode":"readonly-emitter-methods","arguments":0,"native":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"candidate_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"plain","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"plain","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"plain","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-opaque","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-opaque","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-opaque","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-readonly","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-readonly","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-readonly","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call","opaque:call"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply","opaque:apply"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:call"],"error":"Error: getter:call","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"call-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-throwing-getter","arguments":0,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:0"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-throwing-getter","arguments":1,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:1"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"apply-throwing-getter","arguments":3,"native":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_hooked":{"body_calls":0,"correct_receiver":true,"trace":["get:apply"],"error":"Error: getter:apply","listeners_after":0},"candidate_hooked":{"body_calls":1,"correct_receiver":true,"trace":["body:3"],"error":null,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":true}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"inherited-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"candidate_hooked":{"body_calls":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":2,"mode":"on-getter-listener-setter","arguments":0,"native":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_hooked":{"body_calls":1,"reads":1,"writes":[],"public_identity":true,"error":null},"candidate_hooked":{"body_calls":1,"reads":1,"writes":[true],"public_identity":false,"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":2,"mode":"throwing-listener-setter","arguments":0,"native":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"candidate_hooked":{"body_calls":1,"writes":[true],"error":"Error: metadata-write","listeners_after":1},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":2,"mode":"readonly-emitter-methods","arguments":0,"native":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"candidate_hooked":{"body_calls":1,"seen":["on:true","removeListener:true"],"error":null},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"main-event-invocation","event":null,"error":false}]}
|
||||
{"runtime":0,"mode":"on-getter-native-alias","arguments":0,"native":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"candidate_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":1,"mode":"on-getter-native-alias","arguments":0,"native":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"candidate_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
{"runtime":2,"mode":"on-getter-native-alias","arguments":0,"native":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"candidate_hooked":{"body_calls":1,"reads":1,"listeners_after":0},"baseline_observation":[{"kind":"main-event-invocation","event":null,"error":false}],"candidate_observation":[{"kind":"ipc-observation-gap","event":"once-metadata","error":false}]}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
# Electron synchronous shell boundary verification — 2026-10-16
|
||||
|
||||
Fix #4393 reuses Nitin13-git's existing PR #4562. The boundary owns a fixed
|
||||
method/return table: existing asynchronous blocks still reject, while
|
||||
`showItemInFolder` returns `undefined` and `writeShortcutLink` returns `false`.
|
||||
Both new blocks use the existing shape-only event recorder, shared by CLI/MCP.
|
||||
No tool, schema, capture authority, analysis cache or launch behavior is added.
|
||||
|
||||
## Source and controlled regression
|
||||
|
||||
The fresh main base is `f26973a52918a938631e90fdc9116f7b85a62a52`;
|
||||
implementation integration is `0386b60fb` and frozen verifier source is
|
||||
`3f231735c3fbc28918590ddc61461871c8a2aa4d`.
|
||||
The adjacent JSON records frozen final script/runtime/input hashes and all attempts;
|
||||
unpinned exploratory helper revisions are explicitly identified.
|
||||
Original contributor attribution remains in Git ancestry.
|
||||
|
||||
Using the candidate test with the base boundary produces **73 passes / 1 failure**:
|
||||
the fake native shortcut implementation executes and returns true. With the
|
||||
candidate boundary, **87 cases pass** (74 staged-hook, 5 projection, 8 MCP).
|
||||
These test native dispatch suppression, synchronous results, shape-only
|
||||
retention and the shared event schema. The fake Electron cases and controlled
|
||||
MCP provider do not prove a real Windows shell implementation was exercised.
|
||||
`npm run check:fast` passes both uncached tasks. Build-cache differences and
|
||||
different case counts make the red/green timings unsuitable as a speed comparison.
|
||||
|
||||
## Real Joplin workflow and limits
|
||||
|
||||
Inputs: unchanged Joplin Desktop **3.7.21** ASAR (233,575,014 bytes), and the
|
||||
explicit generic Electron **44.7.0** runtime. Acquisition/checksum references
|
||||
are in `javascript-native-handoff-identity-2026-10-15.json` and
|
||||
`electron-cancellation-2026-10-16.json`; this is not vendor-executable compatibility.
|
||||
No executable fuse is changed. Large downloadable inputs and generated profiles
|
||||
are not committed or retained as an experiment bundle.
|
||||
|
||||
The verifier opens Joplin's shipped **1. Welcome to Joplin!** note and real PNG
|
||||
attachment. It selects Joplin's actual editor layout and resource context-menu
|
||||
callbacks; Joplin's existing bridge calls the real patched shell method. The
|
||||
native popup presentation is captured for automation; its application callbacks
|
||||
are not replaced. Before any app actions, the verifier requires the active hook.
|
||||
It requires zero earlier reveal events, one new blocked shape-only reveal event,
|
||||
a still-rendered attachment and an identical event in the shared retained capture.
|
||||
|
||||
**Standard launch remains unsuccessful.** The earlier standard probe reports
|
||||
`Unknown flag: -r` in Joplin's start-flag parser; the frozen standard verifier
|
||||
times out waiting for the application search input. This is relevant to #2974,
|
||||
not fixed here. A discarded NODE_OPTIONS experiment was invalid: Playwright
|
||||
removes that variable, so no hook was installed. Those attempts are never used
|
||||
as interception or performance evidence.
|
||||
|
||||
The explicit `--argv-shim-transport` test adapter loads the provider's staged
|
||||
canonical hook and removes only its own exact `-r`/shim pair from app argv before
|
||||
Joplin starts. It does not modify the ASAR or production provider. Its successful
|
||||
operation phase is not a successful standard public CLI/MCP workflow. Provider
|
||||
cleanup still fails (#4282); the verifier exits nonzero, preserving the distinction.
|
||||
External owned-scope teardown does not establish provider cleanup.
|
||||
|
||||
The first valid shim probe reaches one blocked reveal, visible 1075×666 PNG,
|
||||
no page errors and retained capture evidence. The final frozen rerun additionally
|
||||
checks event causality and exact capture equality. Detailed measured results and
|
||||
whole-family observation status are in the JSON. The final controlled pipeline
|
||||
takes16.989s wall /16.055812 family CPU s /1,376,382,976 peak cgroup bytes
|
||||
with verified payload-exit observation; the overall workflow still fails cleanup. Each final trial has a new
|
||||
private config mount; earlier exploratory shared-config trials are not independent
|
||||
cold runs. Filesystem/runtime/build caches are warm; no cold-system or warm-repeat
|
||||
speedup is claimed. Agent/billed tokens are unmeasured; there is no token benefit claim.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use the pinned Node/npm toolchain. Install dependencies and build with
|
||||
`npm ci` and `npm run build:cached`. Create an owned writable packet directory
|
||||
with `tmp`, `profiles`, and `config/joplin-desktop`. On Linux, bind that private
|
||||
config over the actual user config directory in a disposable `bwrap` mount
|
||||
namespace (do not change HOME), keep the application resources read-only and
|
||||
run under Xvfb. Set TMPDIR to the owned writable packet `tmp` directory. Run:
|
||||
|
||||
```sh
|
||||
node experiments/analysis-efficiency/joplin-shell-boundary.mjs \
|
||||
--runtime-root /absolute/rea-checkout \
|
||||
--executable /absolute/generic-electron \
|
||||
--application /absolute/Joplin/resources/app.asar \
|
||||
--profile-root /absolute/owned-packet/profiles \
|
||||
--argv-shim-transport
|
||||
```
|
||||
|
||||
Omit the last flag for the failing standard launch. Run heavy work serially under
|
||||
`resource-run.py --memory-gib 8 --observe-payload-exit`, one CPU, no swap, the
|
||||
6 GiB early-stop margin and 192-task ceiling. CPU/peak memory receipts are at
|
||||
payload exit with the observer alive; memory includes file cache, not just RSS.
|
||||
Unverified complete-family observations are explicitly marked. No Windows host,
|
||||
macOS host, vendor packaged runtime or full `check:pr` was executed here.
|
||||
@@ -46,7 +46,10 @@ Equivalent MCP input:
|
||||
```
|
||||
|
||||
Each selected regular file is read through the existing stable artifact reader;
|
||||
symlinks, changing files and files over 8 MiB fail closed. WASM bytes are copied
|
||||
symlinks, changing files and files over 8 MiB fail closed. An oversized input
|
||||
returns `resource_constraint` with resource `file-size` and its fixed byte
|
||||
allowance in `reported_limits.maximum_input_bytes`; retrying the unchanged file
|
||||
does not raise that allowance. WASM bytes are copied
|
||||
into a private workspace as read-only `module.wasm`; its digest is rechecked
|
||||
before and after each tool. WABT validates with `--enable-all`,
|
||||
reports sections through `wasm-objdump -h -x`, and decodes through
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Controlled producer oracle, not real-application/provider evidence. Stage
|
||||
// this file beside a minimal Electron module and the actual unmodified hook.
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
if (process.argv[2]) await import(pathToFileURL(process.argv[2]));
|
||||
// Match the hook's CommonJS module identity. Electron's ESM bare-module
|
||||
// resolver can select its builtin even when run as Node with a staged fixture.
|
||||
const { ipcMain } = createRequire(import.meta.url)("electron");
|
||||
if (globalThis.__reaElectronActiveSnapshot?.().hook_error !== false)
|
||||
throw new Error("The canonical hook must be installed without errors");
|
||||
|
||||
const modes = [
|
||||
"plain",
|
||||
"call-opaque",
|
||||
"apply-opaque",
|
||||
"call-readonly",
|
||||
"apply-readonly",
|
||||
"call-getter",
|
||||
"apply-getter",
|
||||
"call-throwing-getter",
|
||||
"apply-throwing-getter",
|
||||
"call-non-callable",
|
||||
"apply-non-callable",
|
||||
];
|
||||
|
||||
const execute = (emitter, channel, mode, argumentCount) => {
|
||||
let bodyCalls = 0;
|
||||
let correctReceiver = true;
|
||||
const trace = [];
|
||||
const listener = function listener() {
|
||||
bodyCalls += 1;
|
||||
correctReceiver &&= this === emitter;
|
||||
trace.push(`body:${arguments.length}`);
|
||||
};
|
||||
if (mode !== "plain") {
|
||||
const method = mode.startsWith("call") ? "call" : "apply";
|
||||
const opaque = () => trace.push(`opaque:${method}`);
|
||||
if (mode.includes("getter"))
|
||||
Object.defineProperty(listener, method, {
|
||||
get() {
|
||||
trace.push(`get:${method}`);
|
||||
if (mode.includes("throwing")) throw new Error(`getter:${method}`);
|
||||
return opaque;
|
||||
},
|
||||
});
|
||||
else if (mode.includes("readonly"))
|
||||
Object.defineProperty(listener, method, { value: opaque });
|
||||
else listener[method] = mode.includes("non-callable") ? null : opaque;
|
||||
}
|
||||
emitter.once(channel, listener);
|
||||
let error = null;
|
||||
try {
|
||||
emitter.emit(
|
||||
channel,
|
||||
...Array.from({ length: argumentCount }, (_, i) => i),
|
||||
);
|
||||
emitter.emit(channel, "must-not-run-twice");
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
}
|
||||
return {
|
||||
body_calls: bodyCalls,
|
||||
correct_receiver: correctReceiver,
|
||||
trace,
|
||||
error,
|
||||
listeners_after: emitter.listenerCount(channel),
|
||||
};
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
for (const mode of modes)
|
||||
for (const argumentCount of [0, 1, 3]) {
|
||||
const channel = `${mode}:${argumentCount}`;
|
||||
const native = execute(new EventEmitter(), channel, mode, argumentCount);
|
||||
const hooked = execute(ipcMain, channel, mode, argumentCount);
|
||||
const events = globalThis
|
||||
.__reaElectronActiveSnapshot()
|
||||
.events.filter((event) => event.channel === channel);
|
||||
rows.push({
|
||||
mode,
|
||||
argument_count: argumentCount,
|
||||
native,
|
||||
hooked,
|
||||
semantic_match: JSON.stringify(native) === JSON.stringify(hooked),
|
||||
observation: events.map(({ kind, event, error }) => ({
|
||||
kind,
|
||||
event,
|
||||
error,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const additional = (mode, scenario) => {
|
||||
const channel = `metadata:${mode}`;
|
||||
const native = scenario(new EventEmitter(), channel);
|
||||
const hooked = scenario(ipcMain, channel);
|
||||
rows.push({
|
||||
mode,
|
||||
argument_count: 0,
|
||||
native,
|
||||
hooked,
|
||||
semantic_match: JSON.stringify(native) === JSON.stringify(hooked),
|
||||
observation: globalThis
|
||||
.__reaElectronActiveSnapshot()
|
||||
.events.filter((event) => event.channel === channel)
|
||||
.map(({ kind, event, error }) => ({ kind, event, error })),
|
||||
});
|
||||
};
|
||||
|
||||
additional("inherited-listener-setter", (emitter, channel) => {
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
Function.prototype,
|
||||
"listener",
|
||||
);
|
||||
const writes = [];
|
||||
let bodyCalls = 0;
|
||||
const listener = () => {
|
||||
bodyCalls += 1;
|
||||
};
|
||||
Object.defineProperty(Function.prototype, "listener", {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
writes.push(value === listener);
|
||||
},
|
||||
});
|
||||
let error = null;
|
||||
try {
|
||||
emitter.once(channel, listener);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
if (original)
|
||||
Object.defineProperty(Function.prototype, "listener", original);
|
||||
else delete Function.prototype.listener;
|
||||
}
|
||||
const publicIdentity =
|
||||
emitter.rawListeners(channel)[0]?.listener === listener;
|
||||
emitter.emit(channel);
|
||||
return {
|
||||
body_calls: bodyCalls,
|
||||
writes,
|
||||
public_identity: publicIdentity,
|
||||
error,
|
||||
};
|
||||
});
|
||||
|
||||
additional("on-getter-listener-setter", (emitter, channel) => {
|
||||
const prototypeMetadata = Object.getOwnPropertyDescriptor(
|
||||
Function.prototype,
|
||||
"listener",
|
||||
);
|
||||
const onMetadata = Object.getOwnPropertyDescriptor(emitter, "on");
|
||||
const on = emitter.on;
|
||||
let reads = 0,
|
||||
bodyCalls = 0,
|
||||
error = null;
|
||||
const writes = [];
|
||||
const listener = () => {
|
||||
bodyCalls += 1;
|
||||
};
|
||||
Object.defineProperty(emitter, "on", {
|
||||
configurable: true,
|
||||
get() {
|
||||
reads += 1;
|
||||
Object.defineProperty(Function.prototype, "listener", {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
writes.push(value === listener);
|
||||
},
|
||||
});
|
||||
return on;
|
||||
},
|
||||
});
|
||||
try {
|
||||
emitter.once(channel, listener);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
if (prototypeMetadata)
|
||||
Object.defineProperty(Function.prototype, "listener", prototypeMetadata);
|
||||
else delete Function.prototype.listener;
|
||||
if (onMetadata) Object.defineProperty(emitter, "on", onMetadata);
|
||||
else delete emitter.on;
|
||||
}
|
||||
const publicIdentity =
|
||||
emitter.rawListeners(channel)[0]?.listener === listener;
|
||||
emitter.emit(channel);
|
||||
return {
|
||||
body_calls: bodyCalls,
|
||||
reads,
|
||||
writes,
|
||||
public_identity: publicIdentity,
|
||||
error,
|
||||
};
|
||||
});
|
||||
|
||||
additional("on-getter-native-alias", (emitter, channel) => {
|
||||
const original = Object.getOwnPropertyDescriptor(emitter, "on");
|
||||
let reads = 0,
|
||||
bodyCalls = 0;
|
||||
const listener = () => {
|
||||
bodyCalls += 1;
|
||||
};
|
||||
Object.defineProperty(emitter, "on", {
|
||||
configurable: true,
|
||||
get() {
|
||||
reads += 1;
|
||||
return EventEmitter.prototype.on;
|
||||
},
|
||||
});
|
||||
try {
|
||||
emitter.once(channel, listener);
|
||||
} finally {
|
||||
if (original) Object.defineProperty(emitter, "on", original);
|
||||
else delete emitter.on;
|
||||
}
|
||||
emitter.emit(channel);
|
||||
return {
|
||||
body_calls: bodyCalls,
|
||||
reads,
|
||||
listeners_after: emitter.listenerCount(channel),
|
||||
};
|
||||
});
|
||||
|
||||
additional("on-getter-native-alias-throws", (emitter, channel) => {
|
||||
const original = Object.getOwnPropertyDescriptor(emitter, "on");
|
||||
const failure = new Error("registered-before-error");
|
||||
let bodyCalls = 0,
|
||||
sameError = false;
|
||||
const listener = () => {
|
||||
bodyCalls += 1;
|
||||
};
|
||||
Object.defineProperty(emitter, "on", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return function registerThenThrow(name, callback) {
|
||||
EventEmitter.prototype.on.call(this, name, callback);
|
||||
throw failure;
|
||||
};
|
||||
},
|
||||
});
|
||||
try {
|
||||
emitter.once(channel, listener);
|
||||
} catch (cause) {
|
||||
sameError = cause === failure;
|
||||
} finally {
|
||||
if (original) Object.defineProperty(emitter, "on", original);
|
||||
else delete emitter.on;
|
||||
}
|
||||
const publicIdentity =
|
||||
emitter.rawListeners(channel)[0]?.listener === listener;
|
||||
emitter.emit(channel);
|
||||
return {
|
||||
body_calls: bodyCalls,
|
||||
same_error: sameError,
|
||||
public_identity: publicIdentity,
|
||||
listeners_after: emitter.listenerCount(channel),
|
||||
};
|
||||
});
|
||||
|
||||
additional("throwing-listener-setter", (emitter, channel) => {
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
Function.prototype,
|
||||
"listener",
|
||||
);
|
||||
const writes = [];
|
||||
let bodyCalls = 0,
|
||||
error = null;
|
||||
const listener = () => {
|
||||
bodyCalls += 1;
|
||||
};
|
||||
Object.defineProperty(Function.prototype, "listener", {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
writes.push(value === listener);
|
||||
throw new Error("metadata-write");
|
||||
},
|
||||
});
|
||||
try {
|
||||
emitter.once(channel, listener);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
if (original)
|
||||
Object.defineProperty(Function.prototype, "listener", original);
|
||||
else delete Function.prototype.listener;
|
||||
}
|
||||
// A failed registration must not leave later registrations unobservable.
|
||||
emitter.on(channel, listener);
|
||||
emitter.emit(channel);
|
||||
return {
|
||||
body_calls: bodyCalls,
|
||||
writes,
|
||||
error,
|
||||
listeners_after: emitter.listenerCount(channel),
|
||||
};
|
||||
});
|
||||
|
||||
additional("readonly-emitter-methods", (emitter, channel) => {
|
||||
const seen = [];
|
||||
for (const key of ["on", "removeListener"]) {
|
||||
const original = emitter[key];
|
||||
Object.defineProperty(emitter, key, {
|
||||
value: function readonlyMethod(...args) {
|
||||
seen.push(`${key}:${this === emitter}`);
|
||||
return Reflect.apply(original, this, args);
|
||||
},
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
let bodyCalls = 0,
|
||||
error = null;
|
||||
const listener = () => {
|
||||
bodyCalls += 1;
|
||||
};
|
||||
try {
|
||||
emitter.once(channel, listener);
|
||||
emitter.emit(channel);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
}
|
||||
return { body_calls: bodyCalls, seen, error };
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
node: process.versions.node,
|
||||
electron: process.versions.electron ?? null,
|
||||
cases: rows.length,
|
||||
disagreements: rows.filter((row) => !row.semantic_match).length,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serial native/hooked producer controls; not real-application evidence."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def sha256(path):
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--hook-root", type=Path, required=True)
|
||||
parser.add_argument("--node", type=Path, action="append", required=True)
|
||||
parser.add_argument("--electron", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
arguments.output.mkdir(parents=True, exist_ok=True)
|
||||
packet = Path(tempfile.mkdtemp(prefix="native-once-", dir=arguments.output))
|
||||
stage = packet / "staged"
|
||||
module = stage / "node_modules/electron"
|
||||
module.mkdir(parents=True)
|
||||
fixture = module / "index.js"
|
||||
fixture.write_text(
|
||||
'const { EventEmitter } = require("node:events");\n'
|
||||
"module.exports = { app: new EventEmitter(), ipcMain: new EventEmitter() };\n"
|
||||
)
|
||||
sources = [
|
||||
arguments.hook_root / "scripts/electron-active-hook.cjs",
|
||||
arguments.hook_root / "scripts/electron-active-hook-boundaries.cjs",
|
||||
Path(__file__).with_name("electron-once-runtime.mjs"),
|
||||
]
|
||||
pins = {path.name: sha256(path) for path in sources}
|
||||
pins["electron-fixture"] = sha256(fixture)
|
||||
for source in sources:
|
||||
shutil.copyfile(source, stage / source.name)
|
||||
engines = [(engine, False) for engine in arguments.node]
|
||||
if arguments.electron is not None:
|
||||
engines.append((arguments.electron, True))
|
||||
results = []
|
||||
for index, (engine, run_as_node) in enumerate(engines):
|
||||
selected = engine.absolute()
|
||||
environment = dict(os.environ)
|
||||
if run_as_node:
|
||||
environment["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
execution = subprocess.run(
|
||||
[str(selected), str(stage / sources[-1].name), str(stage / sources[0].name)],
|
||||
env=environment,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
(packet / f"engine-{index}.stdout").write_bytes(execution.stdout)
|
||||
(packet / f"engine-{index}.stderr").write_bytes(execution.stderr)
|
||||
receipt = {
|
||||
"selected_binary": str(engine),
|
||||
"absolute_binary": str(selected),
|
||||
"binary_sha256": sha256(selected),
|
||||
"electron_run_as_node": run_as_node,
|
||||
"exit_code": execution.returncode,
|
||||
"stdout_sha256": hashlib.sha256(execution.stdout).hexdigest(),
|
||||
"stderr_sha256": hashlib.sha256(execution.stderr).hexdigest(),
|
||||
}
|
||||
if execution.returncode == 0:
|
||||
receipt["result"] = json.loads(execution.stdout)
|
||||
results.append(receipt)
|
||||
report = {"hook_root": str(arguments.hook_root), "source_sha256": pins,
|
||||
"engines": results, "scope": __doc__}
|
||||
(packet / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
if execution.returncode != 0:
|
||||
raise RuntimeError(f"Engine failed; preserved receipt at {packet}")
|
||||
print(json.dumps({"packet": str(packet), "engines": [
|
||||
{"node": row["result"]["node"], "electron": row["result"]["electron"],
|
||||
"cases": row["result"]["cases"],
|
||||
"disagreements": row["result"]["disagreements"]} for row in results
|
||||
]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env node
|
||||
// Unchanged Joplin ASAR and its shipped welcome note/attachment. The actual
|
||||
// resource context menu, bridge and shell call execute; menu automation is explicit.
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, 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" },
|
||||
"argv-shim-transport": { 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 profile = await mkdtemp(
|
||||
join(resolve(values["profile-root"]), "joplin-shell-"),
|
||||
);
|
||||
let application;
|
||||
let child;
|
||||
let probe;
|
||||
let stagedHookHashes;
|
||||
const started = performance.now();
|
||||
const controller = new AbortController();
|
||||
const deadline = setTimeout(
|
||||
() => controller.abort(new Error("Verifier deadline")),
|
||||
45_000,
|
||||
);
|
||||
|
||||
const verifyHookSources = async (stagedHook) => {
|
||||
const hashes = {};
|
||||
for (const name of [
|
||||
"electron-active-hook.cjs",
|
||||
"electron-active-hook-boundaries.cjs",
|
||||
]) {
|
||||
const hash = (data) => createHash("sha256").update(data).digest("hex");
|
||||
const actualPath =
|
||||
name === "electron-active-hook.cjs"
|
||||
? stagedHook
|
||||
: join(dirname(stagedHook), name);
|
||||
const [actual, expected] = await Promise.all([
|
||||
readFile(actualPath).then(hash),
|
||||
readFile(join(runtime, "scripts", name)).then(hash),
|
||||
]);
|
||||
assert.equal(
|
||||
actual,
|
||||
expected,
|
||||
`The staged ${name} must match the selected runtime source`,
|
||||
);
|
||||
hashes[name] = actual;
|
||||
}
|
||||
return hashes;
|
||||
};
|
||||
|
||||
const launch = async (options) => {
|
||||
const stagedIndex = options.args.indexOf("-r");
|
||||
assert.notEqual(
|
||||
stagedIndex,
|
||||
-1,
|
||||
"Expected the provider's staged require pair",
|
||||
);
|
||||
stagedHookHashes = await verifyHookSources(options.args[stagedIndex + 1]);
|
||||
let launchOptions = options;
|
||||
if (values["argv-shim-transport"]) {
|
||||
// Explicit test adapter for #2974, not the public provider launch path.
|
||||
const args = [...options.args];
|
||||
const index = args.indexOf("-r");
|
||||
assert.notEqual(index, -1, "Expected the provider's staged require pair");
|
||||
const hook = args[index + 1];
|
||||
const shim = join(profile, "launch-shim.cjs");
|
||||
await writeFile(
|
||||
shim,
|
||||
`require(${JSON.stringify(hook)});\nconst index = process.argv.indexOf("-r");\nif (index < 0 || process.argv[index + 1] !== __filename) throw new Error("Unexpected staged argv");\nprocess.argv.splice(index, 2);\n`,
|
||||
);
|
||||
args[index + 1] = shim;
|
||||
launchOptions = { ...options, args };
|
||||
}
|
||||
application = await _electron.launch(launchOptions);
|
||||
child = application.process();
|
||||
assert.equal(
|
||||
await application.evaluate(
|
||||
() => typeof globalThis.__reaElectronActiveSnapshot,
|
||||
),
|
||||
"function",
|
||||
"The staged active hook must be installed before app actions",
|
||||
);
|
||||
const page = await application.firstWindow({ timeout: 15_000 });
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => {
|
||||
if (pageErrors.length < 5) pageErrors.push(String(error).slice(0, 600));
|
||||
});
|
||||
await page.getByPlaceholder("Search...").waitFor({ timeout: 15_000 });
|
||||
await page
|
||||
.getByText("1. Welcome to Joplin!", { exact: true })
|
||||
.first()
|
||||
.click({ timeout: 20_000 });
|
||||
const viewer = page.locator("iframe.noteTextViewer");
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 3 && !(await viewer.isVisible());
|
||||
attempt += 1
|
||||
) {
|
||||
await application.evaluate(({ Menu }) => {
|
||||
const item =
|
||||
Menu.getApplicationMenu()?.getMenuItemById("toggleVisiblePanes");
|
||||
if (!item) throw new Error("Missing Joplin editor layout menu");
|
||||
item.click();
|
||||
});
|
||||
await viewer
|
||||
.waitFor({ state: "visible", timeout: 3_000 })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const image = page
|
||||
.frameLocator("iframe.noteTextViewer")
|
||||
.locator("img")
|
||||
.first();
|
||||
try {
|
||||
await image.waitFor({ timeout: 15_000 });
|
||||
} catch (cause) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
viewer_visible: await viewer.isVisible(),
|
||||
frames: await Promise.all(
|
||||
page
|
||||
.frames()
|
||||
.slice(0, 5)
|
||||
.map(async (frame) => ({
|
||||
url: frame.url(),
|
||||
state: await frame.evaluate(() => ({
|
||||
text: document.body?.innerText.slice(0, 500),
|
||||
images: document.images.length,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
}),
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const attachment = await image.evaluate((element) => ({
|
||||
src: element.getAttribute("src"),
|
||||
alt: element.getAttribute("alt"),
|
||||
width: element.naturalWidth,
|
||||
height: element.naturalHeight,
|
||||
}));
|
||||
assert.ok(
|
||||
attachment.width > 0 && attachment.height > 0,
|
||||
"The shipped welcome attachment must render",
|
||||
);
|
||||
const beforeShellCount = await application.evaluate(
|
||||
() =>
|
||||
globalThis
|
||||
.__reaElectronActiveSnapshot()
|
||||
.events.filter(
|
||||
(event) =>
|
||||
event.kind === "shell-attempt" &&
|
||||
event.event === "showItemInFolder",
|
||||
).length,
|
||||
);
|
||||
assert.equal(
|
||||
beforeShellCount,
|
||||
0,
|
||||
"A startup event cannot prove the requested reveal",
|
||||
);
|
||||
// Capture the app's actual context menu instead of opening a native popup.
|
||||
await application.evaluate(({ Menu }) => {
|
||||
const original = Menu.prototype.popup;
|
||||
globalThis.__reaShellMenuProbe = { original, menu: null };
|
||||
Menu.prototype.popup = function captureMenu() {
|
||||
globalThis.__reaShellMenuProbe.menu = this;
|
||||
};
|
||||
});
|
||||
await image.click({ button: "right" });
|
||||
const revealed = await application.evaluate(async ({ Menu }) => {
|
||||
const state = globalThis.__reaShellMenuProbe;
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (state.menu === null && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
Menu.prototype.popup = state.original;
|
||||
delete globalThis.__reaShellMenuProbe;
|
||||
const item = state.menu?.items.find(
|
||||
(entry) => entry.label === "Reveal file in folder",
|
||||
);
|
||||
if (!item) throw new Error("Missing actual Joplin resource reveal menu");
|
||||
await item.click();
|
||||
return item.label;
|
||||
});
|
||||
const shell = await application.evaluate(async () => {
|
||||
const read = () =>
|
||||
globalThis
|
||||
.__reaElectronActiveSnapshot()
|
||||
.events.filter(
|
||||
(event) =>
|
||||
event.kind === "shell-attempt" &&
|
||||
event.event === "showItemInFolder",
|
||||
);
|
||||
const deadline = Date.now() + 5_000;
|
||||
let events = read();
|
||||
while (events.length === 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
events = read();
|
||||
}
|
||||
return events;
|
||||
});
|
||||
assert.equal(shell.length, 1);
|
||||
assert.equal(shell[0].phase, "blocked");
|
||||
assert.deepEqual(shell[0].argument_shapes, ["string"]);
|
||||
probe = {
|
||||
before_shell_count: beforeShellCount,
|
||||
note: "1. Welcome to Joplin!",
|
||||
attachment,
|
||||
revealed,
|
||||
image_visible: await image.isVisible(),
|
||||
shell,
|
||||
page_errors: pageErrors,
|
||||
};
|
||||
return application;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await captureElectronScenario(
|
||||
new PlaywrightElectronActiveProvider(process.env, launch),
|
||||
{
|
||||
executable_path: resolve(values.executable),
|
||||
application_path: resolve(values.application),
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
`--user-data-dir=${profile}`,
|
||||
"--profile",
|
||||
profile,
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
step_id: "settle-after-reveal",
|
||||
kind: "wait",
|
||||
window_index: 0,
|
||||
duration_ms: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await writeFile(join(profile, "result.json"), JSON.stringify(result));
|
||||
const capture = result.ok
|
||||
? result.value.normalized_result
|
||||
: result.error.partialObservation?.capture;
|
||||
const retainedShell = capture?.timeline.events.filter(
|
||||
(event) =>
|
||||
event.kind === "shell-attempt" && event.event === "showItemInFolder",
|
||||
);
|
||||
if (probe)
|
||||
assert.deepEqual(
|
||||
retainedShell,
|
||||
probe.shell,
|
||||
"The shared capture must retain the observed shell event",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
profile,
|
||||
runtime,
|
||||
staged_hook_sha256: stagedHookHashes,
|
||||
elapsed_ms: performance.now() - started,
|
||||
launch_transport: values["argv-shim-transport"]
|
||||
? "controlled-argv-shim"
|
||||
: "standard-staged-argv",
|
||||
probe: probe ?? null,
|
||||
provider_ok: result.ok,
|
||||
error: result.ok
|
||||
? null
|
||||
: {
|
||||
tag: result.error._tag,
|
||||
reason: result.error.reason,
|
||||
diagnostics: result.error.diagnostics,
|
||||
},
|
||||
coverage: capture?.coverage,
|
||||
retention: capture?.retention,
|
||||
actions: capture?.actions,
|
||||
deadline_triggered: controller.signal.aborted,
|
||||
workflow_status:
|
||||
result.ok && probe !== undefined ? "verified" : "partial-or-failed",
|
||||
}),
|
||||
);
|
||||
assert.ok(probe, "Real Joplin resource reveal was not verified");
|
||||
assert.ok(result.ok, "Provider cleanup remains unverified");
|
||||
} catch (cause) {
|
||||
console.log(
|
||||
JSON.stringify({ verifier_failure: String(cause), probe: probe ?? null }),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
if (application && child?.exitCode === null && child?.signalCode === null) {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
application.close().catch(() => undefined),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, 5_000);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
// The external owned scope recovers leftovers; that is not provider cleanup.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
@@ -335,9 +335,22 @@ const createElectronActiveBoundaryPatches = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Each blocked method keeps the return contract of the Electron API it
|
||||
// replaces, so a target that checks the result sees an ordinary refusal.
|
||||
const blockedShellResults = {
|
||||
openExternal: () =>
|
||||
Promise.reject(new Error("External shell effects are blocked")),
|
||||
openPath: () =>
|
||||
Promise.reject(new Error("External shell effects are blocked")),
|
||||
// Synchronous `void`: reveals a path in the host file manager.
|
||||
showItemInFolder: () => undefined,
|
||||
// Synchronous `boolean`: creates or replaces a Windows shortcut file.
|
||||
writeShortcutLink: () => false,
|
||||
};
|
||||
|
||||
const patchShell = (shell) => {
|
||||
if (shell === null || shell === undefined) return;
|
||||
for (const method of ["openExternal", "openPath"]) {
|
||||
for (const [method, blockedResult] of Object.entries(blockedShellResults)) {
|
||||
const original = shell[method];
|
||||
if (typeof original !== "function") continue;
|
||||
shell[method] = function blockedShellCall(...args) {
|
||||
@@ -345,7 +358,7 @@ const createElectronActiveBoundaryPatches = ({
|
||||
argument_shape_values: args,
|
||||
error: true,
|
||||
});
|
||||
return Promise.reject(new Error("External shell effects are blocked"));
|
||||
return blockedResult();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
"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 nativeFunctionPrototype = getPrototypeOf(callFunction);
|
||||
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 +123,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 +188,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 +216,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 +267,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 +317,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 +346,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 +380,360 @@ const recordInvocation = (kind, channel, event, args) =>
|
||||
error: false,
|
||||
});
|
||||
|
||||
const invokeObservedEventListener = (thisArg, channel, listener, args) => {
|
||||
// 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 {
|
||||
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;
|
||||
|
||||
// Parse native property lookup without invoking application getters or Proxy
|
||||
// traps. Undefined means absent; null means the lookup is unknown.
|
||||
const lookupPropertyDescriptor = (listener, key) => {
|
||||
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, key);
|
||||
if (descriptor !== undefined) return descriptor;
|
||||
candidate = getPrototypeOf(candidate);
|
||||
}
|
||||
return candidate === null ? undefined : null;
|
||||
};
|
||||
|
||||
const listenerDataDescriptor = (listener) => {
|
||||
const descriptor = lookupPropertyDescriptor(listener, "listener");
|
||||
if (descriptor === null || descriptor === undefined) return descriptor;
|
||||
if (hasOwnProperty(descriptor, "value")) return descriptor;
|
||||
// A setter-only property shadows ancestors with a known undefined read.
|
||||
return descriptor.get === undefined ? 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 createObservedOnceCallback = (target, channel, listener, receiver) => {
|
||||
// A private callable target avoids Proxy get invariants on an app's
|
||||
// non-configurable, read-only call/apply properties. It never escapes
|
||||
// through the native once wrapper's public listener link.
|
||||
const callback = new ProxyConstructor(
|
||||
function observedOnceListener() {
|
||||
return invokeObservedEventListener(
|
||||
this === receiver ? target : this,
|
||||
channel,
|
||||
listener,
|
||||
arguments,
|
||||
);
|
||||
},
|
||||
{
|
||||
get: (_callable, key) => {
|
||||
let value;
|
||||
try {
|
||||
value = getProperty(listener, key, listener);
|
||||
} catch (cause) {
|
||||
if (key === "call" || key === "apply")
|
||||
recordIpcObservationGap(channel, `custom-${key}`, true);
|
||||
throw cause;
|
||||
}
|
||||
if (key !== "call" && key !== "apply") return value;
|
||||
if (typeof value !== "function") {
|
||||
recordIpcObservationGap(channel, `custom-${key}`, true);
|
||||
return value;
|
||||
}
|
||||
if (value === callFunction || value === applyArguments) return value;
|
||||
// A runtime that selects an application method must preserve that
|
||||
// method's receiver and original argument-list object. Its body may
|
||||
// bypass the callable Proxy, so retain a gap rather than an invocation.
|
||||
return new ProxyConstructor(value, {
|
||||
apply: (method, thisArg, args) => {
|
||||
const gap = recordIpcObservationGap(channel, `custom-${key}`);
|
||||
if (args[0] === receiver) args[0] = target;
|
||||
try {
|
||||
return applyFunction(
|
||||
method,
|
||||
thisArg === callback ? listener : thisArg,
|
||||
args,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (gap !== null) {
|
||||
gap.error = true;
|
||||
gap.phase = "failed";
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
return callback;
|
||||
};
|
||||
|
||||
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;
|
||||
let unobservedOnceScope = null;
|
||||
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);
|
||||
if (unobservedOnceScope !== null) {
|
||||
const scope = unobservedOnceScope;
|
||||
const result = on.call(this, channel, listener);
|
||||
recordIpcObservationGap(channel, "once-metadata");
|
||||
if (scope.channel === channel) scope.gapRecorded = true;
|
||||
return result;
|
||||
}
|
||||
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);
|
||||
};
|
||||
// Native once owns firing, removal and callback dispatch. Node versions
|
||||
// differ in whether they read callback.call/apply. Observe actual invocation
|
||||
// through a private callable Proxy instead of copying either implementation.
|
||||
if (typeof once === "function")
|
||||
ipcMain.once = function patchedIpcMainOnce(channel, listener) {
|
||||
if (
|
||||
typeof listener !== "function" ||
|
||||
this === null ||
|
||||
(typeof this !== "object" && typeof this !== "function")
|
||||
)
|
||||
return once.call(this, channel, listener);
|
||||
const target = this;
|
||||
const metadata = lookupPropertyDescriptor(
|
||||
nativeFunctionPrototype,
|
||||
"listener",
|
||||
);
|
||||
const onMetadata = lookupPropertyDescriptor(target, "on");
|
||||
if (
|
||||
metadata === null ||
|
||||
(metadata !== undefined && !hasOwnProperty(metadata, "value")) ||
|
||||
onMetadata === null ||
|
||||
(onMetadata !== undefined && !hasOwnProperty(onMetadata, "value"))
|
||||
) {
|
||||
// Native wrapper creation can expose its callback through an inherited
|
||||
// setter/Proxy, including one installed by an on getter. Pass the exact
|
||||
// app callback; registrations in this synchronous scope stay unwrapped.
|
||||
const previousScope = unobservedOnceScope;
|
||||
const scope = { channel, gapRecorded: false };
|
||||
let failed = true;
|
||||
unobservedOnceScope = scope;
|
||||
try {
|
||||
const result = applyFunction(once, target, [channel, listener]);
|
||||
failed = false;
|
||||
return result;
|
||||
} finally {
|
||||
unobservedOnceScope = previousScope;
|
||||
// An app decorator can register through an unhooked alias and then
|
||||
// throw. Preserve a gap for either outcome, not proof of registration.
|
||||
if (!scope.gapRecorded)
|
||||
recordIpcObservationGap(channel, "once-metadata", failed);
|
||||
}
|
||||
}
|
||||
const receiver = new ProxyConstructor(
|
||||
{ __proto__: null },
|
||||
{
|
||||
get: (_target, key) => {
|
||||
const value = getProperty(target, key, target);
|
||||
if (
|
||||
(key !== "on" && key !== "removeListener") ||
|
||||
typeof value !== "function"
|
||||
)
|
||||
return value;
|
||||
return function forwardOnceOperation(...args) {
|
||||
if (key === "on") {
|
||||
const wrapped = args[1];
|
||||
defineProperty(wrapped, "listener", {
|
||||
...ownPropertyDescriptor(wrapped, "listener"),
|
||||
value: listener,
|
||||
});
|
||||
weakMapSet(hookListenerRemovalTargets, wrapped, wrapped);
|
||||
}
|
||||
return applyFunction(value, target, args);
|
||||
};
|
||||
},
|
||||
set: (_target, key, value) => setProperty(target, key, value, target),
|
||||
},
|
||||
);
|
||||
const callback = createObservedOnceCallback(
|
||||
target,
|
||||
channel,
|
||||
listener,
|
||||
receiver,
|
||||
);
|
||||
const result = applyFunction(once, receiver, [channel, callback]);
|
||||
return result === receiver ? target : result;
|
||||
};
|
||||
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 +1003,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 +1029,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
import { runKunaCommand } from "../../dist/kuna/KunaCommand.js";
|
||||
import { kunaProjectDeadline } from "../../dist/kuna/KunaConfiguration.js";
|
||||
import { inspectKunaInstallation } from "../../dist/kuna/KunaInstallation.js";
|
||||
import { KUNA_XREF_OUTPUT_BYTES } from "../../dist/kuna/KunaReferenceFiles.js";
|
||||
|
||||
@@ -24,14 +25,15 @@ export const nativeKunaIncomingSites = async ({
|
||||
);
|
||||
assert.ok(installed.ok, "Native reference oracle could not inspect Kuna");
|
||||
const input = await realpath(binary);
|
||||
const timeoutMs = installed.value.configuration.functionTimeoutMs;
|
||||
// Match the provider's own xrefs scan, which runs under the project deadline.
|
||||
const deadline = kunaProjectDeadline(installed.value.configuration);
|
||||
const outcome = await runKunaCommand({
|
||||
installation: installed.value,
|
||||
arguments: ["xrefs", input, "--to", address, "--json"],
|
||||
workspace,
|
||||
operation: "verify:kuna-native-xrefs",
|
||||
timeoutMs,
|
||||
reportedTimeoutMs: timeoutMs,
|
||||
timeoutMs: deadline.ms,
|
||||
deadline,
|
||||
outputBytes: KUNA_XREF_OUTPUT_BYTES,
|
||||
signal: new AbortController().signal,
|
||||
retainFailedFileClose: (owner, resource) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readdir, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runKunaCommand } from "../../dist/kuna/KunaCommand.js";
|
||||
import { kunaProjectDeadline } from "../../dist/kuna/KunaConfiguration.js";
|
||||
import { inspectKunaInstallation } from "../../dist/kuna/KunaInstallation.js";
|
||||
|
||||
const entries = (path) =>
|
||||
@@ -46,7 +47,7 @@ export const nativeKunaStrings = async ({
|
||||
workspace,
|
||||
operation: "verify:kuna-native-strings",
|
||||
timeoutMs: installed.value.configuration.projectTimeoutMs,
|
||||
reportedTimeoutMs: installed.value.configuration.projectTimeoutMs,
|
||||
deadline: kunaProjectDeadline(installed.value.configuration),
|
||||
outputBytes: 16 * 1024 * 1024,
|
||||
signal: new AbortController().signal,
|
||||
retainFailedFileClose: (owner, resource) =>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileBytes,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import { probeAdbPathExistence, runAdbCapture } 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"],
|
||||
] 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 },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, "caller stopped", new Error("caller stopped")] as const)(
|
||||
"uses the command owner's exact %s cancellation reason",
|
||||
async (reason) => {
|
||||
const cancelled = Object.assign(new ExecFileOutputCancelledError(reason), {
|
||||
stdout: "partial",
|
||||
stderr: "diagnostic",
|
||||
stdoutBytes: 7,
|
||||
stderrBytes: 10,
|
||||
});
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(cancelled);
|
||||
const result = await probe();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
if (result.ok) throw new Error("Expected owned cancellation");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
stdout: "partial",
|
||||
stderr: "diagnostic",
|
||||
stdout_bytes: 7,
|
||||
stderr_bytes: 10,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not infer cancellation from a foreign ABORT_ERR code", async () => {
|
||||
const failure = Object.assign(new Error("foreign code"), {
|
||||
code: "ABORT_ERR",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(failure);
|
||||
expect(await probe()).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisProtocolError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a physical failure even when the later abort reason is that same error", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error("physical command failure");
|
||||
vi.mocked(execFileBytes).mockImplementationOnce(async () => {
|
||||
controller.abort(failure);
|
||||
throw failure;
|
||||
});
|
||||
const result = await runAdbCapture(
|
||||
"inspect_adb_device",
|
||||
"device",
|
||||
"/host/adb",
|
||||
[],
|
||||
{ timeoutMs: 100, maxOutputBytes: 1024 },
|
||||
controller.signal,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisProtocolError", cause: failure },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisProtocolError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisCapturedOutput,
|
||||
AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutputFailure,
|
||||
type ExecFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import { packageChangeFailureReason } from "./AdbShellOutput.js";
|
||||
|
||||
const STDERR_EXCERPT_BYTES = 512;
|
||||
|
||||
/** Project parsed command-owner facts without reinterpreting the rejection. */
|
||||
export const adbCommandCapturedOutput = (
|
||||
failure: ExecFileOutputFailure,
|
||||
): AnalysisCapturedOutput => ({
|
||||
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,
|
||||
});
|
||||
|
||||
/** 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 (cause instanceof ExecFileOutputCancelledError)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cause.cause,
|
||||
...(failure === undefined
|
||||
? {}
|
||||
: { capturedOutput: adbCommandCapturedOutput(failure) }),
|
||||
}),
|
||||
);
|
||||
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: adbCommandCapturedOutput(failure),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,19 @@
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
execFileOutput,
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { adbCommandCapturedOutput } from "./AdbCommand.js";
|
||||
|
||||
/** Environment key selecting the caller's adb binary. */
|
||||
export const ADB_BINARY_ENV = "REA_ADB_PATH";
|
||||
@@ -115,8 +121,39 @@ export const inspectAdbClient = async (
|
||||
} catch (cause) {
|
||||
if (cause instanceof AdbConfigurationFailure) throw cause;
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (signal?.aborted || failure?.code === "ABORT_ERR")
|
||||
throw new AnalysisCancelledError("inspect_adb_client", { cause });
|
||||
if (cause instanceof ExecFileOutputCancelledError)
|
||||
throw new AnalysisCancelledError("inspect_adb_client", {
|
||||
cause: cause.cause,
|
||||
...(failure === undefined
|
||||
? {}
|
||||
: { capturedOutput: adbCommandCapturedOutput(failure) }),
|
||||
});
|
||||
const diagnostics = {
|
||||
selected_binary: selection.binary,
|
||||
path_source: selection.pathSource,
|
||||
...(failure === undefined ? {} : { exit_code: failure.code }),
|
||||
};
|
||||
const options = {
|
||||
cause,
|
||||
diagnostics,
|
||||
...(failure === undefined
|
||||
? {}
|
||||
: { capturedOutput: adbCommandCapturedOutput(failure) }),
|
||||
};
|
||||
if (failure?.outputTruncated === true)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"inspect_adb_client",
|
||||
"memory",
|
||||
`adb version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`,
|
||||
{ max_output_bytes: VERSION_MAX_OUTPUT_BYTES },
|
||||
options,
|
||||
);
|
||||
if (failure?.code === "ETIMEDOUT")
|
||||
throw new AnalysisTimeoutError(
|
||||
"inspect_adb_client",
|
||||
VERSION_TIMEOUT_MS,
|
||||
options,
|
||||
);
|
||||
if (failure?.code === "ENOENT")
|
||||
throw new AdbConfigurationFailure(
|
||||
"binary_missing",
|
||||
|
||||
@@ -706,6 +706,28 @@ exit 1`,
|
||||
expect(result.total_bytes).toBe(34);
|
||||
});
|
||||
|
||||
it("handles `pkg=null` by returning null for requested_permissions_count", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
userId=10234
|
||||
pkg=null
|
||||
versionCode=45 minSdk=24 targetSdk=34
|
||||
codePath=/data/app/com.example
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
requested_permissions_count: null,
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("projects package details from dumpsys package output", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
@@ -1316,8 +1338,8 @@ it("reads Android 16 focus declarations from displays when windows omits them",
|
||||
focused_window: "Window{abc u0 com.example/.Main}",
|
||||
focused_app: "ActivityRecord{def u0 com.example/.Main t1}",
|
||||
observed_lines: [
|
||||
"Window{abc u0 com.example/.Main}",
|
||||
"ActivityRecord{def u0 com.example/.Main t1}",
|
||||
"mCurrentFocus=Window{abc u0 com.example/.Main}",
|
||||
"mFocusedApp=ActivityRecord{def u0 com.example/.Main t1}",
|
||||
],
|
||||
});
|
||||
const execution = await providerOver(binary).execute({
|
||||
@@ -1354,6 +1376,41 @@ it("describes missing window focus as unknown after both sections omit declarati
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["windows", "displays"])(
|
||||
"preserves explicitly absent focus from %s without another fallback",
|
||||
async (section) => {
|
||||
const declarations = " mCurrentFocus=null\n mFocusedApp=null\n";
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:window:windows":
|
||||
section === "windows" ? declarations : "no focus\n",
|
||||
...(section === "displays"
|
||||
? { "dumpsys:window:displays": declarations }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
const execution = await providerOver(binary).execute({
|
||||
operation: "inspect_adb_window",
|
||||
input: { serial: "emulator-5554" },
|
||||
});
|
||||
expect(execution.ok).toBe(true);
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result).toMatchObject({
|
||||
focused_window: null,
|
||||
focused_app: null,
|
||||
observed_lines: [" mCurrentFocus=null", " mFocusedApp=null"],
|
||||
});
|
||||
expect(execution.value.limitations).toContain(
|
||||
`Focus declarations were read from \`dumpsys window ${section}\``,
|
||||
);
|
||||
expect(
|
||||
execution.value.limitations.some((line) =>
|
||||
line.includes("focus is unknown"),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("reads one settings value and reports device-side nulls", async () => {
|
||||
const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" }));
|
||||
const enabled = await evidenceResult(providerOver(binary), {
|
||||
@@ -1480,7 +1537,7 @@ exit 1`,
|
||||
it("uninstalls a package and reports the device's refusal for absent ones", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Success\\n'; exit 0; fi
|
||||
if [ "$#" = 8 ] && [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "uninstall" ] && [ "$6" = "--user" ] && [ "$7" = 0 ]; then printf 'Success\\n'; exit 0; fi
|
||||
exit 1`,
|
||||
);
|
||||
const removed = await evidenceResult(providerOver(binary), {
|
||||
@@ -1491,7 +1548,7 @@ exit 1`,
|
||||
|
||||
const { binary: refusing } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi
|
||||
if [ "$#" = 8 ] && [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "uninstall" ] && [ "$6" = "--user" ] && [ "$7" = 0 ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi
|
||||
exit 1`,
|
||||
);
|
||||
const projected = await refusedBy(providerOver(refusing), {
|
||||
@@ -1535,7 +1592,7 @@ exit 1`,
|
||||
it("preserves the device's uninstall refusal when adb exits nonzero", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi
|
||||
if [ "$#" = 8 ] && [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "uninstall" ] && [ "$6" = "--user" ] && [ "$7" = 0 ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
|
||||
+79
-211
@@ -56,11 +56,16 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
stableFileHashCause,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
regularFileCancellation,
|
||||
RegularFileCancelled,
|
||||
RegularFileCleanupFailure,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import {
|
||||
@@ -70,9 +75,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 +111,6 @@ import {
|
||||
parseBugreportCopiedPath,
|
||||
parseDumpsysPackageOutput,
|
||||
parseFeatureListOutput,
|
||||
packageChangeFailureReason,
|
||||
parsePackageChangeOutput,
|
||||
parseProcessListOutput,
|
||||
parsePngDimensions,
|
||||
@@ -144,7 +152,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 +177,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)`,
|
||||
@@ -180,9 +187,8 @@ const hashAdbFile = async (
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause !== signal?.reason &&
|
||||
(cause instanceof RegularFileCleanupFailure ||
|
||||
cause instanceof RegularFileCancelled)
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
cause instanceof RegularFileCancelled
|
||||
)
|
||||
throw new AdbDigestFailure(
|
||||
cause as
|
||||
@@ -192,8 +198,8 @@ const hashAdbFile = async (
|
||||
closeLease?.bindCleanupError(error);
|
||||
},
|
||||
);
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(operation, { cause });
|
||||
if (cause instanceof RegularFileOperationCancelled)
|
||||
throw new AnalysisCancelledError(operation, { cause: cause.reason });
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
@@ -201,7 +207,6 @@ const hashAdbFile = async (
|
||||
const adbDigestError = (
|
||||
cause: unknown,
|
||||
operation: string,
|
||||
signal: AbortSignal | undefined,
|
||||
partialObservation:
|
||||
| AdbAcquiredFilePartialObservation
|
||||
| AdbPackagePullPartialObservation,
|
||||
@@ -236,12 +241,10 @@ const adbDigestError = (
|
||||
);
|
||||
const primaryCause =
|
||||
digestOutcome.kind === "failed" ? digestOutcome.cause : undefined;
|
||||
if (
|
||||
signal?.aborted === true ||
|
||||
primaryCause instanceof AnalysisCancelledError
|
||||
) {
|
||||
const cancellation = regularFileCancellation(digestFailure);
|
||||
if (cancellation !== undefined) {
|
||||
const error = new AnalysisCancelledError(operation, {
|
||||
cause: digestFailure,
|
||||
cause: cancellation.reason,
|
||||
cleanup: { reason, resources: [resource] },
|
||||
partialObservation,
|
||||
});
|
||||
@@ -256,7 +259,8 @@ const adbDigestError = (
|
||||
operation,
|
||||
`The pulled file could not be fully digested or its handle could not be closed: ${digestFailure.path}`,
|
||||
{
|
||||
cause: primaryCause ?? digestFailure,
|
||||
cause:
|
||||
digestOutcome.kind === "failed" ? primaryCause : digestFailure,
|
||||
partialObservation,
|
||||
},
|
||||
);
|
||||
@@ -326,7 +330,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 +368,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 +468,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> => {
|
||||
@@ -1427,7 +1293,7 @@ export class AdbProvider {
|
||||
selection = this.selection();
|
||||
identity = await inspectAdbClient(selection, signal);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (cause instanceof AdbConfigurationFailure)
|
||||
return err(
|
||||
providerConfigurationRefusal(
|
||||
@@ -1870,38 +1736,30 @@ export class AdbProvider {
|
||||
);
|
||||
}
|
||||
case "inspect_adb_window": {
|
||||
let section = "windows";
|
||||
let section: "windows" | "displays" = "windows";
|
||||
const ran = await shell(["dumpsys", "window", section], {
|
||||
timeoutMs: SHELL_TIMEOUT_MS,
|
||||
maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES,
|
||||
});
|
||||
if (!ran.ok) return ran;
|
||||
let { focusedWindow, focusedApp } = parseWindowFocusOutput(
|
||||
ran.value.stdout,
|
||||
);
|
||||
if (focusedWindow === null && focusedApp === null) {
|
||||
let focus = parseWindowFocusOutput(ran.value.stdout);
|
||||
if (focus.focus_summary === "unavailable") {
|
||||
section = "displays";
|
||||
const fallback = await shell(["dumpsys", "window", section], {
|
||||
timeoutMs: SHELL_TIMEOUT_MS,
|
||||
maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES,
|
||||
});
|
||||
if (!fallback.ok) return fallback;
|
||||
({ focusedWindow, focusedApp } = parseWindowFocusOutput(
|
||||
fallback.value.stdout,
|
||||
));
|
||||
focus = parseWindowFocusOutput(fallback.value.stdout);
|
||||
}
|
||||
const declared = focusedWindow !== null || focusedApp !== null;
|
||||
const declared = focus.focus_summary !== "unavailable";
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: request.input.serial,
|
||||
focused_window: focusedWindow,
|
||||
focused_app: focusedApp,
|
||||
observed_lines: [
|
||||
...(focusedWindow === null ? [] : [focusedWindow]),
|
||||
...(focusedApp === null ? [] : [focusedApp]),
|
||||
],
|
||||
source_section: section,
|
||||
...focus,
|
||||
},
|
||||
provider,
|
||||
{
|
||||
@@ -1910,6 +1768,11 @@ export class AdbProvider {
|
||||
declared
|
||||
? `Focus declarations were read from \`dumpsys window ${section}\``
|
||||
: "Neither `dumpsys window windows` nor `dumpsys window displays` declared mCurrentFocus or mFocusedApp; focus is unknown, not absent",
|
||||
...(focus.focus_summary === "ambiguous"
|
||||
? [
|
||||
"Multiple focus contexts or repeated declarations prevent a singular focus summary; inspect focus_contexts for every declaration",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
),
|
||||
@@ -2221,7 +2084,14 @@ export class AdbProvider {
|
||||
device_path: string | null;
|
||||
stage: "resolve_paths" | "pull" | "digest";
|
||||
message: string;
|
||||
cause: unknown;
|
||||
}[] = [];
|
||||
const observedFailures = () =>
|
||||
failures.map(({ device_path, stage, message }) => ({
|
||||
device_path,
|
||||
stage,
|
||||
message,
|
||||
}));
|
||||
const cancelledPackagePull = (
|
||||
error: AnalysisCancelledError,
|
||||
localPath: string,
|
||||
@@ -2238,7 +2108,7 @@ export class AdbProvider {
|
||||
output_directory: directory.value,
|
||||
artifacts,
|
||||
failures: [
|
||||
...failures,
|
||||
...observedFailures(),
|
||||
{
|
||||
device_path: devicePath,
|
||||
stage,
|
||||
@@ -2275,6 +2145,7 @@ export class AdbProvider {
|
||||
device_path: entry.device_path,
|
||||
stage: "pull",
|
||||
message: pulled.error.message,
|
||||
cause: pulled.error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -2325,7 +2196,7 @@ export class AdbProvider {
|
||||
},
|
||||
],
|
||||
failures: [
|
||||
...failures,
|
||||
...observedFailures(),
|
||||
{
|
||||
device_path: entry.device_path,
|
||||
stage: "digest",
|
||||
@@ -2343,7 +2214,6 @@ export class AdbProvider {
|
||||
const cleanupError = adbDigestError(
|
||||
cause,
|
||||
operation,
|
||||
signal,
|
||||
partialObservation,
|
||||
);
|
||||
if (cleanupError !== undefined) return err(cleanupError);
|
||||
@@ -2354,16 +2224,24 @@ export class AdbProvider {
|
||||
entry.device_path,
|
||||
"digest",
|
||||
);
|
||||
const physicalCause = stableFileHashCause(cause);
|
||||
failures.push({
|
||||
device_path: entry.device_path,
|
||||
stage: "digest",
|
||||
message: `The pulled file could not be read back after pulling: ${localPath}`,
|
||||
message: `The pulled file could not be read back after pulling: ${localPath}: ${physicalCause instanceof Error ? physicalCause.message : String(physicalCause)}`,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (artifacts.length === 0 && failures.length > 0) {
|
||||
const primary = new AnalysisProtocolError(
|
||||
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
|
||||
{
|
||||
cause: new AggregateError(
|
||||
failures.map(({ cause }) => cause),
|
||||
"APK pulls failed",
|
||||
),
|
||||
},
|
||||
);
|
||||
return failOwnedPackage(primary);
|
||||
}
|
||||
@@ -2375,7 +2253,7 @@ export class AdbProvider {
|
||||
package_name: packageName,
|
||||
output_directory: directory.value,
|
||||
artifacts,
|
||||
failures,
|
||||
failures: observedFailures(),
|
||||
coverage: failures.length === 0 ? "complete" : "partial",
|
||||
},
|
||||
provider,
|
||||
@@ -2466,12 +2344,7 @@ export class AdbProvider {
|
||||
digest_status: "unknown",
|
||||
},
|
||||
};
|
||||
const cleanupError = adbDigestError(
|
||||
cause,
|
||||
operation,
|
||||
signal,
|
||||
partialObservation,
|
||||
);
|
||||
const cleanupError = adbDigestError(cause, operation, partialObservation);
|
||||
if (cleanupError !== undefined) return err(cleanupError);
|
||||
if (cause instanceof AnalysisCancelledError) {
|
||||
cause.retainPartialObservation(partialObservation);
|
||||
@@ -2529,28 +2402,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(
|
||||
@@ -2760,7 +2621,14 @@ export class AdbProvider {
|
||||
operation,
|
||||
input.serial,
|
||||
selection.binary,
|
||||
adbArguments(input.serial, ["uninstall", input.package]),
|
||||
adbArguments(input.serial, [
|
||||
"shell",
|
||||
"pm",
|
||||
"uninstall",
|
||||
"--user",
|
||||
"0",
|
||||
input.package,
|
||||
]),
|
||||
{
|
||||
timeoutMs: PACKAGE_LIST_TIMEOUT_MS,
|
||||
maxOutputBytes: PULL_MAX_OUTPUT_BYTES,
|
||||
@@ -2791,7 +2659,7 @@ export class AdbProvider {
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"Uninstall removes the package and its data for user 0; keeping data (-k) is not exposed",
|
||||
"Uninstall explicitly requests user 0 without data retention (-k); device enforcement is not independently verified",
|
||||
],
|
||||
},
|
||||
),
|
||||
@@ -3293,7 +3161,7 @@ export class AdbProvider {
|
||||
const cleanupError =
|
||||
retainedObservation === undefined
|
||||
? undefined
|
||||
: adbDigestError(cause, operation, signal, retainedObservation);
|
||||
: adbDigestError(cause, operation, retainedObservation);
|
||||
if (cleanupError !== undefined) return err(cleanupError);
|
||||
if (cause instanceof AnalysisCancelledError) {
|
||||
if (retainedObservation !== undefined)
|
||||
|
||||
@@ -1,6 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAmStartOutput, parseWmDensityOutput } from "./AdbShellOutput.js";
|
||||
import {
|
||||
parseAmStartOutput,
|
||||
parseDumpsysPackageOutput,
|
||||
parseWmDensityOutput,
|
||||
parseWindowFocusOutput,
|
||||
} from "./AdbShellOutput.js";
|
||||
|
||||
describe("parseWindowFocusOutput display contexts", () => {
|
||||
it.each([false, true])(
|
||||
"retains both display owners independent of block order: %s",
|
||||
(reverse) => {
|
||||
const inactive =
|
||||
" Display: mDisplayId=3 rootTasks=0\n mCurrentFocus=null\n mFocusedApp=null\n";
|
||||
const active =
|
||||
" Display: mDisplayId=0 rootTasks=12\n mCurrentFocus=Window{active}\n mFocusedApp=ActivityRecord{active}\n";
|
||||
const result = parseWindowFocusOutput(
|
||||
reverse ? active + inactive : inactive + active,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
focused_window: null,
|
||||
focused_app: null,
|
||||
focus_summary: "ambiguous",
|
||||
focus_contexts: expect.arrayContaining([
|
||||
{
|
||||
scope: "display",
|
||||
display_id: "3",
|
||||
header_line: " Display: mDisplayId=3 rootTasks=0",
|
||||
declarations: [
|
||||
{
|
||||
field: "mCurrentFocus",
|
||||
value: null,
|
||||
raw_line: " mCurrentFocus=null",
|
||||
},
|
||||
{
|
||||
field: "mFocusedApp",
|
||||
value: null,
|
||||
raw_line: " mFocusedApp=null",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
scope: "display",
|
||||
display_id: "0",
|
||||
header_line: " Display: mDisplayId=0 rootTasks=12",
|
||||
declarations: [
|
||||
{
|
||||
field: "mCurrentFocus",
|
||||
value: "Window{active}",
|
||||
raw_line: " mCurrentFocus=Window{active}",
|
||||
},
|
||||
{
|
||||
field: "mFocusedApp",
|
||||
value: "ActivityRecord{active}",
|
||||
raw_line: " mFocusedApp=ActivityRecord{active}",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not pair declarations from different displays", () => {
|
||||
expect(
|
||||
parseWindowFocusOutput(
|
||||
"Display: mDisplayId=0\nmCurrentFocus=Window{one}\nDisplay: mDisplayId=1\nmFocusedApp=ActivityRecord{two}\n",
|
||||
),
|
||||
).toMatchObject({
|
||||
focus_summary: "ambiguous",
|
||||
focused_window: null,
|
||||
focused_app: null,
|
||||
focus_contexts: [
|
||||
{
|
||||
display_id: "0",
|
||||
declarations: [{ field: "mCurrentFocus", value: "Window{one}" }],
|
||||
},
|
||||
{
|
||||
display_id: "1",
|
||||
declarations: [
|
||||
{ field: "mFocusedApp", value: "ActivityRecord{two}" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains an unknown display identity and missing fields without borrowing its neighbor", () => {
|
||||
expect(
|
||||
parseWindowFocusOutput(
|
||||
"Display: vendor-format\nmCurrentFocus=null\nDisplay: mDisplayId=0\n",
|
||||
),
|
||||
).toMatchObject({
|
||||
focus_summary: "ambiguous",
|
||||
focus_contexts: [
|
||||
{
|
||||
scope: "display",
|
||||
display_id: null,
|
||||
header_line: "Display: vendor-format",
|
||||
declarations: [{ field: "mCurrentFocus", value: null }],
|
||||
},
|
||||
{ scope: "display", display_id: "0", declarations: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindowFocusOutput declaration presence", () => {
|
||||
it("separates missing declarations from explicit absence and retains source lines", () => {
|
||||
expect(parseWindowFocusOutput("no declarations\n")).toEqual({
|
||||
focused_window: null,
|
||||
focused_app: null,
|
||||
focus_summary: "unavailable",
|
||||
focus_contexts: [],
|
||||
observed_lines: [],
|
||||
});
|
||||
expect(
|
||||
parseWindowFocusOutput(" mCurrentFocus=null\nmFocusedApp=null\n"),
|
||||
).toEqual({
|
||||
focused_window: null,
|
||||
focused_app: null,
|
||||
focus_summary: "single_context",
|
||||
focus_contexts: [
|
||||
{
|
||||
scope: "unscoped",
|
||||
display_id: null,
|
||||
header_line: null,
|
||||
declarations: [
|
||||
{
|
||||
field: "mCurrentFocus",
|
||||
value: null,
|
||||
raw_line: " mCurrentFocus=null",
|
||||
},
|
||||
{ field: "mFocusedApp", value: null, raw_line: "mFocusedApp=null" },
|
||||
],
|
||||
},
|
||||
],
|
||||
observed_lines: [" mCurrentFocus=null", "mFocusedApp=null"],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains repeated declarations without choosing one or erasing explicit absence", () => {
|
||||
expect(
|
||||
parseWindowFocusOutput(
|
||||
"mCurrentFocus=null\nmCurrentFocus=Window{later}\nmFocusedApp=ActivityRecord{current}\n",
|
||||
),
|
||||
).toMatchObject({
|
||||
focus_summary: "ambiguous",
|
||||
focused_window: null,
|
||||
focused_app: null,
|
||||
focus_contexts: [
|
||||
{
|
||||
declarations: [
|
||||
{ field: "mCurrentFocus", value: null },
|
||||
{ field: "mCurrentFocus", value: "Window{later}" },
|
||||
{ field: "mFocusedApp", value: "ActivityRecord{current}" },
|
||||
],
|
||||
},
|
||||
],
|
||||
observed_lines: [
|
||||
"mCurrentFocus=null",
|
||||
"mCurrentFocus=Window{later}",
|
||||
"mFocusedApp=ActivityRecord{current}",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDumpsysPackageOutput", () => {
|
||||
it("reports unknown requested permissions when the package is null", () => {
|
||||
expect(
|
||||
parseDumpsysPackageOutput("Package [com.example] (abcdef):\n pkg=null\n")
|
||||
.requestedPermissions,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("counts listed permissions when package data is available", () => {
|
||||
expect(
|
||||
parseDumpsysPackageOutput(
|
||||
"Package [com.example] (abcdef):\n requested permissions:\n android.permission.INTERNET\n",
|
||||
).requestedPermissions,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWmDensityOutput", () => {
|
||||
it("reads labeled physical and override densities", () => {
|
||||
|
||||
+76
-14
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
AdbWindowFocusContext,
|
||||
AdbWindowFocusObservation,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
|
||||
/**
|
||||
* Parsers for fixed observation commands REA runs through `adb shell`.
|
||||
*
|
||||
@@ -181,23 +186,75 @@ export const parseWmDensityOutput = (
|
||||
return { physical: physical ?? bare, override };
|
||||
};
|
||||
|
||||
/** Parse focus declarations from `dumpsys window windows` or `displays`. */
|
||||
/** Own declaration identity and omission before deriving a singular summary. */
|
||||
export const parseWindowFocusOutput = (
|
||||
stdout: string,
|
||||
): {
|
||||
focusedWindow: string | null;
|
||||
focusedApp: string | null;
|
||||
} => {
|
||||
let focusedWindow = null;
|
||||
let focusedApp = null;
|
||||
): AdbWindowFocusObservation => {
|
||||
const contexts: AdbWindowFocusContext[] = [];
|
||||
const observedLines: string[] = [];
|
||||
let current: AdbWindowFocusContext | undefined;
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (focusedWindow === null && trimmed.startsWith("mCurrentFocus="))
|
||||
focusedWindow = trimmed.slice("mCurrentFocus=".length).trim();
|
||||
if (focusedApp === null && trimmed.startsWith("mFocusedApp="))
|
||||
focusedApp = trimmed.slice("mFocusedApp=".length).trim();
|
||||
if (trimmed.startsWith("Display:")) {
|
||||
current = {
|
||||
scope: "display",
|
||||
display_id:
|
||||
/^Display:\s+mDisplayId=(-?[0-9]+)(?:\s|$)/u.exec(trimmed)?.[1] ??
|
||||
null,
|
||||
header_line: line,
|
||||
declarations: [],
|
||||
};
|
||||
contexts.push(current);
|
||||
observedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
const field = trimmed.startsWith("mCurrentFocus=")
|
||||
? "mCurrentFocus"
|
||||
: trimmed.startsWith("mFocusedApp=")
|
||||
? "mFocusedApp"
|
||||
: undefined;
|
||||
if (field === undefined) continue;
|
||||
if (current === undefined) {
|
||||
current = {
|
||||
scope: "unscoped",
|
||||
display_id: null,
|
||||
header_line: null,
|
||||
declarations: [],
|
||||
};
|
||||
contexts.push(current);
|
||||
}
|
||||
const value = trimmed.slice(field.length + 1).trim();
|
||||
current.declarations.push({
|
||||
field,
|
||||
value: value === "null" ? null : value,
|
||||
raw_line: line,
|
||||
});
|
||||
observedLines.push(line);
|
||||
}
|
||||
return { focusedWindow, focusedApp };
|
||||
const windows =
|
||||
contexts.length === 1
|
||||
? contexts[0]!.declarations.filter(
|
||||
({ field }) => field === "mCurrentFocus",
|
||||
)
|
||||
: [];
|
||||
const apps =
|
||||
contexts.length === 1
|
||||
? contexts[0]!.declarations.filter(({ field }) => field === "mFocusedApp")
|
||||
: [];
|
||||
const declared = contexts.some(({ declarations }) => declarations.length > 0);
|
||||
const summary = !declared
|
||||
? "unavailable"
|
||||
: contexts.length === 1 && windows.length <= 1 && apps.length <= 1
|
||||
? "single_context"
|
||||
: "ambiguous";
|
||||
return {
|
||||
focused_window:
|
||||
summary === "single_context" ? (windows[0]?.value ?? null) : null,
|
||||
focused_app: summary === "single_context" ? (apps[0]?.value ?? null) : null,
|
||||
focus_summary: summary,
|
||||
focus_contexts: contexts,
|
||||
observed_lines: observedLines,
|
||||
};
|
||||
};
|
||||
|
||||
export interface AdbPackageDetails {
|
||||
@@ -241,6 +298,7 @@ export const parseDumpsysPackageOutput = (
|
||||
};
|
||||
let inPermissions = false;
|
||||
let permissions = 0;
|
||||
let packageIsNull = false;
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
if (line.trim().startsWith("requested permissions:")) {
|
||||
inPermissions = true;
|
||||
@@ -259,7 +317,8 @@ export const parseDumpsysPackageOutput = (
|
||||
if (match === null) continue;
|
||||
const key = match[1]!;
|
||||
const value = match[2]!.trim();
|
||||
if (key === "versionName" && details.versionName === null)
|
||||
if (key === "pkg" && value === "null") packageIsNull = true;
|
||||
else if (key === "versionName" && details.versionName === null)
|
||||
details.versionName = value === "" ? null : value;
|
||||
else if (key === "versionCode" && details.versionCode === null) {
|
||||
const parsed = /^(-?\d+)/u.exec(value);
|
||||
@@ -279,7 +338,10 @@ export const parseDumpsysPackageOutput = (
|
||||
} else if (key === "pkgFlags" && details.pkgFlags === null)
|
||||
details.pkgFlags = value === "" ? null : value;
|
||||
}
|
||||
return { ...details, requestedPermissions: permissions };
|
||||
return {
|
||||
...details,
|
||||
requestedPermissions: packageIsNull ? null : permissions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
openRegularFile,
|
||||
assertRegularFileActive,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
@@ -25,7 +26,7 @@ export const snapshotAndroidTarget = async (
|
||||
const snapshot = join(root, "target.apk");
|
||||
try {
|
||||
await copyAndroidSnapshot(path, snapshot, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
await chmod(snapshot, 0o400);
|
||||
return snapshot;
|
||||
} catch (cause: unknown) {
|
||||
@@ -51,7 +52,7 @@ export const snapshotAndroidEngine = async (
|
||||
const path = join(root, "engine.jar");
|
||||
try {
|
||||
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
await chmod(path, 0o400);
|
||||
return { path, sha256: observed };
|
||||
} catch (cause: unknown) {
|
||||
@@ -71,7 +72,7 @@ const copyAndroidSnapshot = async (
|
||||
expectedSha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
const source = await openRegularFile(sourcePath, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
@@ -84,7 +85,7 @@ const copyAndroidSnapshot = async (
|
||||
const hash = createHash("sha256");
|
||||
try {
|
||||
const initial = await source.stat();
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
const digest = new Transform({
|
||||
transform(
|
||||
chunk: Buffer,
|
||||
@@ -106,7 +107,7 @@ const copyAndroidSnapshot = async (
|
||||
});
|
||||
try {
|
||||
destination = await open(snapshotPath, "wx", 0o600);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
inputStream = source.createReadStream({
|
||||
start: 0,
|
||||
end: initial.size,
|
||||
@@ -114,7 +115,7 @@ const copyAndroidSnapshot = async (
|
||||
});
|
||||
outputStream = destination.createWriteStream({ autoClose: false });
|
||||
await pipeline(inputStream, digest, outputStream, { signal });
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
await verifySourceState(source, sourcePath, initial, copiedBytes);
|
||||
const observedSha256 = hash.digest("hex");
|
||||
if (observedSha256 !== expectedSha256)
|
||||
|
||||
+42
-31
@@ -27,10 +27,17 @@ import {
|
||||
snapshotAndroidEngine,
|
||||
snapshotAndroidTarget,
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
stableFileHashCause,
|
||||
StableFileHashFailure,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
regularFileCancellation,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfigurationInputs,
|
||||
@@ -70,35 +77,33 @@ const executionError = (context: {
|
||||
session: JadxSession | undefined;
|
||||
}): AnalysisError => {
|
||||
const { cause, request, timeout, timeoutMs, signal, session } = context;
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
const cancellationError =
|
||||
cancellation === undefined
|
||||
? undefined
|
||||
: timeout.aborted && cancellation.reason === timeout.reason
|
||||
? new AnalysisTimeoutError(request.operation, timeoutMs, {
|
||||
cause: cancellation.reason,
|
||||
})
|
||||
: new AnalysisCancelledError(request.operation, {
|
||||
cause: cancellation.reason,
|
||||
});
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const primaryCause =
|
||||
cause.outcome.kind === "failed"
|
||||
? cause.outcome.cause
|
||||
: signal?.aborted
|
||||
? signal.reason
|
||||
: timeout.aborted
|
||||
? timeout.reason
|
||||
: undefined;
|
||||
const primaryOptions =
|
||||
primaryCause === undefined ? {} : { cause: primaryCause };
|
||||
? stableFileHashCause(cause.outcome.cause)
|
||||
: cause;
|
||||
const primary =
|
||||
signal?.aborted === true
|
||||
? new AnalysisCancelledError(request.operation, primaryOptions)
|
||||
: timeout.aborted
|
||||
? new AnalysisTimeoutError(
|
||||
request.operation,
|
||||
timeoutMs,
|
||||
primaryOptions,
|
||||
)
|
||||
: primaryCause instanceof AnalysisError
|
||||
? primaryCause
|
||||
: new ProviderAdapterError("jadx", request.operation, {
|
||||
cause: primaryCause ?? cause,
|
||||
userMessage:
|
||||
primaryCause instanceof Error
|
||||
? primaryCause.message
|
||||
: cause.message,
|
||||
});
|
||||
cancellationError ??
|
||||
(primaryCause instanceof AnalysisError
|
||||
? primaryCause
|
||||
: new ProviderAdapterError("jadx", request.operation, {
|
||||
cause: primaryCause,
|
||||
userMessage:
|
||||
primaryCause instanceof Error
|
||||
? primaryCause.message
|
||||
: String(primaryCause),
|
||||
}));
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"jadx",
|
||||
[cause.path],
|
||||
@@ -112,11 +117,17 @@ const executionError = (context: {
|
||||
);
|
||||
return analysisErrorWithCleanupFailure(primary, cleanup, request.operation);
|
||||
}
|
||||
if (cancellationError !== undefined) return cancellationError;
|
||||
if (cause instanceof StableFileHashFailure)
|
||||
return new ProviderAdapterError("jadx", request.operation, {
|
||||
cause: cause.cause,
|
||||
userMessage: cause.message,
|
||||
});
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(request.operation, { cause });
|
||||
if (timeout.aborted)
|
||||
return new AnalysisTimeoutError(request.operation, timeoutMs);
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
const protocolFailure = session?.transport.failureReason();
|
||||
if (protocolFailure !== undefined && protocolFailure !== null)
|
||||
return new AnalysisOutputError(request.operation, protocolFailure, {
|
||||
@@ -193,9 +204,9 @@ const hashAndroidSource = async (
|
||||
try {
|
||||
return (await hashStableFile(path, { signal, retainFailedClose })).sha256;
|
||||
} catch (cause: unknown) {
|
||||
if (signal.aborted) throw cause;
|
||||
if (cause instanceof RegularFileChangedError)
|
||||
throw androidSourceChanged(operation, path, cause);
|
||||
const physical = stableFileHashCause(cause);
|
||||
if (physical instanceof RegularFileChangedError)
|
||||
throw androidSourceChanged(operation, path, physical);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,6 +127,22 @@ describe("parseManifestPackage", () => {
|
||||
expect(parseManifestPackage("<manifest>\n</manifest>\n")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the package when unrelated manifest text contains U+FFFD", () => {
|
||||
expect(
|
||||
parseManifestPackage(
|
||||
'<manifest package="org.rea.app"><application label="\uFFFD"/></manifest>',
|
||||
),
|
||||
).toBe("org.rea.app");
|
||||
});
|
||||
|
||||
it("still rejects malformed attributes alongside U+FFFD", () => {
|
||||
expect(() =>
|
||||
parseManifestPackage(
|
||||
'<manifest package=org.rea.app><application label="\uFFFD"/></manifest>',
|
||||
),
|
||||
).toThrow(/AndroidManifest.xml is not well-formed XML/u);
|
||||
});
|
||||
|
||||
it("rejects a well-formed document with the wrong root element", () => {
|
||||
expect(() => parseManifestPackage("<resources/>")).toThrowError(
|
||||
new TypeError("AndroidManifest.xml root must be <manifest>"),
|
||||
@@ -172,6 +188,30 @@ describe("parseDecodedStrings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves literal U+FFFD and unaffected sibling strings", () => {
|
||||
expect(
|
||||
parseDecodedStrings(
|
||||
'<resources><string name="good">Hello</string><string name="edge">\uFFFD</string></resources>',
|
||||
),
|
||||
).toEqual({
|
||||
entries: [
|
||||
{ name: "good", value: "Hello" },
|
||||
{ name: "edge", value: "\uFFFD" },
|
||||
],
|
||||
unparsedCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<resources><string name=unquoted>\uFFFD</string></resources>",
|
||||
'<resources><string name="broken">\uFFFD</resources>',
|
||||
])("still rejects malformed XML containing U+FFFD: %s", (xml) => {
|
||||
expect(parseDecodedStrings(xml)).toEqual({
|
||||
entries: [],
|
||||
unparsedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps empty, self-closing, and reordered string attributes", () => {
|
||||
expect(
|
||||
parseDecodedStrings(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
DOMParser,
|
||||
type Document,
|
||||
type Element,
|
||||
type Node,
|
||||
} from "@xmldom/xmldom";
|
||||
import type { Document, Element, Node } from "@xmldom/xmldom";
|
||||
import { parseXmlDocument } from "../domain/xmlDocument.js";
|
||||
import {
|
||||
isMap,
|
||||
isScalar,
|
||||
@@ -147,16 +143,16 @@ const yamlScalar = (
|
||||
};
|
||||
|
||||
const parseXml = (xml: string, label: string) => {
|
||||
const errors: string[] = [];
|
||||
const document = new DOMParser({
|
||||
normalizeLineEndings: (source) => source,
|
||||
onError: (_level, message) => errors.push(message),
|
||||
}).parseFromString(xml, "application/xml");
|
||||
if (errors.length > 0 || document.documentElement === null)
|
||||
const parsed = parseXmlDocument(xml, {
|
||||
doctype: "allow",
|
||||
lineEndings: "preserve",
|
||||
});
|
||||
if (!parsed.ok)
|
||||
throw new TypeError(
|
||||
`${label} is not well-formed XML${errors[0] === undefined ? "" : `: ${errors[0]}`}`,
|
||||
`${label} is not well-formed XML: ${parsed.error.message}`,
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
return document;
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
const childNodes = (node: Node): Node[] => {
|
||||
|
||||
@@ -263,6 +263,59 @@ it("preserves empty resource strings and reports markup omissions as partial", a
|
||||
expect(result.coverage).toBe("partial");
|
||||
});
|
||||
|
||||
it("preserves literal U+FFFD in decoded XML with complete coverage", async () => {
|
||||
const stringsXml =
|
||||
'<resources><string name="good">Hello</string><string name="edge">\uFFFD</string></resources>';
|
||||
const { command, root } = await writeApktoolStub((root) =>
|
||||
decodeStub({
|
||||
stringsXml,
|
||||
manifestFile: join(root, "AndroidManifest.xml"),
|
||||
})(root),
|
||||
);
|
||||
const manifest = REAL_MANIFEST.replace("@string/app_name", "\uFFFD");
|
||||
await writeFile(join(root, "AndroidManifest.xml"), manifest, "utf8");
|
||||
const apk = await apkFixture(root);
|
||||
const result = await evidenceResult(providerOver(command), {
|
||||
operation: "decode_android_resources",
|
||||
input: { path: apk.path, include_strings: true },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
manifest,
|
||||
metadata: { package_name: "com.rea.apktool.probe" },
|
||||
strings: [
|
||||
{ name: "good", value: "Hello" },
|
||||
{ name: "edge", value: "\uFFFD" },
|
||||
],
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("still refuses invalid UTF-8 bytes in decoded strings", async () => {
|
||||
const { command, root } = await writeApktoolStub((root) =>
|
||||
decodeStub({ stringsFile: join(root, "strings.xml") })(root),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "strings.xml"),
|
||||
Buffer.concat([
|
||||
Buffer.from('<resources><string name="bad">'),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from("</string></resources>"),
|
||||
]),
|
||||
);
|
||||
const apk = await apkFixture(root);
|
||||
const result = await providerOver(command).execute({
|
||||
operation: "decode_android_resources",
|
||||
input: { path: apk.path, include_strings: true },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a refusal");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
});
|
||||
expect(result.error.message).toContain("invalid UTF-8");
|
||||
expect(result.error.message).toContain("strings.xml");
|
||||
});
|
||||
|
||||
it("ignores Android resource qualifiers when discovering locales", async () => {
|
||||
const { command, root } = await writeApktoolStub(
|
||||
decodeStub({ extraValuesDirectories: ["car", "night", "v21"] }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import {
|
||||
@@ -846,6 +847,15 @@ export class ApktoolProvider {
|
||||
);
|
||||
} catch (cause) {
|
||||
await removeWorkspace();
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
if (cancellation !== undefined)
|
||||
return err(
|
||||
failureWithCleanup(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: cancellation.reason,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return err(
|
||||
failureWithCleanup(
|
||||
signal?.aborted === true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
@@ -158,6 +159,8 @@ export interface AnalysisClient extends AnalysisOperationPort {
|
||||
runtimeLineageSnapshots?(): readonly ProviderRuntimeLineageSnapshot[];
|
||||
/** Current provider work that may outlive a timed-out or cancelled caller. */
|
||||
requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[];
|
||||
/** Local reuse admission is separate from observed native process health. */
|
||||
reuseReadiness?(): "ready" | "blocked" | "unknown";
|
||||
/** Current operational health for the active provider bridge. */
|
||||
operationHealthSnapshot?(): ProviderOperationHealth | undefined;
|
||||
/** Close owned resources and retain failed cleanup for a later retry. */
|
||||
@@ -191,8 +194,12 @@ export interface AnalysisProfileResolution {
|
||||
}
|
||||
|
||||
/** Cancellation context for side-effect-free provider profile resolution. */
|
||||
export interface AnalysisProfileResolutionOptions {
|
||||
export interface AnalysisProviderInspectionOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly scope?: ProviderInspectionScope;
|
||||
}
|
||||
|
||||
export interface AnalysisProfileResolutionOptions extends AnalysisProviderInspectionOptions {
|
||||
readonly fileCloseResources?: FileCloseResourceOwner;
|
||||
}
|
||||
|
||||
@@ -290,12 +297,25 @@ 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,
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
inspectAvailability(): ProviderAvailability;
|
||||
inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot;
|
||||
/** Await prerequisite observations and finalization under the supplied owner signal. */
|
||||
inspectAvailability(
|
||||
options?: AnalysisProviderInspectionOptions,
|
||||
): ProviderAvailability | Promise<ProviderAvailability>;
|
||||
inspectReadinessSnapshot?(
|
||||
options?: AnalysisProviderInspectionOptions,
|
||||
):
|
||||
| AnalysisProviderReadinessSnapshot
|
||||
| Promise<AnalysisProviderReadinessSnapshot>;
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
|
||||
/** Observe retained candidate cleanup without starting or retrying work. */
|
||||
hasPendingOwnedResources?(): boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -224,6 +225,13 @@ export const parseBinaryTarget = async (
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
if (cancellation !== undefined)
|
||||
return err(
|
||||
new AnalysisCancelledError("resolve_binary_target", {
|
||||
cause: cancellation.reason,
|
||||
}),
|
||||
);
|
||||
if (signal?.aborted === true && cause === signal.reason)
|
||||
return err(
|
||||
new AnalysisCancelledError("resolve_binary_target", { cause }),
|
||||
|
||||
@@ -82,6 +82,16 @@ type DirectAnalysisTool =
|
||||
| "trace_native_ui_action"
|
||||
| "trace_native_values";
|
||||
|
||||
// These one-shot CLI questions require native utilities, not a deep engine.
|
||||
// Runtime observation retains the ordinary session and authority checks.
|
||||
const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"inspect_macho",
|
||||
"inspect_signature",
|
||||
"list_architectures",
|
||||
"inspect_plist",
|
||||
"demangle_swift",
|
||||
] satisfies readonly NativeToolName[]);
|
||||
|
||||
const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = new Set(
|
||||
ARTIFACT_ANALYSIS_OPERATIONS,
|
||||
);
|
||||
@@ -152,7 +162,9 @@ export const runProviderAnalysis = async (
|
||||
signal: operationSignal,
|
||||
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
|
||||
? { createSession: dependencies.createArtifactBinarySession }
|
||||
: {}),
|
||||
: NATIVE_INSPECTION_OPERATIONS.has(tool)
|
||||
? { createSession: dependencies.createNativeBinarySession }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -236,7 +248,11 @@ const runAnalysis = async (
|
||||
} else {
|
||||
const config = dependencies.readConfiguration();
|
||||
if (!config.ok) return cliError(config.error);
|
||||
session = await dependencies.createBinarySession(config.value, logger);
|
||||
session = await (
|
||||
tool === "inspect_native_dispatch_metadata" && snapshotPath === undefined
|
||||
? dependencies.createNativeDispatchBinarySession
|
||||
: dependencies.createBinarySession
|
||||
)(config.value, logger);
|
||||
}
|
||||
return withSessionCleanup(
|
||||
session,
|
||||
|
||||
@@ -11,10 +11,19 @@ export interface DirectAnalysisDependencies {
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession | Promise<BinarySession>;
|
||||
/** Prefer byte-backed dispatch inspection while preserving explicit selection. */
|
||||
readonly createNativeDispatchBinarySession: (
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
) => BinarySession | Promise<BinarySession>;
|
||||
/** Create an artifact-only session without selecting native analysis engines. */
|
||||
readonly createArtifactBinarySession: () =>
|
||||
| BinarySession
|
||||
| Promise<BinarySession>;
|
||||
/** Create a native utility session without selecting deep analysis engines. */
|
||||
readonly createNativeBinarySession: () =>
|
||||
| BinarySession
|
||||
| Promise<BinarySession>;
|
||||
readonly createManagedBinarySession: () =>
|
||||
| BinarySession
|
||||
| Promise<BinarySession>;
|
||||
|
||||
@@ -21,7 +21,7 @@ export const runSessionStatus = async (
|
||||
if (!config.ok) return { error: projectAnalysisError(config.error) };
|
||||
const session = await dependencies.createBinarySession(config.value, logger);
|
||||
try {
|
||||
const status = jsonObjectSchema.parse(session.status());
|
||||
const status = jsonObjectSchema.parse(await session.status());
|
||||
return {
|
||||
...status,
|
||||
capabilities: [
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { createDoctorHostFixture } from "./Doctor.fixture.js";
|
||||
import { runDoctorOwned, systemDoctorHost } from "./Doctor.js";
|
||||
|
||||
it("joins a late provider inspection and retries only its unresolved cleanup owners", async () => {
|
||||
let enter!: (signal: AbortSignal) => void;
|
||||
const entered = new Promise<AbortSignal>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
let handOff!: () => void;
|
||||
const handoff = new Promise<void>((resolve) => {
|
||||
handOff = resolve;
|
||||
});
|
||||
let finish!: () => void;
|
||||
const finishing = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let denied = true;
|
||||
let closed = false;
|
||||
const closes = { successful: 0, refused: 0 };
|
||||
const host = systemDoctorHost({
|
||||
environment: {},
|
||||
providerInspections: async (owner, signal) => {
|
||||
enter(signal);
|
||||
await handoff;
|
||||
owner.retain(
|
||||
{
|
||||
close: async () => {
|
||||
closes.successful++;
|
||||
await finishing;
|
||||
},
|
||||
},
|
||||
"successful-prerequisite",
|
||||
);
|
||||
owner.retain(
|
||||
{
|
||||
close: async () => {
|
||||
closes.refused++;
|
||||
if (denied) throw new Error("controlled cleanup refusal");
|
||||
},
|
||||
},
|
||||
"refused-prerequisite",
|
||||
);
|
||||
return [];
|
||||
},
|
||||
});
|
||||
const inspection = host.providerInspections!();
|
||||
const signal = await entered;
|
||||
const closing = host.close!().then(
|
||||
() => {
|
||||
closed = true;
|
||||
return undefined;
|
||||
},
|
||||
(cause: unknown) => {
|
||||
closed = true;
|
||||
return cause;
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(closed).toBe(false);
|
||||
handOff();
|
||||
await inspection;
|
||||
expect(closed).toBe(false);
|
||||
finish();
|
||||
const failure = await closing;
|
||||
if (!(failure instanceof AnalysisError))
|
||||
throw new Error("Expected attributed cleanup failure");
|
||||
expect(failure.cleanupResources).toEqual(["refused-prerequisite"]);
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
expect(closes).toEqual({ successful: 1, refused: 1 });
|
||||
denied = false;
|
||||
await host.close!();
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toEqual({ successful: 1, refused: 2 });
|
||||
await expect(host.providerInspections!()).rejects.toThrow(
|
||||
"scope is closed",
|
||||
);
|
||||
} finally {
|
||||
denied = false;
|
||||
handOff();
|
||||
finish();
|
||||
await inspection;
|
||||
await closing;
|
||||
await host.close!();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a completed healthy report when cancellation arrives during final cleanup", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel final doctor cleanup" };
|
||||
await expect(
|
||||
runDoctorOwned(
|
||||
undefined,
|
||||
createDoctorHostFixture({
|
||||
signal: controller.signal,
|
||||
close: async () => {
|
||||
controller.abort(reason);
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
diagnostics: { doctor_report: { healthy: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an inspection-authored physical failure when final cleanup also observes cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const physical = new ProviderAdapterError("ghidra", "doctor", {
|
||||
diagnostics: { reason: "controlled inspection fault" },
|
||||
});
|
||||
await expect(
|
||||
runDoctorOwned(
|
||||
undefined,
|
||||
createDoctorHostFixture({
|
||||
signal: controller.signal,
|
||||
providerInspections: async () => {
|
||||
throw physical;
|
||||
},
|
||||
close: async () => {
|
||||
controller.abort(physical);
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(physical);
|
||||
expect(physical.cause).toBeUndefined();
|
||||
});
|
||||
@@ -1,3 +1,12 @@
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
@@ -9,7 +18,7 @@ import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentat
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
javaScriptRuntime,
|
||||
type JavaScriptRuntime,
|
||||
@@ -93,6 +102,7 @@ export interface DoctorProviderInspection {
|
||||
}
|
||||
/** Read-only host capabilities required by diagnostics. */
|
||||
export interface DoctorHost {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly architecture: NodeJS.Architecture;
|
||||
readonly runtime: JavaScriptRuntime;
|
||||
@@ -203,15 +213,36 @@ export const runDoctorOwned = async (
|
||||
report = await inspectDoctor(target, host, requestedScope);
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = await closeDoctorHost(host);
|
||||
if (cleanup !== undefined && cause instanceof AnalysisError) {
|
||||
const cleanupError =
|
||||
typeof cleanup === "string"
|
||||
? new ProviderCleanupError(
|
||||
"rea",
|
||||
[],
|
||||
{ reason: cleanup },
|
||||
{ operation: "doctor" },
|
||||
)
|
||||
: cleanup;
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
cause,
|
||||
cleanupError,
|
||||
"doctor",
|
||||
).bindCleanupDependencies([cause, cleanupError]);
|
||||
}
|
||||
if (cleanup !== undefined)
|
||||
throw new AggregateError(
|
||||
[cause, new Error(cleanup)],
|
||||
[cause, typeof cleanup === "string" ? new Error(cleanup) : cleanup],
|
||||
"Doctor failed and its resource cleanup remains incomplete.",
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const cleanup = await closeDoctorHost(host);
|
||||
if (cleanup === undefined && report.healthy && host.signal?.aborted)
|
||||
throw new AnalysisCancelledError("doctor", {
|
||||
cause: host.signal.reason,
|
||||
diagnostics: { doctor_report: jsonValueSchema.parse(report) },
|
||||
});
|
||||
return cleanup === undefined
|
||||
? report
|
||||
: doctorReportWithCleanup(report, cleanup);
|
||||
@@ -222,6 +253,8 @@ const inspectDoctor = async (
|
||||
host: DoctorHost,
|
||||
requestedScope?: DoctorScope,
|
||||
): Promise<DoctorReport> => {
|
||||
if (host.signal?.aborted)
|
||||
throw new AnalysisCancelledError("doctor", { cause: host.signal.reason });
|
||||
const scope = normalizeDoctorScope(requestedScope, target);
|
||||
const {
|
||||
checks: diagnosticChecks,
|
||||
@@ -283,23 +316,30 @@ const registrationAdvisoryChecks = (
|
||||
|
||||
const closeDoctorHost = async (
|
||||
host: DoctorHost,
|
||||
): Promise<string | undefined> => {
|
||||
): Promise<string | AnalysisError | undefined> => {
|
||||
try {
|
||||
return await host.close?.();
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
return `Doctor host cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const doctorReportWithCleanup = (
|
||||
report: DoctorReport,
|
||||
detail: string,
|
||||
cleanup: string | AnalysisError,
|
||||
): DoctorReport => {
|
||||
const check: DoctorCheck = {
|
||||
name: "doctor-resource-cleanup",
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
detail,
|
||||
detail:
|
||||
typeof cleanup === "string"
|
||||
? cleanup
|
||||
: `${cleanup.message} ${cleanup.cleanupResources.join(", ")}`,
|
||||
...(typeof cleanup === "string"
|
||||
? {}
|
||||
: { details: { cleanup_failure: projectAnalysisError(cleanup) } }),
|
||||
remediation: "Resolve the reported cleanup failure, then rerun rea doctor.",
|
||||
};
|
||||
return {
|
||||
@@ -340,7 +380,10 @@ const collectDoctorIdentity = async (
|
||||
cli_package_version: PRODUCT_IDENTITY.packageVersion,
|
||||
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
|
||||
sdk: SDK_IDENTITY,
|
||||
catalog: CATALOG_IDENTITY,
|
||||
catalog: {
|
||||
counts: CATALOG_IDENTITY.counts,
|
||||
digests: CATALOG_IDENTITY.digests,
|
||||
},
|
||||
live_server: {
|
||||
state: "unknown",
|
||||
remediation:
|
||||
@@ -430,12 +473,14 @@ const installationState = (
|
||||
|
||||
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
|
||||
export interface SystemDoctorHostOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly architecture?: NodeJS.Architecture;
|
||||
readonly environment?: NodeJS.ProcessEnv;
|
||||
readonly execFileOutput?: typeof execFileOutput;
|
||||
readonly providerInspections?: (
|
||||
fileCloseResources: FileCloseResourceOwner,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly DoctorProviderInspection[]>;
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
@@ -449,6 +494,11 @@ export const systemDoctorHost = (
|
||||
options: SystemDoctorHostOptions = {},
|
||||
): DoctorHost => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const inspectionSignal =
|
||||
options.signal === undefined
|
||||
? controller.signal
|
||||
: AbortSignal.any([options.signal, controller.signal]);
|
||||
const platform = options.platform ?? process.platform;
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const environment = options.environment ?? process.env;
|
||||
@@ -460,11 +510,12 @@ export const systemDoctorHost = (
|
||||
resources.retain({
|
||||
kind: "lease",
|
||||
resource,
|
||||
lease: { release: () => owner.close() },
|
||||
lease: new AnalysisCleanupLease(() => owner.close(), [resource]),
|
||||
}),
|
||||
run: (operation) => resources.run(operation),
|
||||
};
|
||||
return {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
platform,
|
||||
architecture,
|
||||
runtime: javaScriptRuntime(process.versions),
|
||||
@@ -561,7 +612,12 @@ export const systemDoctorHost = (
|
||||
? {}
|
||||
: {
|
||||
providerInspections: () =>
|
||||
options.providerInspections!(fileCloseResources),
|
||||
resources.run(() =>
|
||||
options.providerInspections!(
|
||||
fileCloseResources,
|
||||
inspectionSignal,
|
||||
),
|
||||
),
|
||||
}),
|
||||
...(options.providerRegistrationEnvironment === undefined
|
||||
? {}
|
||||
@@ -612,11 +668,23 @@ export const systemDoctorHost = (
|
||||
resources,
|
||||
),
|
||||
close: async () => {
|
||||
controller.abort(STARTUP_OWNER_CLOSED);
|
||||
try {
|
||||
await resources.close();
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return `Doctor resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
const observation = ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
"doctor resources",
|
||||
);
|
||||
const failure = new ProviderCleanupError(
|
||||
"rea",
|
||||
observation.resources,
|
||||
{ reason: observation.reason },
|
||||
{ cause, operation: "doctor" },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, failure);
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
clientRegistrations: () =>
|
||||
|
||||
@@ -10,9 +10,16 @@ import {
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
StableFileHashFailure,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
RegularFileCancelled,
|
||||
regularFileCancellation,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
@@ -705,8 +712,17 @@ export class LinuxHopperPublisher {
|
||||
},
|
||||
);
|
||||
}
|
||||
if (hashFailure.outcome.kind === "failed")
|
||||
throw hashFailure.outcome.cause;
|
||||
if (hashFailure.outcome.kind === "failed") {
|
||||
if (regularFileCancellation(hashFailure) !== undefined)
|
||||
throw hashFailure.outcome.cause;
|
||||
throw new StableFileHashFailure(path, hashFailure.outcome.cause);
|
||||
}
|
||||
const cancellation = regularFileCancellation(hashFailure);
|
||||
if (cancellation !== undefined)
|
||||
throw new RegularFileCancelled(path, hashFailure.owner, {
|
||||
outcome: hashFailure.outcome,
|
||||
reason: cancellation.reason,
|
||||
});
|
||||
return hashFailure.outcome.value;
|
||||
}
|
||||
}
|
||||
@@ -1084,21 +1100,26 @@ const releaseFailure = (
|
||||
signal: AbortSignal | undefined,
|
||||
fallback: HopperReleaseError["reason"],
|
||||
): HopperReleaseError => {
|
||||
if (cause instanceof HopperReleaseError && cause.reason === "cleanup_failed")
|
||||
return cause;
|
||||
if (cause instanceof HopperReleaseError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
||||
return new HopperReleaseError(
|
||||
"cleanup_failed",
|
||||
"Hopper publisher cleanup remains incomplete.",
|
||||
{ cause },
|
||||
);
|
||||
if (signal?.aborted === true)
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
if (cancellation !== undefined)
|
||||
return new HopperReleaseError(
|
||||
"cancelled",
|
||||
"Hopper publisher verification was cancelled.",
|
||||
{ cause: cancellation.reason },
|
||||
);
|
||||
if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true)
|
||||
return new HopperReleaseError(
|
||||
"cancelled",
|
||||
"Hopper publisher verification was cancelled.",
|
||||
{ cause },
|
||||
);
|
||||
if (cause instanceof HopperReleaseError) return cause;
|
||||
return new HopperReleaseError(
|
||||
fallback,
|
||||
"Hopper publisher verification could not complete.",
|
||||
|
||||
@@ -108,12 +108,13 @@ const buildIgnored = async (
|
||||
try {
|
||||
text = await readRegularFileText(policyPath, { signal });
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true && cause === signal.reason) throw cause;
|
||||
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
||||
const retry = await retryRegularFileCleanup(cause, resources);
|
||||
const retry = await retryRegularFileCleanup(cause, resources, signal);
|
||||
if (retry.cleanup !== undefined) {
|
||||
const primary =
|
||||
cause.outcome.kind === "failed"
|
||||
? cause.outcome.cause
|
||||
retry.outcome.kind === "failed"
|
||||
? retry.outcome.cause
|
||||
: cause.cleanupCause;
|
||||
return err({
|
||||
...failure(
|
||||
|
||||
@@ -88,8 +88,9 @@ const resolveSourceHead = async (
|
||||
try {
|
||||
return await readRegularFile(path, { signal });
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true && cause === signal.reason) throw cause;
|
||||
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
||||
const retry = await retryRegularFileCleanup(cause, resources);
|
||||
const retry = await retryRegularFileCleanup(cause, resources, signal);
|
||||
if (
|
||||
retry.outcome.kind === "failed" &&
|
||||
errorCode(retry.outcome.cause) !== "ENOENT"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { lstat, stat, type FileHandle } from "node:fs/promises";
|
||||
import {
|
||||
sameRegularFileState,
|
||||
RegularFileChangedError,
|
||||
RegularFileOperationCancelled,
|
||||
assertRegularFileActive,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
@@ -41,18 +43,25 @@ export type RegularFileReadOutcome<Value> = RegularFileOperationOutcome<Value>;
|
||||
export const retryRegularFileCleanup = async <Value>(
|
||||
failure: RegularFileCleanupFailure<Value>,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
readonly outcome: RegularFileReadOutcome<Value>;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
}> => {
|
||||
const outcome: RegularFileReadOutcome<Value> =
|
||||
failure.outcome.kind === "failed" &&
|
||||
failure.outcome.cause instanceof RegularFileOperationCancelled &&
|
||||
!(signal?.aborted === true && failure.outcome.cause === signal.reason)
|
||||
? { kind: "failed", cause: failure.outcome.cause.reason }
|
||||
: failure.outcome;
|
||||
const released = await resources.release({
|
||||
kind: "file-handle",
|
||||
handle: failure.owner,
|
||||
resource: failure.path,
|
||||
});
|
||||
if (released.kind === "released") return { outcome: failure.outcome };
|
||||
if (released.kind === "released") return { outcome };
|
||||
return {
|
||||
outcome: failure.outcome,
|
||||
outcome,
|
||||
cleanup: {
|
||||
reason: `${failureMessage(failure.cleanupCause)}; retry: ${failureMessage(released.cause)}`,
|
||||
resources: [failure.path],
|
||||
@@ -82,9 +91,9 @@ export const withRegularFile = async <Value>(
|
||||
async (handle) => {
|
||||
admitted = handle;
|
||||
const stats = await handle.stat();
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
const value = await read(handle, stats);
|
||||
signal?.throwIfAborted();
|
||||
assertRegularFileActive(signal);
|
||||
const [opened, currentPath] = await Promise.all([
|
||||
handle.stat(),
|
||||
options.symlinks === "reject" ? lstat(path) : stat(path),
|
||||
@@ -94,11 +103,15 @@ export const withRegularFile = async <Value>(
|
||||
!sameRegularFileState(stats, currentPath)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(signal);
|
||||
return value;
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// Caller reasons are opaque, including objects shaped like file failures.
|
||||
// They do not transfer ownership of any descriptor to this operation.
|
||||
if (signal?.aborted === true && cause === signal.reason) throw cause;
|
||||
if (cause instanceof RegularFileOperationCancelled) throw cause.reason;
|
||||
if (
|
||||
cause instanceof RegularFileCancelled &&
|
||||
cause.owner.handle === admitted
|
||||
@@ -109,7 +122,7 @@ export const withRegularFile = async <Value>(
|
||||
resources === undefined
|
||||
)
|
||||
throw cause;
|
||||
const retry = await retryRegularFileCleanup(cause, resources);
|
||||
const retry = await retryRegularFileCleanup(cause, resources, signal);
|
||||
if (retry.cleanup !== undefined) throw cause;
|
||||
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
|
||||
return retry.outcome.value;
|
||||
|
||||
@@ -360,7 +360,7 @@ describe("setup workflow action selection", () => {
|
||||
],
|
||||
[
|
||||
"readback",
|
||||
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
||||
"Agent configuration could not be verified. Repair the configuration file, then rerun setup.",
|
||||
],
|
||||
] as const)("explains %s configuration recovery", async (reason, message) => {
|
||||
const host = new FakeSetupHost();
|
||||
@@ -375,6 +375,25 @@ describe("setup workflow action selection", () => {
|
||||
expect(result.remediation).toBe(message);
|
||||
expect(result.remediation).not.toContain("internal_client_key");
|
||||
});
|
||||
|
||||
it("names the recovery backup that holds the restored original after a readback failure", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "cursor", configPath: "/agent.json" }];
|
||||
const backupPath = `/agent.json.rea.backup.${"a".repeat(64)}.recovery`;
|
||||
host.clientResults.set("cursor", {
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
backupPath,
|
||||
});
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["cursor"] },
|
||||
host,
|
||||
);
|
||||
expect(result.remediation).toBe(
|
||||
`Agent configuration could not be verified after writing. The original was restored and its backup at ${backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSIX Claude Desktop setup", () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import {
|
||||
preserveRecoveryBackup,
|
||||
preserveCurrentRecoveryBackup,
|
||||
selectCurrentRecoveryBackup,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
||||
@@ -74,6 +74,8 @@ export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanu
|
||||
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
|
||||
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
|
||||
),
|
||||
failure.retention,
|
||||
failure.completionCancellation,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -247,27 +249,15 @@ const persistClientConfiguration = async ({
|
||||
};
|
||||
if (target.value) return { status: "failed", reason: "target_changed" };
|
||||
}
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
let recoveryBackupPath: string | undefined;
|
||||
if (backupPath !== undefined && original !== undefined) {
|
||||
const selection = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (!selection.ok) return { status: "failed", reason: "backup" };
|
||||
if (
|
||||
!(await preserveRecoveryBackup(
|
||||
transactionPath,
|
||||
selection.value.backupPath,
|
||||
))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
const verified = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = verified.value.backupPath;
|
||||
if (original !== undefined) {
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
transactionPath,
|
||||
original,
|
||||
);
|
||||
if (!preserved.ok) return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = preserved.value;
|
||||
}
|
||||
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
||||
// otherwise conflict with it.
|
||||
@@ -664,7 +654,11 @@ const rollbackOrReadbackFailure = async (
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const rollback = await restoreConfig(targetPath, original);
|
||||
return rollback.ok
|
||||
? { status: "failed", reason: "readback" }
|
||||
? {
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
|
||||
@@ -9,21 +9,26 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
export const failedConfigurationMessage = (
|
||||
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
|
||||
): string => {
|
||||
if (result.reason === "target_changed")
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
if (result.reason === "target_unavailable")
|
||||
return `The client configuration read target could not be established because ${result.failure.path} could not be inspected: ${result.failure.detail}. Repair access to that path, then rerun setup.`;
|
||||
if (result.reason === "path")
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
if (result.reason === "backup")
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "write")
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "rollback")
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
|
||||
switch (result.reason) {
|
||||
case "target_changed":
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
case "target_unavailable":
|
||||
return `The client configuration read target could not be established because ${result.failure.path} could not be inspected: ${result.failure.detail}. Repair access to that path, then rerun setup.`;
|
||||
case "path":
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
case "backup":
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
case "write":
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
case "rollback":
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
case "readback":
|
||||
return result.backupPath === undefined
|
||||
? "Agent configuration could not be verified. Repair the configuration file, then rerun setup."
|
||||
: `Agent configuration could not be verified after writing. The original was restored and its backup at ${result.backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup.`;
|
||||
}
|
||||
};
|
||||
|
||||
/** Configure every detected agent and report the first failed transaction. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readRegularFile,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
export interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
@@ -28,15 +29,19 @@ const errorDetail = (cause: unknown): string =>
|
||||
export const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string | Buffer,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const read = (path: string) =>
|
||||
readRegularFile(path, {
|
||||
symlinks: "reject",
|
||||
...(resources === undefined ? {} : { resources }),
|
||||
});
|
||||
const expected = Buffer.isBuffer(original)
|
||||
? original
|
||||
: Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
firstBackup = await read(firstBackupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
@@ -55,7 +60,7 @@ export const selectCurrentRecoveryBackup = async (
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
recovery = await read(recoveryPath);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
@@ -74,15 +79,65 @@ export const selectCurrentRecoveryBackup = async (
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
/** Preserve a snapshot without replacing an earlier recovery file. */
|
||||
export const preserveRecoveryBackup = async (
|
||||
export interface RecoveryBackupPreservationFailure {
|
||||
readonly kind: RecoveryBackupSelectionFailure["kind"] | "copy" | "unverified";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const copyExclusive = (source: string, destination: string): Promise<void> =>
|
||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
|
||||
export interface RecoveryBackupPreservationOptions {
|
||||
/** Copy without replacing an existing destination; reject one with EEXIST. */
|
||||
readonly copy?: (source: string, destination: string) => Promise<void>;
|
||||
/** Scope that retains a backup read whose handle cleanup failed. */
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a transaction's original bytes before it edits the file: keep a
|
||||
* matching first backup, otherwise copy them to the content-addressed recovery
|
||||
* path without replacing an earlier file, then verify the selected snapshot.
|
||||
*/
|
||||
export const preserveCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
original: string | Buffer,
|
||||
{ copy = copyExclusive, resources }: RecoveryBackupPreservationOptions = {},
|
||||
): Promise<Result<string, RecoveryBackupPreservationFailure>> => {
|
||||
const selection = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
resources,
|
||||
);
|
||||
if (!selection.ok) return selection;
|
||||
const { backupPath } = selection.value;
|
||||
if (!selection.value.present) {
|
||||
try {
|
||||
await copy(source, backupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
)
|
||||
return err({
|
||||
kind: "copy",
|
||||
path: backupPath,
|
||||
detail: `The recovery backup could not be written: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const verified = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
resources,
|
||||
);
|
||||
if (!verified.ok) return verified;
|
||||
if (verified.value.backupPath !== backupPath || !verified.value.present)
|
||||
return err({
|
||||
kind: "unverified",
|
||||
path: backupPath,
|
||||
detail: `The recovery backup does not match the configuration it preserves: ${backupPath}`,
|
||||
});
|
||||
return ok(backupPath);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
withRegularFile,
|
||||
} from "./RegularFileRead.js";
|
||||
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||
import { selectCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
|
||||
import { preserveCurrentRecoveryBackup } from "./SetupRecoveryBackup.js";
|
||||
|
||||
export const MANAGED_SKILL_FILES = [
|
||||
"SKILL.md",
|
||||
@@ -153,15 +153,8 @@ const systemSkillInstallFileOperations: SkillInstallFileOperations = {
|
||||
mkdir: async (path) => {
|
||||
await mkdir(path, { recursive: true });
|
||||
},
|
||||
backup: async (source, destination) => {
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
||||
return;
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
backup: (source, destination) =>
|
||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
||||
write: (path, content, mode) =>
|
||||
writeFileAtomic(path, content, { encoding: "utf8", mode }),
|
||||
remove: (path) => rm(path, { force: true }),
|
||||
@@ -513,32 +506,6 @@ const restoreSkillFiles = async (
|
||||
return failures;
|
||||
};
|
||||
|
||||
const preserveCurrentSkillBackup = async (
|
||||
destination: string,
|
||||
original: Buffer,
|
||||
operations: SkillInstallFileOperations,
|
||||
): Promise<string> => {
|
||||
const firstBackupPath = `${destination}.rea.backup`;
|
||||
const selection = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
);
|
||||
if (!selection.ok) throw new Error(selection.error.detail);
|
||||
if (selection.value.present) return selection.value.backupPath;
|
||||
|
||||
await operations.backup(destination, selection.value.backupPath);
|
||||
const verified = await selectCurrentRecoveryBackup(firstBackupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
throw new Error(
|
||||
`The current file was not preserved at ${selection.value.backupPath}`,
|
||||
);
|
||||
return verified.value.backupPath;
|
||||
};
|
||||
|
||||
/** Transactionally install the canonical skill bundle and optional references. */
|
||||
export const installCanonicalSkill = async (
|
||||
home: string,
|
||||
@@ -571,14 +538,17 @@ export const installCanonicalSkill = async (
|
||||
failingPath = destination;
|
||||
await operations.mkdir(dirname(destination));
|
||||
if (original !== undefined) {
|
||||
recoveryPaths.set(
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${destination}.rea.backup`,
|
||||
destination,
|
||||
await preserveCurrentSkillBackup(
|
||||
destination,
|
||||
original.bytes,
|
||||
operations,
|
||||
),
|
||||
original.bytes,
|
||||
{
|
||||
copy: operations.backup,
|
||||
...(resources === undefined ? {} : { resources }),
|
||||
},
|
||||
);
|
||||
if (!preserved.ok) throw new Error(preserved.error.detail);
|
||||
recoveryPaths.set(destination, preserved.value);
|
||||
}
|
||||
}
|
||||
stage = "write";
|
||||
|
||||
@@ -35,12 +35,13 @@ export type ClientConfigurationResult =
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason:
|
||||
| "path"
|
||||
| "target_changed"
|
||||
| "backup"
|
||||
| "write"
|
||||
| "readback";
|
||||
readonly reason: "path" | "target_changed" | "backup" | "write";
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "readback";
|
||||
/** Snapshot of the restored original, when this transaction made one. */
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
|
||||
@@ -26,6 +26,10 @@ import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.j
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
preserveCurrentRecoveryBackup,
|
||||
type RecoveryBackupPreservationOptions,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import {
|
||||
InvalidUtf8FileError,
|
||||
RegularFileCleanupFailure,
|
||||
@@ -264,7 +268,9 @@ export const systemUninstallHost = (
|
||||
: item(client.name, "failed", blocker);
|
||||
},
|
||||
removeClient: (client) =>
|
||||
removeClient(client, fileSystem, () => clientWriteBlocker(client)),
|
||||
removeClient(client, fileSystem, resources, () =>
|
||||
clientWriteBlocker(client),
|
||||
),
|
||||
inspectSkill: async () => {
|
||||
for (const destination of skillDestinations(
|
||||
home,
|
||||
@@ -435,9 +441,42 @@ const clientRegistrationRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Preserve the bytes uninstall is about to edit and return the backup holding them. */
|
||||
const backupClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
transactionPath: string,
|
||||
original: string,
|
||||
options: RecoveryBackupPreservationOptions,
|
||||
): Promise<string | UninstallItem> => {
|
||||
const failed = (detail: string) =>
|
||||
item(
|
||||
client.name,
|
||||
"failed",
|
||||
`Configuration could not be backed up, so no change was made. ${detail}`,
|
||||
);
|
||||
try {
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
transactionPath,
|
||||
original,
|
||||
options,
|
||||
);
|
||||
if (preserved.ok) return preserved.value;
|
||||
if (preserved.error.kind === "occupied")
|
||||
return failed(
|
||||
`${preserved.error.detail}. Move that file aside, then rerun uninstall.`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// The resource scope retains a backup handle whose close failed.
|
||||
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
||||
}
|
||||
return failed("Check file permissions, then rerun uninstall.");
|
||||
};
|
||||
|
||||
const removeClient = async (
|
||||
client: SetupClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
resources: ArtifactResourceScope,
|
||||
writeBlocker?: () => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
@@ -479,17 +518,16 @@ const removeClient = async (
|
||||
...(removeNative ? [clientServerPath(parsed, name)] : []),
|
||||
...(legacyPath === undefined ? [] : [legacyPath]),
|
||||
];
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await fileSystem.copy(transactionPath, backupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST"))
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
const backupPath = await backupClientConfiguration(
|
||||
client,
|
||||
transactionPath,
|
||||
original,
|
||||
{
|
||||
copy: (source, destination) => fileSystem.copy(source, destination),
|
||||
resources,
|
||||
},
|
||||
);
|
||||
if (typeof backupPath !== "string") return backupPath;
|
||||
try {
|
||||
await fileSystem.writeText(
|
||||
transactionPath,
|
||||
@@ -526,13 +564,13 @@ const removeClient = async (
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
||||
`Configuration could not be updated or restored. Restore the backup at ${backupPath} manually, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
||||
`Configuration could not be updated. The original was restored and its backup at ${backupPath} was retained. Repair the configuration or restore that backup, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,18 +236,20 @@ const readCapture = async (
|
||||
try {
|
||||
return ok({ bytes: await readRegularFile(path, { signal }) });
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true && cause === signal.reason)
|
||||
return err(captureReadFailure(path, cause, signal));
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const retry = await retryRegularFileCleanup(cause, resources);
|
||||
const retry = await retryRegularFileCleanup(cause, resources, signal);
|
||||
const cleanup = {
|
||||
reason: retry.cleanup?.reason ?? errorMessage(cause.cleanupCause),
|
||||
incomplete: retry.cleanup !== undefined,
|
||||
cause: cause.cleanupCause,
|
||||
};
|
||||
if (cause.outcome.kind === "completed")
|
||||
return ok({ bytes: cause.outcome.value as Buffer, cleanup });
|
||||
if (retry.outcome.kind === "completed")
|
||||
return ok({ bytes: retry.outcome.value as Buffer, cleanup });
|
||||
const primary = captureReadFailure(
|
||||
path,
|
||||
cause.outcome.cause,
|
||||
retry.outcome.cause,
|
||||
signal,
|
||||
retry.cleanup === undefined ? { cause: cause.cleanupCause } : undefined,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import type { AnalysisClient } from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
|
||||
const clientClosingWith = (cause: unknown): AnalysisClient => ({
|
||||
|
||||
@@ -7,13 +7,15 @@ 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 {
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProviderCandidate,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js";
|
||||
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
|
||||
/** Profile-resolution truth for one discovered provider candidate. */
|
||||
export interface AnalysisProviderCandidateEvaluation {
|
||||
@@ -28,40 +30,32 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
target: BinaryTarget,
|
||||
signal: AbortSignal | undefined,
|
||||
fileCloseResources?: FileCloseResourceOwner,
|
||||
options: AnalysisProfileResolutionOptions = {},
|
||||
): Promise<
|
||||
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
|
||||
> => {
|
||||
const { signal } = options;
|
||||
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, options);
|
||||
} 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 +79,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 = {
|
||||
@@ -64,7 +65,7 @@ it("rejects malformed provider capability declarations", () => {
|
||||
).toThrow(/Invalid analysis provider ID: auto/u);
|
||||
});
|
||||
|
||||
it("uses one owner readiness snapshot for candidate availability and capabilities", () => {
|
||||
it("uses one owner readiness snapshot for candidate availability and capabilities", async () => {
|
||||
const base = candidate("alpha").provider;
|
||||
let observed = 0;
|
||||
let capabilityReads = 0;
|
||||
@@ -91,7 +92,7 @@ it("uses one owner readiness snapshot for candidate availability and capabilitie
|
||||
},
|
||||
};
|
||||
|
||||
const [status] = new AnalysisProviderRegistry([provider]).candidates();
|
||||
const [status] = await new AnalysisProviderRegistry([provider]).candidates();
|
||||
expect(observed).toBe(1);
|
||||
expect(capabilityReads).toBe(1);
|
||||
expect(status).toMatchObject({
|
||||
@@ -145,6 +146,88 @@ it("selects the sole available provider and honors explicit selection precedence
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspection without deep binding", () => {
|
||||
it("retains discovery facts without resolving a deep profile for covered automatic inspection", async () => {
|
||||
const alpha = candidate("alpha", { profile: "error" });
|
||||
const beta = candidate("beta", { profile: "error" });
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[alpha.provider, beta.provider],
|
||||
"auto",
|
||||
(target) => target === DATABASE_TARGET,
|
||||
);
|
||||
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
requestedProviderId: "auto",
|
||||
binding: null,
|
||||
candidates: [
|
||||
{
|
||||
provider: { id: "alpha" },
|
||||
selected: false,
|
||||
availability: { status: "available" },
|
||||
},
|
||||
{
|
||||
provider: { id: "beta" },
|
||||
selected: false,
|
||||
availability: { status: "available" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(alpha.created).toEqual([]);
|
||||
expect(beta.created).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicit request and environment choices even when auxiliary inspection covers the target", async () => {
|
||||
const alpha = candidate("alpha");
|
||||
const beta = candidate("beta");
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[alpha.provider, beta.provider],
|
||||
"beta",
|
||||
() => true,
|
||||
);
|
||||
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
binding: { identity: { id: "beta" }, selectionSource: "environment" },
|
||||
},
|
||||
});
|
||||
expect(await registry.select(DATABASE_TARGET, "alpha")).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
binding: { identity: { id: "alpha" }, selectionSource: "request" },
|
||||
},
|
||||
});
|
||||
expect(await registry.select(DATABASE_TARGET, "auto")).toMatchObject({
|
||||
ok: true,
|
||||
value: { binding: null },
|
||||
});
|
||||
expect(await registry.select(DATABASE_TARGET, "missing")).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "unknown_provider" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves ordinary ambiguity when auxiliary inspection does not cover the target", async () => {
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[candidate("alpha").provider, candidate("beta").provider],
|
||||
"auto",
|
||||
() => false,
|
||||
);
|
||||
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "ambiguous" },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(
|
||||
await registry.select(DATABASE_TARGET, undefined, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis provider registry: selection failures", () => {
|
||||
it("returns actionable unknown, unavailable, and unsupported failures", async () => {
|
||||
const unavailable = candidate("alpha", { available: false });
|
||||
@@ -313,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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,3 +559,401 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
it("joins cancelled readiness finalization and shutdown before admitting another observation", async () => {
|
||||
const base = candidate("alpha").provider;
|
||||
let enter!: () => void;
|
||||
let finish!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
let observed: AbortSignal | undefined;
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: async ({ signal } = {}) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
observed = signal;
|
||||
enter();
|
||||
await gate;
|
||||
}
|
||||
return base.inspectAvailability();
|
||||
},
|
||||
},
|
||||
]);
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "opaque readiness cancellation" };
|
||||
let settled = false;
|
||||
const observing = registry
|
||||
.candidates(undefined, { signal: controller.signal })
|
||||
.catch((cause) => {
|
||||
settled = true;
|
||||
return cause;
|
||||
});
|
||||
await entered;
|
||||
controller.abort(reason);
|
||||
let closed = false;
|
||||
const closing = registry.closeOwnedResources().then((value) => {
|
||||
closed = true;
|
||||
return value;
|
||||
});
|
||||
const next = registry.candidates();
|
||||
try {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
expect(closed).toBe(false);
|
||||
expect(calls).toBe(1);
|
||||
expect(observed?.reason).toBe(reason);
|
||||
expect(registry.hasPendingOwnedResources()).toBe(true);
|
||||
finish();
|
||||
const failure = await observing;
|
||||
expect(failure).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(failure.cause).toBe(reason);
|
||||
expect(await closing).toEqual(ok(null));
|
||||
expect(await next).toMatchObject([
|
||||
{
|
||||
availability: {
|
||||
status: "unknown",
|
||||
diagnostics: { finalization: "in_progress" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(await registry.candidates()).toHaveLength(1);
|
||||
expect(calls).toBe(2);
|
||||
expect(registry.hasPendingOwnedResources()).toBe(false);
|
||||
} finally {
|
||||
finish();
|
||||
await Promise.allSettled([observing, closing, next]);
|
||||
await registry.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
|
||||
it("joins every candidate and retains physical failure with exact mixed cleanup owners", async () => {
|
||||
const alpha = candidate("alpha").provider;
|
||||
const beta = candidate("beta").provider;
|
||||
const gamma = candidate("gamma").provider;
|
||||
let finish!: () => void;
|
||||
let enter!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const physical = new ProviderAdapterError("alpha", "binary_session", {
|
||||
diagnostics: { reason: "physical observation failed" },
|
||||
});
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"beta",
|
||||
["beta retained prerequisite"],
|
||||
{ reason: "controlled cleanup refusal" },
|
||||
);
|
||||
let releases = 0;
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
{
|
||||
...alpha,
|
||||
inspectAvailability: async () => {
|
||||
throw physical;
|
||||
},
|
||||
},
|
||||
{
|
||||
...beta,
|
||||
inspectAvailability: async () => {
|
||||
enter();
|
||||
await gate;
|
||||
throw cleanup;
|
||||
},
|
||||
closeOwnedResources: async () => {
|
||||
releases++;
|
||||
cleanup.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
gamma,
|
||||
]);
|
||||
let settled = false;
|
||||
const observing = registry.candidates().catch((cause) => {
|
||||
settled = true;
|
||||
return cause;
|
||||
});
|
||||
await entered;
|
||||
try {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finish();
|
||||
const failure = await observing;
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
expect(failure.cleanupResources).toContain("beta retained prerequisite");
|
||||
expect(failure.diagnostics).toMatchObject({
|
||||
readiness_candidates: [
|
||||
{ provider: { id: "gamma" }, availability: { status: "available" } },
|
||||
],
|
||||
});
|
||||
expect(failure.cleanupPrimary).toBe(physical);
|
||||
expect(projectAnalysisError(failure).details).toMatchObject({
|
||||
diagnostics: {
|
||||
readiness_errors: expect.arrayContaining([
|
||||
{
|
||||
provider_id: "alpha",
|
||||
error: expect.objectContaining({ code: "execution_failure" }),
|
||||
},
|
||||
{
|
||||
provider_id: "beta",
|
||||
error: expect.objectContaining({ code: "cleanup_incomplete" }),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(registry.hasPendingOwnedResources()).toBe(true);
|
||||
expect(await registry.closeOwnedResources()).toEqual(ok(null));
|
||||
expect(releases).toBe(1);
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(failure)).toMatchObject({
|
||||
code: "execution_failure",
|
||||
details: { diagnostics: { reason: "physical observation failed" } },
|
||||
});
|
||||
expect(registry.hasPendingOwnedResources()).toBe(false);
|
||||
} finally {
|
||||
finish();
|
||||
await observing;
|
||||
await registry.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
|
||||
it("registers selection before async readiness can race shutdown", async () => {
|
||||
let calls = 0;
|
||||
let observed: AbortSignal | undefined;
|
||||
const base = candidate("alpha").provider;
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: async ({ signal } = {}) => {
|
||||
calls++;
|
||||
observed = signal;
|
||||
return base.inspectAvailability();
|
||||
},
|
||||
},
|
||||
]);
|
||||
const selecting = registry.select(DATABASE_TARGET, "alpha");
|
||||
const closing = registry.closeOwnedResources();
|
||||
const [selected, closed] = await Promise.all([selecting, closing]);
|
||||
expect(selected).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(closed).toEqual(ok(null));
|
||||
expect(calls).toBe(1);
|
||||
expect(observed?.aborted).toBe(true);
|
||||
expect((await registry.select(DATABASE_TARGET, "alpha")).ok).toBe(true);
|
||||
expect(calls).toBe(2);
|
||||
expect(registry.hasPendingOwnedResources()).toBe(false);
|
||||
});
|
||||
@@ -1,3 +1,11 @@
|
||||
import { ProviderInspectionScope } from "../../providers/ProviderInspectionScope.js";
|
||||
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,
|
||||
@@ -7,13 +15,17 @@ import {
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
ProviderSelectionError,
|
||||
type ProviderSelectionFailureReason,
|
||||
type ProviderSelectionRejection,
|
||||
} from "../../domain/providerSelectionError.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type {
|
||||
AnalysisProviderCandidate,
|
||||
@@ -32,7 +44,14 @@ import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
|
||||
/** Deterministic discovery truth for one configured deep provider. */
|
||||
export interface AnalysisProviderCandidateStatus {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly availability: ProviderAvailability;
|
||||
readonly availability:
|
||||
| ProviderAvailability
|
||||
| {
|
||||
readonly status: "unknown";
|
||||
readonly code: null;
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
readonly targetSupport:
|
||||
| ProviderTargetSupport
|
||||
| {
|
||||
@@ -66,10 +85,12 @@ export interface AnalysisProviderSelection {
|
||||
interface TargetSelectionContext {
|
||||
readonly target: BinaryTarget;
|
||||
readonly baseline: readonly AnalysisProviderCandidateStatus[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly fileCloseResources?: FileCloseResourceOwner;
|
||||
readonly signal: AbortSignal;
|
||||
readonly scope: ProviderInspectionScope;
|
||||
}
|
||||
|
||||
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,9 +101,31 @@ export class AnalysisProviderRegistry {
|
||||
readonly #declaredOperations: readonly string[];
|
||||
readonly #defaultSelector: AnalysisProviderSelector;
|
||||
|
||||
readonly #selections = new Set<SharedStartupAttempt<SelectionResult>>();
|
||||
readonly #discoveries = new Set<
|
||||
SharedStartupAttempt<
|
||||
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
|
||||
>
|
||||
>();
|
||||
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",
|
||||
private readonly canInspectWithoutDeepProvider?: (
|
||||
target: BinaryTarget,
|
||||
) => boolean,
|
||||
) {
|
||||
this.#defaultSelector =
|
||||
analysisProviderSelectorSchema.parse(defaultSelector);
|
||||
@@ -140,29 +183,73 @@ 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.#discoveries.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];
|
||||
const discoveries = [...this.#discoveries];
|
||||
for (const discovery of discoveries) discovery.close();
|
||||
for (const selection of selections) selection.close();
|
||||
await Promise.allSettled([
|
||||
...discoveries.map((discovery) => discovery.promise),
|
||||
...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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -173,76 +260,262 @@ export class AnalysisProviderRegistry {
|
||||
}
|
||||
|
||||
/** Discover candidates for status without resolving profiles or starting clients. */
|
||||
candidates(
|
||||
async candidates(
|
||||
target?: BinaryTarget,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
return this.#providers.map((provider) => candidateStatus(provider, target));
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<readonly AnalysisProviderCandidateStatus[]> {
|
||||
if (options.signal?.aborted)
|
||||
throw new AnalysisCancelledError("binary_session", {
|
||||
cause: options.signal.reason,
|
||||
});
|
||||
if (this.#closing !== undefined)
|
||||
return this.#providers.map((provider) =>
|
||||
projectCandidateStatus(provider, target, {
|
||||
availability: {
|
||||
status: "unknown",
|
||||
code: null,
|
||||
reason:
|
||||
"Provider ownership finalization is in progress; readiness has not been re-observed.",
|
||||
diagnostics: { finalization: "in_progress" },
|
||||
},
|
||||
capabilities: provider.capabilities(),
|
||||
}),
|
||||
);
|
||||
const scope = new ProviderInspectionScope();
|
||||
const discovery = new SharedStartupAttempt<
|
||||
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
|
||||
>(
|
||||
(signal) =>
|
||||
Promise.resolve().then(() =>
|
||||
this.#discoverCandidates(target, {
|
||||
signal,
|
||||
scope,
|
||||
operation: "binary_session",
|
||||
}),
|
||||
),
|
||||
options.signal,
|
||||
);
|
||||
this.#discoveries.add(discovery);
|
||||
try {
|
||||
const result = await discovery.promise;
|
||||
if (!result.ok) throw result.error;
|
||||
if (discovery.signal.aborted)
|
||||
throw new AnalysisCancelledError("binary_session", {
|
||||
cause: discovery.signal.reason,
|
||||
diagnostics: {
|
||||
readiness_candidates: jsonValueSchema.parse(result.value),
|
||||
},
|
||||
});
|
||||
return result.value;
|
||||
} finally {
|
||||
scope.close();
|
||||
this.#discoveries.delete(discovery);
|
||||
}
|
||||
}
|
||||
|
||||
async #discoverCandidates(
|
||||
target: BinaryTarget | undefined,
|
||||
context: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly scope: ProviderInspectionScope;
|
||||
readonly operation: "binary_session" | "open_binary";
|
||||
},
|
||||
): Promise<
|
||||
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
|
||||
> {
|
||||
const { signal, operation } = context;
|
||||
if (signal.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
this.#providers.map((provider) =>
|
||||
candidateStatus(provider, target, context),
|
||||
),
|
||||
);
|
||||
const statuses: AnalysisProviderCandidateStatus[] = [];
|
||||
const failures: {
|
||||
readonly providerId: string;
|
||||
readonly error: AnalysisError;
|
||||
}[] = [];
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "fulfilled") statuses.push(result.value);
|
||||
else
|
||||
failures.push({
|
||||
providerId: this.#providers[index]!.identity().id,
|
||||
error:
|
||||
result.reason instanceof AnalysisError
|
||||
? result.reason
|
||||
: new ProviderAdapterError(
|
||||
this.#providers[index]!.identity().id,
|
||||
operation,
|
||||
{ cause: result.reason },
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const { error } of failures)
|
||||
if (error.cleanupIncomplete) this.#selectionCleanup.add(error);
|
||||
if (failures.length > 0)
|
||||
return err(
|
||||
providerInspectionFailure(
|
||||
failures,
|
||||
{ operation, candidates: statuses },
|
||||
signal,
|
||||
),
|
||||
);
|
||||
return ok(statuses);
|
||||
}
|
||||
|
||||
/** Select one whole deep-operation family for a parsed target. */
|
||||
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"));
|
||||
const parsedRequest =
|
||||
requestedSelector === undefined
|
||||
? undefined
|
||||
: analysisProviderSelectorSchema.safeParse(requestedSelector);
|
||||
if (parsedRequest !== undefined && !parsedRequest.success)
|
||||
return err(
|
||||
selectionError(
|
||||
"invalid_options",
|
||||
typeof requestedSelector === "string"
|
||||
? requestedSelector
|
||||
: "<invalid>",
|
||||
this.candidates(target),
|
||||
),
|
||||
new AnalysisCancelledError("open_binary", {
|
||||
cause: options.signal?.reason,
|
||||
}),
|
||||
);
|
||||
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 (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 (
|
||||
request === undefined &&
|
||||
this.#byId.has(selector) &&
|
||||
baseline.length > 0 &&
|
||||
baseline.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
)
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (selector !== "auto")
|
||||
return this.#selectConcrete(
|
||||
{
|
||||
target,
|
||||
baseline,
|
||||
signal: options.signal,
|
||||
...(options.fileCloseResources === undefined
|
||||
? {}
|
||||
: { fileCloseResources: options.fileCloseResources }),
|
||||
},
|
||||
selector,
|
||||
request === undefined ? "environment" : "request",
|
||||
[...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 || this.#discoveries.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,
|
||||
}),
|
||||
);
|
||||
return this.#selectAutomatically({
|
||||
target,
|
||||
baseline,
|
||||
signal: options.signal,
|
||||
...(options.fileCloseResources === undefined
|
||||
? {}
|
||||
: { fileCloseResources: options.fileCloseResources }),
|
||||
});
|
||||
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> {
|
||||
const scope = new ProviderInspectionScope();
|
||||
try {
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
|
||||
);
|
||||
const discovery = await this.#discoverCandidates(target, {
|
||||
signal,
|
||||
scope,
|
||||
operation: "open_binary",
|
||||
});
|
||||
if (!discovery.ok) return discovery;
|
||||
const baseline = discovery.value;
|
||||
const parsedRequest =
|
||||
requestedSelector === undefined
|
||||
? undefined
|
||||
: analysisProviderSelectorSchema.safeParse(requestedSelector);
|
||||
if (parsedRequest !== undefined && !parsedRequest.success)
|
||||
return err(
|
||||
selectionError(
|
||||
"invalid_options",
|
||||
typeof requestedSelector === "string"
|
||||
? requestedSelector
|
||||
: "<invalid>",
|
||||
baseline,
|
||||
),
|
||||
);
|
||||
const request = parsedRequest?.data;
|
||||
const selector = request ?? this.#defaultSelector;
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
|
||||
);
|
||||
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (
|
||||
request === undefined &&
|
||||
this.#byId.has(selector) &&
|
||||
baseline.length > 0 &&
|
||||
baseline.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
)
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (selector !== "auto")
|
||||
return await this.#selectConcrete(
|
||||
{
|
||||
target,
|
||||
baseline,
|
||||
signal: signal,
|
||||
scope,
|
||||
},
|
||||
selector,
|
||||
request === undefined ? "environment" : "request",
|
||||
);
|
||||
return await this.#selectAutomatically({
|
||||
target,
|
||||
baseline,
|
||||
signal: signal,
|
||||
scope,
|
||||
});
|
||||
} finally {
|
||||
scope.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Explain why a deep operation is unavailable on an unbound target. */
|
||||
@@ -270,7 +543,7 @@ export class AnalysisProviderRegistry {
|
||||
providerId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
const { baseline, signal, target } = context;
|
||||
const { baseline, signal, target, scope } = context;
|
||||
const provider = this.#byId.get(providerId);
|
||||
if (provider === undefined)
|
||||
return err(selectionError("unknown_provider", providerId, baseline));
|
||||
@@ -289,8 +562,7 @@ export class AnalysisProviderRegistry {
|
||||
provider,
|
||||
initial,
|
||||
target,
|
||||
signal,
|
||||
context.fileCloseResources,
|
||||
{ signal, fileCloseResources: this.#profileFileOwner, scope },
|
||||
);
|
||||
if (!evaluation.ok) return evaluation;
|
||||
const evaluated = evaluation.value;
|
||||
@@ -315,7 +587,7 @@ export class AnalysisProviderRegistry {
|
||||
async #selectAutomatically(
|
||||
context: TargetSelectionContext,
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
const { baseline, signal, target } = context;
|
||||
const { baseline, signal, target, scope } = context;
|
||||
const results = await Promise.all(
|
||||
this.#providers.map(
|
||||
async (
|
||||
@@ -332,13 +604,11 @@ export class AnalysisProviderRegistry {
|
||||
);
|
||||
return status.availability.status === "available" &&
|
||||
status.targetSupport.status === "supported"
|
||||
? evaluateAnalysisProviderCandidate(
|
||||
provider,
|
||||
status,
|
||||
target,
|
||||
? evaluateAnalysisProviderCandidate(provider, status, target, {
|
||||
signal,
|
||||
context.fileCloseResources,
|
||||
)
|
||||
fileCloseResources: this.#profileFileOwner,
|
||||
scope,
|
||||
})
|
||||
: ok({
|
||||
candidate: provider,
|
||||
status,
|
||||
@@ -346,17 +616,27 @@ 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(
|
||||
providerInspectionFailure(
|
||||
failures,
|
||||
{ operation: "resolve_analysis_profile" },
|
||||
signal,
|
||||
),
|
||||
);
|
||||
const statuses = evaluations.map(({ status }) => status);
|
||||
const usable = evaluations.filter(
|
||||
(
|
||||
@@ -382,14 +662,36 @@ export class AnalysisProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
const candidateStatus = (
|
||||
const candidateStatus = async (
|
||||
provider: AnalysisProviderCandidate,
|
||||
target: BinaryTarget | undefined,
|
||||
context: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly scope: ProviderInspectionScope;
|
||||
readonly operation: "binary_session" | "open_binary";
|
||||
},
|
||||
): Promise<AnalysisProviderCandidateStatus> => {
|
||||
const { signal, scope, operation } = context;
|
||||
if (signal.aborted)
|
||||
throw new AnalysisCancelledError(operation, { cause: signal.reason });
|
||||
const readiness =
|
||||
provider.inspectReadinessSnapshot === undefined
|
||||
? {
|
||||
availability: await provider.inspectAvailability({ signal, scope }),
|
||||
capabilities: provider.capabilities(),
|
||||
}
|
||||
: await provider.inspectReadinessSnapshot({ signal, scope });
|
||||
return projectCandidateStatus(provider, target, readiness);
|
||||
};
|
||||
|
||||
const projectCandidateStatus = (
|
||||
provider: AnalysisProviderCandidate,
|
||||
target: BinaryTarget | undefined,
|
||||
readiness: {
|
||||
readonly availability: AnalysisProviderCandidateStatus["availability"];
|
||||
readonly capabilities: readonly CapabilityDescriptor[];
|
||||
},
|
||||
): AnalysisProviderCandidateStatus => {
|
||||
const readiness = provider.inspectReadinessSnapshot?.() ?? {
|
||||
availability: provider.inspectAvailability(),
|
||||
capabilities: provider.capabilities(),
|
||||
};
|
||||
return {
|
||||
provider: structuredClone(provider.identity()),
|
||||
availability: cloneAvailability(readiness.availability),
|
||||
@@ -486,8 +788,8 @@ const candidateRejections = (
|
||||
);
|
||||
|
||||
const cloneAvailability = (
|
||||
availability: ProviderAvailability,
|
||||
): ProviderAvailability => ({
|
||||
availability: AnalysisProviderCandidateStatus["availability"],
|
||||
): AnalysisProviderCandidateStatus["availability"] => ({
|
||||
...availability,
|
||||
diagnostics: jsonObjectSchema.parse(availability.diagnostics),
|
||||
});
|
||||
@@ -498,3 +800,91 @@ const cloneTargetSupport = (
|
||||
...support,
|
||||
diagnostics: jsonObjectSchema.parse(support.diagnostics),
|
||||
});
|
||||
|
||||
/** Every terminal child remains attributed; cleanup is conjunctive across owners. */
|
||||
const providerInspectionFailure = (
|
||||
failures: readonly {
|
||||
readonly providerId: string;
|
||||
readonly error: AnalysisError;
|
||||
}[],
|
||||
context:
|
||||
| { readonly operation: "resolve_analysis_profile" }
|
||||
| {
|
||||
readonly operation: "binary_session" | "open_binary";
|
||||
readonly candidates: readonly AnalysisProviderCandidateStatus[];
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
const primary =
|
||||
failures.find(({ error }) => !(error instanceof AnalysisCancelledError)) ??
|
||||
failures[0];
|
||||
if (primary === undefined)
|
||||
return new AnalysisCancelledError(
|
||||
context.operation === "resolve_analysis_profile"
|
||||
? "open_binary"
|
||||
: context.operation,
|
||||
{ cause: signal?.reason },
|
||||
);
|
||||
if (
|
||||
failures.length === 1 &&
|
||||
(context.operation === "resolve_analysis_profile" ||
|
||||
context.candidates.length === 0)
|
||||
)
|
||||
return primary.error;
|
||||
const cleanupFailures = failures
|
||||
.filter(({ error }) => error.cleanupIncomplete)
|
||||
.map(({ error }) => error);
|
||||
const options = {
|
||||
diagnostics: {
|
||||
[context.operation === "resolve_analysis_profile"
|
||||
? "profile_errors"
|
||||
: "readiness_errors"]: failures.map(({ providerId, error }) => ({
|
||||
provider_id: providerId,
|
||||
error: projectAnalysisError(error),
|
||||
})),
|
||||
...(context.operation === "resolve_analysis_profile"
|
||||
? {}
|
||||
: { readiness_candidates: jsonValueSchema.parse(context.candidates) }),
|
||||
},
|
||||
...(primary.error.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: primary.error.capturedOutput }),
|
||||
...(primary.error.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: primary.error.partialObservation }),
|
||||
...(cleanupFailures.length === 0
|
||||
? {}
|
||||
: {
|
||||
cleanupPrimary: primary.error,
|
||||
cleanup: {
|
||||
reason: cleanupFailures.map((error) => error.message).join("; "),
|
||||
resources: [
|
||||
...new Set(
|
||||
cleanupFailures.flatMap((error) => error.cleanupResources),
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const failure =
|
||||
primary.error instanceof AnalysisCancelledError
|
||||
? new AnalysisCancelledError(
|
||||
context.operation === "resolve_analysis_profile"
|
||||
? "open_binary"
|
||||
: context.operation,
|
||||
{
|
||||
...options,
|
||||
cause: primary.error.cause,
|
||||
},
|
||||
)
|
||||
: new ProviderAdapterError("rea", context.operation, {
|
||||
...options,
|
||||
userMessage: projectAnalysisError(primary.error).message,
|
||||
cause: new AggregateError(
|
||||
failures.map(({ error }) => error),
|
||||
"Provider inspections failed",
|
||||
{ cause: primary.error },
|
||||
),
|
||||
});
|
||||
return failure.bindCleanupDependencies(cleanupFailures);
|
||||
};
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
commitExecutionProfile,
|
||||
prepareSessionExecution,
|
||||
} from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
@@ -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];
|
||||
@@ -591,10 +591,11 @@ export class BinarySession
|
||||
}
|
||||
|
||||
/** Describe the active binary session. */
|
||||
status(): JsonValue {
|
||||
status(options: { readonly signal?: AbortSignal } = {}): Promise<JsonValue> {
|
||||
const run = this.#active ?? this.#pendingCleanup?.run;
|
||||
const client = run?.client;
|
||||
return binarySessionStatus({
|
||||
...options,
|
||||
target: this.#active?.target,
|
||||
route: this.#currentRoute(),
|
||||
router: this.#providerRouter,
|
||||
@@ -926,6 +927,7 @@ export class BinarySession
|
||||
client: AnalysisClient,
|
||||
options: Pick<BinarySessionOpenOptions, "signal">,
|
||||
): Promise<boolean> {
|
||||
if (client.reuseReadiness?.() === "blocked") return false;
|
||||
const snapshot = client.operationHealthSnapshot?.();
|
||||
if (snapshot !== undefined)
|
||||
return snapshot.state === "idle" || snapshot.state === "busy";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { elf } from "../../domain/binaryTarget.fixture.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -37,6 +41,23 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
* recorded process exit.
|
||||
*/
|
||||
describe("BinarySession open liveness", () => {
|
||||
it("retires a locally blocked owner even when its native health is idle", async () => {
|
||||
const { session, deep } = openSession();
|
||||
try {
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
const retained = deep.created[0];
|
||||
if (retained === undefined) throw new Error("Expected an acquired child");
|
||||
retained.reuseReadiness = () => "blocked";
|
||||
expect(retained.operationHealthSnapshot?.()?.state).toBe("idle");
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect(retained.closeCalled).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(2);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
it("reuses the retained client while the child stays live", async () => {
|
||||
const { session, deep } = openSession();
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
@@ -205,20 +226,25 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
|
||||
});
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(false);
|
||||
const availability = () =>
|
||||
buildCapabilityInventory(session.status(), {
|
||||
const availability = async () =>
|
||||
buildCapabilityInventory(await session.status(), {
|
||||
processCaptureEnabled: false,
|
||||
}).find(({ name }) => name === "address_name");
|
||||
const initialAvailability = availability();
|
||||
const initialAvailability = await availability();
|
||||
expect(initialAvailability).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_unavailable",
|
||||
});
|
||||
const originalTarget = session.activeTarget();
|
||||
const initialRunId = sessionRunId(session.status());
|
||||
const initialRunId = sessionRunId(await session.status());
|
||||
|
||||
const controller = new AbortController();
|
||||
const switching = session.open("/bin/ls", { signal: controller.signal });
|
||||
const alternate = join(
|
||||
await createTestTempDirectory("rea-liveness-switch-"),
|
||||
"alternate.elf",
|
||||
);
|
||||
await writeFile(alternate, elf(2, 1, 62));
|
||||
const switching = session.open(alternate, { signal: controller.signal });
|
||||
await closing;
|
||||
controller.abort();
|
||||
releaseClose();
|
||||
@@ -231,9 +257,9 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
|
||||
expect(session.activeTarget()).toEqual(originalTarget);
|
||||
expect(deep.created).toHaveLength(1);
|
||||
expect(deep.created[0]?.closeCalled).toBe(true);
|
||||
const restoredStatus = session.status();
|
||||
const restoredStatus = await session.status();
|
||||
expect(sessionRunId(restoredStatus)).not.toBe(initialRunId);
|
||||
expect(availability()).toMatchObject({
|
||||
expect(await availability()).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
@@ -242,20 +268,20 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
|
||||
// restoration, while the new restored client is healthy and usable.
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(deep.created).toHaveLength(2);
|
||||
expect(availability()).toMatchObject({
|
||||
expect(await availability()).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
});
|
||||
deep.created[1]?.setOperationFailure(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(false);
|
||||
expect(availability()).toMatchObject({
|
||||
expect(await availability()).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_unavailable",
|
||||
});
|
||||
deep.created[1]?.setOperationFailure(false);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(availability()).toMatchObject({
|
||||
expect(await availability()).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
@@ -263,7 +289,9 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
|
||||
await session.close();
|
||||
});
|
||||
|
||||
const sessionRunId = (status: ReturnType<BinarySession["status"]>): string => {
|
||||
const sessionRunId = (
|
||||
status: Awaited<ReturnType<BinarySession["status"]>>,
|
||||
): string => {
|
||||
if (typeof status !== "object" || status === null || Array.isArray(status))
|
||||
throw new Error("Expected a binary session status object");
|
||||
const analysisRun = status.analysis_run;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface BinarySessionPort
|
||||
overwrite: boolean,
|
||||
options?: Pick<ExecutionOptions, "progress">,
|
||||
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
status(options?: { readonly signal?: AbortSignal }): Promise<JsonValue>;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
|
||||
importAnalysisSnapshot(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
expandProviderCapabilities,
|
||||
sessionProvider,
|
||||
type ProviderCapabilitySet,
|
||||
} from "../../contracts/toolOutputSchemaPrimitives.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
@@ -16,16 +17,10 @@ import type {
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
|
||||
type ProjectedStatus = {
|
||||
capabilities: ProviderCapabilitySet[];
|
||||
analysis_provider_candidates: {
|
||||
provider: ProviderIdentity;
|
||||
availability: unknown;
|
||||
target_support: unknown;
|
||||
selected: boolean;
|
||||
capabilities: ProviderCapabilitySet[];
|
||||
}[];
|
||||
};
|
||||
const projectedStatusSchema = sessionProvider.pick({
|
||||
capabilities: true,
|
||||
analysis_provider_candidates: true,
|
||||
});
|
||||
|
||||
const providerA: ProviderIdentity = {
|
||||
id: "provider-a",
|
||||
@@ -117,32 +112,40 @@ const candidate = (
|
||||
capabilities,
|
||||
});
|
||||
|
||||
const project = (
|
||||
const project = async (
|
||||
routeCapabilities: readonly CapabilityDescriptor[],
|
||||
candidates: readonly AnalysisProviderCandidateStatus[],
|
||||
runtimeUnavailability: ReadonlyMap<
|
||||
string,
|
||||
{ readonly reason: string }
|
||||
> = new Map(),
|
||||
): ProjectedStatus => {
|
||||
const route = {
|
||||
) => {
|
||||
const route: SessionProviderRoute = {
|
||||
identity: providerA,
|
||||
profile: null,
|
||||
selection: undefined,
|
||||
createClient: () => {
|
||||
throw new Error("Projection does not create clients");
|
||||
},
|
||||
capabilities: new Map(
|
||||
routeCapabilities.map((capability) => [capability.operation, capability]),
|
||||
),
|
||||
binding: null,
|
||||
} as unknown as SessionProviderRoute;
|
||||
const router = {
|
||||
candidateStatuses: () => candidates,
|
||||
} as unknown as SessionProviderRouter;
|
||||
return binarySessionStatus({
|
||||
target: undefined,
|
||||
route,
|
||||
router,
|
||||
runtimeUnavailability,
|
||||
runId: undefined,
|
||||
runtimeLineageSnapshots: [],
|
||||
requestActivitySnapshots: [],
|
||||
}) as unknown as ProjectedStatus;
|
||||
};
|
||||
const router: Pick<SessionProviderRouter, "candidateStatuses"> = {
|
||||
candidateStatuses: async () => candidates,
|
||||
};
|
||||
return projectedStatusSchema.parse(
|
||||
await binarySessionStatus({
|
||||
target: undefined,
|
||||
route,
|
||||
router,
|
||||
runtimeUnavailability,
|
||||
runId: undefined,
|
||||
runtimeLineageSnapshots: [],
|
||||
requestActivitySnapshots: [],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const expandCanonicalCapabilities = (sets: readonly ProviderCapabilitySet[]) =>
|
||||
@@ -323,10 +326,10 @@ const expectedExpandedCandidateCapabilities = [
|
||||
];
|
||||
|
||||
describe("binary session provider capability projection", () => {
|
||||
it("preserves provider scope and route overlays while factoring exact prefixes", () => {
|
||||
it("preserves provider scope and route overlays while factoring exact prefixes", async () => {
|
||||
const selectedBefore = structuredClone(selectedCapabilities);
|
||||
const otherBefore = structuredClone(otherCapabilities);
|
||||
const status = project(
|
||||
const status = await project(
|
||||
selectedCapabilities,
|
||||
[
|
||||
candidate(providerA, selectedCapabilities),
|
||||
@@ -355,13 +358,13 @@ describe("binary session provider capability projection", () => {
|
||||
).toEqual(expectedExpandedCandidateCapabilities[1]);
|
||||
});
|
||||
|
||||
it("keeps empty route and candidate capability collections empty", () => {
|
||||
const status = project([], [candidate(providerA, [])]);
|
||||
it("keeps empty route and candidate capability collections empty", async () => {
|
||||
const status = await project([], [candidate(providerA, [])]);
|
||||
expect(status.capabilities).toEqual([]);
|
||||
expect(status.analysis_provider_candidates[0]?.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves repeated limitations in their original order", () => {
|
||||
it("preserves repeated limitations in their original order", async () => {
|
||||
const capabilities = [
|
||||
descriptor(providerA, "procedure_info", [
|
||||
"Repeated",
|
||||
@@ -370,7 +373,7 @@ describe("binary session provider capability projection", () => {
|
||||
]),
|
||||
descriptor(providerA, "xrefs", ["Repeated", "Repeated", "right detail"]),
|
||||
];
|
||||
const status = project(capabilities, []);
|
||||
const status = await project(capabilities, []);
|
||||
|
||||
expect(status.capabilities[0]).toMatchObject({
|
||||
shared_limitations: ["Repeated", "Repeated"],
|
||||
|
||||
@@ -21,7 +21,7 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"
|
||||
interface BinarySessionStatusInput {
|
||||
readonly target: BinaryTarget | undefined;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly router: SessionProviderRouter;
|
||||
readonly router: Pick<SessionProviderRouter, "candidateStatuses">;
|
||||
readonly runtimeUnavailability: ReadonlyMap<
|
||||
string,
|
||||
{ readonly reason: string }
|
||||
@@ -31,10 +31,11 @@ interface BinarySessionStatusInput {
|
||||
readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[];
|
||||
readonly providerOperationHealth?: ProviderOperationHealth | null;
|
||||
readonly cleanupPending?: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Project internal provider routing state into the caller-visible session status. */
|
||||
export const binarySessionStatus = ({
|
||||
export const binarySessionStatus = async ({
|
||||
target,
|
||||
route,
|
||||
router,
|
||||
@@ -44,7 +45,8 @@ export const binarySessionStatus = ({
|
||||
requestActivitySnapshots,
|
||||
providerOperationHealth,
|
||||
cleanupPending = false,
|
||||
}: BinarySessionStatusInput): JsonValue => {
|
||||
signal,
|
||||
}: BinarySessionStatusInput): Promise<JsonValue> => {
|
||||
const routeCapabilities = [...route.capabilities.values()]
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) =>
|
||||
@@ -69,22 +71,22 @@ export const binarySessionStatus = ({
|
||||
selection_source: route.binding.selectionSource,
|
||||
analysis_profile: structuredClone(route.binding.profile),
|
||||
};
|
||||
const analysisProviderCandidates = router
|
||||
.candidateStatuses(route)
|
||||
.map((candidate) => ({
|
||||
provider: providerSummary(candidate.provider),
|
||||
availability: structuredClone(candidate.availability),
|
||||
target_support: structuredClone(candidate.targetSupport),
|
||||
selected: candidate.selected,
|
||||
capabilities: providerCapabilitySets(
|
||||
candidate.capabilities
|
||||
.slice()
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
),
|
||||
),
|
||||
}));
|
||||
const analysisProviderCandidates = (
|
||||
await router.candidateStatuses(route, signal)
|
||||
).map((candidate) => ({
|
||||
provider: providerSummary(candidate.provider),
|
||||
availability: structuredClone(candidate.availability),
|
||||
target_support: structuredClone(candidate.targetSupport),
|
||||
selected: candidate.selected,
|
||||
capabilities: providerCapabilitySets(
|
||||
candidate.capabilities
|
||||
.slice()
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.operation, right.operation),
|
||||
),
|
||||
),
|
||||
}));
|
||||
const common = {
|
||||
cleanup_pending: cleanupPending,
|
||||
capabilities,
|
||||
|
||||
@@ -78,6 +78,26 @@ const executableTarget = {
|
||||
availableArchitectures: ["x86_64"],
|
||||
} as const;
|
||||
|
||||
it("does not create a previously unused child after the composite closes", async () => {
|
||||
const child = failingProvider("provider-only", "address_name", "one-db");
|
||||
const create = vi.spyOn(child.provider, "createClient");
|
||||
const client = new CompositeProvider([child.provider]).createClient(
|
||||
executableTarget,
|
||||
);
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(client.reuseReadiness?.()).toBe("blocked");
|
||||
expect(await client.executeIfReady?.("address_name", {})).toBeUndefined();
|
||||
expect(await client.execute("address_name", {})).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { reason: "client_closed" },
|
||||
},
|
||||
});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(child.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains every failed child resource across composite cleanup retries", async () => {
|
||||
const left = failingProvider("provider-left", "address_name", "left-db");
|
||||
const right = failingProvider("provider-right", "procedure_info", "right-db");
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Synthetic identity for a deterministic provider set. */
|
||||
@@ -83,6 +83,10 @@ const closeCompositeClients = (
|
||||
"Composed provider cleanup failed.",
|
||||
),
|
||||
},
|
||||
).bindCleanupDependencies(
|
||||
failures
|
||||
.map(({ error }) => error)
|
||||
.filter((error) => error.cleanupIncomplete),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -181,18 +185,29 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
clients.set(provider, created);
|
||||
return created;
|
||||
};
|
||||
const close = closeCompositeClients(this.#identity, clients);
|
||||
let closed = false;
|
||||
const closeClients = closeCompositeClients(this.#identity, clients);
|
||||
const close: AnalysisClient["close"] = (options) => {
|
||||
closed = true;
|
||||
return closeClients(options);
|
||||
};
|
||||
return {
|
||||
executeIfReady: (operation, parameters, options) => {
|
||||
const provider = this.#providerByOperation.get(operation);
|
||||
const client =
|
||||
provider === undefined ? undefined : clients.get(provider);
|
||||
provider === undefined || closed ? undefined : clients.get(provider);
|
||||
return (
|
||||
client?.executeIfReady?.(operation, parameters, options) ??
|
||||
Promise.resolve(undefined)
|
||||
);
|
||||
},
|
||||
execute: async (operation, parameters, options) => {
|
||||
if (closed)
|
||||
return err(
|
||||
new ProviderAdapterError(this.#identity.id, operation, {
|
||||
diagnostics: { reason: "client_closed" },
|
||||
}),
|
||||
);
|
||||
if (operation === "health")
|
||||
return executeCompositeHealth(this.#identity, clients, options);
|
||||
const provider = this.#providerByOperation.get(operation);
|
||||
@@ -220,6 +235,16 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.provider.id, right.provider.id),
|
||||
),
|
||||
reuseReadiness: () => {
|
||||
if (closed) return "blocked";
|
||||
const states = [...clients.values()].map(
|
||||
(client) => client.reuseReadiness?.() ?? "unknown",
|
||||
);
|
||||
if (states.some((state) => state === "blocked")) return "blocked";
|
||||
return states.some((state) => state === "unknown")
|
||||
? "unknown"
|
||||
: "ready";
|
||||
},
|
||||
operationHealthSnapshot: () => {
|
||||
const snapshots = [...clients.values()].map((client) =>
|
||||
client.operationHealthSnapshot?.(),
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { DeferredAnalysisClient } from "../../providers/DeferredAnalysisClient.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { resolvedAnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
|
||||
const identity = { id: "controlled", name: "Controlled client", version: "1" };
|
||||
|
||||
it.each([
|
||||
new AnalysisAccessDeniedError(
|
||||
"ghidra:installation",
|
||||
"/selected/properties",
|
||||
"EACCES",
|
||||
),
|
||||
new AnalysisArtifactChangedError(
|
||||
"ghidra:installation",
|
||||
"/selected/properties",
|
||||
"selected name changed",
|
||||
),
|
||||
new AnalysisResourceConstraintError(
|
||||
"ghidra:installation",
|
||||
"file-size",
|
||||
"metadata budget",
|
||||
{ maximum_bytes: 1024 },
|
||||
{ retryable: false },
|
||||
),
|
||||
])(
|
||||
"attributes prerequisite $_tag to the requested operation without changing its facts",
|
||||
async (physical) => {
|
||||
const client = new DeferredAnalysisClient(async () => {
|
||||
throw physical;
|
||||
}, identity.id);
|
||||
try {
|
||||
const result = await client.execute("analyze_function", {});
|
||||
if (result.ok) throw new Error("Expected prerequisite failure");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: physical._tag,
|
||||
operation: "analyze_function",
|
||||
});
|
||||
expect(result.error.cause).toBe(physical);
|
||||
if (physical instanceof AnalysisResourceConstraintError)
|
||||
expect(result.error).toMatchObject({
|
||||
resource: "file-size",
|
||||
reportedLimits: { maximum_bytes: 1024 },
|
||||
retryable: false,
|
||||
});
|
||||
else expect(result.error).toMatchObject({ path: "/selected/properties" });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
const gate = <T = void>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((finish) => {
|
||||
resolve = finish;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
const child = (
|
||||
close: AnalysisClient["close"] = async () => ok(null),
|
||||
): AnalysisClient => ({
|
||||
execute: async () => ok(createAnalysisExecution(null, identity)),
|
||||
close,
|
||||
operationHealthSnapshot: () => ({
|
||||
state: "idle",
|
||||
stage: null,
|
||||
retryAction: null,
|
||||
exitCode: null,
|
||||
requests: [],
|
||||
}),
|
||||
});
|
||||
|
||||
it("refuses dispatch when close starts after a ready client was acquired", async () => {
|
||||
let operations = 0;
|
||||
const acquired: AnalysisClient = {
|
||||
...child(),
|
||||
execute: async () => {
|
||||
operations++;
|
||||
return ok(createAnalysisExecution(null, identity));
|
||||
},
|
||||
};
|
||||
const client = new DeferredAnalysisClient(async () => acquired, identity.id);
|
||||
expect((await client.execute("health", {})).ok).toBe(true);
|
||||
const pending = client.execute("analyze_function", {});
|
||||
const closing = client.close();
|
||||
try {
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
operation: "analyze_function",
|
||||
diagnostics: { reason: "client_closed" },
|
||||
},
|
||||
});
|
||||
expect(operations).toBe(1);
|
||||
expect(
|
||||
await client.executeIfReady?.("analyze_function", {}),
|
||||
).toBeUndefined();
|
||||
expect(await closing).toEqual(ok(null));
|
||||
} finally {
|
||||
await pending;
|
||||
await closing;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
"caller reason",
|
||||
new ProviderCleanupError("foreign", ["foreign resource"], {
|
||||
reason: "caller metadata",
|
||||
}),
|
||||
])(
|
||||
"does not acquire or adopt resources for pre-aborted opaque reasons (%s)",
|
||||
async (reason) => {
|
||||
let creates = 0;
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
const client = new DeferredAnalysisClient(async () => {
|
||||
creates++;
|
||||
return child();
|
||||
}, identity.id);
|
||||
const result = await client.execute(
|
||||
"health",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.cleanupResources).toEqual([]);
|
||||
expect(creates).toBe(0);
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a shared creation alive for an interested peer", async () => {
|
||||
const entered = gate(),
|
||||
loading = gate<AnalysisClient>();
|
||||
let observed: AbortSignal | undefined,
|
||||
creates = 0,
|
||||
closes = 0;
|
||||
const client = new DeferredAnalysisClient(async (signal) => {
|
||||
creates++;
|
||||
observed = signal;
|
||||
entered.resolve();
|
||||
return loading.promise;
|
||||
}, identity.id);
|
||||
const controller = new AbortController(),
|
||||
reason = { request: "one peer leaves" };
|
||||
const first = client.execute("health", {}, { signal: controller.signal });
|
||||
const second = client.execute("health", {});
|
||||
try {
|
||||
await entered.promise;
|
||||
controller.abort(reason);
|
||||
const cancelled = await first;
|
||||
if (cancelled.ok) throw new Error("Expected peer cancellation");
|
||||
expect(cancelled.error.cause).toBe(reason);
|
||||
expect(observed?.aborted).toBe(false);
|
||||
loading.resolve(
|
||||
child(async () => {
|
||||
closes++;
|
||||
return ok(null);
|
||||
}),
|
||||
);
|
||||
expect((await second).ok).toBe(true);
|
||||
expect(creates).toBe(1);
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(closes).toBe(1);
|
||||
} finally {
|
||||
loading.resolve(child());
|
||||
await Promise.allSettled([first, second]);
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("joins a late acquired client's final cleanup before cancellation settles", async () => {
|
||||
const entered = gate(),
|
||||
loading = gate<AnalysisClient>(),
|
||||
closeEntered = gate(),
|
||||
closing = gate();
|
||||
let closes = 0,
|
||||
settled = false;
|
||||
const client = new DeferredAnalysisClient(async () => {
|
||||
entered.resolve();
|
||||
return loading.promise;
|
||||
}, identity.id);
|
||||
const controller = new AbortController(),
|
||||
reason = { request: "late acquisition" };
|
||||
const pending = client
|
||||
.execute("analyze_function", {}, { signal: controller.signal })
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await entered.promise;
|
||||
controller.abort(reason);
|
||||
loading.resolve(
|
||||
child(async () => {
|
||||
closes++;
|
||||
closeEntered.resolve();
|
||||
await closing.promise;
|
||||
return ok(null);
|
||||
}),
|
||||
);
|
||||
await closeEntered.promise;
|
||||
expect(settled).toBe(false);
|
||||
expect(client.operationHealthSnapshot()?.state).toBe("idle");
|
||||
expect(client.reuseReadiness()).toBe("blocked");
|
||||
closing.resolve();
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(closes).toBe(1);
|
||||
} finally {
|
||||
loading.resolve(child());
|
||||
closing.resolve();
|
||||
await pending;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a producer-authored physical failure primary even when it is the caller's abort reason", async () => {
|
||||
const entered = gate(),
|
||||
finishing = gate();
|
||||
const physical = new ProviderAdapterError(
|
||||
identity.id,
|
||||
"create_analysis_client",
|
||||
{ diagnostics: { reason: "physical fixture fault" } },
|
||||
);
|
||||
const client = new DeferredAnalysisClient(async () => {
|
||||
entered.resolve();
|
||||
await finishing.promise;
|
||||
throw physical;
|
||||
}, identity.id);
|
||||
const controller = new AbortController();
|
||||
const pending = client.execute(
|
||||
"analyze_function",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
try {
|
||||
await entered.promise;
|
||||
controller.abort(physical);
|
||||
finishing.resolve();
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("Expected physical failure");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
operation: "analyze_function",
|
||||
diagnostics: {
|
||||
reason: "physical fixture fault",
|
||||
},
|
||||
});
|
||||
expect(result.error.cause).toBe(physical);
|
||||
expect(physical.cause).toBeUndefined();
|
||||
expect(projectAnalysisError(result.error).code).toBe("execution_failure");
|
||||
} finally {
|
||||
finishing.resolve();
|
||||
await pending;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a failed late client's exact close owner and restores cancellation after confirmed retry", async () => {
|
||||
let denied = true,
|
||||
closes = 0;
|
||||
const controller = new AbortController(),
|
||||
reason = { request: "late cleanup refusal" };
|
||||
const client = new DeferredAnalysisClient(async () => {
|
||||
controller.abort(reason);
|
||||
return child(async () => {
|
||||
closes++;
|
||||
return denied
|
||||
? err(
|
||||
new ProviderCleanupError(identity.id, ["controlled child"], {
|
||||
reason: "controlled close refusal",
|
||||
}),
|
||||
)
|
||||
: ok(null);
|
||||
});
|
||||
}, identity.id);
|
||||
const result = await client.execute(
|
||||
"analyze_function",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation with cleanup");
|
||||
try {
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toEqual(["controlled child"]);
|
||||
expect(client.reuseReadiness()).toBe("blocked");
|
||||
const firstClose = await client.close();
|
||||
if (firstClose.ok) throw new Error("Expected retained close failure");
|
||||
expect(firstClose.error.cleanupIncomplete).toBe(true);
|
||||
expect(closes).toBe(2);
|
||||
denied = false;
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(firstClose.error.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||
expect(resolvedAnalysisError(result.error).cause).toBe(reason);
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(closes).toBe(3);
|
||||
} finally {
|
||||
denied = false;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a failed prerequisite owner without creating a native client", async () => {
|
||||
let denied = true,
|
||||
releases = 0;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["controlled prerequisite"],
|
||||
{ reason: "controlled refusal" },
|
||||
);
|
||||
const reason = { request: "prerequisite finalization" };
|
||||
const client = new DeferredAnalysisClient(async (_signal, resources) => {
|
||||
resources.retain(
|
||||
{
|
||||
close: async () => {
|
||||
releases++;
|
||||
if (denied) throw cleanup;
|
||||
cleanup.resolveCleanup();
|
||||
},
|
||||
},
|
||||
"controlled prerequisite",
|
||||
);
|
||||
throw new AnalysisCancelledError("create_analysis_client", {
|
||||
cause: reason,
|
||||
cleanup: {
|
||||
reason: "controlled refusal",
|
||||
resources: cleanup.cleanupResources,
|
||||
},
|
||||
}).bindCleanupDependencies([cleanup]);
|
||||
}, identity.id);
|
||||
const result = await client.execute("analyze_function", {});
|
||||
if (result.ok) throw new Error("Expected prerequisite failure");
|
||||
try {
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
denied = false;
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||
expect(resolvedAnalysisError(result.error).cause).toBe(reason);
|
||||
expect(releases).toBe(3);
|
||||
} finally {
|
||||
denied = false;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries only unresolved prerequisite and native owners across mixed cleanup outcomes", async () => {
|
||||
let denyPrerequisite = true,
|
||||
denyChild = true,
|
||||
prerequisites = 0,
|
||||
closes = 0;
|
||||
const prerequisiteFailure = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["mixed prerequisite"],
|
||||
{ reason: "controlled prerequisite refusal" },
|
||||
);
|
||||
const client = new DeferredAnalysisClient(async (_signal, resources) => {
|
||||
resources.retain(
|
||||
{
|
||||
close: async () => {
|
||||
prerequisites++;
|
||||
if (denyPrerequisite) throw prerequisiteFailure;
|
||||
prerequisiteFailure.resolveCleanup();
|
||||
},
|
||||
},
|
||||
"mixed prerequisite",
|
||||
);
|
||||
return child(async () => {
|
||||
closes++;
|
||||
return denyChild
|
||||
? err(
|
||||
new ProviderCleanupError(identity.id, ["mixed child"], {
|
||||
reason: "controlled child refusal",
|
||||
}),
|
||||
)
|
||||
: ok(null);
|
||||
});
|
||||
}, identity.id);
|
||||
const result = await client.execute("analyze_function", {});
|
||||
if (result.ok)
|
||||
throw new Error("Expected mixed initialization cleanup failure");
|
||||
try {
|
||||
expect(result.error.cleanupResources).toEqual(
|
||||
expect.arrayContaining(["mixed prerequisite", "mixed child"]),
|
||||
);
|
||||
expect(client.reuseReadiness()).toBe("blocked");
|
||||
denyPrerequisite = false;
|
||||
const first = await client.close();
|
||||
expect(first.ok).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toEqual(["mixed child"]);
|
||||
expect(prerequisites).toBe(2);
|
||||
denyChild = false;
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(prerequisites).toBe(2);
|
||||
expect(closes).toBe(3);
|
||||
} finally {
|
||||
denyPrerequisite = false;
|
||||
denyChild = false;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
@@ -5,9 +5,10 @@ import type {
|
||||
AnalysisProvider,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok as resultOk } from "../../domain/result.js";
|
||||
import { ok as resultOk } from "../../domain/result.js";
|
||||
|
||||
const identity: ProviderIdentity = {
|
||||
id: "test-provider",
|
||||
@@ -23,7 +24,7 @@ const providerWithClient = (
|
||||
createClient: () => {
|
||||
onCreate();
|
||||
return {
|
||||
execute: async (operation) => err(new AnalysisCancelledError(operation)),
|
||||
execute: async () => resultOk(createAnalysisExecution(null, identity)),
|
||||
close: async () => resultOk(null),
|
||||
};
|
||||
},
|
||||
@@ -58,34 +59,57 @@ it("returns a tagged provider error when lazy loading fails", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancellation while an unabortable lazy load continues safely", async () => {
|
||||
let finishLoading: ((provider: AnalysisProvider) => void) | undefined;
|
||||
let markCreated: (() => void) | undefined;
|
||||
const created = new Promise<void>((resolve) => {
|
||||
markCreated = resolve;
|
||||
it("joins a cancelled unabortable module load and refuses late client acquisition", async () => {
|
||||
let finishLoading!: (provider: AnalysisProvider) => void;
|
||||
let enter!: () => void;
|
||||
let creates = 0;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const provider = new LazyAnalysisProvider({
|
||||
identity,
|
||||
capabilities: [],
|
||||
load: () =>
|
||||
new Promise<AnalysisProvider>((resolve) => {
|
||||
load: () => {
|
||||
enter();
|
||||
return new Promise((resolve) => {
|
||||
finishLoading = resolve;
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel during import" };
|
||||
const client = provider.createClient(await target());
|
||||
const pending = client.execute("health", {}, { signal: controller.signal });
|
||||
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "health" },
|
||||
});
|
||||
|
||||
finishLoading?.(providerWithClient(() => markCreated?.()));
|
||||
await created;
|
||||
await client.close();
|
||||
let settled = false;
|
||||
const pending = client
|
||||
.execute("health", {}, { signal: controller.signal })
|
||||
.then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await entered;
|
||||
controller.abort(reason);
|
||||
try {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishLoading(
|
||||
providerWithClient(() => {
|
||||
creates++;
|
||||
}),
|
||||
);
|
||||
const result = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "health" },
|
||||
});
|
||||
if (result.ok) throw new Error("Cancellation unexpectedly succeeded");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(creates).toBe(0);
|
||||
expect(await client.close()).toEqual(resultOk(null));
|
||||
} finally {
|
||||
finishLoading(providerWithClient());
|
||||
await pending;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports that a lazy client was closed before its first operation", async () => {
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js";
|
||||
import { DeferredAnalysisClient } from "../../providers/DeferredAnalysisClient.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
ProviderRequestActivitySnapshot,
|
||||
ProviderRuntimeLineageSnapshot,
|
||||
} from "../AnalysisProvider.js";
|
||||
|
||||
type LoadAnalysisProvider = () => Promise<AnalysisProvider>;
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
/**
|
||||
* Preserve synchronous provider discovery while loading implementation modules
|
||||
* only when a target-bound client first executes.
|
||||
@@ -57,8 +48,12 @@ export class LazyAnalysisProvider implements AnalysisProvider {
|
||||
profile?: AnalysisProfileCommitment,
|
||||
context?: AnalysisClientContext,
|
||||
): AnalysisClient {
|
||||
return new LazyAnalysisClient(async () => {
|
||||
return new DeferredAnalysisClient(async (signal) => {
|
||||
const provider = await this.#load();
|
||||
if (signal.aborted)
|
||||
throw new AnalysisCancelledError("create_analysis_client", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
return provider.createClient(target, profile, context);
|
||||
}, this.#providerIdentity.id);
|
||||
}
|
||||
@@ -71,84 +66,3 @@ export class LazyAnalysisProvider implements AnalysisProvider {
|
||||
return this.#provider;
|
||||
}
|
||||
}
|
||||
|
||||
class LazyAnalysisClient implements AnalysisClient {
|
||||
readonly #loadClient: () => Promise<AnalysisClient>;
|
||||
readonly #providerId: string;
|
||||
#client: AnalysisClient | undefined;
|
||||
#loading: Promise<AnalysisClient> | undefined;
|
||||
#closed = false;
|
||||
|
||||
executeIfReady: NonNullable<AnalysisClient["executeIfReady"]> = (
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) =>
|
||||
this.#client?.executeIfReady?.(operation, parameters, options) ??
|
||||
Promise.resolve(undefined);
|
||||
|
||||
constructor(loadClient: () => Promise<AnalysisClient>, providerId: string) {
|
||||
this.#loadClient = loadClient;
|
||||
this.#providerId = providerId;
|
||||
}
|
||||
|
||||
execute: AnalysisClient["execute"] = async (
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) => {
|
||||
try {
|
||||
if (isAborted(options?.signal))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const loaded = await waitForAbortable(
|
||||
this.#load(operation),
|
||||
options?.signal,
|
||||
);
|
||||
if (loaded === ABORTED) return err(new AnalysisCancelledError(operation));
|
||||
return await loaded.execute(operation, parameters, options);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new ProviderAdapterError(this.#providerId, operation, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
runtimeLineageSnapshots(): readonly ProviderRuntimeLineageSnapshot[] {
|
||||
return this.#client?.runtimeLineageSnapshots?.() ?? [];
|
||||
}
|
||||
|
||||
requestActivitySnapshots(): readonly ProviderRequestActivitySnapshot[] {
|
||||
return this.#client?.requestActivitySnapshots?.() ?? [];
|
||||
}
|
||||
|
||||
operationHealthSnapshot(): ProviderOperationHealth | undefined {
|
||||
return this.#client?.operationHealthSnapshot?.();
|
||||
}
|
||||
|
||||
close: AnalysisClient["close"] = async (options) => {
|
||||
this.#closed = true;
|
||||
if (this.#loading === undefined) return ok(null);
|
||||
const client = await this.#loading;
|
||||
return client.close(options);
|
||||
};
|
||||
|
||||
async #load(operation: string): Promise<AnalysisClient> {
|
||||
if (this.#closed)
|
||||
throw new ProviderAdapterError(this.#providerId, operation, {
|
||||
diagnostics: { reason: "client_closed" },
|
||||
});
|
||||
this.#loading ??= this.#loadClient()
|
||||
.then((client) => {
|
||||
this.#client = client;
|
||||
return client;
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
this.#loading = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#loading;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -106,10 +98,17 @@ export class SessionProviderRouter {
|
||||
}
|
||||
|
||||
/** Candidate status authoritative for the active or target-free route. */
|
||||
candidateStatuses(
|
||||
async candidateStatuses(
|
||||
route: SessionProviderRoute,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
return route.selection?.candidates ?? this.registry.candidates();
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly AnalysisProviderCandidateStatus[]> {
|
||||
return (
|
||||
route.selection?.candidates ??
|
||||
this.registry.candidates(
|
||||
undefined,
|
||||
signal === undefined ? {} : { signal },
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Produce exact candidate rejections for an intentionally unbound route. */
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import type { InspectEvmInterfaceInput } from "../../domain/evm/evmInterface.js";
|
||||
import type { EvmInterfaceExecution } from "./EvmInterfacePort.js";
|
||||
|
||||
/** Preserve the same validated inference and provenance on success and failed finalization. */
|
||||
export const evmInterfaceEvidence = (
|
||||
input: InspectEvmInterfaceInput,
|
||||
execution: EvmInterfaceExecution,
|
||||
) =>
|
||||
createEvidence(execution.subject, execution.provider, {
|
||||
operation: "inspect_evm_interface",
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
...(execution.rawResult === null ? {} : { rawResult: execution.rawResult }),
|
||||
confidence: "inferred",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
});
|
||||
@@ -139,3 +139,22 @@ it("keeps malformed interface output ahead of a late cancellation", async () =>
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it.each([null, "caller stopped", { requested: "stop" }])(
|
||||
"preserves the exact admission reason without invoking the provider: %j",
|
||||
async (reason) => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
const service = new EvmInterfaceService({
|
||||
inspect: () => {
|
||||
throw new Error("must not start");
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: "/selected.hex", encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
},
|
||||
);
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { evmInterfaceEvidence } from "./EvmInterfaceEvidence.js";
|
||||
import {
|
||||
inspectEvmInterfaceInputSchema,
|
||||
evmInterfaceSchema,
|
||||
@@ -36,7 +37,9 @@ export class EvmInterfaceService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const input = inspectEvmInterfaceInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
@@ -78,17 +81,7 @@ export class EvmInterfaceService {
|
||||
),
|
||||
);
|
||||
return completeProviderEvidence(
|
||||
createEvidence(execution.subject, execution.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
...(execution.rawResult === null
|
||||
? {}
|
||||
: { rawResult: execution.rawResult }),
|
||||
confidence: "inferred",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
evmInterfaceEvidence(input.data, execution),
|
||||
options?.signal,
|
||||
outputOptions?.capturedOutput,
|
||||
);
|
||||
|
||||
@@ -170,6 +170,22 @@ export interface AnalyzedJavaScriptArtifactFile<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A JavaScript source selected for analysis whose worker produced no facts.
|
||||
* The analysis failure carries the reason; the null parse status alone reads
|
||||
* as "nothing there" instead of "not analyzed".
|
||||
*/
|
||||
export const isSkippedJavaScriptAnalysis = (
|
||||
entry: Pick<
|
||||
AnalyzedJavaScriptArtifactFile,
|
||||
"file" | "javascript" | "analysis_failure"
|
||||
>,
|
||||
): boolean =>
|
||||
entry.file.kind === "javascript" &&
|
||||
entry.file.text.included &&
|
||||
entry.javascript === null &&
|
||||
entry.analysis_failure !== undefined;
|
||||
|
||||
/** Complete static-analysis projection before graph construction. */
|
||||
export interface JavaScriptArtifactAnalysis<
|
||||
SemanticIr extends JavaScriptModuleSemanticIr = JavaScriptSemanticIr,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
|
||||
import type {
|
||||
AnalyzedJavaScriptArtifactFile,
|
||||
JavaScriptModuleArtifactAnalysis,
|
||||
} from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
||||
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
|
||||
import {
|
||||
addJavaScriptArtifactFiles,
|
||||
createJavaScriptArtifactRootNode,
|
||||
} from "./JavaScriptArtifactGraphStructure.js";
|
||||
|
||||
const SHA256 = "a".repeat(64);
|
||||
const REASON =
|
||||
"main.js: Resource constraint during analyze_javascript_application: Owned JavaScript worker exhausted its heap while analyzing main.js during static";
|
||||
|
||||
const skippedFile = (): JavaScriptArtifactFile => ({
|
||||
path: "main.js",
|
||||
container_sha256: SHA256,
|
||||
sha256: SHA256,
|
||||
bytes: 5000107,
|
||||
inventory_artifact_id: `art_${SHA256}`,
|
||||
kind: "javascript",
|
||||
unpacked: false,
|
||||
text: { included: true, value: "const {ipcMain}=require('electron');\n" },
|
||||
});
|
||||
|
||||
const skippedEntry = (
|
||||
file: JavaScriptArtifactFile,
|
||||
): AnalyzedJavaScriptArtifactFile => ({
|
||||
file,
|
||||
javascript: null,
|
||||
semantic: null,
|
||||
analysis_failure: {
|
||||
scope: javascriptArtifactAnalysisScopes["javascript"],
|
||||
status: "interrupted",
|
||||
reason: REASON,
|
||||
error: { code: "resource_constraint" },
|
||||
limits: null,
|
||||
},
|
||||
});
|
||||
|
||||
const analysisFor = (
|
||||
entries: readonly AnalyzedJavaScriptArtifactFile[],
|
||||
): JavaScriptModuleArtifactAnalysis => ({
|
||||
files: entries,
|
||||
packages: [],
|
||||
json_modules: [],
|
||||
html_scripts: [],
|
||||
html_inline_scripts: [],
|
||||
source_maps: [],
|
||||
visited_ast_nodes: 0,
|
||||
findings: 0,
|
||||
modules: 0,
|
||||
parse_failures: 0,
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
const contextFor = (
|
||||
file: JavaScriptArtifactFile,
|
||||
analysis: JavaScriptModuleArtifactAnalysis,
|
||||
): JavaScriptArtifactGraphContext => {
|
||||
const accumulator = new JavaScriptArtifactGraphAccumulator();
|
||||
const snapshot = {
|
||||
manifest: {
|
||||
root_artifact_id: `art_${SHA256}`,
|
||||
root_sha256: SHA256,
|
||||
root_format: "directory",
|
||||
manifest_id: `agm_${SHA256}`,
|
||||
graph_sha256: SHA256,
|
||||
},
|
||||
nodes: [{ artifact_id: `art_${SHA256}`, sha256: SHA256, size: 1 }],
|
||||
} as unknown as ArtifactInventorySnapshot;
|
||||
const fileSet = {
|
||||
files: [file],
|
||||
unavailable_relevant_files: [],
|
||||
containers: [],
|
||||
text_bytes_read: file.bytes,
|
||||
invalid_utf8_files: 0,
|
||||
};
|
||||
return {
|
||||
accumulator,
|
||||
snapshot,
|
||||
fileSet,
|
||||
analysis,
|
||||
directoryPaths: new Set<string>(),
|
||||
root: createJavaScriptArtifactRootNode(accumulator, snapshot),
|
||||
filesByPath: new Map([[file.path, file]]),
|
||||
unanalyzedFiles: undefined,
|
||||
fileNodes: new Map(),
|
||||
assetNodes: new Map(),
|
||||
sourceModuleNodes: new Map(),
|
||||
chunkNodes: new Map(),
|
||||
moduleNodes: new Map(),
|
||||
containerNodes: new Map(),
|
||||
};
|
||||
};
|
||||
|
||||
const fileObservation = (context: JavaScriptArtifactGraphContext) => {
|
||||
const node = context.accumulator
|
||||
.nodes()
|
||||
.find(({ kind }) => kind === "javascript-asset");
|
||||
if (node === undefined) throw new Error("Expected a javascript-asset node");
|
||||
const observation = node.observations[0];
|
||||
if (observation === undefined) throw new Error("Expected a file observation");
|
||||
return observation.properties;
|
||||
};
|
||||
|
||||
describe("skipped JavaScript parse reporting", () => {
|
||||
it("marks parse_status skipped for a JavaScript file whose worker analysis failed", () => {
|
||||
const file = skippedFile();
|
||||
const context = contextFor(file, analysisFor([skippedEntry(file)]));
|
||||
addJavaScriptArtifactFiles(context);
|
||||
expect(fileObservation(context).parse_status).toBe("skipped");
|
||||
});
|
||||
|
||||
it("keeps parse_status null for a JavaScript file never attempted", () => {
|
||||
const file = skippedFile();
|
||||
const entry: AnalyzedJavaScriptArtifactFile = {
|
||||
file,
|
||||
javascript: null,
|
||||
semantic: null,
|
||||
};
|
||||
const context = contextFor(file, analysisFor([entry]));
|
||||
addJavaScriptArtifactFiles(context);
|
||||
expect(fileObservation(context).parse_status).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the parsed status for an analyzed JavaScript file", () => {
|
||||
const file = skippedFile();
|
||||
const javascript = analyzeJavaScriptStaticSource(
|
||||
"const {ipcMain}=require('electron');\n",
|
||||
);
|
||||
const entry: AnalyzedJavaScriptArtifactFile = {
|
||||
file,
|
||||
javascript,
|
||||
semantic: null,
|
||||
};
|
||||
const context = contextFor(file, analysisFor([entry]));
|
||||
addJavaScriptArtifactFiles(context);
|
||||
expect(fileObservation(context).parse_status).toBe(javascript.parse_status);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
JavaScriptArtifactFile,
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
||||
import { isSkippedJavaScriptAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import {
|
||||
addArtifactContainsEdge,
|
||||
addUnavailableStaticParseScope,
|
||||
@@ -145,6 +146,7 @@ export const addJavaScriptArtifactFiles = (
|
||||
file,
|
||||
analyzed.javascript,
|
||||
jsonValue,
|
||||
isSkippedJavaScriptAnalysis(analyzed),
|
||||
);
|
||||
if (
|
||||
analyzed.application_projection_failure !== undefined &&
|
||||
@@ -553,6 +555,7 @@ const createFileTarget = (
|
||||
file: JavaScriptArtifactFile,
|
||||
javascript: JavaScriptModuleArtifactAnalysis["files"][number]["javascript"],
|
||||
json: JavaScriptModuleArtifactAnalysis["json_modules"][number] | undefined,
|
||||
skipped: boolean,
|
||||
): ApplicationNode => {
|
||||
const kind = artifactFileNodeKind(file.kind);
|
||||
return context.accumulator.addNode({
|
||||
@@ -572,7 +575,8 @@ const createFileTarget = (
|
||||
unpacked: file.unpacked,
|
||||
file_kind: file.kind,
|
||||
text_status: file.text.included ? "included" : file.text.reason,
|
||||
parse_status: javascript?.parse_status ?? null,
|
||||
parse_status:
|
||||
javascript?.parse_status ?? (skipped ? "skipped" : null),
|
||||
json_parse_status: json?.status ?? null,
|
||||
json_top_level_keys: json?.top_level_keys ?? [],
|
||||
omitted_json_top_level_keys: json?.omitted_top_level_keys ?? 0,
|
||||
|
||||
@@ -20,7 +20,10 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
|
||||
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
|
||||
import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import {
|
||||
isSkippedJavaScriptAnalysis,
|
||||
javascriptArtifactAnalysisScopes,
|
||||
} from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { createJavaScriptSourceAnalysis } from "../../artifacts/javascript/JavaScriptSourceAnalysis.js";
|
||||
import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
|
||||
import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js";
|
||||
@@ -57,6 +60,7 @@ export interface JavaScriptArtifactReconstructionResult {
|
||||
readonly text_bytes_read: number;
|
||||
readonly invalid_utf8_files: number;
|
||||
readonly parsed_javascript_files: number;
|
||||
readonly skipped_javascript_files: number;
|
||||
readonly visited_ast_nodes: number;
|
||||
readonly findings: number;
|
||||
readonly modules: number;
|
||||
@@ -275,6 +279,9 @@ export const reconstructJavaScriptArtifact = async (
|
||||
parsed_javascript_files: analysis.files.filter(
|
||||
({ javascript }) => javascript !== null,
|
||||
).length,
|
||||
skipped_javascript_files: analysis.files.filter(
|
||||
isSkippedJavaScriptAnalysis,
|
||||
).length,
|
||||
visited_ast_nodes: analysis.visited_ast_nodes,
|
||||
findings: analysis.findings,
|
||||
modules: analysis.modules,
|
||||
|
||||
@@ -21,7 +21,9 @@ interface ChildUnknownInput {
|
||||
| "forwards-signal"
|
||||
| "listens-exit"
|
||||
| "listens-error"
|
||||
| "connects-stdio"
|
||||
)[];
|
||||
readonly reason?: "unknown-value";
|
||||
readonly detail: string;
|
||||
readonly candidateNodeIds: readonly string[];
|
||||
}
|
||||
@@ -113,6 +115,16 @@ const projectSpawn = (
|
||||
relation: "connects-stdio",
|
||||
resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved",
|
||||
});
|
||||
if (spawn.stdioMode === "dynamic")
|
||||
addChildUnknown({
|
||||
context,
|
||||
node: child,
|
||||
location: spawn.location,
|
||||
relationKinds: ["connects-stdio"],
|
||||
reason: "unknown-value",
|
||||
detail: `Static ${spawn.method} stdio options remain unresolved.`,
|
||||
candidateNodeIds: [],
|
||||
});
|
||||
if (spawn.resolution !== "complete")
|
||||
addChildUnknown({
|
||||
context,
|
||||
@@ -228,7 +240,7 @@ const addChildUnknown = (input: ChildUnknownInput): void => {
|
||||
node_id: node?.node_id ?? null,
|
||||
family: "child-process",
|
||||
relation_kinds: relationKinds,
|
||||
reason: "ambiguous-target",
|
||||
reason: input.reason ?? "ambiguous-target",
|
||||
detail,
|
||||
candidate_node_ids: candidateNodeIds,
|
||||
evidence: unknownSemanticEvidence(context.file, location),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js";
|
||||
|
||||
it.each(
|
||||
[
|
||||
{ pattern: "{ a: selected }", source: "{ a: 1 }", path: ["a"] },
|
||||
{ pattern: "[selected]", source: "[1]", path: ["0"] },
|
||||
{
|
||||
pattern: "{ a: [selected] = [] }",
|
||||
source: "{ a: [1] }",
|
||||
path: ["a", "0"],
|
||||
},
|
||||
{
|
||||
pattern: "{ a: { b: selected } = {} }",
|
||||
source: "{ a: { b: 1 } }",
|
||||
path: ["a", "b"],
|
||||
},
|
||||
{ pattern: "[[selected] = []]", source: "[[1]]", path: ["0", "0"] },
|
||||
].flatMap((pattern) =>
|
||||
[false, true].map((assignment) => ({ ...pattern, assignment })),
|
||||
),
|
||||
)(
|
||||
"retains source-property flow for $pattern, assignment=$assignment",
|
||||
({ pattern, source, path, assignment }) => {
|
||||
const graph = graphForJavaScript(`
|
||||
const source = ${source};
|
||||
${assignment ? `let selected; (${pattern} = source);` : `const ${pattern} = source;`}
|
||||
`);
|
||||
const target = graph.nodes.find(
|
||||
(node) => node.kind === "binding" && node.label === "selected",
|
||||
);
|
||||
expect(target).toBeDefined();
|
||||
const relations = graph.relations.filter(
|
||||
(relation) =>
|
||||
relation.relation === "destructures" &&
|
||||
relation.target_node_id === target?.node_id,
|
||||
);
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0]?.resolution).toBe("resolved");
|
||||
const slot = graph.nodes.find(
|
||||
(node) => node.node_id === relations[0]?.source_node_id,
|
||||
);
|
||||
expect(slot?.properties.property_path).toEqual(path);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["{}", "unknownSource"])(
|
||||
"keeps nested default source flow conservative for %s",
|
||||
(source) => {
|
||||
const graph = graphForJavaScript(
|
||||
`const source = ${source}; const { a: [selected] = [1] } = source;`,
|
||||
);
|
||||
const target = graph.nodes.find(
|
||||
(node) => node.kind === "binding" && node.label === "selected",
|
||||
);
|
||||
const relation = graph.relations.find(
|
||||
(item) =>
|
||||
item.relation === "destructures" &&
|
||||
item.target_node_id === target?.node_id,
|
||||
);
|
||||
expect(relation).toMatchObject({ resolution: "candidate" });
|
||||
expect(graph.unknowns).toContainEqual(
|
||||
expect.objectContaining({
|
||||
family: "object-flow",
|
||||
relation_kinds: ["destructures"],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user