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:
10 files changed
+4371
-29
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user