[ai] Report a death after TEST_COMPLETE in test-run

Task: 2026/08/10/report-post-complete-death-in-test-run
This commit is contained in:
John Preston
2026-08-10 20:59:31 +04:00
parent b776b75686
commit fdae24e725
4 changed files with 234 additions and 25 deletions

View File

@@ -191,9 +191,10 @@ handles that unmarked live folder by step 3.
Deletion guard — the only folder the flow may ever delete is a live `TelegramForcePortable` that
either carries the `testing` marker or coexists with `real_...` (step 3). If the test account
breaks mid-loop (login screen, `AUTH_KEY_DUPLICATED`), delete the MARKED live folder, re-run SETUP
for a fresh golden copy, and retry once; if it is still broken the run is UNRECOVERABLE. Never
delete or alter `test_...` or `real_...` under any circumstances.
breaks mid-loop (login screen, `AUTH_KEY_DUPLICATED`), delete the MARKED live folder (that deletion
takes the Crashpad database under `tdata/dumps/completed/` with it, so copy out any dump worth
keeping first), re-run SETUP for a fresh golden copy, and retry once; if it is still broken the run
is UNRECOVERABLE. Never delete or alter `test_...` or `real_...` under any circumstances.
**Serialize app runs.** Never have two `Telegram.exe` instances alive against this account at once —
concurrent reuse of one auth key can trigger a server-side session reset. Before SETUP, launching, or
@@ -519,9 +520,16 @@ bypass it with hand-built relative paths.
`<EVIDENCE_DIR>/app_stdout.txt` and stderr to `<EVIDENCE_DIR>/app_stderr.txt` (the flag prevents
modal crash hangs, and stderr captures assertion text), enforces **a hard wall-clock deadline
from launch** and a quiet-log watchdog while polling `<EVIDENCE_DIR>/test_log.txt`, detects
`TEST_COMPLETE` (success) versus process death (crash) versus the caps elapsing (hang), kills any
`TEST_COMPLETE` versus process death (crash) versus the caps elapsing (hang), kills any
straggler, and returns one JSON report with the parsed markers, stderr tail, fresh crash
diagnostics, and `stale_crash_cleared`. That field is an ordered list of `{from, kind, to}`
diagnostics, `crashpad_dumps_added`, `death_signals`, and `stale_crash_cleared`.
`TEST_COMPLETE` alone is not success: when the process writes it and then dies, the verdict is
`died-after-complete`, not `complete`, on any of three independent signals — a non-zero
`exit_code`, a new `.dmp` in the live `tdata/dumps/completed/` Crashpad database across the run,
or a fresh top-level `tdata/dumps/*.dmp` from a Breakpad build. `crashpad_dumps_added` is that
before/after delta, listed in full because `test-run` never clears `completed/` between runs;
`death_signals` names which of `"breakpad_dump"`, `"crashpad_dump"` and `"exit_code"` fired, and
is `[]` for a healthy run. `stale_crash_cleared` is an ordered list of `{from, kind, to}`
entries whose `kind` is `"report"` or `"dump"`, and is `[]` when nothing was cleared. If the
stale report cannot be moved, `test-run` refuses before launch, prints the helper error on stderr,
exits non-zero, and emits no JSON. If a dump cannot be moved, `test-run` leaves it in place,
@@ -545,11 +553,15 @@ set, the binary:
`<EVIDENCE_DIR>/app_stderr.txt`, tagged `[testagent]`;
- also turns on debug logging (`-testagent` implies `-debug`).
**Do NOT key the crash decision on exit code.** Breakpad handles the crash and the process usually
exits **0** — exactly as tdesktop's own crash detection assumes. The reliable crash signals are: the
process is gone WITHOUT a `TEST_COMPLETE` marker, AND a fresh non-empty
`<workdir>/tdata/working` exists. On macOS, a fresh matching system `.ips`
report is also sufficient when Telegram's reporter wrote nothing. So **always
**Do NOT key a pre-`TEST_COMPLETE` crash decision on exit code.** Breakpad handles the crash and
the process usually exits **0** — exactly as tdesktop's own crash detection assumes. The reliable
crash signals before `TEST_COMPLETE` are: the process is gone WITHOUT a `TEST_COMPLETE` marker,
AND a fresh non-empty `<workdir>/tdata/working` exists. On macOS, a fresh matching system `.ips`
report is also sufficient when Telegram's reporter wrote nothing. **After** `TEST_COMPLETE` the
opposite holds: `CrashReports::Finish()` unlinks `tdata/working` during the clean shutdown that
precedes a teardown fault, so the only signals left are a non-zero `exit_code`, a new
`tdata/dumps/completed/*.dmp` on the macOS Crashpad build, and a new top-level `tdata/dumps/*.dmp`
on the Breakpad builds — `test-run` reads all three and reports `died-after-complete`. So **always
pass `-testagent`**, and on a crash gather diagnostics in this order before
deciding the verdict:
@@ -559,8 +571,12 @@ deciding the verdict:
`CrtAssert:` annotations, the failed `file:line`, and `Caught signal …` / minidump id. Plain text;
read it directly. `<workdir>` is the launch `-workdir` (in portable test runs,
`out/Debug/TelegramForcePortable/`).
3. **`<workdir>/tdata/dumps/*.dmp`** — the minidump (full stack, needs symbols to read; note its path
in `test.md`, don't try to symbolize inline).
3. **`<workdir>/tdata/dumps/`** — the minidump (full stack, needs symbols to read; note its path in
`test.md`, don't try to symbolize inline). Breakpad writes `*.dmp` at that top level; the macOS
Crashpad build keeps its database one directory below, in `<workdir>/tdata/dumps/completed/`, so
a top-level listing can be empty while a real dump exists. `test-run` reports this run's fresh
top-level dumps in `dumps` and its new `completed/` entries in `crashpad_dumps_added`, so on a
Breakpad build the first is the field to read and the second is always `[]`.
4. **macOS `~/Library/Logs/DiagnosticReports/Telegram-*.ips`** — when the preceding files are empty,
inspect reports created after the exact process launch and match the app UUID/start time. These
reports can contain a fully symbolicated stack even when Telegram's reporter wrote nothing.

View File

@@ -575,19 +575,30 @@ rules, with these external-task safety adaptations:
wall-clock deadline and quiet-log watchdog and returns one JSON report:
outcome, `TEST_COMPLETE` state, parsed
`TEST_STEP`/`TEST_RESULT`/`SCREENSHOT` markers, stderr tail, fresh
`tdata/working` crash excerpt, minidump paths, and
`stale_crash_cleared`. That field is an ordered list of
`{from, kind, to}` entries whose `kind` is `"report"` or `"dump"`, and is
`[]` when nothing was cleared. If a stale report cannot be moved, `test-run`
refuses before launch, prints the helper error on stderr, exits non-zero,
and emits no JSON. If a dump cannot be moved, the run leaves it in place,
records its entry with `"to": null` (a null destination), and continues to
launch. The performer then judges the evidence itself — the runner gathers,
it never assesses. Crash detection keys on process death without
`TEST_COMPLETE` plus a fresh `tdata/working`, not exit code.
`tdata/working` crash excerpt, minidump paths, `crashpad_dumps_added`,
`death_signals`, and `stale_crash_cleared`. `crashpad_dumps_added` is the
before/after delta of the live `tdata/dumps/completed/` Crashpad database
across the run — a delta because `test-run` never clears that directory
between runs — and `death_signals` names which of `"breakpad_dump"` /
`"crashpad_dump"` / `"exit_code"` fired. A run that writes `TEST_COMPLETE`
and then dies on any of those signals reports `died-after-complete`, not
`complete`. `stale_crash_cleared` is an ordered list of `{from, kind, to}`
entries whose `kind` is `"report"` or `"dump"`, and is `[]` when nothing
was cleared. If a stale report cannot be moved, `test-run` refuses before
launch, prints the helper error on stderr, exits non-zero, and emits no
JSON. If a dump cannot be moved, the run leaves it in place, records its
entry with `"to": null` (a null destination), and continues to launch. The
performer then judges the evidence itself — the runner gathers, it never
assesses. Pre-`TEST_COMPLETE` crash detection keys on process death
without `TEST_COMPLETE` plus a fresh `tdata/working`, not exit code;
post-`TEST_COMPLETE` death keys on `exit_code`, the `completed/` delta and
a fresh top-level `tdata/dumps/*.dmp`, because the clean shutdown unlinks
`tdata/working` before the fault.
- If the account breaks mid-loop (login screen, `AUTH_KEY_DUPLICATED`), run
`test-account-reset --exe EXE` — it deletes only a marked live copy and
re-copies golden — then retry once.
re-copies golden — then retry once. That deletion takes the Crashpad
database under `tdata/dumps/completed/` with it, so copy out any dump
worth keeping first.
- Enforce the in-app watchdog too. Count test runs independently from
implementation attempts and stop at `MAX_TEST_RUNS`.
- A repeated setup failure is not a reason to stop below that cap. Apply the

View File

@@ -56,6 +56,7 @@ OVERLAY_SUBMODULES_DIR = "test-overlay-submodules"
TEST_LOG_FILE = "test_log.txt"
TEST_COMPLETE_MARKER = "TEST_COMPLETE"
STALE_CRASH_DIR = "stale-crash"
CRASHPAD_COMPLETED_DIR = "completed"
BUILD_LOCK_PROCESS_NAMES = {
"cl.exe",
"cmake.exe",
@@ -1816,6 +1817,8 @@ def command_test_run(args):
stderr_path = run_dir / "app_stderr.txt"
working = portable / PORTABLE_LIVE / "tdata" / "working"
dumps_dir = portable / PORTABLE_LIVE / "tdata" / "dumps"
completed_dir = dumps_dir / CRASHPAD_COMPLETED_DIR
completed_before = set(completed_dir.glob("*.dmp"))
launched_at = time.time()
with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
@@ -1880,15 +1883,27 @@ def command_test_run(args):
str(path) for path in dumps_dir.glob("*.dmp")
if path.stat().st_mtime >= launched_at
) if dumps_dir.is_dir() else []
crashpad_dumps_added = sorted(
str(path)
for path in set(completed_dir.glob("*.dmp")) - completed_before
)
death_signals = []
if dumps:
death_signals.append("breakpad_dump")
if crashpad_dumps_added:
death_signals.append("crashpad_dump")
if outcome == "exited" and exit_code:
death_signals.append("exit_code")
after_complete = "died-after-complete" if death_signals else "complete"
if outcome == "exited":
if test_complete:
verdict_hint = "complete"
verdict_hint = after_complete
elif crash_report_fresh or dumps:
verdict_hint = "crash"
else:
verdict_hint = "died-without-complete"
elif outcome == "killed-after-complete":
verdict_hint = "complete"
verdict_hint = after_complete
else:
verdict_hint = "hang"
@@ -1901,6 +1916,8 @@ def command_test_run(args):
else None
),
"crash_report_fresh": crash_report_fresh,
"crashpad_dumps_added": crashpad_dumps_added,
"death_signals": death_signals,
"dumps": dumps,
"duration_seconds": round(ended_at - launched_at, 1),
"exe": str(exe),

View File

@@ -1149,6 +1149,23 @@ def write_complete_markers_exe(path):
))
def write_dump_after_complete_exe(path, dump, tail, windows_tail):
directory = dump.parent
return write_fake_exe(path, (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_COMPLETE" >> "$LOG"\n'
f'mkdir -p "{directory}"\n'
f'echo "MDMP fresh minidump" > "{dump}"\n'
+ tail
), (
'set "LOG=%TDESKTOP_TEST_EVIDENCE_DIR%\\test_log.txt"\n'
'echo TEST_COMPLETE>>"%LOG%"\n'
f'if not exist "{directory}" mkdir "{directory}"\n'
f'echo MDMP fresh minidump>"{dump}"\n'
+ windows_tail
))
def make_portable_root(root):
debug = root / "out" / "Debug"
golden = debug / workspace.PORTABLE_GOLDEN
@@ -1452,6 +1469,115 @@ class MechanicsTest(unittest.TestCase):
self.assertEqual(result["markers"]["screenshots"], ["/tmp/fake.png"])
self.assertFalse(result["crash_report_fresh"])
def test_test_run_reports_a_death_after_complete(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_RESULT: PASS: row painted" >> "$LOG"\n'
'echo "TEST_COMPLETE" >> "$LOG"\n'
"exit 3\n"
), (
'set "LOG=%TDESKTOP_TEST_EVIDENCE_DIR%\\test_log.txt"\n'
'echo TEST_RESULT: PASS: row painted>>"%LOG%"\n'
'echo TEST_COMPLETE>>"%LOG%"\n'
"exit /b 3\n"
))
result = run_test_run(exe, root / "run1")
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["verdict_hint"], "died-after-complete")
self.assertTrue(result["test_complete"])
self.assertEqual(result["exit_code"], 3)
self.assertEqual(result["death_signals"], ["exit_code"])
self.assertEqual(result["crashpad_dumps_added"], [])
self.assertFalse(result["crash_report_fresh"])
self.assertEqual(result["dumps"], [])
def test_test_run_reports_both_death_signals_after_complete(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
debug = make_portable_root(root)
fresh = (
debug / workspace.PORTABLE_LIVE / "tdata" / "dumps"
/ workspace.CRASHPAD_COMPLETED_DIR / "both.dmp"
)
exe = write_dump_after_complete_exe(
debug / "Telegram", fresh, "exit 11\n", "exit /b 11\n",
)
result = run_test_run(exe, root / "run1")
self.assertEqual(result["verdict_hint"], "died-after-complete")
self.assertEqual(result["exit_code"], 11)
self.assertEqual(
result["death_signals"],
["crashpad_dump", "exit_code"],
)
self.assertEqual(result["crashpad_dumps_added"], [str(fresh)])
def test_test_run_counts_a_crashpad_dump_written_during_the_run(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
debug = make_portable_root(root)
fresh = (
debug / workspace.PORTABLE_LIVE / "tdata" / "dumps"
/ workspace.CRASHPAD_COMPLETED_DIR / "fresh.dmp"
)
exe = write_dump_after_complete_exe(
debug / "Telegram", fresh, "exit 0\n", "exit /b 0\n",
)
result = run_test_run(exe, root / "run1")
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["exit_code"], 0)
self.assertTrue(result["test_complete"])
self.assertEqual(result["verdict_hint"], "died-after-complete")
self.assertEqual(result["death_signals"], ["crashpad_dump"])
self.assertEqual(result["crashpad_dumps_added"], [str(fresh)])
self.assertEqual(result["dumps"], [])
def test_test_run_counts_a_breakpad_dump_written_during_the_run(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
debug = make_portable_root(root)
fresh = (
debug / workspace.PORTABLE_LIVE / "tdata" / "dumps"
/ "breakpad.dmp"
)
exe = write_dump_after_complete_exe(
debug / "Telegram", fresh, "exit 0\n", "exit /b 0\n",
)
result = run_test_run(exe, root / "run1")
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["exit_code"], 0)
self.assertTrue(result["test_complete"])
self.assertEqual(result["verdict_hint"], "died-after-complete")
self.assertEqual(result["death_signals"], ["breakpad_dump"])
self.assertEqual(result["dumps"], [str(fresh)])
self.assertEqual(result["crashpad_dumps_added"], [])
def test_test_run_ignores_a_crashpad_dump_from_before_the_run(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
debug = make_portable_root(root)
self.assertEqual(workspace.setup_test_account(debug), "fresh-copy")
completed = (
debug / workspace.PORTABLE_LIVE / "tdata" / "dumps"
/ workspace.CRASHPAD_COMPLETED_DIR
)
completed.mkdir(parents=True)
old = completed / "old.dmp"
old.write_bytes(b"MDMP old minidump\n")
exe = write_complete_markers_exe(debug / "Telegram")
run_dir = root / "run1"
result = run_test_run(exe, run_dir)
self.assertEqual(result["account"], "reused-marked-live")
self.assertEqual(result["verdict_hint"], "complete")
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["death_signals"], [])
self.assertEqual(result["crashpad_dumps_added"], [])
self.assertEqual(result["stale_crash_cleared"], [])
self.assertEqual(old.read_bytes(), b"MDMP old minidump\n")
self.assertFalse((run_dir / workspace.STALE_CRASH_DIR).exists())
def test_test_run_reports_crash_diagnostics(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -1543,6 +1669,45 @@ class MechanicsTest(unittest.TestCase):
self.assertEqual(result["verdict_hint"], "hang")
self.assertFalse(result["test_complete"])
def test_test_run_keeps_a_grace_kill_complete(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_COMPLETE" >> "$LOG"\n'
"sleep 30\n"
), (
'set "LOG=%TDESKTOP_TEST_EVIDENCE_DIR%\\test_log.txt"\n'
'echo TEST_COMPLETE>>"%LOG%"\n'
":loop\ngoto loop\n"
))
result = run_test_run(exe, root / "run1", grace=1.0)
self.assertEqual(result["outcome"], "killed-after-complete")
self.assertEqual(result["verdict_hint"], "complete")
self.assertTrue(result["test_complete"])
self.assertIsNone(result["exit_code"])
self.assertEqual(result["death_signals"], [])
def test_test_run_reports_a_grace_kill_with_a_dump(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
debug = make_portable_root(root)
dump = (
debug / workspace.PORTABLE_LIVE / "tdata" / "dumps"
/ workspace.CRASHPAD_COMPLETED_DIR / "grace.dmp"
)
exe = write_dump_after_complete_exe(
debug / "Telegram", dump, "sleep 30\n", ":loop\ngoto loop\n",
)
result = run_test_run(exe, root / "run1", grace=1.0)
self.assertEqual(result["outcome"], "killed-after-complete")
self.assertEqual(result["verdict_hint"], "died-after-complete")
self.assertTrue(result["test_complete"])
self.assertIsNone(result["exit_code"])
self.assertEqual(result["death_signals"], ["crashpad_dump"])
self.assertEqual(result["crashpad_dumps_added"], [str(dump)])
def test_test_run_clears_and_preserves_stale_crash_state(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()