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>
155 lines
6.7 KiB
Python
155 lines
6.7 KiB
Python
#!/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()
|