Skip to content

[BUG] Command Mode can hang far past its timeout when a command leaves a background descendant holding stdout or stderr #930

Description

@postoso

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

  1. Run a Command Mode command that backgrounds something outliving the shell, for example ( sleep 60 & ) ; echo started.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions