refactor(logging): one logcode-to-stream funnel mirroring rwrite - #7252
Draft
oferchen wants to merge 2 commits into
Draft
refactor(logging): one logcode-to-stream funnel mirroring rwrite#7252oferchen wants to merge 2 commits into
oferchen wants to merge 2 commits into
Conversation
#352/#7236 and #357/#7242 were not two bugs. They were one missing funnel, found twice in independent code paths, and a per-fix regression test on either would not have caught the other. Upstream cannot have this defect structurally. Every message reaches a stream through rprintf (log.c:406) or rsyserr (log.c:453) and lands in rwrite (log.c:251), which is the sole function that picks a FILE *. That is 606 rprintf(F*) call sites against one chooser. The "zero direct fprintf(stderr)" framing is not quite right and the real shape is more useful: 47 exist in the tree, but only 5 are in the message path - rwrite's own bad-logcode arm (log.c:325), two pre-log_init failures (clientserver.c:1520,1563), and two reports that the message subsystem's own circular buffer is undersized (io.c:596,614). Every one of the five exits immediately. So the rule is not "never write to a stream directly", it is "the funnel decides, except where the funnel itself is unavailable or broken, and there you exit". oc had the rule written down in nine doc comments on LogCode in crates/logging and implemented exactly once, in crates/cli/src/frontend/progress/diagnostic.rs - a leaf crate. The shared crate documented the rule; a leaf owned the only implementation. Every other crate that needed it had to re-derive or guess. This moves the decision into crates/logging as `message_stream`, the single place a diagnostic message's destination is chosen, and collapses the cli copy onto it. Two things the existing mapper did not model, both load-bearing in rwrite: - `quiet` suppresses FINFO INSIDE rwrite (log.c:318-319). oc folds --quiet into `verbosity = 0` at parse time (cli parser/entry.rs:780), which only silences verbosity-gated output; a non-gated FINFO still prints. That is the mechanism task #346 needs and the funnel now has the seam for it. - `msgs2stderr` is a TRI-state (options.c:98, default 2), not a bool. rwrite's stream default keys on `== 1` (log.c:253) while the server-forwarding gate distinguishes all three (log.c:328). Modelling it as a bool at the one canonical site would have baked a lossy representation into the choke point. The class test is six assertions on one function, quantified over the full quiet x msgs2stderr x log_destination cross-product, so a rule that only holds in the default configuration cannot pass. The warning-class list is derived independently from rwrite's structure rather than from this module, which is what makes it an oracle instead of a restatement. Proven red, each mutation hitting its intended assertion and green restoring: warning-class routed to stdout (the #352/#357 defect) -> 2 fail unknown code silently defaults to stdout -> 1 fail FCLIENT normalisation dropped -> 2 fail msgs2stderr collapsed to a bool -> 1 fail cli renderer re-derives the rule for FWARNING -> 5 fail The cli test that pinned the classification in the leaf is replaced by one that asserts the renderer DELEGATES: for every code, where the bytes actually land agrees with what the funnel decided. Re-deriving the rule there, however faithfully, fails it. Diagnostic routing only. Payload output (listings, --list-only, help, itemize rows) is untouched and the funnel is shaped to absorb it later.
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.
The defect, stated structurally
#352/#7236and#357/#7242were not two bugs. They were one missing funnel, found twice in independent code paths, and a per-fix regression test on either would not have caught the other.Upstream cannot have this defect. Every message reaches a stream through
rprintf(log.c:406) orrsyserr(log.c:453) and lands inrwrite(log.c:251), the sole function that picks aFILE *.Correcting the premise (it makes the case stronger, not weaker)
The brief said "zero direct
fprintf(stderr,...)in the message path". Measured, that is not literally true — there are 47 in the tree. The accurate shape is more useful:rprintf(F*)call sites funnelling intorwritefprintf(stderr)in the real message pathtls.c,t_*.c,testrun.c,getfsdev.c)All five exceptions are principled and every one exits immediately:
log.c:325—rwrite's own bad-logcode arm, thenexit_cleanup(RERR_MESSAGEIO).clientserver.c:1520,1563— fork failure and config-parse failure, both before logging exists.io.c:596,614— reporting that the message subsystem's own circular buffer is undersized, thenexit_cleanup(RERR_PROTOCOL).So the rule is not "never write to a stream directly". It is: the funnel decides, except where the funnel is unavailable or is itself broken — and there you exit. A blanket ban would be un-mirrorable and would push a panic path through a broken funnel.
What oc actually had
crates/loggingownsLogCodeand documents the routing rule in nine separate doc comments. It implements it zero times. The only implementation wasclient_stream()incrates/cli/src/frontend/progress/diagnostic.rs— a leaf crate.The shared crate documented the rule; a leaf owned the only implementation. Every other crate that needed it had to re-derive or guess. That is the whole mechanism behind two independent instances.
The change
logging::message_streamis now the single place a diagnostic message's destination is chosen, mirroringrwrite's dispatch in upstream's own order (the normalisations happen before the switch, which is load-bearing). The cli copy is deleted and its renderer delegates.Two things the existing mapper did not model, both load-bearing:
quietsuppresses FINFO insiderwrite(log.c:318-319). oc folds--quietintoverbosity = 0at parse time (cli/frontend/arguments/parser/entry.rs:780), which only silences verbosity-gated output — a non-gated FINFO still prints. That is exactly open task Add protocol version FromStr parity tests #346, and the funnel now has the seam for it.msgs2stderris a tri-state, not a bool (options.c:98, default 2).rwrite's stream default keys on== 1(log.c:253) while the server-forwarding gate distinguishes all three (log.c:328). Modelling it as a bool at the one canonical site would have baked a lossy representation into the choke point — the same mistake class as everything else this task exists to prevent.Proof: every assertion made to fail
Non-negotiable, so here it is. Each mutation reintroduces a real defect; each hits its intended assertion; green restores.
msgs2stderrcollapsed to a boolThe class test quantifies over the full
quiet × msgs2stderr × log_destinationcross-product, so a rule that only holds in the default configuration cannot pass. The warning-class list is derived independently fromrwrite's structure rather than from this module — that is what makes it an oracle instead of a restatement.The cli test that pinned the classification in the leaf is replaced by one asserting the renderer delegates: for every code, where the bytes actually land agrees with what the funnel decided. Re-deriving the rule there, however faithfully, fails it.
The site census (the deliverable, and it revises the ~400 figure)
Measured on this tree,
crates/*/srconly, excluding test files,#[cfg(test)]bodies, thetest-supportcrate, and doc-comment examples:/// println!(...))#[cfg(test)]Not ~400. That figure counted test code and doc examples. Roughly 5 of the 92 are
io::stderr()used as anis_terminal()probe rather than a write.By crate: engine 18, transfer 15, rsync_io 15, daemon 14, metadata 13, core 11, platform 3, protocol 2, batch 1, cli 0.
Diagnostic vs payload: the payload half is small — essentially the itemize row emitters (
transfer/receiver/itemize.rs,core/client/remote/itemize_sink.rs) plus a couple of listing paths, call it ~9 sites. The remaining ~78 are diagnostic: metadata's "cannot preserve X" warnings, daemon listener/config warnings, ssh/auth diagnostics, engine env-var parse warnings, platform privilege-drop warnings (#157).That answers the scoping question directly: the payload half is not worth a separate campaign. It is ~9 sites and can be folded in cheaply once the diagnostic half is routed. The funnel is shaped to absorb it —
MessageStreamalready carries theLogOnly/Suppressedoutcomes payload routing would need.Scope
Diagnostic routing only, and this PR routes one consumer (the cli diagnostic queue) onto the funnel. The remaining ~78 diagnostic sites are follow-on work, now mechanical: each becomes a call to one function instead of a judgement call.
This PR is deliberately the structural half — one definition, one class test, proven red. Landing the funnel before the migration means every subsequent site moves onto something already gated.
Verification
cargo fmt --all -- --checkcleancargo clippy --locked --workspace --all-targets --all-features --no-deps -- -D warningscleancargo nextest run --workspace --all-features -E 'package(logging) or package(cli) or package(core) or package(transfer) or package(daemon)'— 11359 passed, 81 skipped