Enforce logging.rotation.maxSize on the log write path instead of only on the 60-second audit tick - #2475
Enforce logging.rotation.maxSize on the log write path instead of only on the 60-second audit tick#2475kriszyp wants to merge 15 commits into
Conversation
maxSize was only ever checked by the 60-second audit tick, and only on the main thread, so the real ceiling on the active log was write-rate x 60s: QA measured a 1.36 GB active log against a 64K cap, with one rotation across 515,951 requests. Request logging is written by the HTTP workers, which had no rotator at all. Every writing thread's file sink now owns the cap. After each successful append it subtracts the payload's byte length from a fixed quantum (maxSize/16); when the quantum expires it stats the pathname once and, at or over the cap, renames and closes synchronously before it can append again. The bound is therefore a function of maxSize and thread count, never of write rate or event-loop delay. The audit tick keeps its interval, retention and reclamation duties as the backstop. Because every isolate holds its own descriptor on the same file, an archived generation can still be appended to after the rename, and compressing then unlinking it would destroy those records. A rotation-only generation coordinator makes that release provable: the rotating thread announces the archived inode, every peer closes a descriptor matching it and answers, worker exit counts as an answer, and the plain archive is only ever destroyed once every peer has answered. Unproven generations are retained, retried on the next tick, and skipped by retention. The coordinator's transport is injected by the thread layer, so logging still imports nothing from server/threads. Also in the same fault family: - one strict maxSize parser shared with the config validator; parseInt accepted '0K', '-1K' and '1xK', which become a limit of 0, a negative number, and NaN. Every form that produces a usable cap today, exponent notation included, is still accepted. - a getFileLogger call carrying no rotation block no longer tears down the rotation an earlier, configured caller installed for that path. - a logger inheriting main's rotation for main's own file keeps the configured rotation.path; the strip that avoids a cross-device rename only applies when the two logs are different files. Refs #1877 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
The notice is written back through the sink that is rotating, so during a recovery attempt it reached beforeAppend() while rotationPending still held its pre-attempt value and started a second, nested attempt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Written straight to the file sink, the notice bypassed the level/service prefix createLogger's logPrepend adds, so readLog could not parse the one line that says a rotation happened. Also stop the exactly-once assertions racing the sink's buffered flush. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Three of them let an archived generation be destroyed while a writer could still append to it, which is the one thing the coordinator exists to prevent: - The sink, not the size guard, registers with the coordinator. A thread whose rotation is driven only by `interval`, or whose maxSize is missing or invalid, builds no guard but still holds a descriptor; it was answering "released" when its handler had closed nothing. - Every archived generation is tracked until it is proven released, not only the ones bound for compression. Retention unlinks archives too, and unlinking an inode a stalled writer holds loses records exactly as gzip would. - One `compress` decision for both rotation paths. The tick read it from environmentManager and the write-path guard from the rotation block, so one process could apply two destruction policies to one log. And one that produced spurious archives: the tick's size check stat'd the log, awaited, and then renamed, so a writing thread could rotate that generation in between and leave the tick to archive the near-empty replacement. It now stats and renames in the same turn, as the write path does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Retention runs on the main thread and deletes archives whichever thread rotated them, so the per-isolate unproven-archive map could not protect an archive a worker rotated and failed to prove. Before each retention pass every peer is now asked, in one round trip, to release any descriptor that is not on the live generation; if any peer does not answer, the pass deletes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Deriving 'this is the batched form' from an absent keepIno meant a pass taken while the active log was missing fell into the single-generation branch and released nothing, when in fact every descriptor is stale at that point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
… pacing - Losing the rename race is no longer treated as a rotation failure. Another thread, or the audit tick, renaming the generation between this thread's stat and its rename yields ENOENT, which was closing the descriptor and diverting that thread's log lines to raw stdout for five seconds. - A rotation directory on a different filesystem is refused when the guard is built. A rename across devices can never succeed, so discovering it on the first write would fail closed on every write from then on and end file logging for the life of the process; one startup error and today's unrotated behavior is the better failure. - Archives left plain by any isolate are found on disk and compressed by the tick. Write-path rotations happen mostly in the HTTP workers, whose pending archives the main thread's own bookkeeping cannot see, so a worker's archive would otherwise stay uncompressed however the operator configured compress. - The tick no longer overlaps itself, bounds its retry work, and runs the retries after retention rather than ahead of it — retention is the only thing that bounds the rotated directory. - The integration test now runs with compress on, which is the only setting that destroys an archive and therefore the only one that drives the coordinator's release-then-unlink path through the real thread mesh. The coordinator unit suite no longer leaves its fake transport installed for later suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
`logging.rotation.path` defaults to `log`, the same directory `logging.root` defaults to, so the rotated directory normally holds the logs being written alongside the archives. The new compression sweep would therefore have gzipped and unlinked the active hdb.log on a default install with compress on, and retention — unchanged from main in this respect — could already delete the active log, or a component's, once it aged past the window. The sweep now only touches files named the way this module names archives, and retention skips any path an isolate has registered as a log it writes. Also ungates the sweep from retention: retention is unset by default, and a worker's uncompressed archive still has to be finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
…tor order - A size rotation now resets the interval clock. Only the interval branch advanced it, so an instance whose uptime had passed `interval` archived a freshly-created log every interval on top of the size rotations already doing the work. Not unit-covered: the rotator's only surface is a timer, and every discriminating assertion I could construct was a rotation-count race. - A descriptor that cannot be proven to be on the live generation is released. On a filesystem reporting `ino === 0` the batched release kept every descriptor and still answered "released", which is the one answer that lets an archive be destroyed under a peer. - The log file is opened after the write gate, not before. A guard recovering by rotating closes the descriptor inside `beforeAppend()`, and the write that triggered the recovery belongs in the new generation; it only reached the file at all because the rotation notice happened to reopen it first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
The notice is written back through the sink from inside logQueuedData's append, and logBuffer was still holding the batch that append had just written — it was cleared only on the way out. With logImmediately set, which is any notify() or fatal() and the usual way a batch is flushed under load, the re-entrant flush re-joined the whole buffer and wrote every line of it a second time into the new generation; without it the notice was swallowed when the outer call cleared the buffer. The buffer is now released before anything can re-enter. Also from the round-2 review: - byteLength instead of a Buffer copy. appendFileSync writes a string through Node's own encoder without allocating, and rotation is on by default, so the copy was a per-flush allocation on exactly the workload maxSize exists for. - The unproven-archive queue is bounded. It is a compression retry queue, not the safety mechanism — safety is the release the tick proves for the whole directory — and only the main thread drains it while rotation happens mostly in the workers. - The overshoot bound is stated as maxBytes + T x (quantum + batch): the sink batches under load, so the flush that crosses a checkpoint is a batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request introduces log rotation on the write path to prevent log overshoots, coordinating descriptor releases across threads via a new log generation coordinator and enforcing limits with a write-path rotation guard. The review identified three key issues: a potential ENOENT error at startup if the parent directory of the log path does not exist, an O(N^2) performance bottleneck when checking for compressed archives that can be optimized using a Set, and a potential TypeError in harper_logger.ts if mainLoggerRef is null or undefined when accessing its path.
| mkdirSync(rotatedLogDir, { recursive: true }); | ||
| // Rotation is a rename, and a rename across devices can never succeed. Refusing to build the guard | ||
| // here turns a misconfiguration that would otherwise fail closed on every write — ending file | ||
| // logging for the life of the process — into one startup error and today's unrotated behavior. | ||
| if (statSync(rotatedLogDir).dev !== statSync(dirname(logPath)).dev) { |
There was a problem hiding this comment.
If the parent directory of logPath does not exist yet at startup (which is common on clean installations or when using custom log paths), statSync(dirname(logPath)) will throw an ENOENT error. This will cause the creation of the rotationGuard to fail and silently disable log rotation for that path entirely.
To prevent this, we should ensure that the parent directory of logPath is created recursively before performing the statSync check, just like we do for rotatedLogDir.
const logDir = dirname(logPath);
mkdirSync(rotatedLogDir, { recursive: true });
mkdirSync(logDir, { recursive: true });
// Rotation is a rename, and a rename across devices can never succeed. Refusing to build the guard
// here turns a misconfiguration that would otherwise fail closed on every write — ending file
// logging for the life of the process — into one startup error and today's unrotated behavior.
if (statSync(rotatedLogDir).dev !== statSync(logDir).dev) {| let compressed = 0; | ||
| for (const file of files) { | ||
| if (!file.endsWith('.log') || !isArchiveName(file) || files.includes(`${file}.gz`)) continue; |
There was a problem hiding this comment.
Performing files.includes inside a loop over files results in an O(N^2) complexity, where N is the number of files in rotatedLogDir. If the directory contains a large number of archived files, this can block the event loop during the audit tick.
We can optimize this to O(N) by converting files to a Set first and performing O(1) lookups.
| let compressed = 0; | |
| for (const file of files) { | |
| if (!file.endsWith('.log') || !isArchiveName(file) || files.includes(`${file}.gz`)) continue; | |
| const fileSet = new Set(files); | |
| let compressed = 0; | |
| for (const file of files) { | |
| if (!file.endsWith('.log') || !isArchiveName(file) || fileSet.has(file + '.gz')) continue; |
| } else { | ||
| path = mainLoggerRef.path; | ||
| if (!logOptions.root) logOptions.root = pathModule.dirname(path); | ||
| } |
There was a problem hiding this comment.
Since mainLoggerRef can potentially be null or undefined (as anticipated by the optional chaining mainLoggerRef?.rotation and mainLoggerRef?.path on line 123), accessing mainLoggerRef.path directly on line 120 will throw a TypeError at runtime.
We should use optional chaining here as well and guard the dirname call to ensure robust defensive programming.
| } else { | |
| path = mainLoggerRef.path; | |
| if (!logOptions.root) logOptions.root = pathModule.dirname(path); | |
| } | |
| } else { | |
| path = mainLoggerRef?.path; | |
| if (path && !logOptions.root) logOptions.root = pathModule.dirname(path); | |
| } |
…k the peers - The archive directory is enumerated before quiescence is proven, and only that listing is compressed or deleted. Proving first and listing second left an archive created in between destroyed without ever having been covered by a proof, which is the timing dependence this change exists to remove. - Peers report the log paths they are writing along with their release answer. Components load in the workers, so a component's own log was registered only there; the thread that runs retention saw a live file it had never heard of and would delete it by age. Checking the main isolate's own registry could not see it. - The interval clock reads the current generation's own age rather than a counter only this rotator updates, so a rotation by any thread — or by a previous run — resets it. The counter alone left workers doing the rotating and the interval branch still archiving a fresh log every interval. - The buffered-flush regression test asserts its own precondition. It could pass without ever entering buffered mode, which is the only mode that exercises the bug it exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
- The stale release covers every log this process writes. It named one path, but the archive directory holds the archives of every component and external log sharing it, and retention destroys those too. Each sink now judges its own path — release any descriptor that is not on the live generation of the file it is writing — so nothing has to name the live inode of a log it does not own. - The interval clock takes the older of the tracked counter and the log's birthtime. A positive birthtime is not proof the filesystem supports it: where it mirrors a write-updated ctime, trusting it alone would postpone interval rotation indefinitely. The minimum can only rotate at least as often as the counter alone did. - Live-log paths are compared resolved rather than as raw strings. - Retention ignores ENOENT on a file the compression sweep unlinked from the same listing, rather than reporting it as an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
The release became directory-wide in the previous commit, but the test only ever had one sink registered, so it could not tell a per-path release from a per-isolate one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GedBoADHF48DesHvyP7xUp
logging.rotation.maxSizeis now enforced by the writers themselves, so the active log is bounded by the configured size rather than by how much can be written between two audit ticks. Previously the size check ran only inside the 60-second auditsetInterval, and only on the main thread, which made the real ceilingwrite-rate x 60s— QA measured a 1.36 GB active log against a64Kcap, with exactly one rotation across 515,951 requests. Request logging is written by the HTTP workers, which had no rotator at all.Each writing thread's file sink now owns the cap. After a successful append
recordWritesubtracts the payload's byte length from a fixed quantum (maxSize/16); when that quantum expirescheckAndRotatestats the pathname once and, at or over the cap, renames and closes the descriptor with nothing awaited in between before it can append again. The bound is therefore a function ofmaxSizeand thread count, never of write rate or event-loop delay. The audit tick keeps its interval, retention and reclamation duties as the backstop. The move itself is unchanged —moveLogFile()is nowrotateLogFileSync()followed bypublishArchivedGeneration(), and both rotation paths go through those, so there is one implementation of "rename, release, then compress or keep" rather than two.Because every isolate holds its own descriptor on the same file, an archived generation can still be appended to after the rename, and compressing then unlinking it would destroy those records — no before/after size comparison can rule out a write that has not happened yet. A rotation-only generation coordinator makes the release provable instead: the rotating thread announces the archived inode, every peer closes a descriptor matching it and answers, worker exit counts as an answer, and the plain archive is only ever destroyed once every peer has answered. An unproven generation is retained, retried on the next audit tick, and skipped by retention — and because retention deletes archives whichever thread rotated them, each pass first asks every peer, in one round trip, to release any descriptor that is not on the live generation, and deletes nothing if any peer does not answer. The coordinator's transport is injected by the thread layer, so
utility/logging/still imports nothing fromserver/threads/.Three smaller defects in the same fault family are fixed alongside, because each of them independently loses the rotation the operator configured:
maxSizeparser, shared with the config validator.parseIntaccepted0K,-1Kand1xK, which become a byte limit of0, a negative number, andNaN; sampled once a minute those merely misbehave, but on the write path they are evaluated per flush. Every form that produces a usable cap today, exponent notation included, is still accepted.getFileLoggercall carrying no rotation block no longer tears down the rotation an earlier, configured caller installed for that path. Several loggers are created for one log file during startup and the later ones carry no rotation of their own.rotation.path. fix(logging): external/component loggers inherit main rotation config #1880's path strip exists to avoid a cross-device rename when a component logs somewhere else; when the two loggers write the same file there is no such risk, and stripping discarded the operator's configured archive directory.The pre-push review ran seven rounds and converged at
Adjudicated-Severity: nit; every finding above that is fixed here: the file sink now registers with the coordinator instead of the size guard (a thread rotating only oninterval, or with an unusablemaxSize, builds no guard but still holds a descriptor, and was answering "released" having closed nothing); every archived generation is tracked until proven, not only the compressed ones, because retention unlinks archives too; onecompressdecision serves both rotation paths, which previously read it from two different places; and the audit tick's size check now stats and renames in the same turn, so a writing thread can no longer rotate the generation the tick measured and leave the tick to archive the near-empty replacement. Retention proves the archive set quiescent before deleting anything, and the ordering matters: the directory is listed first and only that listing is destroyed, because an archive created after the proof was never covered by it. Peers answer with the log paths they are writing, since components load in the workers and a component's own log is registered only there — the thread that runs retention would otherwise see a live file it had never heard of and delete it by age. Each peer judges its own paths, releasing any descriptor that is not on the live generation of the file it is writing, so nothing has to name the live inode of a log it does not own. The tick also finds and compresses plain archives on disk rather than only the ones this isolate remembers. Losing the rename race is no longer treated as a rotation failure — another thread renaming the generation between this one's stat and its rename was closing the descriptor and diverting five seconds of that thread's log lines to raw stdout — and the tick no longer overlaps itself or runs its retry queue ahead of retention.The one adjudicated major is worth naming: the "log rotated" notice is written back through the sink from inside the append that triggered the rotation, and the flush buffer was still holding the batch that append had just written — it was cleared only on the way out. With
logImmediatelyset, which is anynotify()orfatal()and the usual way a batch is flushed under load, the re-entrant flush re-joined the whole buffer and wrote every line of it a second time into the new generation; without it, the notice was swallowed. The buffer is now released before anything can re-enter, and a unit test drives a rotation out of a buffered flush and asserts each line lands exactly once — it reproduces the duplication on the unfixed code.Two more are worth naming because they are about the directory, not the protocol.
logging.rotation.pathdefaults tolog— the same directorylogging.rootdefaults to — so the archive directory normally holds the logs currently being written. The new compression sweep would have gzipped and unlinked the activehdb.logon a default install withcompresson, and retention, unchanged frommainin this respect, could already delete the active log or a component's once it aged past the window. The sweep now only touches files named the way this module names archives, and retention skips any path reported as a live log. Separately, a descriptor that cannot be proven to be on the live generation is now released rather than kept: on a filesystem reportingino === 0the batched release was keeping every descriptor and still answering "released", which is the one answer that lets an archive be destroyed under a peer.For the human reviewer
Six decisions here are reversible but worth your eye, roughly in the order I'd want them challenged.
Two rotators own one file. The write-path guard rotates on size, and the 60-second audit tick still does too. Every rename race and duplicate-archive concern in this change comes from that. I kept the tick's size check as a backstop because the guard only ever fires on a write — a log already over the cap when an idle instance starts would sit there until something logged — and because a thread whose guard failed to build has nothing else. Giving the guard sole ownership of size and leaving the tick with interval and retention is a one-branch change now and a much harder one once operators depend on tick behavior.
The generation coordinator is the largest new surface, and it only pays off with
compress: true, which ships off. It is confined to rotation — ordinary writes exchange no messages — injected rather than imported, and fails safe to "retain the plain archive", which is exactly what the default configuration does anyway. The cheaper alternative is to have each writer close its descriptor on the announcement and accept the existing 10-second descriptor timeout; that was considered and rejected in planning because a blocked writer's timer is not a bound, but it is the thing to unwind first if the protocol proves troublesome. Note it does not usebroadcastWithAcknowledgement's own ack — that call consumes acks internally and resolves identically for all-acked and timed-out, so it cannot express "unproven" — and its recipient set deliberately includes job workers, which the generic broadcast-eligibility predicate excludes but which do hold log descriptors.A failing rotation stops writing to the log file rather than letting it exceed the cap. Appending anyway would make the overshoot rate-dependent again, which is the bug. The worst case for this policy was a rotation directory on another filesystem: the rename fails
EXDEVdeterministically and forever, so file logging would end permanently while the content went to raw stdout even understdStreams: false. The guard now refuses to build at all in that configuration, with one startup error and today's unrotated behavior, so what remains is a transient failure diverting to stdio for five seconds at a time.Tightening
maxSizevalidation can fail a boot that previously succeeded. A config containing0K,-1Kor1xKis now rejected byconfigValidator, and config validation throws. Those three are broken today in ways an operator would not have chosen —0Kand-1Kmake the tick rotate on every pass,1xKdisables size rotation silently — and the write path evaluates them per flush rather than once a minute. The logger itself degrades rather than throwing on any path that bypasses validation. Flagged because it is the one behavior here that can stop an upgrade.One fix is not unit-covered: a size rotation now resets the interval clock. The interval clock took only tick-driven rotations into account, so an instance whose uptime had passed
intervalarchived a freshly-created log every interval on top of the size rotations already doing the work — a defect that predates this change. The fix is one line; I could not write an assertion for it that was not a rotation-count race against the audit timer, and a flaky test is worse than none.maxSize/16is the check quantum. It sets both the overshoot bound —maxBytes + T x (quantum + payload), which the tests encode as under 4x — and the syscall rate at small caps. A constant, trivially changed, but it is the number that will appear in incident reports.The transport is wired by a
requireat the bottom ofmanageThreads.js. That is load-order-sensitive coupling, chosen to keep the logging → threads dependency direction thatharper_logger.ts:3documents. An explicit init call from the server bootstrap would be testable without importing the whole thread manager; I would take that change.Archive rate is now
write-rate / maxSizeby definition, and rotation retention is unset by default (static/defaultConfig.yaml). At the deliberately tiny caps QA used that is many archives per second; at the shipped64Mdefault and QA's measured rate it is about one every three seconds. Disk footprint is no worse than today's single unbounded file, but inode growth is new and is bounded only by an operator's retention setting. No rate limiter is proposed — one would reintroduce exactly the rate-dependent bound this change removes — and a default retention felt like a separate decision.Archive filenames gain a thread id. #1880 made them
<source>-<hash>-<iso>-<pid>-<seq>.log; that is unique per process, and worker threads share the pid while the sequence counter is per-isolate module state, so once any thread can rotate the suffix needsthreadIdtoo. Nothing in the tree parses these names, but they are operator-visible.Two nits I consciously declined, both in the review's decision ledger. There is no floor on the rotation rate: at a deliberately tiny cap each rotation costs a mesh broadcast plus a descriptor close on every peer, and a coalescing window is a reasonable ask — but a rate limiter reintroduces exactly the rate-dependent bound this change removes, so I would rather that be a separate, deliberate decision. And
path.resolvenormalises relative aliases but not case-insensitive spellings or symlinks;realpathSyncper file per tick is not worth it when every path in play comes from one config resolution.On review coverage. Seven pre-push rounds ran. The Gemini leg is unauthenticated on this worker and never ran; the Codex leg timed out on the full 1,400-line diff three times and completed every delta round after that. Rounds 1 and 2 (Cursor/Grok plus the Harper domain adjudicator) and rounds 4 through 7 (Codex plus the adjudicator) all produced independent findings, and the head carries a current receipt. Round 3 produced nothing.
Declined: a benchmark threshold asserted in CI. The numbers are below; asserting a lines/second floor in a unit test is a flake source on shared runners, so the cost is measured and reported rather than gated.
The framing gate cleared on the third planning round:
Framing-Verdict: chosen-approach-sound. The first two returnedbetter-alternative-existsand both sets of corrections were adopted — a fixed quantum instead of a remaining budget (a per-thread remaining budget lets T writers each add ~maxBytesbefore any of them looks again), a synchronous handoff instead of a promise (an async rotation reintroduceswrite-rate x event-loop delay), and finally the coordinator itself in place of a before/after size comparison, which cannot rule out a write that has not happened yet.Verification
Fails on base, passes with the fix.
integrationTests/server/log-rotation-write-path.test.tsstarts a real Harper with two HTTP workers and a component that emits a unique marker per request, drives 120 requests past a64Kcap, and finishes well inside one audit interval so nothing but the write path can rotate. Onorigin/mainit fails at the first assertion with zero archives; on this branch it passes, every generation is under the bound, and every request marker appears exactly once across the active log and all archives.The integration test runs with
compress: true— the only setting under which an archive is destroyed, and therefore the only one that drives the coordinator's release-then-unlink path through the real thread mesh rather than a fake transport — and asserts that a.gzis actually published.Unit coverage in
unitTests/utility/logging/logRotationGuard.test.jsandlogGenerationCoordinator.test.js: peak-bounded rotation sampled during the writes; a pre-existing oversized log rotating on the first write; exactly-once across generations; a realworker_threadswriter sharing the path; multi-byte payloads counted in bytes; rotation disabled; a broken rotation target failing closed to stdio and then recovering when repaired; the coordinator publishing only after every peer answers, retaining the plain archive when one never does, treating worker exit as an answer, holding an unproven archive back from retention until a later pass proves it, and leaving the plain archive authoritative when compression fails; and the parser's accept/reject set, shared with a newconfigValidatorcase.Gates run:
npm run build,npm run test:unit:main(5312 passing, 1 pre-existing environment-dependent failure —configValidator.test.js's "does not warn when a relative rootPath resolves within the limit" resolves a relative root againstprocess.cwd(), which is long enough in a worktree to exceed the socket-path limit; it fails identically onorigin/mainin this checkout),npm run test:unit:resources,npm run test:integration:all,npm run format:write,npm run lint:required.Hot-path cost, 200,000 lines of ~150 bytes through the real file sink (not asserted in a test):
maxSize: 64M(the shipped default)maxSize: 64K(rotating constantly)About 6.6% with the guard on, and no measurable additional cost at a cap small enough to rotate hundreds of times during the run — roughly 140 MB/s against the ~21 MB/s QA measured in the failing case.
This is the second of #1877's two fix sites; the first (an
external/component logger with norotationblock of its own losing rotation entirely) shipped in #1880. Left asRefsrather thanFixesso closing the issue stays a human call once both halves have been seen together.Refs #1877
Complexity: complicated
Review-Coverage: authored=claude; ran=codex; blocked=gemini(auth); declined=cursor-grok,cursor-composer,domain; rounds=6 @ bbc2023
Human-Review-Need: 4 @ bbc2023