diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7710522..d76a94017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/gmlx/container/session.py b/gmlx/container/session.py index 65baf7132..9c819f239 100644 --- a/gmlx/container/session.py +++ b/gmlx/container/session.py @@ -19,6 +19,7 @@ import os import re import secrets +import select import shlex import shutil import signal @@ -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. @@ -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: @@ -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() @@ -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() diff --git a/tests/container/test_container_session.py b/tests/container/test_container_session.py index 60a65d1a2..854d89df5 100644 --- a/tests/container/test_container_session.py +++ b/tests/container/test_container_session.py @@ -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("""