Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
that free memory allows at load.
- On Muse Glimmer, the thinking budget now applies in `run`, `chat` and
`serve`, and `response_format` constrains only the final answer.
- `gmlx launch` could miss a SIGTERM or SIGHUP on a busy Mac, so a stop or a
closed terminal did not end the session until the client exited.

## [0.5.0] - 2026-10-12

Expand Down
36 changes: 34 additions & 2 deletions gmlx/container/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import os
import re
import secrets
import select
import shlex
import shutil
import signal
Expand Down Expand Up @@ -920,6 +921,35 @@ class TeardownAbandoned(BaseException):
the remaining steps are skipped."""


def _wait_child(child: subprocess.Popen) -> int:
"""Wait for ``child`` and return its exit code. A signal always wakes
the wait, so its handler runs at once. ``child.wait()`` alone blocks in
``waitpid``, and a signal that lands just before that call then waits
for the child to exit. Call it from the main thread."""
wake_r, wake_w = os.pipe()
done_r, done_w = os.pipe()
os.set_blocking(wake_w, False)

def wait() -> None:
try:
child.wait()
with contextlib.suppress(OSError):
os.write(done_w, b".")
finally:
os.close(done_w)

old = signal.set_wakeup_fd(wake_w, warn_on_full_buffer=False)
try:
threading.Thread(target=wait, daemon=True).start()
while done_r not in select.select([wake_r, done_r], [], [])[0]:
os.read(wake_r, 512)
finally:
signal.set_wakeup_fd(old)
for fd in (wake_r, wake_w, done_r):
os.close(fd)
return child.wait()


class _Signals:
"""Signal handling while ``container run`` runs.

Expand Down Expand Up @@ -1278,6 +1308,8 @@ def ready(url: str) -> None:
raise cli.ContainerError(f"cannot start `container run` "
f"({e.strerror or e}).") from None
signals.child = child
if signals.count >= 3: # a third signal came during the start
child.kill()
if relay is not None:
relay.start()
if on_start is not None:
Expand Down Expand Up @@ -1307,7 +1339,7 @@ def found(url: str) -> None:
reader = threading.Thread(target=_tee_for_url, daemon=True, args=(
child.stdout, None, spec.web_port, None, log, None, None, output_max))
reader.start()
rc = child.wait()
rc = _wait_child(child)
signals.done.set()
if relay is not None:
relay.close()
Expand Down Expand Up @@ -1692,7 +1724,7 @@ def on_end(signum, frame) -> None:
relay.start()
if len(hangups) > 1: # a second signal came during the start
kill()
code = child[0].wait()
code = _wait_child(child[0])
finally:
if relay is not None:
relay.close()
Expand Down
23 changes: 23 additions & 0 deletions tests/container/test_container_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2144,6 +2144,29 @@ def test_a_third_signal_leaves_the_terminal_as_it_was(fake_container, tmp_path):
assert b"exit 137 restored" in out, out


def test_a_signal_another_thread_takes_wakes_the_wait():
"""The handler runs while the child runs, also when the signal reaches
a thread other than the main one. The same holds for a signal that
lands just before the wait starts."""
idle = threading.Event()
other = threading.Thread(target=idle.wait, daemon=True)
other.start() # the only thread that takes SIGUSR1
child: list[subprocess.Popen] = []
saved = signal.signal(signal.SIGUSR1, lambda signum, frame: child[0].kill())
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGUSR1})
try:
child.append(subprocess.Popen(["sh", "-c", "kill -USR1 $PPID; exec sleep 30"]))
assert session._wait_child(child[0]) == -signal.SIGKILL
finally:
signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGUSR1})
signal.signal(signal.SIGUSR1, saved)
idle.set()
for c in child:
if c.poll() is None:
c.kill()
c.wait()


# The terminal takes these, as the shell would, while a typed key does not
# wait: no line editing, no echo.
_WAITING_INPUT = textwrap.dedent("""
Expand Down
Loading