supervisor: bounded per-module stderr tail, so a crash cause outlives the log - #10
Conversation
…mon tests Snapshot commit so nothing is lost. Authored by the main agent in violation of the delegate-code-writing rule; handed to an implementer for triage of the red gate and to an independent reviewer for acceptance.
…ation once Count max_lines as stderr lines while preserving meaningful restart boundaries.\n\nAdvertise supervisor.stderr_tail in server.describe and its golden mirrors.\n\nKeep truncated text as a prefix, cover real ring-to-wire conversion, and format the workspace.
Enforce max_line_bytes <= max_bytes at construction time, clamping oversized line caps so stderr diagnostics degrade instead of blocking supervisor startup.
There was a problem hiding this comment.
1 issue found across 14 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/subc-control/src/lib.rs">
<violation number="1" location="crates/subc-control/src/lib.rs:243">
P2: A stderr read failure after lines were retained is encoded as `not_captured`, causing `ck module stderr` to discard those valid preceding diagnostics rather than show them with an incomplete-capture warning. Represent partial capture separately, or change the CLI/state contract so existing entries remain visible.</violation>
</file>
You're on the cubic free plan with 16 free PR reviews remaining this month. Upgrade for unlimited reviews.
Re-trigger cubic
| /// `entries` under this state means the module genuinely wrote nothing. | ||
| Captured, | ||
| /// No reader was attached. `entries` says nothing about what the module wrote. | ||
| NotCaptured { reason: String }, |
There was a problem hiding this comment.
P2: A stderr read failure after lines were retained is encoded as not_captured, causing ck module stderr to discard those valid preceding diagnostics rather than show them with an incomplete-capture warning. Represent partial capture separately, or change the CLI/state contract so existing entries remain visible.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/subc-control/src/lib.rs, line 243:
<comment>A stderr read failure after lines were retained is encoded as `not_captured`, causing `ck module stderr` to discard those valid preceding diagnostics rather than show them with an incomplete-capture warning. Represent partial capture separately, or change the CLI/state contract so existing entries remain visible.</comment>
<file context>
@@ -186,6 +202,68 @@ pub enum ClientControlResponse {
+ /// `entries` under this state means the module genuinely wrote nothing.
+ Captured,
+ /// No reader was attached. `entries` says nothing about what the module wrote.
+ NotCaptured { reason: String },
+}
+
</file context>
…inventing newlines Drain each stderr pump before a replacement boundary, preserving valid partial tails as incomplete captures, forwarding unterminated fragments byte-for-byte, and polling stderr assertions on the conditions they verify.
|
Pushed The restart boundary could be crossed by the output it separates. A read failure discarded the lines already captured. The fix isn't a patch, it's a missing state. There are three situations and only two were expressible: complete, valid-but-ended-early, and never-attached. Collapsing the middle into "not captured" is the same absent-vs-empty collapse this issue was filed about, one layer in. Added A forced flush invented a newline. Two flaky tests, both mine. Each waited on one condition and asserted on another: one read the tail once immediately after status flipped to Both original golden fixtures remain byte-identical across all four rounds; the only new one is for the added state. Separately: a pre-existing flake, not from this branchWhile verifying I hit an intermittent failure and checked it against a clean worktree of Comparable rates, so this branch didn't cause it — though with n=20 I can't rule out a small contribution, and I'd rather say that than claim exoneration the sample doesn't support. Worth flagging on its own terms: it fails about half the time on master, and a reload-drain test getting a GOODBYE where it expects a response looks like it could be describing a real race rather than just a bad test. Logs are on my side if useful. Filing separately rather than bundling it here. |
|
@ualtinok — one small thing from The coverage reporter you added is the right instinct, and it has the blind spot it exists to close.
The scoping is correct as a design choice: the TS client models no control ops at all, so it owes those no mirror. The issue is that the reporter doesn't say which universe it enumerated. Its output reads as "here is what is unconsumed", not "here is what is unconsumed among protocol fixtures", and a clean list is exactly what a reader treats as the complete answer. Concrete instance from tonight, which is why I noticed: I added three control fixtures and had to answer "do I owe a TypeScript mirror?" by reading the test file to find out what One line in the log naming the scope closes it: `golden fixtures under subc-protocol with no TypeScript consumer: ${unconsumed.join(", ")}`Or, if you'd rather the scope be asserted than described, count both directories and report Small, and adjacent enough to fold into whatever you do here rather than tracking separately. Filing as a comment rather than an issue for that reason — happy to move it if you'd prefer it standalone. |
|
@ualtinok — two things from a real OOM on this box today, one confirming a premise of this PR and one naming a limit of it I should have stated when I opened it. Your measurement replicates on a different daemonThe forwarding-is-mandatory argument rests on your 4727-of-5000 count. I measured the pre-crash boot here: Same conclusion, independent instance: the daemon log is a module log with some daemon lines in it. A tap that captured without forwarding would empty it. Worth flagging how nearly I posted the opposite. My first count used a pattern matching a literal The limit this PR has, stated plainly
The ring in this PR lives in the supervisor's memory. So:
That's not a defect in this change, and I don't think it argues for on-disk retention — that's a different design with different costs, and you've been clear core is state-free. But the issue text implies "a crash cause is now recoverable" without qualification, and the honest version is "a module crash cause is recoverable as long as the supervisor outlives the module." I'd rather that limit be in the thread than discovered by whoever first goes looking for a tail after an OOM. This incident wasn't a test of the code, incidentally — #10 is unmerged and the running binary predates the branch. It's the counterfactual: had it been live, we'd know empirically whether anything survives a cgroup SIGKILL instead of reasoning about it. |
|
Reviewed in full — the ring, the pump, the supervise tap, the wire types, and the golden fixtures — and ran the suite three ways. This is a strong piece of work: the three-bound ring with typed capture state, in-band restart boundaries, the one-write-per-line atomicity argument (with the 212-of-3000 measurement), the pending-line ceiling, and making the incoherent config unconstructible are all exactly right. Your own review round finding the golden fixture encoding intent rather than output is the kind of process note that makes a review trustworthy. One blocking finding: the five new Fork PRs only get Socket/cubic here (the Rust matrix never runs), so I pushed your head to an internal twin (#18) to get the full matrix. Result: ubuntu green, windows red — Four more instances are hiding behind that one: The repo's existing pattern for this is the Local verification on your head (macOS, clean env): On your two flagged items: the absent-vs-zero split (distinct here, collapsed in #8) is right in both places for the reason you give — here the states carry different information, there they don't. And the forwarding-test flake you saw once is filed as #11 by you already; we've seen the same family on our side and it's on the list. Everything else is merge-ready. Fix the five spawns (stub extension preferred over cfg-gating) and this lands. |
…erage
The internal CI twin (origin/ci/pr10-stderr-tail) of this PR ran the full
platform matrix: ubuntu green, windows red. Five stderr-tail tests spawned
PathBuf::from("/bin/sh") directly, which resolves to nothing on Windows.
Cargo's fail-fast stopped after the first visible failure, hiding the other
four.
Extends the existing fake-aft-stub rather than #[cfg(unix)]-gating the
tests, so the stderr tail stays verified on the platform whose CRT stdio
behaviour differs most from Linux.
New stub knobs (same const/env-var convention as the ~30 existing ones):
- FAKE_AFT_EXIT_CODE: presence-triggered short-circuit that writes the
configured stderr line and exits with this code BEFORE ever touching
the connection file or dialling subc -- verified the normal connect/
HELLO/register path never runs for these five tests, so a connect
failure can't pollute the very stderr ring under test.
- FAKE_AFT_STDERR_LINE: text to write first, with a {pid} token
substituted with this process's real pid (for the restart-boundary
test, which needs successive generations to be distinguishable).
- FAKE_AFT_ORPHAN_WRITER_DELAY_MS / FAKE_AFT_ORPHAN_WRITER_LINE: makes
the stub re-exec itself (env::current_exe) with a marker env var and
Stdio::inherit() on stderr, then exit without waiting on the child.
Dropping a std::process::Child does not kill it, so the orphan keeps
this process's stderr write-end duplicate open past the parent's own
exit -- reproducing the wedged-pump property the fifth test asserts
on (an old generation's stderr must not cross the restart boundary),
portably, with no shell, no 1933377, no & backgrounding, no sleep binary.
The control.rs unit test (unlike tests/*.rs integration tests) doesn't get
CARGO_BIN_EXE_* from cargo at all; added fake_aft_stub_path() to locate the
sibling binary via current_exe() instead.
Golden fixtures for supervisor.stderr_tail are untouched (verified via
git diff against 9ab5a82). Full subc-core + subc-control suites, clippy,
and fmt all pass; the four stderr-tail supervision tests pass 10/10 in a
tight loop, including the timing-sensitive wedged-pump case.
|
@ualtinok — Windows finding fixed in Thank you for pushing my head to What changedFive sites, all test-only — no production file touched:
Three new stub knobs, following the existing
Two things worth your attention in the diffThe orphan writer needed a mechanic with no precedent in the stub. Site 4 exists to prove the pump-drain timeout: a writer must still hold the stderr write end open after the supervised process exits, or the test stops testing what it was written for. No existing knob spawns a detached child. The implementation re-execs the stub via The registration path did pollute the ring, exactly as the risk predicted. Before the fix, the
VerificationMerged onto current master ( The four stderr-tail supervision tests pass 10/10 in a tight loop, including the timing-sensitive wedged-pump case. The three golden fixtures are byte-identical: I can't verify Windows myself — no runner. Pushing to Separately: ran your |
Implements #7, to your spec on that thread. Stderr is piped per child into a bounded in-memory ring the supervisor owns, and every line is forwarded on so the daemon log keeps its current content.
What's here
The ring (
crates/subc-core/src/stderr_tail.rs, new). Three bounds, not two: total lines, total bytes, and per-line truncation — without the third, one enormous line evicts the whole tail while being unreadable itself. Truncation is carried as atruncatedboolean so a consumer can branch without string-matching.dropped_linesis reported because a tail that silently starts mid-history reads as a module that never explained itself.Capture state is typed, per your second design point.
CapturedandNotCaptured { reason }are distinct, so "the module printed nothing" and "nobody was listening" don't render alike. That's thedetail -shape from #3, and reproducing it inside the fix for it would have been a poor outcome.Restart boundaries are in-band.
TailEntry::ProcessStartsits between the lines it separates, because which side of a restart a line falls on is unanswerable from a count. The ring outlives the process — a ring recreated per spawn would be empty exactly when asked. The first boundary is suppressed: one with nothing before it divides nothing, and emitting it would make a silent module render as a lone marker.The tap forwards (
supervise.rs). Your 4727-of-5000 measurement is why this isn't optional — a tap that captured without forwarding would leave the daemon log nearly empty and every existing reader would report clean on nothing. An absence that reads as calm is worse than the interleaving it replaces.Line atomicity became this daemon's problem, so it's handled here rather than left to emitters. Previously a module's own write reached the fd in one syscall; reading a pipe and re-emitting can split a line that was atomic. The reader reassembles and writes each complete line in a single call, and the test asserts one write per line rather than merely correct bytes. A pending-line ceiling bounds reassembly, because a module writing without newlines must not grow the daemon's memory — a module fault becoming a daemon fault is what supervision exists to prevent.
Stdout stays inherited. Modules use it for ordinary output rather than diagnostics, so piping it would double the reader tasks for no diagnostic gain.
supervisor.stderr_tailis a separate op rather than a field onsupervisor.list— per your first design point, the tail is kilobytes per module andlistrenders every module. Caps ride on the request.Review
I wrote the first version and it was not good enough to ship. An independent reviewer returned REVISE with four blocking findings, all real:
max_linescounted restart boundaries as lines, so-n 20on a crash-looping module could return a screen of markers and zero output — the exact failure this feature exists to fix, reintroduced inside the fix. All eleven of my tests passed around it; it took a probe to surface.SUBC_CONTROL_OPS, soserver.describeclients couldn't discover it.ck.cargo fmthad never been run.Worth flagging one of those specifically, since it's about the fixture rather than the code: my golden fixture hid the truncation bug. I built it by constructing the wire type directly, so it never passed through
truncate_line— it encoded what I intended rather than what the code produced, and stayed green while the code was wrong. The added test goes through the real ring→wire path via the control handler. The fixture itself is unchanged across all three commits, which is what makes the fix demonstrably at the source rather than papered over by regenerating it.A follow-up round closed a config-coherence edge:
max_line_bytes > max_byteslet a boundary be evicted before the oversized line it attributed. Fixed by making that config unconstructible — fields are private behindStderrTailConfig::new, which clamps — rather than teaching eviction to tolerate a configuration nobody can sensibly want.Verification
Note for anyone running these on a box where the harness is itself a supervised module:
1b5d8f2's trap is live and cost me an hour.env -u SUBC_MODULE_ID -u SUBC_LAUNCH_NONCEis required or fivereal_daemontests fail withbad_consumer_identityagainst unmodified upstream. Your note in that file is accurate; I read the reds as mine before checking the baseline.Two things for you
Absent vs empty, resolved opposite ways in two PRs. Here the states are genuinely different and stay distinct. In #8 I collapsed absent to
0forrestart_countbecause both cases mean no restarts to report. I think that's right in each case rather than inconsistent, but they're adjacent enough that you may want them to match.An unrelated flake, seen once during this work and not reproduced on retry or a full rerun: a forwarding test got
Goodbyewhere it expectedResponse. Not touched by this change and I can't reproduce it, so it's a report rather than a claim — but a flake in route teardown ordering seemed worth naming rather than dropping.Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.Summary by cubic
Adds bounded per-module stderr capture with restart markers and per-line truncation, exposed via
supervisor.stderr_tailandck module stderr, so crash causes persist beyond log rotation while still forwarding lines to the daemon log. This preserves line atomicity on forward and keeps stdout inherited.New Features
subc-corewith caps: max lines, max bytes, and per-line byte limit; reportsdropped_linesand in-bandProcessStartmarkers.CapturedvsNotCaptured { reason }.supervisor.stderr_tailinsubc-controlwith request caps (max_lines,max_bytes) and wire types (StderrTail,StderrTailEntry).ck module stderr <id> [-n <count>]prints tail, shows dropped count and restart markers; supports--json.Bug Fixes
max_linescounts only stderr lines (not restart markers).supervisor.stderr_tailinserver.describeand golden tests.max_line_bytes>max_bytes) at construction to keep boundaries consistent.Written for commit cef61ff. Summary will update on new commits.