Skip to content
Open
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
106 changes: 106 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,112 @@ def start(self):
assert fired["timer"] is False


def test_ws_orphan_reap_rearms_after_system_sleep(monkeypatch):
"""A reap timer the host slept through re-arms instead of reaping.

Regression for #44183: threading.Timer's wait elapses in wall-clock time
on macOS, so closing the lid for longer than the grace made the timer
fire at the instant of wake — before the Desktop app could reconnect —
and every >20s sleep/wake cycle 404'd the open session. The reap must
grant the full grace window in *awake* (monotonic) time.
"""
timers = []

class _Timer:
def __init__(self, interval, function):
self.interval = interval
self.function = function
timers.append(self)

def start(self):
pass

closed = []
clock = {"now": 1000.0}
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 20.0)
monkeypatch.setattr(server.threading, "Timer", _Timer)
monkeypatch.setattr(server.time, "monotonic", lambda: clock["now"])
monkeypatch.setattr(
server,
"_close_session_by_id",
lambda sid, *, end_reason: closed.append((sid, end_reason)) or True,
)

server._sessions["slept-sid"] = _session(
transport=server._detached_ws_transport, running=False
)
try:
server._schedule_ws_orphan_reap("slept-sid")
assert len(timers) == 1
assert timers[0].interval == 20.0

# Host sleeps 2s into the grace: the wall-clock wait expires during
# the sleep and the timer fires at wake with only 2s of awake time
# elapsed — spared, re-armed for the remaining 18s.
clock["now"] += 2.0
timers[0].function()
assert closed == []
assert len(timers) == 2
assert abs(timers[1].interval - 18.0) < 1e-9

# The re-armed timer runs its full remainder awake: reap proceeds.
clock["now"] += 18.0
timers[1].function()
assert closed == [("slept-sid", "ws_orphan_reap")]
assert len(timers) == 2
finally:
server._sessions.pop("slept-sid", None)


def test_ws_orphan_reap_rearm_spares_post_wake_reconnect(monkeypatch):
"""A session that reconnects within the post-wake grace is not reaped."""
timers = []

class _Timer:
def __init__(self, interval, function):
self.interval = interval
self.function = function
timers.append(self)

def start(self):
pass

class _LiveTransport:
def write(self, *a, **k):
return True

closed = []
clock = {"now": 1000.0}
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 20.0)
monkeypatch.setattr(server.threading, "Timer", _Timer)
monkeypatch.setattr(server.time, "monotonic", lambda: clock["now"])
monkeypatch.setattr(
server,
"_close_session_by_id",
lambda sid, *, end_reason: closed.append((sid, end_reason)) or True,
)

server._sessions["woke-sid"] = _session(
transport=server._detached_ws_transport, running=False
)
try:
server._schedule_ws_orphan_reap("woke-sid")
clock["now"] += 2.0
timers[0].function()
assert len(timers) == 2 # slept through the wait — re-armed

# Desktop reconnects (session.resume re-binds a live transport)
# before the re-armed remainder elapses: the reap is a no-op and
# the chain stops.
server._sessions["woke-sid"]["transport"] = _LiveTransport()
clock["now"] += 18.0
timers[1].function()
assert closed == []
assert len(timers) == 2
finally:
server._sessions.pop("woke-sid", None)


def test_init_session_fires_reset_hook(monkeypatch):
hooks = []

Expand Down
24 changes: 24 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ def _thread_panic_hook(args):
except (ValueError, TypeError):
_ws_orphan_reap_grace = 20.0
_WS_ORPHAN_REAP_GRACE_S = max(0.0, _ws_orphan_reap_grace)
# If the reap timer fires with more than this much of the grace still
# unelapsed on the monotonic clock, the host slept through the wait —
# re-arm instead of reaping (#44183). Big enough to ignore timer jitter
# and wall-clock NTP nudges, small relative to any real sleep.
_WS_ORPHAN_REAP_SLEEP_SLACK_S = 0.5
_DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
_DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})

Expand Down Expand Up @@ -516,7 +521,26 @@ def _schedule_ws_orphan_reap(sid: str) -> None:
if _WS_ORPHAN_REAP_GRACE_S <= 0:
return

# time.monotonic() (mach_absolute_time / CLOCK_MONOTONIC) does not advance
# while the host is asleep, so this deadline measures *awake* time.
deadline = time.monotonic() + _WS_ORPHAN_REAP_GRACE_S

def _reap() -> None:
# threading.Timer's wait elapses in wall-clock time on platforms
# without a monotonic condvar (macOS lacks pthread_condattr_setclock),
# so a system sleep makes the timer fire "early" in awake-time terms:
# closing a MacBook lid for >20s reaped the parked session at the
# instant of wake, before the Desktop app's WS reconnect or
# session.resume could re-bind a transport — every sleep/wake cycle
# 404'd the open chat (#44183). If the monotonic (awake-time) clock
# says the grace hasn't actually elapsed, re-arm for the remainder
# so the Desktop gets the full grace of awake time to reconnect.
remaining = deadline - time.monotonic()
if remaining > _WS_ORPHAN_REAP_SLEEP_SLACK_S:
rearm = threading.Timer(remaining, _reap)
rearm.daemon = True
rearm.start()
return
# Serialize the orphan re-check against session.resume (which re-binds a
# live transport under _session_resume_lock and would make this session
# non-orphaned). The actual pop + teardown then goes through the shared
Expand Down
Loading