Skip to content

fix(run): run attached containers in the foreground and return their exit code - #95

Merged
us merged 3 commits into
us:mainfrom
rasmusbe:fix/attached-run-exit-and-logs
Oct 20, 2026
Merged

us merged 3 commits into
us:mainfrom
rasmusbe:fix/attached-run-exit-and-logs

Conversation

@rasmusbe

@rasmusbe rasmusbe commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

A container started without -d was killed after about 7 seconds while the client exited with 0.

Cause

mocker run without -d started the container detached, printed the log once and, with --rm, force-removed it while it was still running. Without --rm the client returned at once and left the container running. The runtime does not report an exit code for a detached container, so it was always 0.

Change

  • An attached run now spawns the runtime's own container run without -d and returns its exit status. Output streams live and --rm is handled by the runtime. It is spawned with posix_spawn rather than Process, because Process puts the child in its own process group and a -it session then ignores keyboard input.
  • logs -f, attach, start -a and compose logs -f re-read the log every 0.5 s and stop once the container is no longer running. The runtime's logs -f returns at once without error while the log is empty, and never returns for an exited container.
  • logs -n/--tail now has an effect. It was parsed and ignored.

Reproduce

Run each block with the released mocker (fails) and with this branch (passes). Needs the container runtime.

Killed mid-run, exit 0:

mkdir -p /tmp/beat && : > /tmp/beat/beat
mocker run --rm -v /tmp/beat:/w --entrypoint sh alpine -c \
  'i=0; while [ $i -lt 15 ]; do echo $i >> /w/beat; i=$((i+1)); sleep 1; done; echo done; exit 7' </dev/null
echo "exit=$?"; sleep 15; wc -l < /tmp/beat/beat; mocker ps -a | grep -c alpine
before after
done printed no yes
exit 0 7
lines in the file 6 15
container record left no no

Exit code without --rm (before: 0 each time, after: 7):

mocker run --entrypoint sh alpine -c 'exit 7' </dev/null; echo "exit=$?"
mocker rm -f $(mocker ps -aq)

-it ignored keyboard input when spawned with Process (needs a real terminal; the Python pty module also works). Type echo hej, then exit 3:

mocker run -it --rm alpine sh; echo "exit=$?"    # after: exit=3

logs -f on a container that writes late (before: returns at once with no output; after: prints first and second, returns after about 6 s):

mocker run -d --name lg1 --entrypoint sh alpine -c 'sleep 3; echo first; sleep 3; echo second'
mocker logs -f lg1
mocker logs -f lg1      # exited container: before it never returned, after it prints both lines and returns
mocker logs -n 1 lg1    # prints "second"
mocker rm -f lg1

Testing

Container runtime 1.5.0, arm64. All of the above, plus swift test: 339 tests pass, including a new one for the attached argument list.

Not covered: mocker wait still prints a hardcoded 0, since the runtime exposes no exit code for detached containers.

…exit code

A run without -d started the container detached, printed the log once and, with
--rm, force-removed it, so it was killed after a few seconds with exit 0.

Attached runs now hand the runtime's own `run` the terminal (posix_spawn, so -it
reads from the tty) and return its exit status. `logs -f` re-reads the log until
the container has exited, since the runtime's `logs -f` returns at once on an
empty log and never returns on an exited container. `--tail` is honoured.
@us

us commented Oct 5, 2026

Copy link
Copy Markdown
Owner

hey @rasmusbe thanks for great pr, will review it quickly!

@rasmusbe

Copy link
Copy Markdown
Contributor Author

@us Friendly reminder on this one. CI is green and the branch merges cleanly. Happy to adjust anything if you'd like it split up or changed. 😄

@us us left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and sorry for the wait. I ran your repro on container 1.5.0: released mocker exits 0 with 6 of 15 lines written, this branch exits 7 with all 15 and prints done. The core fix is right.

