test(cli): capture the server log when a startup probe times out (BLO-28818) - #1412
Conversation
…-28818) `company-import-export-e2e` boots a real server and waits for /api/health. When that wait times out the error it raised was unactionable, because the server is configured with `logging.mode: "file"` — its diagnostics go to `logDir`, NOT to the stdout/stderr the test captures from the child process. So the failure reported two empty strings, and `afterAll` then deleted the temp root holding the one artifact that could explain the stall. Every occurrence of this flake destroyed its own evidence. Seen 2026-08-19 in run 32208119898 (`General tests (workspaces-a)`): doctor passed clean — Postgres connected, port free — then `Starting Paperclip server...` and nothing, for the full 120s, with the process still alive and 42/43 test files passing. Nothing in that failure says where startup stopped. Read the log back into both throw paths, and include the elapsed time and the exit code so the two failure modes (died vs. stalled) are distinguishable at a glance. `serverLogDir` is now the single source for the path, used by the code that configures the server and the code that reads it back — a second literal would let reader and writer drift, and the symptom would be silent, since an empty log dir in a failure message reads exactly like "the server logged nothing". Deliberately NOT raising the timeout. This file has been timeout-bumped twice already (BLO-17053, and "test(cli): tolerate ARC startup and reseed latency") without the cause ever being established; a third bump would hide the signal again. Why a local server needs >120s to answer /api/health, with Postgres already connected, is the real defect — this change is what makes the next occurrence diagnosable enough to find it. The reader lives in helpers/server-logs.ts so it can be tested: it only runs on a path that a green run never touches, so without a test a regression here would surface as "the flake is still undiagnosable" months later. All seven cases mutation-tested — throwing on a missing dir, collapsing an empty dir to "", keeping the head instead of the tail, always claiming truncation, reversing file order, and dropping the "logs" segment each fail the specific test that claims to catch them. Known gap: the per-file read-error branch is not covered — as root, chmod 000 is still readable, so the case cannot be constructed portably here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 similar comment
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c624ba1
A well-argued, well-tested diagnostic. Two things to fix first — both in the exact
comment/message surface this PR exists to make trustworthy.
Critical Issues (0)
Important Issues (2)
-
[code/errors]
cli/src/__tests__/company-import-export-e2e.test.ts:264— a signal-killed server is misreported as(process still alive).child.exitCodeisnullboth when the child has not exited and when it was terminated by a signal, so theexitCode !== nullguard never fires for aSIGKILL. The run then polls for the full 120 s and throws the line 281 message, which hard-asserts the process is alive. Verified on this runtime:$ node -e 'c=spawn("sleep",["30"]); c.kill("SIGKILL"); …' after SIGKILL -> exitCode: null signalCode: SIGKILL killed: trueThis is not incidental to the PR — an OOM-kill of a server co-resident with embedded Postgres in a CI shard is a leading candidate for the very stall being investigated, and the PR body reasons "Process still alive — a dead child throws a different error" to conclude the observed flake was a stall rather than a death. Under a signal kill the old code would not have thrown that different error either, so the new message now states as fact something the code cannot establish.
- Check
child.signalCodealongsideexitCodein the loop guard, and have the timeout message report the observed pair rather than asserting liveness — e.g.(exitCode=${child.exitCode} signalCode=${child.signalCode}). That also exits early on an OOM-kill instead of burning the remaining 120 s.
- Check
-
[comments]
cli/src/__tests__/company-import-export-e2e.test.ts:250— the orienting comment cites the wrong ticket: "Before BLO-28813's sibling fix, …". BLO-28813 is a real but unrelated issue — "CI: pnpm/action-setup@v6 makes every job depend on a fail-closed registry.npmjs.org fetch" (PR #1410) — so a future reader chasing the log-capture rationale lands on pnpm registry retries. A dangling ID would merely dead-end; this one actively misdirects. The helper (helpers/server-logs.ts:22) and the new test both correctly say BLO-28818. The tense is also off: "Before X's sibling fix" implies the change landed elsewhere, when this diff is the change.- Suggest:
Until BLO-28818 (this change), a failed startup probe reported two empty strings ….
- Suggest:
Suggestions (3)
- [types/code]
cli/src/__tests__/helpers/server-logs.ts:4,46,48—SERVER_LOG_TAIL_BYTESis not a byte budget.readFileSync(…, "utf8")returns a string, soslice(-tailBytes)andbody.lengthcount UTF-16 code units, while the message at line 48 says "bytes". The server logs throughpino-pretty, and the captured output quoted in the PR body is full of◇ │ ✓ └— for exactly those glyphs,.lengthis 4 andBuffer.byteLength(…, "utf8")is 12. So the cap can exceed 8 KB by up to ~3–4× and the "last N of M bytes" figure is wrong. Either rename to…_TAIL_CHARSand say "characters", or read viaBufferand slice bytes. - [tests]
cli/src/__tests__/helpers/server-logs.ts:51— the per-file read-error branch is portably testable, contrary to the PR body's stated gap ("running as root,chmod 000is still readable"). No permission trick is needed: create a subdirectory inside the log dir.readdirSynclists it, andreadFileSyncon it throwsEISDIRon every platform and every uid. Confirmed here as uid 1000 —entries: [ 'rotated', 'server.log' ],throws: EISDIR. That also covers a realistic shape, since a rotated-log subdirectory is a plausible future layout. - [code]
cli/src/__tests__/helpers/server-logs.ts:46—body.slice(-tailBytes)withtailBytes === 0returns the whole string (-0 === 0, so"abc".slice(-0) === "abc") and then reports no truncation, i.e. a zero budget dumps the entire log. Unreachable from the current call sites, but the function is exported and takes the budget as a parameter. AMath.max(1, tailBytes)or an explicit zero guard closes it.
Strengths
- The
serverLogDirsingle-source argument holds up under checking, which is what makes it worth the indirection. The writer (:70) and reader (:335) derive from the sametempRootin the samebeforeAllscope, andresolveServerLogDir()prefersPAPERCLIP_LOG_DIRover the config — butcreateBasePaperclipEnvstrips everyPAPERCLIP_*var and never re-sets that one, so the env branch is genuinely dead here and reader and writer cannot diverge. - Never-throwing on a failure path, with the reasoning recorded at the call site rather than left implicit — a diagnostic that can mask the incident it is explaining is a real hazard, and this one can't.
- Distinguishing "directory missing" from "directory empty" is the right cut: the logger
mkdirSyncs at import, so an empty dir specifically means the process reached module load and then went quiet — a much sharper signal than an empty string. - The mutation table in the PR description is the kind of evidence that makes a test suite believable, and each listed mutation does map to a test that would catch it.
- Refusing a third timeout bump, and saying why, is the right call.
Recommended Action
- No Critical issues — nothing blocks on correctness of shipped behavior.
- Address both Important issues this cycle: the signal-kill guard (a diagnostic that can assert something false undercuts the PR's own goal) and the BLO-28813 → BLO-28818 correction.
- Take the Suggestions opportunistically; the
EISDIRone is a two-line test that closes the gap the description flags as unclosable.
…s (BLO-28818) Addresses both Important findings from review of c624ba1. 1. A signal-killed server was misreported as alive. `child.exitCode` is null BOTH while a child runs and after it dies from a signal — Node records the signal in `child.signalCode` instead — so the `exitCode !== null` guard never fired for a SIGKILL. The probe then polled the full 120s and threw a message that hard-asserted "(process still alive)". That is not incidental to this PR. An OOM-kill of a server co-resident with embedded Postgres in a CI shard is a leading candidate for the very stall being investigated, and this PR's own reasoning ("a dead child throws a different error") used that guard to conclude the observed flake was a stall rather than a death. Under a signal kill the old code would not have thrown a different error either — so the message stated as fact something the code could not establish. Verified on this runtime: spawn("sleep",["30"]); kill("SIGKILL") -> exitCode: null signalCode: SIGKILL killed: true Both throw paths now report the observed pair, and the loop exits early on a signal kill instead of burning the remaining budget. 2. The orienting comment cited BLO-28813 — a real but unrelated issue (pnpm registry retries, PR #1410) — so a reader chasing the log-capture rationale landed somewhere actively misleading. The tense implied the fix landed elsewhere, when this diff IS the change. Also takes all three suggestions, each mutation-verified: - The tail budget now counts BYTES. `readFileSync(…, "utf8")` returns a string, so the old `slice(-n)` counted UTF-16 code units: the server logs through pino-pretty, whose glyphs ("◇ │ ✓ └") are 7 units but 15 bytes, so an "8000 byte" cap admitted multiples of that and the "last N of M bytes" figure was simply wrong. Reads a Buffer and slices bytes. - A zero budget is a floor, not "unlimited". `slice(-0)` is `slice(0)` — the whole string — so asking for nothing returned everything. - The per-file read-error branch is tested. The previous message claimed this was not portably constructible ("running as root, chmod 000 is still readable"); that was wrong. A subdirectory inside the log dir throws EISDIR from readFileSync on every platform and every uid — confirmed here as uid 1000 — and a rotated-log subdirectory is a plausible real layout. Mutation-verified (each reverted after): char-based slice -> caught by "budgets in BYTES"; clamp removed -> caught by "zero budget as a floor"; per-file guard removed -> caught by "unreadable entry". Tree green before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both Important findings fixed at Important 1 — signal kill. You're right that this is not incidental. The guard now checks Important 2 — wrong ticket. Corrected to BLO-28818, and the tense fixed to "Until BLO-28818 (this change)". That was a copy-paste from the sibling PR, and you're right that a wrong-but-real ID is worse than a dangling one. Suggestion 1 — bytes vs code units. Taken, and fixed rather than renamed: a log-tail budget genuinely wants to be bytes. Reads a Suggestion 2 — EISDIR. Taken, and thank you for this one specifically: it doesn't just add a test, it falsifies a claim I'd put in the PR body. I've corrected that body text rather than quietly deleting it — it said the branch couldn't be covered portably, and that was simply wrong. Suggestion 3 — zero budget. Taken. Each of the three is mutation-verified, reverted after, tree green before and after:
One gap I'm stating rather than papering over: the |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0bc26f3
Both Important findings are genuinely fixed, and all three Suggestions were taken. I re-ran every new
test assertion against this runtime rather than reading the arithmetic — all nine pass, including the
two I would most expect to be off-by-one. Clean.
Prior Findings Dispositioned (2)
- prior:c624ba1 important 1 — fixed —
cli/src/__tests__/company-import-export-e2e.test.ts:270— the loop guard is nowchild.exitCode !== null || child.signalCode !== null, and the timeout message at:291-293reports the observed pair instead of asserting liveness. Verified on this runtime: afterSIGKILL,exitCode=null signalCode=SIGKILL— the old guard returnsfalse, the new onetrue. The OOM-kill path now exits immediately rather than burning the remaining budget and then claiming the process is alive. - prior:c624ba1 important 2 — fixed —
cli/src/__tests__/company-import-export-e2e.test.ts:250— now reads "Until BLO-28818 (this change), a failed startup probe reported two empty strings…". Correct ticket, and the tense no longer implies the fix landed elsewhere.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [code]
cli/src/__tests__/company-import-export-e2e.test.ts:191-202— pre-existing, outside this diff, but it is the exact idiom you just fixed 60 lines below, and here the consequence is worse than a misleading message.stopServerProcessguards onchild.exitCode !== nullalone, so a signal-killed child does not take the early return.kill("SIGTERM")is then a no-op, and the awaited promise resolves only fromchild.once("exit", …)— which never fires, becauseexitalready fired before the listener was attached. The 120 ssetTimeoutcallskill("SIGKILL")but never callsresolve(), so the promise never settles andafterAllhangs until vitest's hook timeout. Confirmed here:guard returns early? false/did a late once("exit") listener ever fire? false. Applying the samesignalCodecheck (or resolving onexitCode !== null || signalCode !== nullbefore awaiting) closes it. - [types]
cli/src/__tests__/helpers/server-logs.ts:4,45— the budget is now honestly measured in bytes, but it is applied per file, not across the report:entries.map(...)gives each entry its own 8 KB.SERVER_LOG_TAIL_BYTESreads like a cap on the diagnostic, so a rotated-log directory with N files yields N × 8 KB in a single failure message. Either name it…_PER_FILEor track a running total across the map.
Strengths
- The signal-kill fix is the version that actually helps: reporting
exitCode=… signalCode=…rather than substituting a new assertion for the old one. The comment at:287-290says why — "a diagnostic that states liveness as fact is exactly the kind of claim that sent this investigation down the wrong path once already" — which is the right lesson to leave in the file. readFileSync→Buffer→subarrayis the correct shape, and:58-60names the multi-byte truncation consequence (a leading U+FFFD) instead of pretending it away. Choosing an honest size figure over a clean-looking tail is the right trade for a diagnostic.- The byte-budget test at
server-logs.test.ts:92is well-constructed:"◇◇◇◇TAIL"at budget 8 is 8 UTF-16 units but 16 bytes, so a char-counting implementation reports no truncation at all. Verified —last 8 of 16 bytes, andnot.toContain("◇◇◇◇")genuinely discriminates, since exactly one glyph survives the cut. - The
EISDIRtest closed the gap the previous PR description called unclosable, and does it without a permission trick — verified as uid 1000 here, and it would hold as uid 0 too. Math.max(1, tailBytes)with the-0 === 0reasoning recorded at:41-44, plus a test that pinslast 1 of 6 bytes. The guard and the explanation of why it is needed are in the same place.
Recommended Action
- No Critical issues.
- No Important issues — both prior blockers are fixed at this head.
- Consider the two Suggestions opportunistically. The
stopServerProcessone is the same class of bug this PR exists to fix and can hang a suite, so it is worth a follow-up ticket even though it is out of scope here.
Thinking Path
Linked Issues or Issue Description
Path (B) — no GitHub issue; tracked on the Paperclip board as BLO-28818, described here.
Bug.
cli/src/__tests__/company-import-export-e2e.test.tsintermittently fails withTimed out waiting for http://127.0.0.1:<port>/api/health, and the error cannot explain why.Expected: a startup timeout says where startup stopped. Actual: it says nothing, and the evidence is deleted.
Seen in run
32208119898(General tests (workspaces-a)), which ejected the CI of unrelated PR #1410. Captured output ends:stderr:empty. Process still alive — a dead child throws a different error. 42/43 files and 232 tests passed. Also observed on unrelated branchstaff/blo-19124-backstop-skip-telemetry(run32193651765), so it is not branch-specific.Distinct from the ARC mid-job-kill shape (BLO-25898 / BLO-21662): the job ran to completion. Not a duplicate — searched all open+closed PRs for
import-export/waitForServer/server log/workspaces-a/health: nothing overlapping. No ROADMAP overlap.What Changed
waitForServernow readslogDirinto both throw paths, and reports elapsed time and the child's exit code so "died" and "stalled" are distinguishable at a glance.serverLogDiris the single source for the path, used by the code that configures the server and the code that reads it back. A second literal would let reader and writer drift, and the symptom would be silent — an empty log dir reads exactly like "the server logged nothing".helpers/server-logs.tsspecifically so it is testable, plusserver-logs.test.ts(7 cases).Verification
pnpm exec vitest run --project paperclipai cli/src/__tests__/server-logs.test.ts→ 7 passed.Mutation-tested against a green tree — each fails the specific test that claims to catch it:
reports a missing directory instead of throwing""distinguishes an empty log directory from a silent serverkeeps the TAIL when a log exceeds the byte budget, and says sodoes not claim truncation when the log fitsincludes every log file, in a stable order, with its contentslogspath segmentderives the log directory from the temp rootslice(-n)(char budget)budgets in BYTES, not UTF-16 code unitsMath.max(1, …)clamptreats a zero budget as a floor, not as unlimitedreports an unreadable entry without losing the readable onestsc --noEmit -p cli/tsconfig.json: zero errors in the changed files. (144 pre-existing errors underserver/**come from@paperclipai/plugin-sdkhaving nodist/in a local--ignore-scriptsinstall, not from this change.)Risks
test(cli): tolerate ARC startup and reseed latency— without the cause ever being established. A third would hide the signal a third time. Why the server needs >120s with Postgres already connected is the real defect; this change is what makes it findable.chmod 000is still readable as root. That was wrong, and review caught it. A subdirectory inside the log dir needs no permission trick:readdirSynclists it andreadFileSyncthrowsEISDIRon every platform and every uid (confirmed here as uid 1000). It is now covered byreports an unreadable entry without losing the readable ones, and a rotated-log subdirectory is a plausible real layout rather than a contrived one.Model Used
Claude Opus 5 — exact model ID
claude-opus-5[1m], 1M context window, extended thinking enabled, agentic tool use via Claude Code (shell, GitHub API, file edits, local vitest runs).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatehelpers/server-logs.ts