fix(electron): preserve cancellation across waits, state reads and cleanup

Fixes #4388. Refs #4027.

Observe cancellation during first-window waits and state reads; preserve cancellation and caller reasons before cleanup and retain partial observations when cleanup fails. Keep late cancellation and cleanup failure precedence.

Validation: 49 focused cases, static/docs checks, six controlled verifier-oracle cases and 39 current-head CI checks passed. Codex and Greptile found no remaining blocking issues. Real Joplin cancellation phases verified; complete Linux cleanup/first-signal CLI exit and packaged-runtime startup remain explicitly unverified/failed. Both verifiers now fail incomplete cleanup, including after main-process exit.

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-16 21:12:26 +08:00
1 parent 1ff705473a
commit 180ba03bfd
10 files changed
+4371 -29

No files matched your search

+7 -2
View File
@@ -107,8 +107,13 @@ scenario controls; their attempted and observed outcomes remain in the
timeline. The active hook blocks and records external shell/navigation,
permission, download, popup, updater, and OS-integration effects. The timeline
is explicitly partial when attachment starts after application activity. The
startup has a 60-second deadline. Actions wait until completion or request
cancellation; a click whose target never appears remains pending until cancelled.
startup, including the initial state snapshot, has a 60-second deadline.
Actions wait until completion or request cancellation; a click whose target
never appears, or a `wait` without `window_index` in an app that opens no
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.
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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,158 @@
# Electron cancellation verification, 2026-10-16
[#4388](https://github.com/morluto/rea/issues/4388), existing
[#4387](https://github.com/morluto/rea/pull/4387), umbrella
[#4027](https://github.com/morluto/rea/issues/4027).
The cancellation boundaries and retained cancellation cause are verified below.
The complete real Linux Joplin workflow remains **partial or failed** because
process ownership/cleanup cannot be verified. Publishing a typed reply, exiting
the CLI, and cleaning up its application are separate observations.
## Source and deterministic regressions
Latest-main control is `fe2a398dd9ab290ba6cb3411403d319cec8d07a2`.
The existing reviewed PR head `af732dd8` was integrated with that main in
`3b094802a`. Its initial 47 focused cases passed. An unrelated compact-catalog
test hit its unchanged 10-second CI deadline; an unchanged-source job retry
passed. The original failed execution remains in CI history.
Real CLI verification exposed an introduced regression: the PR replaced an
already observed cancellation with success after the final snapshot. A later
cleanup failure then lost the cancellation category and caller reason. Two
deterministic cases reproduced this for process and transport cleanup: 20 old
cases passed and both new cases failed. Main's public reply retained category
`cancelled`; the reviewed PR returned `execution_failure` for the same kind of
cancelled wait plus incomplete cleanup.
Fix `73d4a712b` records cancellation before finalization and uses one constructor
for the early and late partial-capture errors. Cleanup failure keeps precedence,
its diagnostics and the earlier cancellation cause. Cancellation first observed
during cleanup retains the existing precedence. The fix changes the producing
outcome; no CLI/MCP projection repair or ownership-policy exception was added.
All **49 focused cases in three files** passed after the fix. Full static checks
passed: six tasks plus architecture guards. Documentation checks also passed
(six tasks, including two cached, plus five README consistency cases). One intermediate build rejected an
over-narrowed `aborted === true` comparison; the original truthiness pattern
resolved that TypeScript error before the passing build. Recorded catalog
metadata is byte-identical to main. No new tool, schema, cache or normal-case
result field was added.
## Real application observations
The unchanged official Joplin 3.7.21 ASAR is 233,575,014 bytes, SHA-256
`767df409b18effe60421fbefcca873de237c7e813eac744ecdeebbc5e825a33a`.
Acquisition is linked in the [original Joplin verification](javascript-native-handoff-identity-2026-10-15.md).
Its complete unpacked native files/assets accompany it. These trials use the
actual application with **generic Electron 44.7.0**, fresh profiles and Xvfb.
They exercise startup/cancellation, not a completed Joplin business workflow.
Frozen verifier revision `279aa29a7` records source/runtime hashes before the
final probes. The controlled cases hold a REA-side Playwright reply while the
real application runs. They do not establish a naturally occurring Joplin stall.
| Controlled condition | Observed result |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Main: first-window reply stays pending | Still pending 20.001 s after cancellation; verifier watchdog fails |
| Fixed: same pending first-window reply | Cancellation returned in 5.658 s; cancelled action retained |
| Fixed: initial snapshot reply stays pending | Cancellation returned in 5.335 s; no invented partial snapshot |
| Fixed: final snapshot reply stays pending | Cancellation returned in 10.262 s; five-second cancellation grace expiry and initial snapshot retained |
Every fixed case preserves the `cancelled` category. Every real case still has
unverified provider cleanup and a nonzero verifier exit; these are cancellation
phase proofs, not complete runtime passes. The known Linux ownership gap is
tracked in [#4282](https://github.com/morluto/rea/issues/4282). Ownership was not
inferred from process names, PIDs or executable paths to make these tests pass.
The public CLI trials send SIGTERM after actual Joplin startup logging. Main
and the fixed version both publish `cleanup_incomplete` with category `cancelled`
and a cancelled action. The fixed reply was observed about 5.268 s after SIGTERM
(50 ms polling resolution). Both fail the verifier's 20-second first-signal exit
deadline and require a second, explicitly recorded recovery signal. The resulting
143 exit is not proof that the first signal completed cleanup.
The official packaged Joplin executable also passed static fuse admission, but
its initial Inspector evaluation failed with a destroyed execution context before
the cancellation condition was reached. Those attempts remain failed diagnostics;
they establish no bundled-runtime compatibility or cancellation result.
## Costs, limits and reproducibility
Costly operations run serially on one CPU 10 with an 8 GiB hard limit, 6 GiB
early stop, no swap and 192 tasks. The passing static gate used 153.534 s wall,
152.274161 family CPU seconds and 6,442,016,768 peak cgroup bytes. It reported
no OOM and verified payload cleanup. Cgroup memory includes file cache, not RSS.
Other trials' exact counters and failures are preserved in the
[compact record](electron-cancellation-2026-10-16.json).
Payload-exit counters are collected while the observer remains alive and are
not final whole-scope counters. Some failed probes retain live app/Xvfb processes
at that point. Every recorded scope was independently confirmed inactive/dead
after the external owner stopped it. That recovery does not establish successful
provider cleanup.
OS/build caches are uncontrolled. A timed-out baseline and a partial fixed
workflow do not establish a completed-task speedup. Live event sets also differ,
so reply-size differences do not establish token savings. Model billing, peak
agent context and complete host/tool-schema token costs are unmeasured. Historical
diagnostic helper hashes were not captured at execution; the record marks that
gap instead of assigning the later frozen hash to earlier runs.
Build each pinned checkout, then run under the existing
[resource observer](../../experiments/analysis-efficiency/resource-counters.md)
and a private Xvfb instance. The shared verifier supports all four named cases:
```sh
node experiments/analysis-efficiency/electron-cancellation.mjs \
--runtime-root /absolute/pinned-checkout \
--executable /absolute/electron-44.7.0/electron \
--application /absolute/Joplin/resources/app.asar \
--profile-root /absolute/temporary-profile-parent --case window-wait
```
Use `initial-state`, `final-state` or `normal-wait` for the other boundaries.
The public CLI probe has no injected stalled reply:
```sh
python3 experiments/analysis-efficiency/joplin-cli-cancellation.py \
--node /absolute/node-24.18.0/bin/node --runtime-root /absolute/pinned-checkout \
--executable /absolute/electron-44.7.0/electron \
--application /absolute/Joplin/resources/app.asar \
--profile-root /absolute/temporary-profile-parent
```
The scripts return failure rather than claiming complete cleanup when the real
owned app remains unresolved. App binaries, extracted runtimes/profiles and raw
large output are regenerable and are not committed. No macOS, Windows, full
Joplin GUI or live-model success is claimed.
## Review follow-up: verifier exit semantics
Review of `beb4a47db` found a verifier false pass: the shared probe could exit
successfully once its main process had exited even though provider cleanup was
still unverified. The CLI probe also accepted `cleanup_incomplete`. Those are
verifier bugs; they do not invalidate the recorded failed Joplin trials above.
Revision `227217ac3` requires verified provider cleanup in both probes and the
public `cancelled` category in the CLI. Six controlled oracle cases cover an
exited main process with incomplete cleanup, complete cleanup, transport failure
after process cleanup, and a wrong public category. Both old scripts fail these
regressions; the fixed scripts pass all six. These cases substitute provider/CLI
replies and do **not** establish real-provider compatibility or performance.
The fixed, frozen scripts were then rerun on the same real Joplin artifact. The
shared window-wait probe retained cancellation in 5.577 s and explicitly failed
because cleanup remained unverified. The public CLI retained category `cancelled`
and a cancelled action, published its reply about 6.335 s after SIGTERM, and
again failed the first-signal exit deadline. Both verifiers exited nonzero; their
externally owned scopes were stopped separately. Production source and the
earlier historical records are unchanged. The compact record appends these
observations under `review_followup`.
Run the controlled oracle regressions separately from the real-app commands:
```sh
python3 experiments/analysis-efficiency/electron-cancellation-oracles.py \
--node /absolute/node-24.18.0/bin/node
```
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Check verifier exit semantics with controlled replies, not real providers/apps.
The JS case owns a short-lived Node child so the main process has exited while
the provider reply still reports incomplete cleanup. The CLI cases substitute
only Popen. These are negative-oracle regressions, not runtime compatibility or
performance evidence; real Joplin trials remain separately required.
"""
import argparse
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import tempfile
import textwrap
from unittest.mock import patch
def write(root, name, text):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(text))
def check_js(arguments, root, complete):
shutil.copyfile(arguments.javascript, 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", """
import { spawn } from 'node:child_process';
import { once } from 'node:events';
export const _electron = { async launch() {
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'],
{ stdio: 'ignore' });
await once(child, 'spawn');
return { process: () => child, async close() {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, 'exit'); child.kill(); await exited;
}};
}};
""")
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", f"""
import {{ once }} from 'node:events';
export async function captureElectronScenario(provider, input, options) {{
const app = await provider.launch({{}});
try {{
void app.firstWindow();
await once(options.signal, 'abort');
}} finally {{ await app.close(); }}
const error = Object.assign(new Error('controlled cancellation'), {{
_tag: 'BrowserObservationError', reason: 'cancelled',
userCategory: 'cancelled', cleanupIncomplete: {str(not complete).lower()},
partialObservation: {{capture: {{application: {{}},
actions: [{{status: 'cancelled'}}], coverage: {{}}, windows: [], limitations: []}}}}
}});
return {{ok: false, error}};
}}
""")
result = subprocess.run(
[arguments.node, str(root / "probe.mjs"), "--runtime-root", str(root),
"--executable", arguments.node, "--application", str(root),
"--profile-root", str(root), "--case", "window-wait"],
capture_output=True, text=True, timeout=15,
)
receipts = [json.loads(line) for line in result.stdout.splitlines()]
receipt = next(value for value in receipts if "cleanup_phase" in value)
assert receipt["launched_process_exited"] is True
assert receipt["cleanup_phase"] == ("verified" if complete else "unverified")
assert (result.returncode == 0) is complete, (result.returncode, result.stdout, result.stderr)
return {"verifier": "shared", "controlled_cleanup_complete": complete,
"main_exited": True, "exit_code": result.returncode}
def check_cli(arguments, root, code, category, cleanup):
spec = importlib.util.spec_from_file_location("cli_cancellation_probe", arguments.python)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
error = {"code": code, "category": category, "details": {"partial_observation": {
"kind": "electron-active-observation", "capture": {
"application": {"cleanup": cleanup}, "actions": [{"status": "cancelled"}]}}}}
class ExitedCLI:
def __init__(self, command, **options):
request = json.loads(command[3])
(Path(request["args"][-1]) / "log-main-process.txt").touch()
self.returncode = None
self.output = options["stdout"]
def poll(self):
return self.returncode
def wait(self, timeout):
raise subprocess.TimeoutExpired("controlled CLI", timeout)
def send_signal(self, signal):
self.output.write(json.dumps(error).encode())
self.output.flush()
self.returncode = 143
supplied = argparse.Namespace(profile_root=root, node=arguments.node,
runtime_root=root, executable=Path(arguments.node), application=root)
output = io.StringIO()
passed = True
with patch.object(module.subprocess, "Popen", ExitedCLI), contextlib.redirect_stdout(output):
try:
module.run(supplied)
except AssertionError:
passed = False
expected = code == "cancelled" and category == "cancelled" and cleanup == "terminated-owned-process"
assert passed is expected, (code, category, cleanup, output.getvalue())
receipt = json.loads(output.getvalue())
assert receipt["exit_code"] == 143
return {"verifier": "cli", "code": code, "category": category,
"application_cleanup": cleanup, "accepted": passed}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", required=True)
parser.add_argument("--javascript", type=Path, default=Path(__file__).with_name("electron-cancellation.mjs"))
parser.add_argument("--python", type=Path, default=Path(__file__).with_name("joplin-cli-cancellation.py"))
arguments = parser.parse_args()
receipts = []
with tempfile.TemporaryDirectory(prefix="rea-cancellation-oracles-") as directory:
root = Path(directory)
for complete in (False, True):
case = root / str(complete)
case.mkdir()
receipts.append(check_js(arguments, case, complete))
for code, category, cleanup in [
("cleanup_incomplete", "cancelled", "unverified"),
("cleanup_incomplete", "cancelled", "terminated-owned-process"),
("cancelled", "execution_failure", "terminated-owned-process"),
("cancelled", "cancelled", "terminated-owned-process"),
]:
receipts.append(check_cli(arguments, root, code, category, cleanup))
print(json.dumps({"oracle_cases": receipts, "real_provider_verified": False}))
if __name__ == "__main__":
main()
@@ -0,0 +1,273 @@
#!/usr/bin/env node
// Real applications run unchanged. Fault cases hold a REA-side Playwright reply;
// they do not establish that the application naturally exhibits that failure.
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { _electron } from "playwright-core";
const { values } = parseArgs({
options: {
executable: { type: "string" },
application: { type: "string" },
"profile-root": { type: "string" },
"runtime-root": { type: "string" },
case: { type: "string", default: "normal-wait" },
},
});
const cases = ["normal-wait", "window-wait", "initial-state", "final-state"];
assert.equal(
process.platform,
"linux",
"This verifier checks process identity through Linux /proc",
);
assert.ok(cases.includes(values.case));
assert.ok(values.executable && values.application && values["profile-root"]);
const runtime = resolve(
values["runtime-root"] ?? fileURLToPath(new URL("../..", import.meta.url)),
);
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"]), "joplin-"));
const controller = new AbortController();
const reason = new Error(`real-application cancellation: ${values.case}`);
const started = performance.now();
let application;
let child;
let initialBirth;
let evaluateCalls = 0;
let injected = false;
let cancelledAt;
let cancelTimer;
let watchdog;
let rejectDeadline;
const deadline = new Promise((_, reject) => {
rejectDeadline = reject;
});
const birth = async (pid) => {
try {
const text = await readFile(`/proc/${pid}/stat`, "utf8");
const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
return fields[0] === "Z" ? null : fields[19];
} catch (cause) {
if (cause.code === "ENOENT") return null;
throw cause;
}
};
const armCancellation = () => {
if (cancelTimer !== undefined) return;
cancelTimer = setTimeout(
() => {
cancelledAt = performance.now();
controller.abort(reason);
watchdog = setTimeout(
() =>
rejectDeadline(
new Error("Cancellation did not settle within 20 seconds"),
),
20_000,
);
},
values.case === "normal-wait" ? 1_000 : 100,
);
};
const launch = async (options) => {
application = await _electron.launch(options);
child = application.process();
initialBirth = await birth(child.pid);
assert.ok(initialBirth, "Launched process identity is unavailable");
return new Proxy(application, {
get(target, key) {
if (key === "evaluate")
return (...args) => {
evaluateCalls += 1;
if (
(values.case === "initial-state" && evaluateCalls === 1) ||
(values.case === "final-state" && evaluateCalls === 5)
) {
injected = true;
armCancellation();
return new Promise(() => {});
}
const result = target.evaluate(...args);
if (values.case === "normal-wait" && evaluateCalls === 4)
void result.then(armCancellation, () => undefined);
return result;
};
if (key === "firstWindow" && values.case === "window-wait")
return () => {
injected = true;
armCancellation();
return new Promise(() => {});
};
const value = Reflect.get(target, key, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
};
let record;
try {
const input = {
executable_path: resolve(values.executable),
application_path: resolve(values.application),
args: [
"--no-sandbox",
"--disable-gpu",
`--user-data-dir=${profile}/chromium`,
"--profile",
profile,
],
actions: values.case.endsWith("state")
? []
: [{ step_id: "settle", kind: "wait", duration_ms: 60_000 }],
};
const result = await Promise.race([
captureElectronScenario(
new PlaywrightElectronActiveProvider(process.env, launch),
input,
{ signal: controller.signal },
),
deadline,
]);
assert.equal(result.ok, false, "Expected a typed cancellation");
const error = result.error;
const partial = error.partialObservation?.capture;
const causes = [];
const seen = new Set();
for (
let cause = error;
cause instanceof Error && !seen.has(cause);
cause = cause.cause
) {
seen.add(cause);
causes.push({
tag: cause._tag ?? null,
reason: cause.reason ?? null,
message: cause.message,
diagnostics: cause.diagnostics ?? null,
});
}
record = {
case: values.case,
fault_injected: injected,
input,
profile,
evaluate_calls: evaluateCalls,
elapsed_ms: performance.now() - started,
cancellation_to_result_ms:
cancelledAt === undefined ? null : performance.now() - cancelledAt,
tag: error._tag,
reason: error.reason,
user_category: error.userCategory,
diagnostics: error.diagnostics ?? null,
message: error.message,
user_message: error.userMessage ?? null,
cleanup: error.cleanup ?? null,
cause: error.cause instanceof Error ? error.cause.message : null,
causes,
cleanup_incomplete: error.cleanupIncomplete,
partial:
partial === undefined
? null
: {
application: partial.application,
actions: partial.actions,
coverage: partial.coverage,
window_count: partial.windows.length,
limitations: partial.limitations,
},
launched_process_exited:
child !== undefined && (await birth(child.pid)) !== initialBirth,
};
assert.equal(controller.signal.aborted, true);
assert.equal(error.userCategory, "cancelled");
if (values.case !== "normal-wait") assert.equal(injected, true);
if (values.case.endsWith("wait"))
assert.ok(partial?.actions.some((action) => action.status === "cancelled"));
if (values.case === "initial-state") assert.equal(partial, undefined);
if (values.case === "final-state") {
assert.ok(
partial?.limitations.some((text) =>
text.includes("initial pre-action snapshot"),
),
);
assert.ok(
causes.some(
(cause) =>
cause.diagnostics?.final_state_read === "cancellation_grace_expired",
),
);
}
// Cleanup is a separate oracle. A known Linux lineage limitation must remain
// visible, rather than turning cancellation coverage into a cleanup claim.
record.cancellation_phase = "verified";
record.cleanup_phase =
record.launched_process_exited && !error.cleanupIncomplete
? "verified"
: "unverified";
record.workflow_status =
record.cleanup_phase === "verified" ? "verified" : "partial";
console.log(JSON.stringify(record));
assert.equal(
record.cleanup_phase,
"verified",
"Provider cleanup remains unverified; cancellation alone is a partial result",
);
} catch (cause) {
console.log(
JSON.stringify({
case: values.case,
fault_injected: injected,
evaluate_calls: evaluateCalls,
elapsed_ms: performance.now() - started,
cancellation_to_failure_ms:
cancelledAt === undefined ? null : performance.now() - cancelledAt,
completed: false,
observed: record ?? null,
verifier_error: cause instanceof Error ? cause.message : String(cause),
}),
);
throw cause;
} finally {
clearTimeout(cancelTimer);
clearTimeout(watchdog);
if (
application !== undefined &&
child?.exitCode === null &&
child?.signalCode === null
)
await runWithExecutionLimits(
application.close(),
undefined,
performance.now() + 5_000,
).catch((cause) => {
console.error(
JSON.stringify({
verifier_cleanup: "unverified",
message: cause.message,
}),
);
process.exitCode = 1;
});
// The external resource scope owns any leftovers; do not leave a failed
// verifier awaiting an application whose ownership the provider cannot prove.
if (child?.exitCode === null && child?.signalCode === null) process.exit(1);
}
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Verify real Joplin cancellation through REA's compiled public CLI.
The selected runtime and app run normally. Fresh profiles and raw CLI output
remain local; the printed receipt distinguishes cancellation from cleanup.
"""
import argparse
import json
from pathlib import Path
import signal
import subprocess
import tempfile
import time
def run(arguments):
profile = Path(tempfile.mkdtemp(prefix="joplin-cli-", dir=arguments.profile_root))
stdout_path = profile / "verifier-stdout.json"
stderr_path = profile / "verifier-stderr.txt"
request = {
"executable_path": str(arguments.executable.resolve()),
"application_path": str(arguments.application.resolve()),
"args": ["--no-sandbox", "--disable-gpu", f"--user-data-dir={profile}/chromium",
"--profile", str(profile)],
"actions": [{"step_id": "settle", "kind": "wait", "duration_ms": 60_000}],
}
started = time.monotonic()
cancelled = None
response_at = None
failure = None
signals = []
with stdout_path.open("wb") as output, stderr_path.open("wb") as errors:
process = subprocess.Popen(
[arguments.node, str(arguments.runtime_root.resolve() / "scripts/rea.mjs"),
"capture-electron-scenario", json.dumps(request), "--json"],
stdin=subprocess.DEVNULL, stdout=output, stderr=errors,
)
try:
readiness_deadline = started + 70
while not (profile / "log-main-process.txt").exists():
if process.poll() is not None:
raise RuntimeError(f"CLI exited before application startup: {process.returncode}")
if time.monotonic() > readiness_deadline:
raise TimeoutError("No real Joplin startup log within 70 seconds")
time.sleep(0.1)
# This only schedules cancellation. The returned action, not this
# delay, proves whether cancellation actually reached the wait.
try:
process.wait(timeout=3)
raise RuntimeError("CLI exited before the cancellation signal")
except subprocess.TimeoutExpired:
pass
cancelled = time.monotonic()
signals.append("request SIGTERM")
process.send_signal(signal.SIGTERM)
deadline = cancelled + 20
previous_size = 0
while process.poll() is None:
size = stdout_path.stat().st_size
if response_at is None and size != previous_size:
previous_size = size
try:
published = json.loads(stdout_path.read_text())
if isinstance(published, dict) and "code" in published:
response_at = time.monotonic()
except json.JSONDecodeError:
pass
if time.monotonic() > deadline:
raise TimeoutError("CLI did not exit within 20 seconds after request SIGTERM")
time.sleep(0.05)
except Exception as cause:
failure = cause
finally:
if process.poll() is None:
signals.append("verifier recovery SIGTERM")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
signals.append("verifier recovery SIGKILL")
process.kill()
process.wait()
error = json.loads(stdout_path.read_text()) if stdout_path.stat().st_size else {}
partial = error.get("details", {}).get("partial_observation", {})
capture = partial.get("capture", {})
cancelled_action = any(action["status"] == "cancelled" for action in capture.get("actions", []))
receipt = {
"request": request,
"exit_code": process.returncode,
"elapsed_seconds": time.monotonic() - started,
"cancellation_to_observation_end_seconds": None if cancelled is None else time.monotonic() - cancelled,
"cancellation_to_response_seconds": None if response_at is None else response_at - cancelled,
"signals": signals,
"verifier_failure": None if failure is None else str(failure),
"code": error.get("code"), "category": error.get("category"),
"cancelled_action": cancelled_action,
"application": capture.get("application"),
"actions": capture.get("actions"), "coverage": capture.get("coverage"),
"stdout_bytes": stdout_path.stat().st_size,
"stderr_bytes": stderr_path.stat().st_size,
"stdout_path": str(stdout_path), "stderr_path": str(stderr_path),
"cleanup_verified": error.get("code") == "cancelled" and
capture.get("application", {}).get("cleanup") == "terminated-owned-process",
}
print(json.dumps(receipt))
if failure is not None:
raise failure
assert process.returncode == 143, "CLI must retain its SIGTERM exit convention"
assert partial.get("kind") == "electron-active-observation"
assert cancelled_action, "Startup alone does not establish action cancellation"
assert error.get("category") == "cancelled"
assert receipt["cleanup_verified"], "Cancellation alone does not establish complete provider cleanup"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", required=True)
parser.add_argument("--runtime-root", type=Path, required=True)
parser.add_argument("--executable", type=Path, required=True)
parser.add_argument("--application", type=Path, required=True)
parser.add_argument("--profile-root", type=Path, required=True)
run(parser.parse_args())
@@ -1,13 +1,22 @@
// Fixture-backed action coverage; real Electron verification lives in
// `npm run verify:electron`.
import { expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import type { ElectronApplication } from "playwright-core";
import {
electronActiveObservationInputSchema,
electronActiveObservationResultSchema,
} from "../domain/javascript/electronActiveObservation.js";
import { createElectronActiveObservationFixtureResult } from "../domain/javascript/electronActiveObservation.fixture.js";
import { runElectronActions } from "./PlaywrightElectronActiveActions.js";
import {
readFinalApplicationState,
runElectronActions,
runWithExecutionLimits,
} from "./PlaywrightElectronActiveActions.js";
afterEach(() => {
vi.useRealTimers();
});
it("requires observed Electron retention and coverage instead of repairing old results", () => {
const result = createElectronActiveObservationFixtureResult("/opt/app");
@@ -367,3 +376,180 @@ it("rejects action outcomes that disagree with their error state", () => {
}).success,
).toBe(false);
});
it("cancels a wait without window_index while the app has no window", async () => {
const controller = new AbortController();
const application = {
firstWindow: vi.fn(() => {
queueMicrotask(() => controller.abort());
return new Promise<never>(() => undefined);
}),
} as unknown as ElectronApplication;
const actions = await runElectronActions(
application,
electronActiveObservationInputSchema.parse({
executable_path: "/opt/electron",
application_path: "/opt/app/main.js",
application_root: "/opt/app",
actions: [{ step_id: "settle", kind: "wait", duration_ms: 10 }],
}),
{ signal: controller.signal },
);
expect(actions).toEqual([
{
step_id: "settle",
kind: "wait",
window_index: null,
target: null,
status: "cancelled",
elapsed_ms: expect.any(Number),
error: expect.stringContaining("cancel"),
},
]);
});
it("bounds a final state read that is still pending after cancellation", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const application = {
evaluate: vi.fn(() => new Promise<never>(() => undefined)),
} as unknown as ElectronApplication;
const reason = new Error("caller-reason");
const state = readFinalApplicationState(application, controller.signal);
const outcome = state.then(
() => null,
(cause: unknown) => cause,
);
controller.abort(reason);
await vi.advanceTimersByTimeAsync(4_000);
expect(await Promise.race([outcome, Promise.resolve("pending")])).toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(1_000);
const cause = await outcome;
expect(cause).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
diagnostics: {
final_state_read: "cancellation_grace_expired",
cancellation_grace_ms: 5_000,
},
});
expect((cause as Error).cause).toBe(reason);
});
it("keeps a final state read failure inside the cancellation grace period primary", async () => {
const controller = new AbortController();
controller.abort(new Error("caller-reason"));
const failure = new Error("renderer state unavailable");
const application = {
evaluate: vi.fn(() => Promise.reject(failure)),
} as unknown as ElectronApplication;
await expect(
readFinalApplicationState(application, controller.signal),
).rejects.toBe(failure);
});
const finalState = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: {
events: [],
retention_budget_bytes: 0,
estimated_retained_bytes: 0,
event_serialized_byte_upper_bound: 0,
retained: 0,
dropped: 0,
dropped_ipc: 0,
dropped_runtime: 0,
dropped_event_families: [],
dropped_event_roles: [],
observed: 0,
observed_ipc: 0,
observed_runtime: 0,
hook_error: false,
},
};
/** An application whose four state reads settle only when `release` runs. */
const gatedStateApplication = () => {
let release = (): void => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const evaluate = vi
.fn()
.mockImplementationOnce(() => gate.then(() => finalState.windows))
.mockImplementationOnce(() => gate.then(() => finalState.metrics))
.mockImplementationOnce(() => gate.then(() => finalState.electronVersion))
.mockImplementationOnce(() => gate.then(() => finalState.hookSnapshot));
return {
application: { evaluate } as unknown as ElectronApplication,
release,
};
};
it("returns a final state read that settles in the grace period after cancellation during the read", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const { application, release } = gatedStateApplication();
const state = readFinalApplicationState(application, controller.signal);
controller.abort();
await vi.advanceTimersByTimeAsync(1_000);
release();
await expect(state).resolves.toEqual(finalState);
});
it("returns a final state read that settles in the grace period when cancellation preceded it", async () => {
vi.useFakeTimers();
const controller = new AbortController();
controller.abort();
const { application, release } = gatedStateApplication();
const state = readFinalApplicationState(application, controller.signal);
await vi.advanceTimersByTimeAsync(1_000);
release();
await expect(state).resolves.toEqual(finalState);
});
it("observes an operation abandoned by an already-cancelled signal", async () => {
const unhandled: unknown[] = [];
const recordUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", recordUnhandled);
try {
const controller = new AbortController();
const reason = new Error("caller-reason");
controller.abort(reason);
let reject = (_cause: Error): void => undefined;
const operation = new Promise<never>((_, rejectOperation) => {
reject = rejectOperation;
});
await expect(
runWithExecutionLimits(operation, controller.signal),
).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
cause: reason,
});
reject(new Error("application closed during cleanup"));
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", recordUnhandled);
}
});
+68 -6
View File
@@ -10,6 +10,8 @@ import {
} from "../domain/javascript/electronActiveObservation.js";
const OPERATION = "capture_electron_scenario" as const;
const CANCELLED_STATE_READ_TIMEOUT_MS = 5_000;
const GRACE_EXPIRED = Symbol("cancellation grace expired");
const hookSnapshotSchema = z.strictObject({
events: z.array(electronActiveTimelineEventSchema),
@@ -63,6 +65,9 @@ export type ElectronHookEvent = z.infer<
>;
export type ElectronMetrics = z.infer<typeof metricSchema>[];
type ElectronWindowMetadata = z.infer<typeof windowMetadataSchema>;
type ElectronApplicationState = Awaited<
ReturnType<typeof readApplicationState>
>;
/** Run explicit actions against provider-owned Electron windows. */
export const runElectronActions = async (
@@ -160,9 +165,12 @@ const runAction = async ({
case "wait": {
const page =
selectedWindow ??
(await application.firstWindow({
timeout: 0,
}));
(await runWithExecutionLimits(
application.firstWindow({
timeout: 0,
}),
options.signal,
));
await runWithExecutionLimits(
page.waitForTimeout(action.duration_ms),
options.signal,
@@ -355,14 +363,64 @@ export const readApplicationState = async (
};
};
/**
* Read the post-action state. Cancellation before or during the read leaves the
* same bounded grace period so a responsive app still yields its final state;
* expiry of that period is the caller's cancellation, with the expiry kept in
* the error diagnostics.
*/
export const readFinalApplicationState = async (
application: ElectronApplication,
signal: AbortSignal | undefined,
): Promise<ElectronApplicationState> => {
const state = readApplicationState(application);
if (signal?.aborted !== true)
try {
return await runWithExecutionLimits(state, signal);
} catch (cause: unknown) {
if (
!(cause instanceof BrowserObservationError) ||
cause.reason !== "cancelled"
)
throw cause;
}
let timer: ReturnType<typeof setTimeout> | undefined;
const graceExpired = new Promise<typeof GRACE_EXPIRED>((resolve) => {
timer = setTimeout(
() => resolve(GRACE_EXPIRED),
CANCELLED_STATE_READ_TIMEOUT_MS,
);
});
try {
const settled = await Promise.race([state, graceExpired]);
if (settled !== GRACE_EXPIRED) return settled;
} finally {
clearTimeout(timer);
}
// The grace period exists only because of cancellation, so its expiry is
// reported as that cancellation; a read failure inside it stays primary.
throw new BrowserObservationError(OPERATION, "cancelled", {
cause: signal?.reason,
diagnostics: {
final_state_read: "cancellation_grace_expired",
cancellation_grace_ms: CANCELLED_STATE_READ_TIMEOUT_MS,
},
});
};
/** Apply an optional lifecycle deadline and caller cancellation to an operation. */
export const runWithExecutionLimits = async <Value>(
operation: Promise<Value>,
signal: AbortSignal | undefined,
deadline?: number,
): Promise<Value> => {
if (signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
if (signal?.aborted === true) {
// The operation has already started; observe its abandoned settlement.
void operation.catch(() => undefined);
throw new BrowserObservationError(OPERATION, "cancelled", {
cause: signal.reason,
});
}
if (signal === undefined && deadline === undefined) return operation;
let timer: ReturnType<typeof setTimeout> | undefined;
let abortListener: (() => void) | undefined;
@@ -380,7 +438,11 @@ export const runWithExecutionLimits = async <Value>(
interruptions.push(
new Promise<never>((_, reject) => {
abortListener = () =>
reject(new BrowserObservationError(OPERATION, "cancelled"));
reject(
new BrowserObservationError(OPERATION, "cancelled", {
cause: signal.reason,
}),
);
signal.addEventListener("abort", abortListener, { once: true });
}),
);
@@ -233,12 +233,16 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
const terminateTree = vi
.spyOn(processOwnership, "cleanupWindowsProcessTree")
.mockResolvedValue({ cleaned: true, signaled: true });
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue({
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
});
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
@@ -293,9 +297,12 @@ it("retains the initial snapshot when the final state read fails", async () => {
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState")
.mockResolvedValueOnce(initialState)
.mockRejectedValueOnce(new Error("final renderer state unavailable"));
vi.spyOn(electronActions, "readApplicationState").mockResolvedValueOnce(
initialState,
);
vi.spyOn(electronActions, "readFinalApplicationState").mockRejectedValueOnce(
new Error("final renderer state unavailable"),
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
@@ -362,3 +369,447 @@ it("rejects active capture before launch when EnableNodeCliInspectArguments fuse
remediationAction: expect.stringContaining("inspect_electron_page"),
});
});
const stubVerifiedElectronCleanup = () => {
vi.spyOn(
ownershipObservation,
"observeOwnedProcessLineage",
).mockImplementation(async (group) => ({
status: "verified",
observedAt: "2026-10-10T00:00:00.000Z",
lineage: {
runId: group.runId,
launcherPid: group.leaderPid,
launcherParentPid: process.pid,
processGroupId: group.processGroupId,
descendants: [],
},
}));
return [
vi
.spyOn(processOwnership, "cleanupWindowsProcessTree")
.mockResolvedValue({ cleaned: true, signaled: true }),
vi
.spyOn(processOwnership, "cleanupOwnedProcessGroup")
.mockResolvedValue({ cleaned: true, signaled: true }),
vi
.spyOn(processOwnership, "verifyNoTokenOwnedProcesses")
.mockResolvedValue({ cleaned: true, signaled: false }),
];
};
/** Run a capture whose test doubles abort `controller` at a chosen phase. */
const captureWithController = async (
controller: AbortController,
application: Record<string, unknown>,
actions: unknown[],
) => {
const provider = new PlaywrightElectronActiveProvider(
{},
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
return provider.capture(
{
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
actions,
}),
application_root: dirname(process.execPath),
},
{ signal: controller.signal },
);
};
it("cancels a windowless wait action and cleans up the owned application", async () => {
const cleanupSpies = stubVerifiedElectronCleanup();
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
const controller = new AbortController();
const reason = new Error("caller-reason");
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
firstWindow: vi.fn(() => {
queueMicrotask(() => controller.abort(reason));
return new Promise<never>(() => undefined);
}),
};
const result = await captureWithController(controller, application, [
{ step_id: "settle", kind: "wait", duration_ms: 10 },
]);
expect(application.firstWindow).toHaveBeenCalledOnce();
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: {
kind: "electron-active-observation",
capture: {
actions: [{ step_id: "settle", kind: "wait", status: "cancelled" }],
},
},
});
expect(result.error.cause).toBe(reason);
});
it("cancels a pending initial state read and cleans up the owned application", async () => {
const cleanupSpies = stubVerifiedElectronCleanup();
const controller = new AbortController();
const reason = new Error("caller-reason");
vi.spyOn(electronActions, "readApplicationState").mockImplementation(() => {
queueMicrotask(() => controller.abort(reason));
return new Promise<never>(() => undefined);
});
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
});
expect(result.error.cause).toBe(reason);
});
it("observes an initial state read started after cancellation and rejected by cleanup", async () => {
const cleanupSpies = stubVerifiedElectronCleanup();
const unhandled: unknown[] = [];
const recordUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", recordUnhandled);
try {
const controller = new AbortController();
const reason = new Error("caller-reason");
let rejectReads = (_cause: Error): void => undefined;
const closed = new Promise<never>((_, reject) => {
rejectReads = reject;
});
// Plain functions keep spy bookkeeping from observing the abandoned read.
const application = {
process: () => ({ pid: 424242 }),
evaluate: () => {
controller.abort(reason);
return closed;
},
close: vi.fn(async () => {
rejectReads(
new Error("Target page, context or browser has been closed"),
);
}),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(unhandled).toEqual([]);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
});
expect(result.error.cause).toBe(reason);
} finally {
process.off("unhandledRejection", recordUnhandled);
}
});
it("keeps a failure observed with cancellation as the cancellation cause", async () => {
stubVerifiedElectronCleanup();
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
const controller = new AbortController();
const failure = new Error("final state read failed");
vi.spyOn(electronActions, "readFinalApplicationState").mockImplementation(
async () => {
controller.abort(new Error("caller-reason"));
throw failure;
},
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: { kind: "electron-active-observation" },
});
expect(result.error.cause).toBe(failure);
});
it("reports an expired final state grace period as the caller's cancellation", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
stubVerifiedElectronCleanup();
const controller = new AbortController();
const reason = new Error("caller-reason");
vi.spyOn(electronActions, "readApplicationState").mockResolvedValueOnce({
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
});
// The real final read reaches these evaluations, which never settle.
const evaluate = vi.fn(() => {
queueMicrotask(() => controller.abort(reason));
return new Promise<never>(() => undefined);
});
const application = {
process: () => ({ pid: 424242 }),
evaluate,
close: vi.fn(async () => undefined),
windows: () => [],
};
const capture = captureWithController(controller, application, []);
await vi.waitFor(() => expect(evaluate).toHaveBeenCalled());
await vi.advanceTimersByTimeAsync(5_000);
const result = await capture;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
diagnostics: {
final_state_read: "cancellation_grace_expired",
cancellation_grace_ms: 5_000,
},
partialObservation: {
kind: "electron-active-observation",
capture: {
limitations: expect.arrayContaining([
expect.stringContaining("initial pre-action snapshot"),
]),
},
},
});
expect(result.error.cause).toBe(reason);
} finally {
vi.useRealTimers();
}
});
const stubCompletedCapture = () => {
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
};
it("cancels a completed capture when cancellation arrives during application close", async () => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM during close");
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => {
controller.abort(reason);
}),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(application.close).toHaveBeenCalledOnce();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: {
kind: "electron-active-observation",
capture: { application: { electron_version: "44.0.0" } },
},
});
expect(result.error.cause).toBe(reason);
});
it("cancels a completed capture when cancellation arrives during process cleanup", async () => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM during process cleanup");
// The POSIX path ends with the token sweep; Windows ends with tree termination.
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockImplementation(
async () => {
controller.abort(reason);
return {
cleaned: true,
signaled: false,
unverified: [{ pid: 777, diagnostic: "command line unavailable" }],
};
},
);
vi.spyOn(processOwnership, "cleanupWindowsProcessTree").mockImplementation(
async () => {
controller.abort(reason);
return { cleaned: true, signaled: true };
},
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: {
kind: "electron-active-observation",
capture: { application: { electron_version: "44.0.0" } },
},
});
expect(result.error.cause).toBe(reason);
});
it("keeps cleanup failure precedence over cancellation observed during cleanup", async () => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const failed = async () => {
controller.abort(new Error("SIGTERM during process cleanup"));
return { cleaned: false as const, reason: "process group still alive" };
};
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
failed,
);
vi.spyOn(processOwnership, "cleanupWindowsProcessTree").mockImplementation(
failed,
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup to fail");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
cleanup: { reason: "process group still alive" },
partialObservation: {
kind: "electron-active-observation",
capture: { application: { cleanup: "unverified" } },
},
});
});
it.each(["process", "transport"] as const)(
"preserves cancellation that preceded a %s cleanup failure",
async (failure) => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM before cleanup");
vi.spyOn(electronActions, "runElectronActions").mockImplementation(
async () => {
controller.abort(reason);
return [];
},
);
if (failure === "process") {
const failed = async () => ({
cleaned: false as const,
reason: "process group still alive",
});
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
failed,
);
vi.spyOn(
processOwnership,
"cleanupWindowsProcessTree",
).mockImplementation(failed);
}
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => {
if (failure === "transport")
throw new Error("transport still connected");
}),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected incomplete cleanup");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
userCategory: "cancelled",
cause: {
_tag: "BrowserObservationError",
reason: "cancelled",
cause: reason,
},
partialObservation: {
kind: "electron-active-observation",
},
});
expect(result.error.userMessage).toContain(
failure === "process"
? "process group still alive"
: "transport still connected",
);
expect(application.close).toHaveBeenCalledOnce();
},
);
+49 -14
View File
@@ -13,6 +13,7 @@ import type {
} from "../application/javascript/ElectronActiveObservationPort.js";
import type { ElectronActiveObservationResult } from "../domain/javascript/electronActiveObservation.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -34,6 +35,7 @@ import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProces
import {
runElectronActions,
readApplicationState,
readFinalApplicationState,
runWithExecutionLimits,
} from "./PlaywrightElectronActiveActions.js";
import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js";
@@ -137,23 +139,21 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
...(captureBaseline === undefined ? {} : { captureBaseline }),
};
lineage = await observeOwnedProcessLineage(ownership);
initialState = await readApplicationState(application);
actions = await runElectronActions(application, input, options);
const state = await runWithExecutionLimits(
initialState = await runWithExecutionLimits(
readApplicationState(application),
undefined,
options.signal?.aborted ? performance.now() + 5_000 : undefined,
options.signal,
startupDeadline,
);
actions = await runElectronActions(application, input, options);
const state = await readFinalApplicationState(
application,
options.signal,
);
capture = projectElectronActiveCapture(paths, actions, state);
// Preserve a cancellation already observed before finalization so a
// later cleanup failure keeps its cause. Late cancellation is checked below.
outcome = options.signal?.aborted
? err(
new BrowserObservationError(OPERATION, "cancelled", {
partialObservation: {
kind: "electron-active-observation",
capture,
},
}),
)
? err(cancelledCapture(capture, options.signal.reason))
: ok(capture);
} catch (cause: unknown) {
if (paths !== undefined && initialState !== undefined) {
@@ -173,7 +173,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
if (options.signal?.aborted === true)
outcome = err(
new BrowserObservationError(OPERATION, "cancelled", {
cause,
...cancellationCause(cause, options.signal),
...(capture === undefined
? {}
: {
@@ -276,10 +276,45 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
],
});
}
// Cleanup failures returned above keep precedence; a completed capture
// observed under late cancellation is retained as partial Evidence.
if (outcome.ok && options.signal?.aborted === true)
return err(cancelledCapture(outcome.value, options.signal.reason));
return outcome;
}
}
const cancelledCapture = (
capture: ElectronActiveObservationResult,
reason: unknown,
): BrowserObservationError =>
new BrowserObservationError(OPERATION, "cancelled", {
cause: reason,
partialObservation: { kind: "electron-active-observation", capture },
});
/**
* A cancellation wrapper stands in for the caller's abort reason and keeps its
* producer diagnostics; any other failure observed with cancellation stays the
* primary cause.
*/
const cancellationCause = (
cause: unknown,
signal: AbortSignal,
): {
readonly cause: unknown;
readonly diagnostics?: AnalysisError["diagnostics"];
} =>
cause instanceof AnalysisCancelledError ||
(cause instanceof AnalysisError && cause.userCategory === "cancelled")
? {
cause: signal.reason,
...(cause.diagnostics === undefined
? {}
: { diagnostics: cause.diagnostics }),
}
: { cause };
/** Clean up an Electron launch. Windows signals a process only after lineage is verified. */
export const cleanupElectronProcesses = async (
ownership: OwnedProcessGroup,