Describe the bug
TerminalService.execute reads command output with readDataToEndOfFile() after waitUntilExit() (Sources/Fluid/Services/TerminalService.swift:111-115). That read ends at EOF, and EOF arrives only when every process holding the write end has closed it. A command that leaves a background descendant behind keeps the pipe open after /bin/zsh exits, so the read blocks.
The timeout argument does not bound this. The timeout task calls process.terminate() only while process.isRunning, and by the time the read starts the shell has already exited.
Reproduction steps
- Run a Command Mode command that backgrounds something outliving the shell, for example
( sleep 60 & ) ; echo started.
- Wait past the configured timeout.
Measured with a standalone Swift program mirroring that code shape rather than through the UI: /bin/zsh -c "( sleep 60 & ) ; echo started", both pipes, waitUntilExit() then readDataToEndOfFile().
waitUntilExit returned : 0.068 s (exit 0, isRunning=false)
stdout read returned : 60.010 s
stderr read returned : 60.010 s
stdout : "started" (8 bytes)
Expected behavior
execute returns within its timeout with whatever output has arrived.
Actual behavior
execute returns only when the descendant closes the pipe. The output was complete at 0.07 s and handed back at 60 s.
App Version
1.6.9. Sources/Fluid/Services/TerminalService.swift is identical at tag v1.6.9 and on main (66ca682).
macOS Version
macOS 15.7.7 (24G720)
Architecture
Apple Silicon
Additional context
Two things I found trying to fix this on #430. Both were measured on that patched branch, not on main, so they are notes on the fix rather than on current behaviour.
Bounding each drain by the caller's timeout does fix the return, 60.834 s down to 2.004 s on a 2 second timeout. Polling the descriptor first is not enough on its own though: FileHandle.read(upToCount:) keeps reading until it has the full requested count or hits EOF, so it blocks past the deadline anyway and needs a plain read(2) after the poll.
That still leaves the descendant alive. process.terminate() signals the shell, not its children, so the writer survives and blocks on a write nobody drains. Locally that left a yes writer running for minutes after execute had returned. Launching the command in its own process group and signalling the group looks like the real fix, and it changes how TerminalService starts processes, which is why I did not fold it into #430.
Describe the bug
TerminalService.executereads command output withreadDataToEndOfFile()afterwaitUntilExit()(Sources/Fluid/Services/TerminalService.swift:111-115). That read ends at EOF, and EOF arrives only when every process holding the write end has closed it. A command that leaves a background descendant behind keeps the pipe open after/bin/zshexits, so the read blocks.The
timeoutargument does not bound this. The timeout task callsprocess.terminate()only whileprocess.isRunning, and by the time the read starts the shell has already exited.Reproduction steps
( sleep 60 & ) ; echo started.Measured with a standalone Swift program mirroring that code shape rather than through the UI:
/bin/zsh -c "( sleep 60 & ) ; echo started", both pipes,waitUntilExit()thenreadDataToEndOfFile().Expected behavior
executereturns within its timeout with whatever output has arrived.Actual behavior
executereturns only when the descendant closes the pipe. The output was complete at 0.07 s and handed back at 60 s.App Version
1.6.9.
Sources/Fluid/Services/TerminalService.swiftis identical at tagv1.6.9and onmain(66ca682).macOS Version
macOS 15.7.7 (24G720)
Architecture
Apple Silicon
Additional context
Two things I found trying to fix this on #430. Both were measured on that patched branch, not on main, so they are notes on the fix rather than on current behaviour.
Bounding each drain by the caller's timeout does fix the return, 60.834 s down to 2.004 s on a 2 second timeout. Polling the descriptor first is not enough on its own though:
FileHandle.read(upToCount:)keeps reading until it has the full requested count or hits EOF, so it blocks past the deadline anyway and needs a plainread(2)after the poll.That still leaves the descendant alive.
process.terminate()signals the shell, not its children, so the writer survives and blocks on a write nobody drains. Locally that left ayeswriter running for minutes afterexecutehad returned. Launching the command in its own process group and signalling the group looks like the real fix, and it changes howTerminalServicestarts processes, which is why I did not fold it into #430.