Repository navigation
Fix #2053: hold loadSpooledBodies' shared read through the owner's unlink - #2717
Merged
Merged
Conversation
…link The shared `reading` entry was dropped as soon as the read settled, before the owning caller unlinked an unparseable body. A caller that arrived during the unlink read the file again, got ENOENT, and counted the body as missing instead of unparseable. The entry now counts its users and the last one out drops it in a finally, on every exit path, so it covers the unlink and never outlives the reads in flight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Refs #2053
What was wrong
loadSpooledBodiesshares one in-flightreadFileper body path between overlapping calls (thereadingmap). The entry was removed as soon as the read settled, which is before the owning call unlinks an unparseable body. A second call that arrived while that unlink was in flight no longer found the entry, read the file itself, got ENOENT, and counted the body asmissinginstead ofunparseable.Fix
Each
readingentry now counts its users. A caller joins it before its firstawaitand leaves in afinallyaround its whole classification, so the owner holds the entry until itsunlinkhas settled. The last caller out deletes it. That covers every exit path, including theremoving.hasearlycontinueand the missing arm. One file, 24 lines in and 23 out, ignoring reindentation.Tests (deterministic, explicit ordering)
Two new tests in
test/plugins/claude-telemetry-unparseable-body.test.js:unlinkwhile the second call starts. Expected: unparseable 2, missing 0, bytes reported once, onereadFile. Then the test writes valid JSON at the same path, and a fresh call parses it with a secondreadFile. That proves the entry was released, not left in the map.Mutation check:
bodies.jsfails the first test (unparseable 1, expected 2).Timing probe: 400 trials, with the second call delayed by N
setImmediateticks. "Miscounted" means the second call counted the body as missing.At 20 and 50 ticks the calls rarely overlapped, so most trials did not reach the race. The denominators count only trials where they did.
Single caller is unchanged
hyp statuscapture-health line (captureHealthinsrc/core/daemon/status.js) reads event and transcript timestamps only. It never readsbodiesMissingorbodiesUnparseable. With identical single-caller counters, the line is unchanged.claude_telemetry_capturepasses, including itsbodies_missing === 1check.status_capture_healthfails at its first step ("Daemon is not running") with byte-identical output on master'sbodies.jsand on this branch, so that failure is pre-existing in this environment and unrelated.Checks
npm run typecheck: passnpm test: 8656 tests, 8653 pass, 0 fail, 3 skippedCPU and memory
{ read, users }object per body path with a read in flight, the same count as before. It is now released after classification instead of when the read settles, and always by the last user, so it cannot grow across ticks.readFilethat the race used to cause.Server-suite gate: not triggered (no CLI command, contract, MCP codec, tool schema or error code change).
🤖 Generated with Claude Code