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
57 changes: 53 additions & 4 deletions emrg/client/daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,29 @@ async def _await_daemon_ready(
)


def _port_probe(_pid: int) -> bool:
"""`is_running()` wearing `pid_alive`'s ``win_probe`` contract — ``probe(pid)``.

The two probes this module supplies to `pid_alive` do not have the same
signature, and that is not a detail: `_win_pid_alive` answers *about a pid*
(it opens the process handle), while :func:`is_running` answers about the
**port file** and takes no argument at all. Passing the latter straight
through was a `TypeError` on every Windows restart: `is_running() takes 0
positional arguments but 1 was given`, raised from inside `pid_alive` on the
first iteration of the wait loop — i.e. on the path that exists to keep one
daemon from becoming two. Nothing on POSIX reaches it (`platform` is `win*`
there), which is exactly why it survived local runs, and it was measured on
Windows by the windows-2025 leg of run 35283518915.

The argument is accepted and dropped rather than the call being made without
one, because the *contract* is `pid_alive`'s to define and it is the one
asking; the port probe is what this caller has to answer with (a Windows
SIGTERM is a hard kill, so the pid is gone and the port file is the thing
that lingers).
"""
return is_running()


def _old_daemon_alive(pid: int, *, platform: str = "", kill=None,
win_probe=None) -> bool:
"""Is the daemon we just signalled gone? — asked once, not respelled.
Expand All @@ -382,13 +405,33 @@ def _old_daemon_alive(pid: int, *, platform: str = "", kill=None,
``GenerateConsoleCtrlEvent`` and the call becomes a Ctrl+C delivered to that
pid's console process group — every process sharing it, this CLI's shell
included. Windows SIGTERM is an immediate hard kill, so a *port* probe is
what answers there, and :func:`is_running` is this caller's Windows probe.
what answers there, and :func:`_port_probe` is this caller's Windows probe —
a port probe, because ``is_running`` answers for the port rather than for a
pid and cannot be handed over as it stands.

``platform`` / ``kill`` / ``win_probe`` are forwarded to `pid_alive` so both
answers can be pinned on every runner: the Windows branch is the unsafe one,
and a probe whose Windows behaviour is only observable on Windows is a defect
discovered on Windows.

Both halves of the answer are sourced from **this** module when the caller
injected neither, and that symmetry is load-bearing rather than tidy: the
Windows half already had to come from here (``_port_probe`` over our
``is_running``), and the
POSIX half has to as well, or the restart route's signalling is split across
two modules. It briefly was: `pid_alive` defaults its ``kill`` to its *own*
module's ``os.kill``, so the probe left the tripwire
``conftest::_guard_no_live_daemon_is_signalled`` installs on this module — the
substitute ``os`` the guard scopes here (and the escape hatch it documents:
"tests that drive the restart logic patch the module's ``os.kill``") silently
stopped covering the probe, because the patch lands on the substitute while
the probe asked the real module. Two tests in ``tests/test_daemon_manager.py``
fake that answer to pin the wait loop, and they then read a real
``os.kill(<stale pid>, 0)``: ``ESRCH``, i.e. *gone*, on the first iteration —
no wait, and no SIGKILL fallback. Naming our ``os`` here makes the faked
answer and the guarded answer the same object again, in production and in
tests.

One reading changes, and it is worth naming rather than discovering: the copy
here read ``EPERM`` as *alive* (the process exists, we may not signal it) and
`pid_alive` reads any ``OSError`` as *gone*. The pid this is asked about is
Expand All @@ -401,8 +444,8 @@ def _old_daemon_alive(pid: int, *, platform: str = "", kill=None,
return pid_alive(
pid,
platform=platform,
kill=kill,
win_probe=is_running if win_probe is None else win_probe,
kill=os.kill if kill is None else kill,
win_probe=_port_probe if win_probe is None else win_probe,
)


Expand Down Expand Up @@ -486,8 +529,14 @@ def _old_pid_alive() -> bool:
else:
# SIGTERM didn't work — force kill
logger.warning("old daemon (pid=%d) didn't die, sending SIGKILL", server_pid)
# Windows has no `signal.SIGKILL` (measured — the AttributeError
# escaped the whole restart path, because nothing here catches
# it: `ensure_connected()` died instead of respawning the
# daemon). Its `os.kill` ignores the number and terminates the
# process, which is what this fallback is for, so ask for
# whatever force the platform has.
try:
os.kill(server_pid, signal.SIGKILL)
os.kill(server_pid, getattr(signal, "SIGKILL", signal.SIGTERM))
except (ProcessLookupError, OSError):
pass
for _ in range(10): # up to 2s for SIGKILL to land
Expand Down
Loading
Loading