fix(console): resolve StderrCapture memory leak and fd-2 restoration - #435
Merged
Merged
Conversation
…433) Three bugs in StderrCapture caused unbounded _Block_copy allocations while the app was idle and left stderr permanently redirected after stop: 1. readabilityHandler leak (#433): the handler deferred availableData consumption into a captureQueue.async block, so the read source stayed signalled and re-fired immediately, enqueuing a new dispatch block per callback. Fix: read availableData synchronously inside the handler to clear the signal; only dispatch parsing/forwarding to processingQueue. 2. originalDescriptor aliasing: storing fd 2 directly meant that after dup2 redirected fd 2 onto the capture pipe, writeDirectlyToOriginalStderr wrote back into the capture loop (infinite recursion). Fix: dup() the real stderr fd before redirect so originalDescriptor is an owned copy. 3. stopCapturingInternal freopen ordering: freopen("/dev/stderr") resolves to /dev/fd/2, which post-redirect points at the capture pipe — so stderr stayed redirected after stop. Fix: dup2(originalDescriptor, fd 2) to restore real stderr before freopen, then close the owned descriptor. Adds StderrCaptureTests covering the leak (one write → ≤2 entries) and fd-2 restoration (post-stop writes not captured), with XCTSkip when the simulator environment doesn't support fd-2 redirect. Co-authored-by: oh-my-pi <https://omp.sh>
maatheusgois-dd
force-pushed
the
fix/stderr-leak-433
branch
from
August 10, 2026 16:11
0d9197a to
a7501e7
Compare
DebugSwift: Coverage: 14.94
ExampleTests.xctest: Coverage: 97.03
Generated by 🚫 Danger Swift against 0400cd4 |
The passthrough test was a false positive: it only checked that the full marker string appeared ≤2 times, but the bare uuid fragment (produced by stderrMessageSafe's split-on-]) never matched the full-marker filter even when it re-entered the capture pipe pre-fix. Now asserts the bare uuid fragment does NOT appear as a standalone console error entry — that's the observable difference between pre-fix (originalDescriptor aliases the capture pipe → fragment re-enters loop) and post-fix (originalDescriptor is an owned dup of real stderr → fragment exits the pipe). Co-authored-by: oh-my-pi <https://omp.sh>
freopen("/dev/stderr", "a", stderr) closes fd 2 first, then tries to
open /dev/fd/2 — which is now closed, so it fails with EBADF and leaves
fd 2 permanently invalid. This made the next startCapturing()'s dup(2)
return -1 (silently breaking capture restart) and broke all post-stop
stderr output (NSLog, crash logs, OS-level writes).
Replace with dup2(originalDescriptor, fd2) + clearerr(stderr) +
setvbuf(stderr, nil, _IOLBF, 0). The dup2 already restored fd 2 to
real stderr; the C stderr FILE* stream still references fd 2, so it
writes to the right destination. clearerr resets any error state and
setvbuf restores line-buffered mode (startCapturing set _IONBF).
Also fix tests: add setUp that stops app-launched capture so every
test starts from a known-stopped state and exercises a real
startCapturing -> startCapturingInternal path through the fixed code.
All three regression tests now pass (previously 2/3 skipped because
freopen broke fd 2 after the first test's tearDown):
- testSingleStderrWriteProducesOneConsoleEntry: leak fix verified
- testStderrPassthroughDoesNotRecurse: recursion fix verified
- testStopRestoresStderr: stop-restore fix verified
Co-authored-by: oh-my-pi <https://omp.sh>
maatheusgois-dd
force-pushed
the
fix/stderr-leak-433
branch
from
August 10, 2026 16:36
a7501e7 to
a13cc91
Compare
…ness The _isCapturing flag was set at the top of startCapturingInternal, ~58 lines before the dup2 that actually redirects fd 2 into the capture pipe. Tests polling isCapturing would see true while fd 2 still pointed at real stderr, so the marker escaped capture — the exact CI failure in testSingleStderrWriteProducesOneConsoleEntry (#433). Move _isCapturing = true to after both dup2s succeed so the flag faithfully means "fd 2 is redirected and the readabilityHandler is armed." Remove the now-dead _isCapturing = false resets from the three error paths (the flag is never set true before those points). Replace the fixed 0.3s sleep in waitForCaptureReady with a poll loop on isCapturing (10ms intervals, 5s timeout) so readiness is deterministic instead of a guess. Replace the fixed 1.0s post-write sleeps in both positive-assertion tests with poll loops on ConsoleOutput.getErrorOutput() for the marker — the serial processingQueue can lag under CI load, causing the fixed sleep to elapse before the marker reaches addErrorOutput. Co-authored-by: oh-my-pi <https://omp.sh>
maatheusgois-dd
force-pushed
the
fix/stderr-leak-433
branch
from
August 10, 2026 17:11
109ddb4 to
0400cd4
Compare
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.
Summary
Fixes #433 — memory leak while app is idle caused by
StderrCaptureproducing runaway_Block_copyallocations.Four bugs in
StderrCaptureare fixed:1. readabilityHandler leak (#433, primary)
The
readabilityHandlerdispatchedcaptureQueue.async { … }and readfileHandle.availableDatainside the async block. The read source stayed signalled becauseavailableDatawasn't consumed in the handler callback itself, so it re-fired immediately — enqueuing a new dispatch block per callback and leaking unbounded_Block_copyallocations (48/64-byte blocks matching the Instruments trace).Fix: Read
availableDatasynchronously inside thereadabilityHandlerto clear the dispatch source signal. Only the heavier string parsing/forwarding is dispatched toprocessingQueue.2.
originalDescriptoraliasing (infinite recursion)originalDescriptorstoredFileHandle.standardError.fileDescriptor(fd 2) directly. Afterdup2redirected fd 2 onto the capture pipe,writeDirectlyToOriginalStderrwrote back into the capture loop — infinite recursion.Fix:
dup()the real stderr fd before redirect sooriginalDescriptoris an owned copy that survives the redirect. Close it instopCapturingInternaland on error paths.3.
stopCapturingInternalfreopen breaks fd 2 (found during testing)freopen("/dev/stderr", "a", stderr)closes fd 2 first, then tries to open/dev/fd/2— which is now closed, so it fails withEBADFand leaves fd 2 permanently invalid. This broke the nextstartCapturing()'sdup(2)(returned -1) and silently broke all post-stop stderr output (NSLog, crash logs, OS-level writes).Fix: Replace
freopenwithdup2(originalDescriptor, fd2)+clearerr(stderr)+setvbuf(stderr, nil, _IOLBF, 0). Thedup2restores fd 2 to real stderr; the CstderrFILE* stream still references fd 2, so it writes to the right destination.clearerrresets error state;setvbufrestores line-buffered mode.4.
stopCapturingInternalfd-2 restoration orderingThe original code had no
dup2restore beforefreopen— fd 2 stayed pointing at the capture pipe after stop, so stderr stayed redirected.Fix:
dup2(originalDescriptor, FileHandle.standardError.fileDescriptor)to restore real stderr (now combined with fix #3 — nofreopenat all).Test plan
All three regression tests pass (previously 2/3 skipped due to the freopen bug found and fixed in this PR):
StderrCaptureTests.testSingleStderrWriteProducesOneConsoleEntry— Passed (2.4s). Verifies one stderr write produces ≤2 console entries, not thousands (the leak symptom).StderrCaptureTests.testStderrPassthroughDoesNotRecurse— Passed (2.3s). Writes a[marker] uuidline; the]triggerswriteDirectlyToOriginalStderr. Asserts the bare uuid fragment does NOT appear as its own console entry (the recursion-fix path).StderrCaptureTests.testStopRestoresStderr— Passed (2.3s). After stop, writes to stderr and asserts they do NOT appear in the console (fd 2 restored).xcodebuild teston Example scheme —** TEST SUCCEEDED **Tests use
setUpto stop app-launched capture so every test starts from a known-stopped state and exercises a realstartCapturing → startCapturingInternalpath through the fixed code.Closes #433