Windows updates forced a choice between 'gateway survives' and 'update
proceeds': the pause machinery's only tools were the planned-stop marker
poll and the force-kill ladder, so a mid-turn gateway was tree-killed and
its active turn lost. Step 2 of the socket migration adds the
pause-for-update verb: the updater ASKS the gateway to drain in-flight
turns and exit cleanly — releasing every venv file handle on the way out
— through the same request_restart(via_service=True) drain path SIGUSR1
and service restarts already use.
- gateway/run.py: pause-for-update verb handler registered on the
existing control server; marshals onto the loop thread, ACKs with
{pausing, already_stopping, pid, drain_timeout}.
- gateway/control_socket.py: pause_gateway_for_update() client — None on
no-answer (older gateway / no socket), so every caller keeps the
legacy path when the verb is missing.
- update_cmd.py (_pause_windows_gateways_for_update): socket-first ask
per mapped profile gateway before the drain wait; positive ACKs extend
the wait to the gateway's own declared drain budget (+ teardown grace)
so a mid-turn gateway isn't force-killed at the end of a too-short
local default. Marker write + force-kill ladder retained verbatim as
the fallback.
Live E2E: real gateway process (isolated HERMES_HOME), real socket:
identify -> pause ACK {pausing: true} -> gateway drained and exited on
its own (rc=75, zero signals) -> dead-gateway re-ask returns None.
A step-1 gateway without the verb answers ok:false -> client None ->
legacy path (pinned by test).
110 lines
3.4 KiB
Python
110 lines
3.4 KiB
Python
"""pause-for-update control-socket verb (#92091 step 2, campaign #91277).
|
|
|
|
The updater asks a running gateway to drain and exit cleanly (releasing its
|
|
venv file handles) instead of tree-killing it mid-turn. Fallback contract:
|
|
older gateways without the verb answer nothing, and callers keep the legacy
|
|
marker/force-kill path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from gateway.control_socket import (
|
|
GatewayControlServer,
|
|
pause_gateway_for_update,
|
|
query_gateway_control,
|
|
)
|
|
|
|
|
|
def _make_server(tmp_path, handler):
|
|
server = GatewayControlServer(
|
|
home=tmp_path, verb_handlers={"pause-for-update": handler}
|
|
)
|
|
return server
|
|
|
|
|
|
def test_pause_verb_dispatches_and_returns_ack(tmp_path):
|
|
calls = []
|
|
|
|
def handler():
|
|
calls.append(1)
|
|
return {"pausing": True, "already_stopping": False, "pid": 111,
|
|
"drain_timeout": 30.0}
|
|
|
|
server = _make_server(tmp_path, handler)
|
|
raw = json.dumps({"verb": "pause-for-update", "id": 7}).encode()
|
|
response = json.loads(server.handle_request_line(raw).decode())
|
|
assert response["ok"] is True
|
|
assert response["result"]["pausing"] is True
|
|
assert response["result"]["drain_timeout"] == 30.0
|
|
assert response["id"] == 7
|
|
assert calls == [1]
|
|
|
|
|
|
def test_unknown_verb_still_lists_pause(tmp_path):
|
|
server = _make_server(tmp_path, lambda: {})
|
|
raw = json.dumps({"verb": "nope"}).encode()
|
|
response = json.loads(server.handle_request_line(raw).decode())
|
|
assert response["ok"] is False
|
|
assert "pause-for-update" in response["supported_verbs"]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="unix socket transport")
|
|
def test_pause_client_roundtrip_over_real_socket(tmp_path):
|
|
"""Full client→socket→handler→ACK path over a REAL unix socket."""
|
|
|
|
async def scenario():
|
|
acks = []
|
|
|
|
def handler():
|
|
acks.append(1)
|
|
return {"pausing": True, "already_stopping": False,
|
|
"pid": 4242, "drain_timeout": 12.5}
|
|
|
|
server = _make_server(tmp_path, handler)
|
|
assert await server.start()
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
result = await loop.run_in_executor(
|
|
None, lambda: pause_gateway_for_update(tmp_path, timeout=5.0)
|
|
)
|
|
finally:
|
|
await server.stop()
|
|
return result, acks
|
|
|
|
result, acks = asyncio.run(scenario())
|
|
assert acks == [1]
|
|
assert result is not None
|
|
assert result["pausing"] is True and result["drain_timeout"] == 12.5
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="unix socket transport")
|
|
def test_pause_client_none_when_gateway_lacks_verb(tmp_path):
|
|
"""Back-compat: a step-1 gateway (identify/status only) answers ok:false
|
|
for the unknown verb → the client returns None → caller keeps the legacy
|
|
kill path."""
|
|
|
|
async def scenario():
|
|
server = GatewayControlServer(home=tmp_path) # no pause handler
|
|
assert await server.start()
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
return await loop.run_in_executor(
|
|
None, lambda: pause_gateway_for_update(tmp_path, timeout=5.0)
|
|
)
|
|
finally:
|
|
await server.stop()
|
|
|
|
assert asyncio.run(scenario()) is None
|
|
|
|
|
|
def test_pause_client_none_when_no_socket(tmp_path):
|
|
assert pause_gateway_for_update(tmp_path, timeout=0.5) is None
|