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