Skip to content

fix(console): resolve StderrCapture memory leak and fd-2 restoration - #435

Merged
maatheusgois-dd merged 4 commits into
mainfrom
fix/stderr-leak-433
Aug 10, 2026
Merged

maatheusgois-dd merged 4 commits into
mainfrom
fix/stderr-leak-433

Conversation

@maatheusgois-dd

@maatheusgois-dd maatheusgois-dd commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #433 — memory leak while app is idle caused by StderrCapture producing runaway _Block_copy allocations.

Four bugs in StderrCapture are fixed:

1. readabilityHandler leak (#433, primary)

The readabilityHandler dispatched captureQueue.async { … } and read fileHandle.availableData inside the async block. The read source stayed signalled because availableData wasn't consumed in the handler callback itself, so it re-fired immediately — enqueuing a new dispatch block per callback and leaking unbounded _Block_copy allocations (48/64-byte blocks matching the Instruments trace).

Fix: Read availableData synchronously inside the readabilityHandler to clear the dispatch source signal. Only the heavier string parsing/forwarding is dispatched to processingQueue.

2. originalDescriptor aliasing (infinite recursion)

originalDescriptor stored FileHandle.standardError.fileDescriptor (fd 2) directly. 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 that survives the redirect. Close it in stopCapturingInternal and on error paths.

3. stopCapturingInternal freopen 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 with EBADF and leaves fd 2 permanently invalid. This broke the next startCapturing()'s dup(2) (returned -1) and silently broke all post-stop stderr output (NSLog, crash logs, OS-level writes).

Fix: Replace freopen with dup2(originalDescriptor, fd2) + clearerr(stderr) + setvbuf(stderr, nil, _IOLBF, 0). The dup2 restores fd 2 to real stderr; the C stderr FILE* stream still references fd 2, so it writes to the right destination. clearerr resets error state; setvbuf restores line-buffered mode.

4. stopCapturingInternal fd-2 restoration ordering

The original code had no dup2 restore before freopen — 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 — no freopen at all).

Test plan

All three regression tests pass (previously 2/3 skipped due to the freopen bug found and fixed in this PR):

  • StderrCaptureTests.testSingleStderrWriteProducesOneConsoleEntryPassed (2.4s). Verifies one stderr write produces ≤2 console entries, not thousands (the leak symptom).
  • StderrCaptureTests.testStderrPassthroughDoesNotRecursePassed (2.3s). Writes a [marker] uuid line; the ] triggers writeDirectlyToOriginalStderr. Asserts the bare uuid fragment does NOT appear as its own console entry (the recursion-fix path).
  • StderrCaptureTests.testStopRestoresStderrPassed (2.3s). After stop, writes to stderr and asserts they do NOT appear in the console (fd 2 restored).
  • xcodebuild test on Example scheme — ** TEST SUCCEEDED **

Tests use setUp to stop app-launched capture so every test starts from a known-stopped state and exercises a real startCapturing → startCapturingInternal path through the fixed code.

Closes #433

…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>
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Messages
📖 Project coverage: 25.0%
📖 The PR added 251 and removed 27 lines. 2 file(s) changed.

DebugSwift: Coverage: 14.94

File Coverage
StderrCapture.swift 78.28%

ExampleTests.xctest: Coverage: 97.03

File Coverage
StderrCaptureTests.swift 76.65%

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>
…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
maatheusgois-dd merged commit 27c1ca4 into main Aug 10, 2026
5 checks passed
@maatheusgois-dd
maatheusgois-dd deleted the fix/stderr-leak-433 branch August 10, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Memory Leak while app is idle - StderrCapture produces runaway block allocations

1 participant