test(cli): judge the closed-read-end case by exit status, not a wall clock - #14863
Merged
Conversation
…clock `run-dev-unbuilt-workspace.e2e.test.ts` case 6 asserted `closedEnd.elapsedMs < STALL_MS` — a fixed wall-clock bound borrowed from case 4's parent stall, against a term that is entirely elastic. Tracing what produces that timing shows the bound was not merely fragile, it was a phantom. With the read end destroyed, oclif's `displayWarnings()` makes the first stderr write, the pipe is already gone, node raises `write EPIPE` as an `error` event on `process.stderr`, nothing is listening, and the child dies of an uncaught exception at ~1.4 s with exit 1. `writeStderr()` is never reached, so the bound the case was named after is never armed. Traced with a `--import` observer: the shim's own 415-byte write is #175 at 9250 ms, behind 174 oclif writes that all EPIPE — 7.8 s after the real child is already dead. Ablated on `bin/run-dev.js`, same box, same probe (old bound's verdict in brackets): pristine exit 1 at 1387-1711 ms [green]; write callback removed so a closed path could only finish on the bound, which is the regression this case named, exit 1 at 1517-1633 ms [GREEN]; EPIPE made non-fatal, exit 2 at 8787-8979 ms [green, 1.2 s spare]; both, so the path really pays the bound, exit 2 at 23601-23712 ms [red]. The measurement moved only where the exit code moved too, and it stayed green on its own regression. So the exit status is the observation the wall clock was standing in for, and it carries no load term: 1 means the child died on its first write, 2 means it reached `handle()`, which is only reachable through `writeStderr()`. This asserts that directly and keeps the elapsed reading as evidence in the failure message, the same split #14715 made for case 5. Exit 1 is what the CLI does rather than what anyone contracted — filed as #14858, and pinning today's value is what stops that changing silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-trump
marked this pull request as ready for review
September 3, 2026 06:17
os-trump
enabled auto-merge
September 3, 2026 06:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #14716
File face
One file, and it is the one the card fences to me:
packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts(+58 / -12)⛔
packages/cli/bin/run-dev.jsis not in this diff. It belongs to #14832, and that card stays open and untouched here. The shim was mutated only inside this throwaway worktree for the ablation below, and restored by blob-hash equality every time (transcript in the report comment on #14716).What the bound was standing for, and what it actually measured
The assertion was
expect(closedEnd.elapsedMs).toBeLessThan(STALL_MS)— 10 s borrowed from case 4's parent stall, which has no relationship to this case. Its stated job was to pin "a CLOSED read end is released at once, not held for the bound (EPIPE reaches the callback)."Traced, that is not what happens. With the read end destroyed, oclif's
displayWarnings()makes the first stderr write, the pipe is already gone, node raiseswrite EPIPEas anerrorevent onprocess.stderr, nothing is listening, and the child dies of an uncaught exception at ~1.4 s with exit 1.writeStderr()is never called, so the bound the case was named after is never armed, let alone paid.Two observers, both
--importhooks writing only to a file (stderr is destroyed, so it cannot carry a trace):process.stderrand no write wrapper — 3/3uncaughtException code=EPIPE, msg=write EPIPE, at afterWriteDispatched (node:internal/stream_base_commons:159:15), thenexit code=1, at 1192 / 1269 / 1149 ms;So the measured term is child cold start, ~100% of it, and the property the case names contributes ~1 ms — in a run that does not happen.
The bound was a phantom, by ablation
Mutations on
bin/run-dev.js, in this worktree only, each proved on disk by blob hash and marker count before any reading, each restored by blob-hash equality under atrap … EXIT INT TERMon absolute paths.elapsedverdict under the old bound in brackets:bin/run-dev.jsstateprocess.stderr.on('error', noop)added, callback keptRow 2 is the finding: the assertion could not fail for the reason it named. It moved only on rows 3 and 4, which change the exit code too — and on row 3 its margin was already down to 1.2 s on an ordinary box.
Positive controls, same probe and flags: a drained reader gives exit 2 in ~8.3 s with 145696 bytes delivered; a paused reader gives exit 2 in 8.4 s and, on another iteration, 23.5 s — the second having paid the shim's 15 s bound. So the harness can see a healthy run and can see the bound being paid; the destroyed read end is what is different.
What is asserted instead
The exit status, which is the observation the wall clock was standing in for and carries no load term at all:
1means the child died on its first stderr write and never reached the drain;2means it reachedhandle(), which is only reachable throughwriteStderr()— bound paid or not.Every ablation above that reaches the drain flips it, including the one the old assertion could not see.
signalstays asserted, and the elapsed reading is kept as evidence in the failure message rather than as an input to a threshold — the same split #14715 made for case 5.⛔ What I did not do, and why:
STALL_MSis not raised, no bound is re-derived from a calibration run (that is what #14715 removed), and nothing is skipped, quarantined or retried. Row 2 above is why simply keeping a looser wall clock would have been the worst option — it would have re-hidden a check that never worked.Readings — all on
b90986c7cf, the head of this branchThe target suite, run 8 times (intermittency is the whole subject). 11 passed / 11, every time; durations 44.58, 61.24, 45.38, 44.72, 59.98, 60.67, 58.48, 58.67 s. The last four were joined with
&&so one exit code certifies all of them:VERDICT command-exit 0.Gate families derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(27 for this card, from the script's own changeset derivation, not a hand-written diff). Run locally, each exit code captured before any pipe, quoting each gate's own verdict line:check:nul-bytescheck:cli-test-child-envpackages/cli/test/**, 53 spawns all declare their child envcheck:engine-double-contractcheck:where-matchercheck:query-options-erasurecheck:objectql-double-limitcheck:cross-package-test-inputscheck:published-filescheck:type-check-coveragecheck:test-completenessPREREQUISITE NOT MET: it grades a savedturbo run testlog and there is none locally. Its own text says this is not a red. CI runs it with a real log.check:type-check-debt --re-measure./packages/*+./packages/*/*closure built; I built only the CLI's dependency closure. CI's.packages/cli's owntypecheckisinclude: ["src"], sotsc --noEmit --listFilesfor the package returns 0 hits for this file. "Package typecheck clean" therefore says nothing about the edit. Checked directly instead, with node types supplied —tsc --noEmitover the file: exit 0, 0 errors. Positive control that the check is not vacuous: the same invocation without@types/noderesolvable reported 23 errors on the same file.Changeset fork
skip-changesetlabel, no changeset file.packages/cli'sfilesis["dist","README.md","CHANGELOG.md"], so neithertest/norbin/is published and a test-only diff publishes nothing from any released package. The label is applied additively and read back on this PR (the size-labeler does a whole-set PUT and has been measured stripping a correct additive label about a second later).🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code