Two things I hit before merging:

  1. A failed attached run leaves a ghost record. mocker run --name ghost1 nonexistent-image:1 exits 1, then mocker ps -a shows ghost1 as Exited, and mocker run --name ghost1 alpine true fails with "Conflict ... already in use". The record is saved before the spawn and only deleted on --rm. Released mocker leaves nothing here. Suggest: after waitpid, if the runtime does not know the container (container inspect <name> fails), delete the record regardless of --rm.

  2. Ctrl-C ends mocker but not the child. In tmux, Ctrl-C on mocker run --rm ... sh -c 'while true; do echo tick; sleep 1; done' returns the shell prompt while container run keeps running and keeps printing into the terminal; the record stays "Up" and the container is never removed. Part of this is upstream: container run itself answers Ctrl-C with failed to send signal: missing signal in xpc message (apple/container#1876, #1941). But mocker should not exit before its child does: ignore SIGINT/SIGTERM in mocker while waiting, and reset them to default for the child with posix_spawnattr_setsigdefault + POSIX_SPAWN_SETSIGDEF so the runtime still gets them.

Smaller, optional:

  • followLogs: the first and finished flags can go. Set printed = max(0, lines.count - (tail ?? lines.count)) once before the loop, then loop while running plus one final read.
  • runAttached repeats the name-conflict check from run(); a shared 3-line helper keeps them in sync.
  • compose run without -d (Compose.swift:1066) has the same detached-then-print-logs shape. Fine as a follow-up if you prefer to keep this PR focused.

… run

- Drop the stored record when the runtime never created the container (unknown
  image, bad flag), so the name can be reused. It was only dropped for --rm.
- Catch SIGINT and SIGTERM while an attached run is waiting and pass them to the
  container with `container kill -s`, as Docker's --sig-proxy does. The runtime's
  own `run` does not forward them without a tty, and mocker used to exit before its
  child. The child gets the default signal action back so a tty still delivers
  Ctrl-C directly.
- `compose run` without -d takes the same attached path: it streams output, honours
  --rm, -i and -t, and returns the exit code instead of dumping the log once.
- Share the name-conflict check between run and runAttached, and simplify followLogs.
  A follow on a --rm container that has already removed itself ends quietly.
@rasmusbe
rasmusbe requested a review from us October 20, 2026 06:52
@rasmusbe

Copy link
Copy Markdown
Contributor Author

Thanks for the review, and for running the repro. I pushed 83179b7 with both fixes and the smaller points.

Ghost record: after the child exits I check container inspect <name>. If the runtime doesn't know the container, the record is dropped even without --rm. mocker run --name ghost1 nonexistent-image:1 now exits 1, leaves nothing behind, and the name can be reused.

Ctrl-C: ignoring the signals wasn't enough on its own, since container run doesn't stop on Ctrl-C without a tty. I got the same result running it directly in a pty, so it's the upstream issue you mention. mocker now catches SIGINT and SIGTERM while it waits and passes them on with container kill -s, like --sig-proxy in Docker. The child gets the default signal action back through POSIX_SPAWN_SETSIGDEF, so with a tty it still sees the keystroke itself. Tested in a pty with a real Ctrl-C: a script with trap ... INT exits 130, mocker returns 130 and nothing is left behind. A PID 1 without a handler still ignores SIGINT, same as in Docker without --init.

Smaller points: followLogs is simplified as suggested and the name check is shared through claimName. compose run without -d now takes the same attached path, so it streams output, honours --rm, -i and -t, and returns the exit code. It still builds the container from image, command and environment only, as before.

I can move compose run into its own PR if you'd rather keep this one focused.

A container made outside mocker under the same name was adopted by an
attached run: the record stayed, and `mocker rm` then deleted the other
container. The forwarded `container kill` also echoed the name into the
attached output.

@us us left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rasmusbe, great work. I added one small commit on top (d5fa7ff) so this could land today:

  • claimName also refuses a name the runtime already holds. A container made outside mocker under the same name was being adopted by an attached run, and mocker rm -f then deleted it. Now it is a plain Conflict error and nothing is recorded.
  • The forwarded container kill no longer prints the container name into the attached output.

Verified on container 1.5.0, rebased on main: your repro (exit 7, 15/15 lines), failed run leaves nothing, Ctrl-C with trap ... INT returns 130 and cleans up, -it returns its exit code, compose run streams and returns 4. 347 tests pass. Keeping compose run here was fine.

@us
us merged commit 9f3b76a into us:main Oct 20, 2026
1 check passed
@rasmusbe
rasmusbe deleted the fix/attached-run-exit-and-logs branch October 20, 2026 13:38
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.

2 participants