fix(tui_gateway): withdraw a server request when its wait is interrupted

The settlement rewrite dropped main's try/finally around event.wait():
a KeyboardInterrupt/SystemExit/injected error during the wait left the
request in _open forever — replayed to every reconnecting client as a
zombie card and reported by pending_kind() as a human still being
waited on. Pop it under _lock and emit request.cancel(interrupted)
before re-raising. Also fold the two settlement-wins tests into one
parametrized test (same invariant, two settlement paths).
This commit is contained in:
teknium1
2026-09-16 12:48:44 -07:00
committed by Teknium
parent 4a45fda9ee
commit 4371ed34a9
2 changed files with 22 additions and 20 deletions

View File

@@ -313,18 +313,25 @@ def test_server_request_round_trip_uses_response_frame(capture):
assert not server_requests._open
def test_response_settlement_wins_over_a_later_cancel(capture):
@pytest.mark.parametrize("method, qids, settle, expected", [
("sudo", None,
lambda sr, req: sr.resolve_response({"id": req.id, "result": {"value": "yes"}}) is True,
{"value": "yes"}),
# Batch clarify's lock-based resolution follows the same first-settlement rule.
("clarify", ["q1"], lambda sr, req: sr.lock_answer(req.id, "q1", "yes") == [], {"answers": {"q1": "yes"}}),
])
def test_settlement_wins_over_a_later_cancel(capture, method, qids, settle, expected):
"""A response and cancellation may race; the first settlement owns the result."""
from tui_gateway import server_requests
req = server_requests.ServerRequest("s1", "sudo", {})
req = server_requests.ServerRequest("s1", method, {}, qids=qids)
with server_requests._lock:
server_requests._open[req.id] = req
assert server_requests.resolve_response({"id": req.id, "result": {"value": "yes"}})
assert settle(server_requests, req)
assert server_requests.cancel("s1") == 0
assert req.answered is True
assert req.result == {"value": "yes"}
assert req.result == expected
assert req.event.is_set()
@@ -353,21 +360,6 @@ def test_send_returns_an_answer_committed_after_the_deadline_expired(capture, mo
assert not server_requests._open
def test_final_clarify_lock_settlement_wins_over_a_later_cancel(capture):
"""Batch clarify's lock-based resolution follows the same first-settlement rule."""
from tui_gateway import server_requests
req = server_requests.ServerRequest("s1", "clarify", {}, qids=["q1"])
with server_requests._lock:
server_requests._open[req.id] = req
assert server_requests.lock_answer(req.id, "q1", "yes") == []
assert server_requests.cancel("s1") == 0
assert req.answered is True
assert req.result == {"answers": {"q1": "yes"}}
assert req.event.is_set()
def test_server_request_error_response_fails_fast(capture):
"""A shared-channel client without a handler answers -32601 instead of waiting for the deadline."""
from tui_gateway import server_requests

View File

@@ -109,7 +109,17 @@ def send(method: str, sid: str, params: dict, *, timeout: float | None,
"""
req = ServerRequest(sid, method, params, qids=qids)
_register(req)
req.event.wait(timeout)
try:
req.event.wait(timeout)
except BaseException:
# The wait itself died (KeyboardInterrupt, SystemExit, injected error): withdraw the request
# or it stays in _open forever — replayed to every reconnecting client and reported by
# pending_kind() as a human still being waited on.
with _lock:
still_open = _open.pop(req.id, None) is req
if still_open:
_emit_cancel(req, "interrupted")
raise
with _lock:
# The verdict is the state committed under the lock, never wait()'s return value: a
# response frame can land after the deadline expires and before this removal, and