fix(electron): preserve native IPC listener behavior (#4389)

Preserve original-function removal, native IPC dispatch and argument identity, with explicit metadata ownership and Evidence verification. Runtime cleanup limitations remain documented.

Fixes #4390.

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
Adolanium
2026-10-16 23:17:11 +03:00
committed by GitHub
parent f22e8e7268
commit bf3475867f
11 changed files with 13167 additions and 70 deletions
+40
View File
@@ -127,6 +127,46 @@ If cleanup cannot be verified, the error retains collected observations in
`details.partial_observation` and marks their application cleanup `unverified`.
Cleanup uncertainty does not authorize signaling unrelated processes.
Main-process IPC recording keeps original-function removal for `on` and `once`
registrations and records a fired `once` callback once. Native Node.js owns
removal order and traversal, including listeners registered by removal observers;
the hook translates only its own recording wrappers at the removal boundary.
Wrapper metadata uses weak keys and does not retain removed registrations.
Wrapper metadata operations, private collection methods and traversal constructors are captured
before target startup; later replacement of their global bindings does not
change listener registration or removal.
An `on` listener's application-defined `.apply` receives the original argument-list
object unchanged. Borrowing the exposed method preserves its dynamic receiver.
Because that method owns argument interpretation and may not
invoke the callback, the hook records a bounded `ipc-observation-gap` timeline
entry with the registered channel rather than an IPC invocation. Its empty
`argument_shapes` means unobserved arguments, not a zero-argument callback;
callback execution and result remain unknown. Coverage and retention include
these gaps even when their individual records are dropped. Normal native
dispatch continues to record actual invocation frames. Evidence snapshots copy
retained arrays and counters without invoking application iterators or Array
species.
Listener-chain recognition distinguishes owned, unowned and unknown metadata.
Data links may be inherited; absence is established only after completing the
bounded prototype lookup. Exhausting either walk, encountering a getter or seeing a Proxy leaves
ownership unknown. The hook preserves native registration without adding another
recording wrapper and retains a `listener-chain` observation gap after successful
registration. This gap does not prove IPC delivery. An existing inner recording
wrapper can still report its actual invocation; otherwise callback activity
remains unobserved. Setter-only properties have a known undefined read.
Data-descriptor parsing is shared with removal normalization.
This does not establish complete JavaScript function introspection equivalence.
Caller-defined `.listener` properties can change the identity exposed by
`listeners()` and meta-events; accessors and Proxy metadata are not followed for
wrapper recognition. Re-registering a raw recording wrapper on another channel
retains the original registration's channel attribution.
Mutating `.listener` on a raw recording wrapper can also prevent removal through
application decorators; the reproduced identity conflict is tracked in
[#4513](https://github.com/morluto/rea/issues/4513).
### Target fuse prerequisites
Active scenario capture requires that the target Electron executable honors Node CLI inspector flags (`--inspect` or `--remote-debugging-port`) so that Playwright can establish its DevTools protocol session.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,592 @@
# Electron IPC listener verification, 2026-10-16
[#4390](https://github.com/morluto/rea/issues/4390), existing
[#4389](https://github.com/morluto/rea/pull/4389), umbrella
[#4027](https://github.com/morluto/rea/issues/4027).
All 73 IPC boundary regressions pass, alongside shared projection/actions and
converted MCP schema checks. Real draw.io operation phases succeed.
Every real capture still **fails overall provider cleanup**. These are distinct
results; neither completed-task acceleration nor token savings is established.
## Producing boundary and regressions
The existing PR `efd452948` was integrated with main `180ba03bf` in `5c3b02468`.
It fixes removal through the caller's original function and duplicate recording
of `once` invocations. Independent review found two further producing-boundary
problems: wrapper recognition evaluated caller `.listener` getters, and a copied
`removeAllListeners` snapshot loop differed from native live-array traversal when
removal observers prepended a listener.
Both new differential cases failed on that implementation (14 passed, two
failed). Fix `3c330f259` delegates traversal to Node, translating only owned `on`
wrappers at `removeListener`, and preserves the `off` alias. Descriptor reads
avoid evaluating getters. A further Proxy case exposed that descriptor reflection
can itself run a trap (16 passed, one failed); `2fb7f5523` uses Node's Proxy
identity first and removes the superseded catch. Those initial **17 cases** pass;
the later decorator finding and final 23-case validation are recorded below.
The native EventEmitter controls are semantic regressions, not real-app proof.
The change uses weak wrapper keys, removes the copied traversal and its snapshot
allocation, and adds no tool, cache, schema or normal reply field. Related
compiled provider/service modules and catalog metadata were byte-identical to
baseline main `180ba03bf` at the initial source freeze. This does not prove
arbitrary function introspection equivalence: custom
`.listener` properties and raw wrapper re-registration retain the limitations
documented in [Electron observation](../electron-observation.md).
## Unchanged real application and observed scenario
Official [draw.io Desktop 32.4.1](https://github.com/jgraph/drawio-desktop/releases/tag/v32.4.1)
AppImage SHA-256 is
`c687a9f1ddcaa146b78547452d264c03c2afcf125639cba5deeb65f3e9554c9e`;
both the release asset digest and published checksum file match. Its unchanged
62,941,673-byte ASAR is
`e64b9650c0b8241ca9a7d33b56386a385bceee63270cec701d3a467347b697e6`.
Complete accompanying resources were extracted; no application source was edited.
Public fuse inspection reports the official executable's Inspector fuse disabled.
No vendor Inspector attempt was made. The following trials explicitly select
generic Electron **44.7.0**, Xvfb and fresh profiles; they establish no packaged
runtime compatibility.
The real renderer initially displays an **Invalid file data** dialog. A public
CLI startup smoke and independent DOM/argv probes preserve this failure, tracked
separately in [#4494](https://github.com/morluto/rea/issues/4494). Its exact cause
is not proven. Two attempts to click a palette item without handling the dialog
timed out and were cancelled; they remain failed diagnostics. An initial helper
also incorrectly queried `application.process()` after transport closure; the
corrected helper retains the original ChildProcess handle instead.
The final scenario uses declared REA clicks to dismiss that actual error and
insert a visible palette item, then waits for rendering. Independent DOM reads
confirm SVG elements **0 → 2**, disappearance of the error and the real unsaved
changes indicator. The verifier exercises the real app callbacks and channels;
the production hook wraps their registration as described above.
The source also registers `app-load-finished` through both `once` and `on`;
recording its once callback twice adds a spurious event.
## Initial fixed-source ABBA observations
Verifier/source revision is `d02cc934c`; its hashes were recorded before all four
trials. Baseline is main `180ba03bf`. The same script, runtime, artifact and
declared actions run in the order baseline, candidate, candidate, baseline.
| Variant/run | IPC events | `app-load-finished` | SVG change | Pipeline wall s | Family CPU s | Peak cgroup bytes |
| ----------- | ---------- | ------------------- | ---------- | --------------- | ------------ | ----------------- |
| Baseline 1 | 8 | 3 | 0 → 2 | 11.084 | 6.270053 | 560,615,424 |
| Candidate 1 | 7 | 2 | 0 → 2 | 11.135 | 6.395461 | 550,383,616 |
| Candidate 2 | 7 | 2 | 0 → 2 | 11.898 | 6.907123 | 525,631,488 |
| Baseline 2 | 8 | 3 | 0 → 2 | 11.469 | 6.526674 | 558,456,832 |
Every declared action completed, every insertion oracle passed, and no verifier
deadline fired. Every provider returned `cleanup_failed`; the captures remain
partial/failed, including possible dirty-editor teardown and the known Linux
ownership gap [#4282](https://github.com/morluto/rea/issues/4282). Baseline 2 also
reports Xvfb temporary-directory cleanup failure (payload exit 5). Its directory
identity was not captured; no broad path-based deletion was attempted.
Runs are serial on CPU 10 with one-CPU quota, 8 GiB hard memory, 6 GiB early
stop, no swap and 192 tasks. Counters cover the cgroup family at payload exit
while the observer remains alive; they are **not final whole-scope counters**.
Memory includes file cache, not RSS. Some scopes still contain Xvfb at that
point; all recorded scopes were later independently confirmed inactive/dead.
External recovery does not establish provider cleanup.
OS/build caches are uncontrolled; these are first/repeat fresh-process runs,
not verified cold/warm system pairs. Two samples per variant and a cleanup-bound
failed workflow cannot establish a speedup. Token billing, peak agent context,
complete schema/call costs and actual MCP-wire parity are unmeasured. Fewer
events alone do not establish lower token cost.
Full static checks passed (two tasks, no cache hits): 89.441 s wall,
88.630288 family CPU s and 4,337,172,480 peak cgroup bytes, with no OOM.
Documentation checks also passed (6 tasks plus five README consistency cases).
The [selected JSON record](electron-ipc-listeners-2026-10-16.json) retains exact
source/input identities, failed attempts, coverage, IPC and resource receipts.
Historical diagnostic helper hashes were not captured and remain explicitly
unknown; the frozen hash is not assigned to them retrospectively.
## Reproduction
Build both pinned checkouts. Under the existing resource observer and a private
Xvfb instance, use the committed helper for each runtime root in ABBA order:
```sh
node experiments/analysis-efficiency/drawio-electron-lifecycle.mjs \
--runtime-root /absolute/pinned-checkout \
--executable /absolute/electron-44.7.0/electron \
--application /absolute/drawio/resources/app.asar \
--profile-root /absolute/temporary-profiles --dismiss-error \
--selector '.geSidebar a.geItem:visible >> nth=0'
```
The helper returns failure on incomplete provider cleanup. This shared-service
probe does not establish successful public CLI/MCP workflows. App images,
extractions/profiles and full Result files are regenerable and excluded from Git.
## Review follow-up: normalization before app decorators
Fresh review of `f3bb9b9ed` found that translation inside a patched
`removeListener` happened after an application's outer decorator had already
seen the recording wrapper. Two native controls reproduced this with stable and
replaced decorators (17 passed, two failed).
`c8c494b5c` delegates the original Node traversal through a private forwarding
receiver. It shares the real emitter's live state and translates owned `on`
entries **before** invoking the current application removal method. App methods
and getters receive the real emitter; read-only method properties remain valid.
Native once entries pass unchanged. The public `removeListener`/`off` patches
are removed. Five decorator variants pass: stable, replaced, read-only, getter
and Proxy.
An additional mutation case proved that reading a changed public `.listener`
getter before delegation also changed deletion timing (22 passed, one failed).
Final source `5a094a0df` retains canonical removal targets in the weak owner
registry. Only unchanged data links are translated; changed getters run when
Node performs removal, preserving the exception and state transition. All
**23 cases** pass, including the original retention/fatal-error coverage.
The same frozen draw.io verifier was reused. The follow-up ABBA attempt ran
its first baseline, then the resource launcher refused the other three requests
because 8 GiB plus its 4 GiB host reserve was unavailable. Those are **not
executed**, with no performance measurements. The final source was separately
admitted and completed dialog recovery and insertion with seven IPC events;
overall cleanup still failed, and Xvfb teardown also returned an error. No new
completed-task speedup or cold/warm pair is claimed.
Final static checks passed: two uncached tasks, 135.173 s wall, 133.786166 family
CPU s and 4,274,634,752 peak cgroup bytes, no OOM. The JSON record appends exact
source pins, denial logs, final real-app/public cleanup diagnostics and checks
under `review_followup`; the initial records were not overwritten.
## Callback invocation and remaining raw-metadata limitation
Source `514b73b78` invokes `handle` callbacks through captured `Reflect.apply`,
preserving the receiver and actual arguments without reading an app-defined
`.call` property. The added direct-call control and hook case pass; all **24
IPC regressions** pass (3.661 s wall, 3.404 family CPU s, 318,291,968 peak cgroup
bytes, no OOM). This is a Node boundary check, not a real Electron handle claim.
An initial three-case experiment incorrectly assumed native EventEmitter avoided
callable method-property reads. Its `on` and `once` controls themselves failed;
they do not demonstrate hook regressions. The attempted intrinsic event-call
change was reverted and those invalid cases removed. Both failed runs remain
in `callable_followup`; no successful result is assigned to them.
The same unchanged draw.io verifier at this source completes actual dialog
recovery and shape insertion, with seven IPC events, SVG elements 0 → 2 and an
unsaved-changes indicator. Overall provider cleanup still fails. Pipeline wall
time is 12.610 s, family CPU 7.062834 s and peak cgroup memory 634,187,776 bytes;
there is no paired performance or token claim. All recorded scopes were later
independently inactive/dead, which does not establish provider cleanup.
Fresh review also found a distinct remaining metadata case: changing a raw
recording wrapper's writable `.listener` property can make an app decorator
reject removal. A production-hook/native-Node probe reproduces this (plain:
removed; hook: decorator throws and one listener remains). Exact reproduction
and the ownership conflict are tracked in [#4513](https://github.com/morluto/rea/issues/4513).
No affected real app has been identified. Removing the descriptor guard alone
does not make native removal match the mutated wrapper; temporarily rewriting
public app metadata would add further visible mutations. This limitation is
explicit in the observation guide and is not represented as fixed by this PR.
Current-source static checks also pass: two uncached tasks, 92.912 s wall,
92.031383 family CPU s and 4,340,375,552 peak cgroup bytes, no OOM.
## Native single/array dispatch follow-up
Fresh review of `6fc2d2769` identified that the earlier native controls covered
only one listener. Node 24.18.0 reads `.apply` during single-listener dispatch,
but invokes each callback intrinsically during array dispatch. Native `once`
uses `.call` for zero arguments and `.apply` otherwise. Unconditional `.apply`
in the observation helper changed two of those paths: a multi-listener callback
could throw before later callbacks ran, and zero-argument once read the wrong
property. Four explicit native controls reproduce two failures (26 passed,
two failed), rather than assuming all callable-property reads should disappear.
Source `e8d7e8f20` keeps one observation boundary. An `on` recording wrapper
delegates direct calls intrinsically and forwards native `.apply` calls through
its own nonenumerable method. Node selects the route; the hook does not inspect
or copy listener storage. `once` preserves its native zero/nonzero-argument
invocation. All **28 cases** pass, including the prior callback identity,
retention, reentrant-removal and fatal-error coverage. `handle` remains intrinsic.
The unchanged real draw.io verifier again completes dialog recovery and shape
insertion, with seven IPC events, SVG elements 0 → 2 and the unsaved indicator.
Overall provider cleanup still fails; no deadline fires. Its 12.290 s pipeline
run is a validation observation, not a paired performance comparison. Exact
source hashes, failures, resource measurements and post-scope states are appended
under `native_dispatch_followup`; all previous records remain unchanged.
The separate raw metadata limitation [#4513](https://github.com/morluto/rea/issues/4513)
remains open and is not represented as fixed.
Final-source static checks pass: two uncached tasks, 171.430 s wall,
169.139239 family CPU s and 4,091,392,000 peak cgroup bytes, no OOM.
## Argument-list ownership follow-up
Review of `23700fd2c` found two related representation errors. A raw callback's
`.apply` accepts Arguments objects, other array-like objects, null or omitted
argument lists; the recording helper assumed an Array. Native once also passes
its actual Arguments object to a callback-defined `.apply`, whereas the hook
passed a rest Array. Seven explicit native controls reproduce six failures
(29 passed, six failed of 35).
Source `a82d935ba` binds captured native Function apply to the method-forwarding
function. JavaScript itself converts argument lists, treats null/undefined as
empty and rejects invalid primitives before recording reads values. The
array-like getter control is read exactly once. Once retains its actual
Arguments object for invocation; recording alone takes an array slice. The
captured slice helper is bound once, avoiding an extra helper-argument array
on each invocation. No custom array-like parser or exception repair is added.
All **35 cases** pass. The same frozen real draw.io verifier again completes
dialog recovery and shape insertion with seven IPC events; no deadline fires,
and overall provider cleanup still fails. Exact source identities, both failed
boundaries, intermediate/final tests, resource costs and post-scope states are
appended under `argument_list_followup`; previous records remain unchanged.
Current-source static checks pass: two uncached tasks, 122.454 s wall,
121.287057 family CPU s and 4,185,915,392 peak cgroup bytes, no OOM.
The unchanged ASAR digest was checked again after the real-app trial.
## Owned metadata and successful-Evidence verifier follow-up
Fresh review of `41a513d99` found that `on` wrapper metadata used strict
assignment. Native on does not write such metadata, so inherited Function or
Object prototype setters/read-only fields could prevent registration only under
capture. Four native controls fail before `5dcf79f29` (35 passed, four failed).
The recording wrapper now defines its own data property directly; native once
assignment behavior is retained. All **39 cases** pass.
The verifier also incorrectly treated a successful Evidence envelope as a raw
capture. Real cleanup failures had exercised only the separate partial path.
`4a9a01f7a` reads successful `value.normalized_result` and preserves failed
`error.partialObservation.capture`. The new
`experiments/analysis-efficiency/drawio-electron-lifecycle-oracles.py` exercises
the actual verifier with controlled success, partial cleanup, action failure,
insertion failure and absent capture. The old verifier rejects valid successful
Evidence before producing a summary; all five corrected controls pass.
**These are controlled reply/exit checks, not real-provider evidence.**
The corrected verifier was separately frozen before a real unchanged draw.io
trial. Actual dialog recovery and shape insertion again complete with seven IPC
events, no deadline and failed overall provider cleanup (pipeline 12.777 s).
Success with real provider cleanup remains unverified. Earlier trials retain
their earlier verifier hashes; the corrected hash is not assigned to them.
Exact source pins, failed controls, resource costs and post-scope states are
appended under `owned_metadata_and_verifier_followup` without replacing history.
Final-source static checks pass: two uncached tasks, 99.992 s wall,
98.956939 family CPU s and 4,237,795,328 peak cgroup bytes, no OOM.
## Species-free recording and snapshot ownership
Fresh review of `e2cf2d89b` found that slicing a rest Array invokes application
`Array[Symbol.species]` before dispatch. The first control reproduced failed on
dispatch (40 passed, one failed); once dispatch already used Arguments.
Extending the check to retrieve the retained snapshot while the throwing getter
remains installed exposed the same representation problem in snapshot maps
(39 passed, two failed).
Source `3141ac3f4` records from actual invocation frames while preserving native
callback argument-list types: the method-based on path still passes an Array,
and once passes Arguments. Group validation uses the existing recording loops
without a filtered intermediate Array. Captured `Array.from` produces snapshots
without species lookup, reading dropped-counter Maps directly rather than
spreading them into another Array first. The initial **41 cases** pass, including both
live snapshots under the throwing getter with zero getter reads.
The frozen-source real unchanged draw.io operation again completes recovery and
insertion with seven IPC events, no deadline and failed overall cleanup
(pipeline 12.779 s). This does not establish that draw.io customizes species.
The corrected verifier and its five previously passing controlled oracles are
byte-identical to `4a9a01f7a`, so those unchanged oracle sources were reused.
Exact hashes, failures, resource receipts and scope states are appended under
`array_species_followup`; earlier records remain unchanged. There is no new
completed-task performance or token/context claim.
The native multiple-listener route was then included in the same live-species
control: all **42 cases** pass. Only test coverage expanded; production hook
and verifier bytes remain identical to `3141ac3f4`. Exact validation identities
and the additional result are recorded under `all_native_dispatch_paths`.
A fresh-profile real negative control omits `--selector` while retaining dialog
recovery and the same 500 ms wait. SVG elements remain **0 → 0** and unsaved
changes remain false; insertion gives **0 → 2** and unsaved true. This supports
the operation oracle rather than attributing startup rendering to the click.
The control also returns seven IPC events and failed provider cleanup. These
are different tasks, not a performance pair or a model-cell-count proof.
Final 42-case source validation includes two uncached static tasks passing in
94.234 s wall, 93.308800 family CPU s and 4,406,538,240 peak cgroup bytes,
with no OOM. These are verification costs, not an application speedup.
## Opaque apply inputs and iterator-free snapshots
Fresh review of `84d9cf5db` found two producing-boundary regressions: native
array-like conversion replaced the argument-list object before a custom
listener `.apply` received it, and `Array.from` snapshots consulted application
iterators on retained arrays/Maps. Four exact-input native controls and two
snapshot controls fail before correction (42 passed, six failed of 48).
Source `e442ca27f` forwards custom `.apply` the original receiver and method
arguments without interpreting its opaque list. Custom apply may transform
arguments or skip callback execution, so the hook emits a bounded
`ipc-observation-gap` timeline record with the known registration channel. It
does not fabricate a main-event invocation, argument shapes or callback result.
The shared result projection retains explicit uncertainty and counts dropped
gaps through existing retention. The canonical timeline enum adds that kind;
the captured-IPC subset does not. Native apply still performs its own conversion
before an actual callback frame is observed.
Snapshots now copy arrays through private null-prototype indexed views and
counters through captured native Map traversal. Neither retained buffers nor
counter Maps are exposed to application iterators; returned records and shape
arrays remain independent copies. No cache, tool or normal-dispatch metadata
is added. The raw-wrapper metadata limitation in #4513 remains unfixed.
The focused hook/projection/actions gate passes **68 cases** (48 + 4 + 16).
The separate MCP/schema gate passes **23 cases**: an actual SDK client receives
the gap alongside ordinary IPC Evidence, and Ajv2020 validates the advertised
converted output; all canonical tool schemas also pass their existing checks.
These tests use controlled providers and establish no real-provider success.
The verifier and its five controlled exit/summary oracles are hash-identical to
the earlier passing source and are reused without another redundant run.
## Unrelated CI baseline failure
CI run `38935598884` tested synthetic merge `eedae92d406015895a6d0eced5035da1b9ead07a`
(head `84d9cf5db`, base `9d940c2f2`). Two firmware cleanup tests failed; another
shard was cancelled by fail-fast. A fresh, unmodified detached checkout of that
exact base reproduces the same failures: **33 passed, two failed** in
`tests/boundary/firmware/firmwareIntegration.test.ts`. Evidence is posted on
[the PR](https://github.com/morluto/rea/pull/4389#issuecomment-6221054273) and
[existing #4475](https://github.com/morluto/rea/issues/4475#issuecomment-6221054775).
This establishes that these failures predate the Electron changes; it does not
resolve whether the firmware producer or its expectations need correction. No
firmware code or assertions are changed here, and required CI is not bypassed.
The first final-source real request fails before application launch because the
previous `/tmp` runtime is absent. It is retained as an input-resolution failure,
not a provider measurement. The cached Electron 44.7.0 archive is verified against
the official release SHASUMS256 and extracted into a new owned task directory;
its executable/archive digests are pinned before the next request. No target
source, vendor fuse or scenario success requirement is changed.
The recovered-runtime real operation completes all three declared actions:
SVG elements **0 → 2**, unsaved changes true, seven IPC events, no deadline.
Overall provider cleanup still fails. Current-source static validation passes
two uncached tasks in **94.646 s wall, 93.878644 family CPU s and 4,300,570,624
peak cgroup bytes**, no OOM. The initial static attempt's test-only SDK/Ajv type
error is retained; parsing that boundary fixes it, and 12 affected client and
projection cases pass again. `opaque_apply_and_iterator_followup` preserves all
source pins, failures, resource receipts and independently checked scope states
without replacing earlier measurements. Draw.io does not establish that real
applications exercise the synthetic custom-apply/iterator cases.
The final documentation gate also passes six tasks and five README consistency
cases. Source and unchanged ASAR hashes are checked again after validation.
## Borrowed apply receivers
Fresh review of `7541a900c` supplied the borrowed custom-method case
`raw.apply.call(g, emitter, list)`: the plain emitter runs `g`, while the hook
hard-coded its registered callback as the method receiver. The same hard-coded
receiver also affected borrowed native apply. Both native differential controls
fail before correction (48 passed, two failed of 50).
Source `da04465fe` maps only the hook wrapper itself to the original listener
when forwarding custom apply; other method receivers pass unchanged. Native
apply uses its actual receiver, preserving both ordinary wrapper recording and
borrowed-call dispatch. The hook does not fabricate a registered callback
invocation when another callable is selected. The method-forwarding boundary is
corrected directly; no new helper, state, tool or schema is added. All **70**
hook/projection/actions cases pass (50 + 4 + 16). Shared schema/client, projection
and verifier/oracle sources are byte-identical to the prior passing versions,
so their checks are reused by verified hash.
CI run `38943919946` is confirmed by the successful build checkout log and Git
parents to use merge `98c14dfc01fe4698a50eedc8b1cd1ac9cd03267d`
(head `7541a900c`, main `4838f4449`). Its fifth shard fails the same two firmware
cleanup tests as the unmodified `9d940c2f2` base control. The relevant firmware
source/test and package paths have no changes between these main revisions.
The REST PR `base.sha` remains stale at `2c7583599`; it is not used as the CI
identity. No firmware code/assertion or required-check bypass is included.
The frozen-source unchanged draw.io operation completes recovery/insertion
again with seven IPC events, SVG **0 → 2**, unsaved changes true and failed
overall provider cleanup. Pipeline: **12.038 s wall, 6.662295 family CPU s,
598,835,200 peak cgroup bytes**. This remains a partial workflow, not a speedup
claim. Final static checks pass two uncached tasks in **108.067 s wall,
106.996233 family CPU s and 4,430,647,296 peak cgroup bytes**, no OOM.
All prior measurements remain unchanged under `borrowed_apply_followup`.
The final documentation gate also passes six tasks and five README consistency
cases. Current source digests are checked again after validation.
## Listener-owner intrinsic lifetime
Fresh review of `f5b489c1b` found that late application replacement of
`Object.defineProperty` prevents the hook's new wrapper properties from being
created, while a plain native emitter still registers/invokes its callbacks.
Inspection of this same new registration/removal owner found its other late
property/constructor lookups. Seven independent native controls replace
`Object.defineProperty`, `Object.getOwnPropertyDescriptor`, `Object.create`,
`Reflect.get`, `Reflect.set`, global `Set` or global `Proxy`; each fails before
correction (50 passed, seven failed of 57). The controls include both on/once
execution and complete removal, with globals restored after the scenario.
Source `a9c5611d0` captures the owner's descriptor/property operations and private
constructors before application startup. The private receiver uses a direct
null-prototype object literal instead of looking up Object.create. All **77**
hook/projection/actions cases pass (57 + 4 + 16). Application-owned callback
methods and actual receivers retain their existing semantics. No tool, schema,
state, target authority or general-purpose hardening layer is introduced; this
is not complete JavaScript introspection equivalence. Unchanged schema/client,
projection and verifier/oracle checks are reused by verified source hashes.
The preceding CI run `38946503183` tests confirmed merge
`fcd7a9d92a2e64d6eb96d4fabfed7fc7f44b0d45` (head `f5b489c1b`, main `4838f4449`).
Its 36 jobs finish with 29 successes, the same two firmware failures causing
three failed jobs including summaries, and four fail-fast cancellations. The
fifth-shard log again names the two cases reproduced on untouched main. No
firmware code/assertions or required checks are altered.
Frozen-source real unchanged draw.io actions complete again with seven IPC
events, SVG **0 → 2**, unsaved changes true, no deadline and failed overall
provider cleanup. Pipeline wall is **11.429 s**. Observed payload-exit cgroup
counters are 6.441029 CPU s and 550,363,136 peak bytes, but the observer still
sees one additional scope descendant: **family resource observation is
unverified**, not a complete-family performance measurement. The scope is later
inactive/dead after external recovery; this does not verify provider cleanup.
No identity or cleanup cause is inferred from the remaining PID.
Final static checks pass two uncached tasks in **90.980 s wall, 90.119351 family
CPU s and 4,338,749,440 peak cgroup bytes**, no OOM. Source identities, all seven
failures and resource limitations are appended under `listener_intrinsics_followup`
without replacing any previous measurements.
The final docs gate passes six tasks and five README consistency cases; source
digests are checked again after validation.
## Private collection method lifetime
Fresh review of `e6c373d0` found that replacing `WeakMap.prototype.has` after
hook initialization still intercepts its private wrapper registry. Inspecting
the same owner found analogous get/set and visited-Set has/add lookups. All five
independent native controls fail before correction (57 passed, five failed of
62); plain EventEmitter registration, on/once calls and complete removal succeed.
Source `774ed6aa6` binds these private WeakMap/Set methods before application
startup and reuses captured Set membership for event-family classification.
One initialization-only binding helper replaces duplicated binding construction
for existing Map traversal/argument slicing too. It adds no helper argument
arrays per invocation, cache, schema, tool or target authority. Application-owned
callback methods/receivers remain dynamic. All **82** hook/projection/actions
cases pass (62 + 4 + 16). The previous seven global-binding controls also remain
green, and unchanged schema/client/projection/verifier/oracle checks are reused
by verified source hashes. Arbitrary introspection and #4513 remain unsupported.
Frozen-source unchanged draw.io actions complete again with seven IPC events,
SVG **0 → 2**, unsaved changes true and failed overall provider cleanup. Pipeline
wall **11.238 s**; observed payload-exit cgroup CPU **6.285321 s**, peak
**556,285,952 bytes**. Another scope descendant remains at payload exit, so complete-family resource observation is unverified. Later external scope teardown does not prove provider cleanup.
Final static checks pass two uncached tasks in **92.999 s wall, 92.227652 family
CPU s and 4,371,951,616 peak cgroup bytes**, no OOM. Preceding CI38949129899 checks
confirmed e6c3 + main4838, repeats the same two firmware failures and finishes
31 successful /3 failed jobs including summaries /2 fail-fast cancellations.
Sources, failures and resource limitations are appended under
`private_collection_methods_followup`; prior fields remain unchanged.
The final docs gate passes six tasks and five README consistency cases; source
digests are verified again afterward.
## Unknown listener-chain ownership
Fresh review of `0b4f2e81` found that the bounded Boolean chain test treats a
still-unexamined function as fresh when 17 app decorators hide an owned once
wrapper. Two emits then produce three invocation records for one actual once
callback. The same unknown-as-fresh error affects accessor/Proxy links. Four
native controls fail before correction (63 passed, four failed of 67); the
16-decorator boundary already passes. A deep ordinary on control confirms
missing ownership cannot justify fabricated captured IPC either.
Source `ef46b8f0b` classifies metadata as owned, unowned or unknown. Bounded-walk
exhaustion, accessor links and Proxies preserve native registration without
adding another recording wrapper. Successful unknown registrations retain a
bounded `listener-chain` gap; this is not proof of IPC delivery. An existing
inner wrapper may still report its actual once invocation. Shared native
data-descriptor parsing preserves accessor uncertainty at recognition/removal
and avoids inherited descriptor-value interpretation. The superseded Boolean
classification is removed.
The shared projection explains both gap boundaries and retention/coverage, and
an actual MCP SDK client receives custom-apply and listener-chain gaps alongside
ordinary IPC Evidence, validated by its converted advertised schema. All **96**
hook/projection/actions/MCP cases pass (67 + 5 + 16 + 8). A test-only lint length
failure is corrected by generating repeated fixture rows from one table without
relaxing rules; all **13** affected client/projection cases pass again. The
canonical schema and unchanged verifier/five controlled oracle sources retain
their earlier passing checks by verified hashes; the changed projection/client
are freshly tested rather than claimed unchanged.
Prior CI run `38951509761` uses confirmed merge
`fa090a6bde3a5dadeb71066824477292520fb43b` (0b4f + mainf31b231a). All **36 jobs
succeed**. The earlier unrelated firmware failures do not recur in this
combination; their cause is not inferred or claimed fixed here. Current-source
CI/review remain required. No firmware code/assertions were modified.
Frozen-source unchanged draw.io recovery/insertion again completes with seven
IPC events, SVG **0 → 2**, unsaved true, no deadline and failed overall provider
cleanup. Pipeline wall **11.163 s**; observed payload-exit cgroup CPU **6.203177 s**,
peak **547,418,112 bytes**. An extra scope descendant leaves complete-family
resource observation unverified; later inactive/dead scope after external
recovery does not establish provider cleanup.
Final static checks pass two uncached tasks in **88.383 s wall, 87.726306 family
CPU s and 4,399,632,384 peak cgroup bytes**, no OOM. All source identities, failed
controls, corrected fixture-only lint failure and resource caveats are appended
under `chain_ownership_followup`; historical fields are unchanged.
Final docs validation passes six tasks and five README consistency cases;
current source digests are verified again afterward.
## Inherited listener-link coverage
Fresh review of `ae2cd8d4` found that a missing own descriptor is not absence:
an app decorator can inherit its data link to an owned once wrapper. Native
removal follows that link and removes the decorator; the hook hid it under
another recorder, left a listener installed and produced three invocation
records for one callback. Five native inherited-link cases fail before correction
(68 passed, five failed of 73): data/getter/Proxy prototypes and prototype
depths16/17. A late Object.getPrototypeOf replacement control already passes.
Source `e22ce9aaf` completes bounded native prototype lookup at the descriptor
owner, capturing getPrototypeOf before startup. Data links retain their actual
source value. A setter-only property has a known undefined read that shadows
ancestors; a getter, Proxy or incomplete walk remains unknown. Missing metadata becomes absent only after the prototype chain ends; found
properties retain native read/shadowing semantics. Registration and removal consume
the same parsed descriptor, and redundant outer Proxy interpretation is removed.
Neither existing resource bound is raised. All **102** hook/projection/actions/
MCP cases pass (73 + 5 + 16 + 8). Projection/client are freshly tested unchanged
sources; canonical schema and verifier/oracles retain prior checks by hash.
Prior CI run38955237255 tests confirmed merge9c0ceb152c902c1339806608f8b4a010c3d22fc6
(ae2c + main8116dc06). All36 jobs succeed; current-head CI/review remain required.
Frozen-source unchanged draw.io recovery/insertion again completes with seven
IPC events, SVG **0 → 2**, unsaved true and failed overall provider cleanup.
Pipeline: **11.264 s wall /6.376574 family CPU s /557,068,288 peak cgroup bytes**.
Payload-exit observation is verified at that phase while the observer lives;
these are not final whole-scope counters or provider cleanup proof.
Final static checks pass two uncached tasks in **90.797 s wall, 90.096799 family
CPU s and 4,335,267,840 peak cgroup bytes**, no OOM. All source identities, native
failures and current results are appended under `inherited_listener_links_followup`;
previous fields and earlier resource caveats remain unchanged.
Final docs validation passes six tasks and five README consistency cases;
current source digests are verified again afterward.
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Check verifier result/exit semantics with controlled replies, not real apps."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import textwrap
def write(root, name, source):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(source))
def check(arguments, root, case):
shutil.copyfile(arguments.verifier, root / "probe.mjs")
write(root, "package.json", '{"type":"module"}')
write(root, "node_modules/playwright-core/package.json",
'{"type":"module","exports":"./index.js"}')
write(root, "node_modules/playwright-core/index.js", """
let reads = 0;
const page = {
locator: () => ({ waitFor: async () => {} }),
getByText: () => ({ waitFor: async () => {} }),
async evaluate() {
const after = reads++ > 0;
return { title: 'controlled oracle', invalid_file_dialog: !after,
unsaved_changes: after,
diagram_svg_elements: after && process.env.REA_DRAWIO_ORACLE_CASE !== 'insertion-failure' ? 2 : 0,
cells: 0 };
},
};
export const _electron = { async launch() {
return { process: () => ({exitCode: 0, signalCode: null}),
firstWindow: async () => page, close: async () => {} };
}};
""")
write(root, "dist/browser/PlaywrightElectronActiveProvider.js", """
export class PlaywrightElectronActiveProvider {
constructor(environment, launch) { this.launch = launch; }
}
""")
write(root, "dist/browser/PlaywrightElectronActiveActions.js", """
export const runWithExecutionLimits = operation => operation;
""")
write(root, "dist/application/javascript/ElectronActiveObservationService.js", """
export async function captureElectronScenario(provider, input) {
const app = await provider.launch({});
await app.close();
const name = process.env.REA_DRAWIO_ORACLE_CASE;
const capture = { actions: input.actions.map(action => ({...action,
status: name === 'action-failure' ? 'failed' : 'completed'})),
coverage: {}, retention: {}, ipc: {events: [], observed: 0, dropped: 0} };
if (name === 'no-capture') return {ok: false, error: {reason: 'startup_failed'}};
if (name === 'partial') return {ok: false, error: {reason: 'cleanup_failed',
partialObservation: {capture}}};
return {ok: true, value: {evidence_id: 'ev_controlled',
normalized_result: capture}};
}
""")
result = subprocess.run(
[arguments.node, str(root / "probe.mjs"), "--runtime-root", str(root),
"--executable", arguments.node, "--application", str(root),
"--profile-root", str(root), "--dismiss-error", "--selector", "controlled"],
env={**os.environ, "REA_DRAWIO_ORACLE_CASE": case},
capture_output=True, text=True, timeout=15,
)
replies = []
for line in result.stdout.splitlines():
try:
replies.append(json.loads(line))
except ValueError:
pass
receipt = next((value for value in replies if "workflow_status" in value), None)
expected_success = case == "complete"
expected_provider = case not in ("partial", "no-capture")
expected_actions = case not in ("action-failure", "no-capture")
expected_inserted = case != "insertion-failure"
matched = (
(result.returncode == 0) is expected_success
and receipt is not None
and receipt["provider_ok"] is expected_provider
and receipt["cleanup_verified"] is expected_provider
and receipt["actions_completed"] is expected_actions
and receipt["shape_inserted"] is expected_inserted
and (receipt["workflow_status"] == "verified") is expected_success
)
return {"case": case, "exit_code": result.returncode,
"expected_success": expected_success, "summary_present": receipt is not None,
"matched_expected": matched}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", required=True)
parser.add_argument("--verifier", type=Path,
default=Path(__file__).with_name("drawio-electron-lifecycle.mjs"))
arguments = parser.parse_args()
receipts = []
with tempfile.TemporaryDirectory(prefix="rea-drawio-oracles-") as directory:
for case in ("complete", "partial", "action-failure", "insertion-failure", "no-capture"):
root = Path(directory) / case
root.mkdir()
receipts.append(check(arguments, root, case))
print(json.dumps({"oracle_cases": receipts, "real_provider_verified": False}))
assert all(receipt["matched_expected"] for receipt in receipts), receipts
if __name__ == "__main__":
main()
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// Real draw.io ASAR, renderer and declared actions; DOM reads are independent
// verification probes. No fabricated IPC channels or replacement app callbacks.
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { _electron } from "playwright-core";
const { values } = parseArgs({
options: {
"runtime-root": { type: "string" },
executable: { type: "string" },
application: { type: "string" },
"profile-root": { type: "string" },
selector: { type: "string" },
"dismiss-error": { type: "boolean", default: false },
},
});
for (const key of ["runtime-root", "executable", "application", "profile-root"])
assert.ok(values[key], `Missing --${key}`);
const runtime = resolve(values["runtime-root"]);
const { PlaywrightElectronActiveProvider } = await import(
pathToFileURL(
join(runtime, "dist/browser/PlaywrightElectronActiveProvider.js"),
)
);
const { captureElectronScenario } = await import(
pathToFileURL(
join(
runtime,
"dist/application/javascript/ElectronActiveObservationService.js",
),
)
);
const { runWithExecutionLimits } = await import(
pathToFileURL(
join(runtime, "dist/browser/PlaywrightElectronActiveActions.js"),
)
);
const profile = await mkdtemp(
join(resolve(values["profile-root"]), "drawio-ui-"),
);
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error("Verifier deadline")),
40_000,
);
let application;
let child;
let page;
let before;
let after;
let checkedFinalState = false;
const inspect = () =>
page.evaluate(() => {
const diagram = document.querySelector(".geDiagramContainer svg");
return {
title: document.title,
invalid_file_dialog:
document.body.innerText.includes("Invalid file data"),
unsaved_changes: document.body.innerText.includes("Unsaved changes"),
diagram_svg_elements:
diagram?.querySelectorAll("rect,path,ellipse,text").length ?? null,
cells: document.querySelectorAll(".geDiagramContainer [data-cell-id]")
.length,
};
});
const started = performance.now();
try {
const actions = values["dismiss-error"]
? [
{
step_id: "dismiss-invalid-file",
kind: "click",
window_index: 0,
selector: 'button:has-text("OK"):visible',
},
]
: [];
if (values.selector !== undefined)
actions.push({
step_id: "insert-shape",
kind: "click",
window_index: 0,
selector: values.selector,
});
actions.push({
step_id: "settle-renderer",
kind: "wait",
window_index: 0,
duration_ms: 500,
});
const result = await captureElectronScenario(
new PlaywrightElectronActiveProvider(process.env, async (options) => {
application = await _electron.launch(options);
child = application.process();
page = await application.firstWindow({ timeout: 15_000 });
await page
.locator(".geDiagramContainer")
.waitFor({ state: "visible", timeout: 15_000 });
if (values["dismiss-error"])
await page
.getByText("Invalid file data", { exact: true })
.waitFor({ state: "visible", timeout: 15_000 });
before = await inspect();
return new Proxy(application, {
get(target, key) {
if (key === "close")
return async () => {
if (!checkedFinalState) {
checkedFinalState = true;
after = await inspect();
}
return target.close();
};
const value = Reflect.get(target, key, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
}),
{
executable_path: resolve(values.executable),
application_path: resolve(values.application),
args: ["--no-sandbox", "--disable-gpu", `--user-data-dir=${profile}`],
actions,
},
{ signal: controller.signal },
);
const capture = result.ok
? result.value.normalized_result
: result.error.partialObservation?.capture;
await writeFile(join(profile, "result.json"), JSON.stringify(result));
const actionsCompleted =
capture?.actions.every((action) => action.status === "completed") === true;
const inserted =
values.selector === undefined
? null
: after?.diagram_svg_elements > before?.diagram_svg_elements;
console.log(
JSON.stringify({
profile,
runtime,
elapsed_ms: performance.now() - started,
deadline_triggered: controller.signal.aborted,
selected_shape: values.selector ?? null,
before,
after,
actions_completed: actionsCompleted,
shape_inserted: inserted,
provider_ok: result.ok,
cleanup_verified: result.ok,
reason: result.ok ? null : result.error.reason,
coverage: capture?.coverage,
retention: capture?.retention,
ipc: capture?.ipc,
actions: capture?.actions,
workflow_status:
result.ok && actionsCompleted && inserted !== false
? "verified"
: "partial-or-failed",
}),
);
assert.ok(actionsCompleted, "Declared real-app actions did not complete");
assert.ok(before && after, "Real renderer state was not verified");
assert.notEqual(inserted, false, "Click did not insert a diagram shape");
if (values["dismiss-error"]) assert.equal(after.invalid_file_dialog, false);
assert.equal(
controller.signal.aborted,
false,
"Verifier deadline was reached",
);
assert.ok(result.ok, "Provider cleanup remains unverified");
} finally {
clearTimeout(timer);
if (
application !== undefined &&
child?.exitCode === null &&
child?.signalCode === null
)
await runWithExecutionLimits(
application.close(),
undefined,
performance.now() + 5_000,
).catch(() => {
process.exitCode = 1;
});
// The resource scope owns any unresolved descendants; no PID/path-only kill.
if (child?.exitCode === null && child?.signalCode === null) process.exit(1);
}
+298 -49
View File
@@ -1,10 +1,34 @@
"use strict";
const applyFunction = Reflect.apply;
const applyArguments = Function.prototype.apply;
const bindFunction = Function.prototype.bind;
const callFunction = Function.prototype.call;
const arrayFrom = Array.from;
const defineProperty = Object.defineProperty;
const ownPropertyDescriptor = Object.getOwnPropertyDescriptor;
const getPrototypeOf = Object.getPrototypeOf;
const getProperty = Reflect.get;
const setProperty = Reflect.set;
const SetConstructor = Set;
const ProxyConstructor = Proxy;
const bindBuiltinMethod = (method) =>
applyFunction(bindFunction, callFunction, [method]);
const forEachMapEntry = bindBuiltinMethod(Map.prototype.forEach);
const sliceArguments = bindBuiltinMethod(Array.prototype.slice);
const weakMapHas = bindBuiltinMethod(WeakMap.prototype.has);
const weakMapGet = bindBuiltinMethod(WeakMap.prototype.get);
const weakMapSet = bindBuiltinMethod(WeakMap.prototype.set);
const setHas = bindBuiltinMethod(Set.prototype.has);
const setAdd = bindBuiltinMethod(Set.prototype.add);
const hasOwnProperty = bindBuiltinMethod(Object.prototype.hasOwnProperty);
const { isProxy } = require("node:util").types;
const {
createElectronActiveBoundaryPatches,
} = require("./electron-active-hook-boundaries.cjs");
const ipcEventKinds = new Set([
const ipcEventKinds = new SetConstructor([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
@@ -98,7 +122,7 @@ const eventFamily = (kind, processTypeValue) => {
return "preload-configuration";
if (kind.startsWith("ipc-renderer"))
return processTypeValue === "renderer" ? "renderer-ipc" : "ipc";
return ipcEventKinds.has(kind) ? "ipc" : kind;
return setHas(ipcEventKinds, kind) ? "ipc" : kind;
};
const noteDropped = (kind, processTypeValue, isIpc) => {
@@ -163,7 +187,7 @@ const frameIdentity = (event) => {
const record = (event) => {
observed += 1;
const isIpc = ipcEventKinds.has(event.kind);
const isIpc = setHas(ipcEventKinds, event.kind);
if (isIpc) observedIpc += 1;
else observedRuntime += 1;
const raw = {
@@ -191,7 +215,7 @@ const record = (event) => {
...event,
};
const shapeGroups = Array.isArray(raw.argument_shape_value_groups)
? raw.argument_shape_value_groups.filter(Array.isArray)
? raw.argument_shape_value_groups
: Array.isArray(raw.argument_shape_values)
? [raw.argument_shape_values]
: [];
@@ -242,11 +266,13 @@ const record = (event) => {
eventBytes + estimatedRetainedBytes + snapshotEnvelopeReserveBytes >
retentionBudgetBytes;
};
for (const group of shapeGroups)
for (const group of shapeGroups) {
if (!Array.isArray(group)) continue;
for (const argument of group) {
countShape(argument);
if (shapeOverflow) break;
}
}
if (shapeOverflow) {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
@@ -290,9 +316,11 @@ const record = (event) => {
noteDropped(raw.kind, retainedEvent.process_type, isIpc);
return null;
}
for (const group of shapeGroups)
for (const group of shapeGroups) {
if (!Array.isArray(group)) continue;
for (const argument of group)
retainedEvent.argument_shapes.push(shape(argument));
}
for (const valueShape of existingShapes)
retainedEvent.argument_shapes.push(
typeof valueShape === "string" ? valueShape : "unknown",
@@ -317,6 +345,14 @@ const recordIpc = (kind, channel, args, details = {}) =>
});
})();
const recordIpcObservationGap = (channel, event, error = false) =>
recordRuntime("ipc-observation-gap", event, error ? "failed" : "observed", {
channel,
target: "ipcMain.on",
process_type: "main",
error,
});
const patchEmitter = (emitter, observeEvent) => {
if (emitter === null || emitter === undefined) return;
const original = emitter.emit;
@@ -343,43 +379,248 @@ const recordInvocation = (kind, channel, event, args) =>
error: false,
});
const invokeObservedEventListener = (
thisArg,
channel,
listener,
args,
nativeMethod,
) => {
// Recording slices an actual invocation frame, avoiding Array species;
// callback dispatch keeps its native Array/Arguments representation.
const recorded = recordInvocation(
"main-event-invocation",
channel,
args[0],
sliceArguments(args, 1),
);
try {
if (nativeMethod === "call") return listener.call(thisArg);
if (nativeMethod === "apply") return listener.apply(thisArg, args);
return applyFunction(listener, thisArg, args);
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
// Recording wrappers follow EventEmitter's own wrapper convention: `.listener`
// names the caller's function so native removal can match it. Node retains
// ownership of listener traversal and meta-events; hook-created "on" entries
// are translated before calling the application's removal method/decorators.
// Only hook-created wrappers are tracked, by the registration they stand in
// for: they already record, so patched on() registers them as is. An "on"
// wrapper stands in for the caller's raw function and a "once" wrapper for
// EventEmitter's once wrapper, which removeAllListeners reports accordingly.
// The value is the native removal argument: the app function for an "on"
// wrapper, or the wrapper itself for "once". Public .listener is mutable;
// it is not the authority for the original function's identity.
const hookListenerRemovalTargets = new WeakMap();
const MAX_LISTENER_CHAIN_HOPS = 16;
// Follow native property lookup without invoking application getters or Proxy
// traps. Undefined means the readable link is absent; null means unknown.
const listenerDataDescriptor = (listener) => {
let candidate = listener;
for (
let hops = 0;
hops <= MAX_LISTENER_CHAIN_HOPS && candidate !== null;
hops += 1
) {
if (isProxy(candidate)) return null;
const descriptor = ownPropertyDescriptor(candidate, "listener");
if (descriptor !== undefined) {
if (hasOwnProperty(descriptor, "value")) return descriptor;
// A setter-only property shadows ancestors with a known undefined read.
return descriptor.get === undefined ? undefined : null;
}
candidate = getPrototypeOf(candidate);
}
return candidate === null ? undefined : null;
};
// A decorator that wraps once's hook wrapper and names it through `.listener`
// already records through that wrapper. Registering the decorator as is keeps
// native self-removal working: the once wrapper removes itself by identity, and
// removeListener matches the decorator through its `.listener`.
const classifyHookListenerWrapper = (listener) => {
const visited = new SetConstructor();
let candidate = listener;
for (
let hops = 0;
hops <= MAX_LISTENER_CHAIN_HOPS && typeof candidate === "function";
hops += 1
) {
if (weakMapHas(hookListenerRemovalTargets, candidate)) return "owned";
if (setHas(visited, candidate)) return "unowned";
setAdd(visited, candidate);
const descriptor = listenerDataDescriptor(candidate);
if (descriptor === null) return "unknown";
candidate = descriptor?.value;
}
return typeof candidate === "function" ? "unknown" : "unowned";
};
const patchIpcMain = (ipcMain) => {
if (ipcMain === null || ipcMain === undefined) return;
for (const [method, kind] of [
["handle", "main-handler-invocation"],
["on", "main-event-invocation"],
["once", "main-event-invocation"],
]) {
const original = ipcMain[method];
if (typeof original !== "function") continue;
ipcMain[method] = function patchedIpcMain(channel, listener) {
const { handle, on, once, removeAllListeners } = ipcMain;
if (typeof handle === "function")
ipcMain.handle = function patchedIpcMainHandle(channel, listener) {
if (typeof listener !== "function")
return original.call(this, channel, listener);
const wrapped =
kind === "main-handler-invocation"
? async function wrappedHandler(event, ...args) {
const recorded = recordInvocation(kind, channel, event, args);
try {
const result = await listener.call(this, event, ...args);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
}
: function wrappedEventListener(event, ...args) {
const recorded = recordInvocation(kind, channel, event, args);
try {
return listener.call(this, event, ...args);
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
return original.call(this, channel, wrapped);
return handle.call(this, channel, listener);
return handle.call(
this,
channel,
async function wrappedHandler(event, ...args) {
const recorded = recordInvocation(
"main-handler-invocation",
channel,
event,
args,
);
try {
const result = await applyFunction(listener, this, arguments);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
},
);
};
}
if (typeof on !== "function") return;
ipcMain.on = function patchedIpcMainOn(channel, listener) {
if (typeof listener !== "function") return on.call(this, channel, listener);
const wrapperStatus = classifyHookListenerWrapper(listener);
if (wrapperStatus !== "unowned") {
const result = on.call(this, channel, listener);
if (wrapperStatus === "unknown")
recordIpcObservationGap(channel, "listener-chain");
return result;
}
const wrapped = function wrappedEventListener() {
return invokeObservedEventListener(this, channel, listener, arguments);
};
// Native single-listener dispatch reads .apply; array dispatch invokes the
// function directly. Let Node select that path without inspecting or
// copying the emitter's listener storage, including during reentrant emits.
defineProperty(wrapped, "apply", {
configurable: true,
writable: true,
value: function nativeApply(...methodArgs) {
let applicationApply;
try {
applicationApply = listener.apply;
} catch (cause) {
recordIpcObservationGap(channel, "custom-apply", true);
throw cause;
}
if (applicationApply !== applyArguments) {
// Custom apply owns the original list's interpretation. Forward it
// unchanged rather than executing getters to invent callback facts.
const gap = recordIpcObservationGap(channel, "custom-apply");
try {
return applyFunction(
applicationApply,
this === wrapped ? listener : this,
methodArgs,
);
} catch (cause) {
if (gap !== null) {
gap.error = true;
gap.phase = "failed";
}
throw cause;
}
}
// Native Function.apply performs its own list conversion before the
// recording wrapper observes an actual invocation frame.
// Borrowing the exposed method keeps its dynamic receiver.
return applyFunction(applyArguments, this, methodArgs);
},
});
defineProperty(wrapped, "listener", {
configurable: true,
enumerable: true,
writable: true,
value: listener,
});
weakMapSet(hookListenerRemovalTargets, wrapped, listener);
return on.call(this, channel, wrapped);
};
// Mirror EventEmitter#once and _onceWrap: resolve this.on at registration
// and target.removeListener at firing, so later decorators observe both.
if (typeof once === "function")
ipcMain.once = function patchedIpcMainOnce(channel, listener) {
if (typeof listener !== "function")
return once.call(this, channel, listener);
const target = this;
let fired = false;
const wrapped = function wrappedOnceEventListener() {
if (fired) return undefined;
target.removeListener(channel, wrapped);
fired = true;
return invokeObservedEventListener(
target,
channel,
listener,
arguments,
arguments.length === 0 ? "call" : "apply",
);
};
wrapped.listener = listener;
weakMapSet(hookListenerRemovalTargets, wrapped, wrapped);
this.on(channel, wrapped);
return this;
};
if (typeof removeAllListeners !== "function") return;
// Node owns its live-array traversal. A forwarding receiver translates only
// our "on" entries before calling the current application removal method,
// including decorators installed/replaced during traversal. Application
// getters and methods always receive the real emitter; its public methods
// are never temporarily replaced. Native once entries remain unchanged.
ipcMain.removeAllListeners = function patchedIpcMainRemoveAllListeners(
...args
) {
const emitter = this;
// A separate empty target also permits forwarding a non-writable app
// method; a Proxy of the emitter itself would violate get invariants.
const receiver = new ProxyConstructor(
{ __proto__: null },
{
get(_target, key) {
const value = getProperty(emitter, key, emitter);
if (
(key !== "removeListener" && key !== "removeAllListeners") ||
typeof value !== "function"
)
return value;
return (...methodArgs) => {
if (key === "removeListener") {
const entry = methodArgs[1];
const removalTarget = weakMapGet(
hookListenerRemovalTargets,
entry,
);
// Translate only a still-valid data link. If app code changed it
// to an accessor, native removal owns when that getter executes.
if (
removalTarget !== undefined &&
removalTarget !== entry &&
listenerDataDescriptor(entry)?.value === removalTarget
)
methodArgs[1] = removalTarget;
}
return applyFunction(value, emitter, methodArgs);
};
},
set: (_target, key, value) => setProperty(emitter, key, value, emitter),
},
);
const result = applyFunction(removeAllListeners, receiver, args);
return result === receiver ? emitter : result;
};
};
const patchNavigationMethods = (webContents, contentsId) => {
@@ -649,10 +890,24 @@ try {
);
}
// Never expose retained buffers to application iterators or array constructors.
const snapshotArray = (values, project) =>
arrayFrom({ __proto__: null, length: values.length }, (_unused, index) =>
project === undefined ? values[index] : project(values[index]),
);
const snapshotCounts = (counts, field) => {
const values = { __proto__: null, length: 0 };
forEachMapEntry(counts, (count, value) => {
values[values.length++] = { [field]: value, count };
});
return arrayFrom(values);
};
globalThis.__reaElectronActiveSnapshot = () => ({
events: events.map((event) => ({
events: snapshotArray(events, (event) => ({
...event,
argument_shapes: [...event.argument_shapes],
argument_shapes: snapshotArray(event.argument_shapes),
})),
retention_budget_bytes: retentionBudgetBytes,
estimated_retained_bytes: estimatedRetainedBytes,
@@ -661,14 +916,8 @@ globalThis.__reaElectronActiveSnapshot = () => ({
dropped,
dropped_ipc: droppedIpc,
dropped_runtime: droppedRuntime,
dropped_event_families: [...droppedFamilies].map(([family, count]) => ({
family,
count,
})),
dropped_event_roles: [...droppedRoles].map(([role, count]) => ({
role,
count,
})),
dropped_event_families: snapshotCounts(droppedFamilies, "family"),
dropped_event_roles: snapshotCounts(droppedRoles, "role"),
observed,
observed_ipc: observedIpc,
observed_runtime: observedRuntime,
@@ -2,28 +2,87 @@ import { expect, it } from "vitest";
import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js";
const event = {
sequence: 1,
correlation_id: "capture:123:1",
kind: "main-handler-invocation" as const,
event: null,
phase: "observed" as const,
channel: "readiness:echo",
direction: "renderer-to-main" as const,
sender: null,
receiver: "main",
frame: null,
target: null,
argument_shapes: [],
result_shape: null,
process_type: "main",
source: "electron-active-hook",
capture_method: "api-wrapper" as const,
artifact_path: null,
artifact_sha256: null,
error: false,
};
it.each(["custom-apply", "listener-chain"])(
"keeps %s gaps distinct from captured IPC and retention loss",
(boundary) => {
const result = projectElectronActiveCapture(
{ executable: "/opt/electron", application: "/opt/app/main.js" },
[],
{
windows: [],
metrics: [],
electronVersion: "test-electron",
hookSnapshot: {
events: [
{
...event,
kind: "ipc-observation-gap",
event: boundary,
channel: "custom",
direction: null,
receiver: null,
target: "ipcMain.on",
},
],
retention_budget_bytes: 16 * 1024 * 1024,
estimated_retained_bytes: 1_024,
event_serialized_byte_upper_bound: 512,
retained: 1,
dropped: 2,
dropped_ipc: 0,
dropped_runtime: 2,
dropped_event_families: [{ family: "ipc-observation-gap", count: 2 }],
dropped_event_roles: [{ role: "main", count: 2 }],
observed: 3,
observed_ipc: 0,
observed_runtime: 3,
hook_error: false,
},
},
);
expect(result.ipc).toEqual({ events: [], observed: 0, dropped: 0 });
expect(result.timeline.events[0]).toMatchObject({
kind: "ipc-observation-gap",
channel: "custom",
});
expect(result.coverage.status).toBe("partial_attach");
expect(result.coverage.observed_event_families).toContain(
"ipc-observation-gap",
);
expect(result.coverage.unavailable_event_families).toContain("ipc");
expect(result.retention.dropped).toBe(2);
expect(result.limitations).toContainEqual(
expect.stringContaining("unobserved callback arguments and execution"),
);
expect(result.limitations).not.toContain(
"The active IPC hook could not be installed.",
);
},
);
it("projects hook retention and dropped event families through the result schema", () => {
const event = {
sequence: 1,
correlation_id: "capture:123:1",
kind: "main-handler-invocation" as const,
event: null,
phase: "observed" as const,
channel: "readiness:echo",
direction: "renderer-to-main" as const,
sender: null,
receiver: "main",
frame: null,
target: null,
argument_shapes: [],
result_shape: null,
process_type: "main",
source: "electron-active-hook",
capture_method: "api-wrapper" as const,
artifact_path: null,
artifact_sha256: null,
error: false,
};
const result = projectElectronActiveCapture(
{
executable: "/opt/electron",
@@ -184,6 +184,11 @@ export const projectElectronActiveCapture = (
coverage: createCoverage(hookSnapshot, observations),
limitations: [
"IPC payloads are represented by value shapes; payload values are never retained.",
...(observations.observedEventFamilies.includes("ipc-observation-gap")
? [
"IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the custom-apply or listener-chain boundary and registered channel; dropped gap records are counted by retention. Listener-chain gaps describe registration uncertainty, not proof of IPC delivery.",
]
: []),
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
@@ -97,6 +97,7 @@ export const electronActiveTimelineEventSchema = z.strictObject({
kind: z.enum([
"main-handler-invocation",
"main-event-invocation",
"ipc-observation-gap",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
File diff suppressed because it is too large Load Diff
+41
View File
@@ -5,6 +5,7 @@ import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { afterEach, expect, it, vi } from "vitest";
import { z } from "zod";
@@ -450,6 +451,26 @@ it("runs active Electron scenarios with selected paths and inferred working dire
const capturedInputs: unknown[] = [];
const activeResult =
createElectronActiveObservationFixtureResult(applicationPath);
const capturedEvent = activeResult.timeline.events[0];
if (capturedEvent === undefined) throw new Error("IPC fixture was empty");
const gaps = ["custom-apply", "listener-chain"].map((event, index) => ({
...capturedEvent,
sequence: index + 2,
kind: "ipc-observation-gap" as const,
event,
channel: event === "custom-apply" ? "custom" : "opaque-chain",
direction: null,
receiver: null,
target: "ipcMain.on",
argument_shapes: [],
result_shape: null,
}));
activeResult.timeline.events.push(...gaps);
activeResult.timeline.observed = 3;
activeResult.retention.retained = 3;
activeResult.retention.estimated_retained_bytes = 1_536;
activeResult.retention.observed_runtime = 2;
activeResult.coverage.observed_event_families.push("ipc-observation-gap");
const provider: ElectronActiveObservationPort = {
identity: () => ({
id: "test-electron-active",
@@ -482,6 +503,15 @@ it("runs active Electron scenarios with selected paths and inferred working dire
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
({ name }) => name === "capture_electron_scenario",
);
if (advertised?.outputSchema === undefined)
throw new Error("Active Electron output schema was not advertised");
const validateOutput = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(z.record(z.string(), z.unknown()).parse(advertised.outputSchema));
const captured = await client.callTool({
name: "capture_electron_scenario",
arguments: {
@@ -495,10 +525,21 @@ it("runs active Electron scenarios with selected paths and inferred working dire
},
});
expect(captured.isError, JSON.stringify(captured)).not.toBe(true);
expect(
validateOutput(captured.structuredContent),
JSON.stringify(validateOutput.errors),
).toBe(true);
expect(captured.structuredContent).toMatchObject({
normalized_result: {
application: { process_ownership: "provider-owned" },
ipc: { events: [{ channel: "readiness:echo" }] },
timeline: {
events: [
{ kind: "main-handler-invocation" },
{ kind: "ipc-observation-gap", channel: "custom" },
{ kind: "ipc-observation-gap", channel: "opaque-chain" },
],
},
coverage: {
status: "partial_attach",
pre_capture_activity: "unavailable",