Skip to content

[runner] Stop reading job output once the command has exited - #4276

Merged
un-def merged 1 commit into
masterfrom
pr_runner_bound_output_copy
Sep 9, 2026
Merged

un-def merged 1 commit into
masterfrom
pr_runner_bound_output_copy

Conversation

@un-def

@un-def un-def commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Previously, execJob copied the command's output to completion and only then waited for the command. A read on the pty master returns EIO only once every process holding the slave has closed it, so a job that leaves one behind blocked io.Copy forever.

cmd.Wait() was then never reached: the shell stayed an unreaped zombie, the terminal job state was never reported, and the run hung until the container was destroyed.

Now the command is waited for first, and the copy is given logsDrainDelay to drain what the command already wrote before the master is closed, which unblocks the read.

Closing the master only works if it is pollable. pty.Open() wraps the descriptor with os.NewFile in blocking mode, so it never reaches the runtime poller, and closing such a file does not interrupt a Read already in flight -- the close is deferred until that read returns, which may be never. /dev/ptmx is now opened with O_NONBLOCK, which was the last use of creack/pty.

The log quota watchdog moves into a goroutine, so output keeps being copied until the command exits and a full pty buffer cannot keep it from exiting.

What gets killed is unchanged: the processes the job leaves behind still survive, and are cleaned up when the container is destroyed.

Previously, `execJob` copied the command's output to completion and only
then waited for the command. A read on the pty master returns EIO only
once every process holding the slave has closed it, so a job that leaves
one behind blocked `io.Copy` forever.

`cmd.Wait()` was then never reached: the shell stayed an unreaped
zombie, the terminal job state was never reported, and the run hung
until the container was destroyed.

Now the command is waited for first, and the copy is given
`logsDrainDelay` to drain what the command already wrote before the
master is closed, which unblocks the read.

Closing the master only works if it is pollable. `pty.Open()` wraps the
descriptor with `os.NewFile` in blocking mode, so it never reaches the
runtime poller, and closing such a file does not interrupt a `Read`
already in flight -- the close is deferred until that read returns,
which may be never. `/dev/ptmx` is now opened with `O_NONBLOCK`, which
was the last use of `creack/pty`.

The log quota watchdog moves into a goroutine, so output keeps being
copied until the command exits and a full pty buffer cannot keep it from
exiting.

What gets killed is unchanged: the processes the job leaves behind
still survive, and are cleaned up when the container is destroyed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@un-def un-def changed the title Stop reading job output once the command has exited [runner] Stop reading job output once the command has exited Sep 9, 2026
@un-def
un-def merged commit 4324221 into master Sep 9, 2026
26 checks passed
@un-def
un-def deleted the pr_runner_bound_output_copy branch September 9, 2026 08:20
un-def added a commit that referenced this pull request Sep 10, 2026
Previously, stopping a job sent SIGINT to `cmd.Process`, the wrapper
shell. That reached nothing that mattered. The server runs commands
under a shell with `-i -c`, and an interactive shell turns on job
control, which puts the job in a process group of its own, while
the shell ignores SIGINT for as long as it is waiting for that job.
The signal reached neither, so nothing stopped the job until
WaitDelay's SIGKILL went to the shell alone ten seconds later,
leaving the workload running and reparented to PID 1.

This looked intermittent because some shells exec the command in place.
`bash -i -c` and `busybox ash -i -c` do so for a single simple command,
leaving no shell at all, and the SIGINT then reached the workload
directly. dash does not, so the same configuration behaved differently
between a Debian and an Alpine image.

Now Cancel writes the terminal's INTR character to the pty master, and
the line discipline raises SIGINT in the terminal's foreground process
group -- what pressing Ctrl-C does. The workload receives it whether or
not a shell stands in between, and whether or not job control moved it
into a process group of its own.

The write is bounded by a deadline. A job that never reads its stdin
could fill the terminal's input buffer and block the write, and Cmd only
starts the WaitDelay timer once Cancel has returned -- so an unbounded
write would disable the very backstop meant to catch a job that ignores
the interrupt. The deadline is honoured because the master has been
pollable since #4276.

`max_duration` now interrupts the job too, rather than killing it.

A job can still ignore all of this: a program that puts the terminal in
raw mode clears ISIG, and the INTR character then delivers no signal.
WaitDelay stays the backstop, and it still kills only the shell.

Note, the shell process may still linger if the foreground group
is _killed_ by SIGINT (doesn't handle it) -- some shells (dash, ash,
but not bash) in the interactive mode unwind the interrupt to its
top level and go back to reading the terminal -- the equivalent of
returning to a prompt; e.g., with `image: debian`, `shell: /bin/sh`
or unset -> dash:

* `sleep inf` -- `/bin/sleep` doesn't handle SIGINT, killed,
  dash goes back to reading the terminal, lingers, killed after
  killDelay (10 seconds).
* `python -m http.server` -- the server handles SIGINT, exits with 0,
  dash exits as well.

This issue is considered insignificant for now and can be addressed in
the future with a separate fix (one possible option is to send SIGHUP
to the shell process after a graceful timeout).

Fixes: #2233
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant