Dependency publication now recovers interrupted config/facts changes before activation and leases live generations during collection. Receipts retain update correlation and failed steps across nested command boundaries. Doctor and desktop surfaces report those failures through shared owners. Move checkout updates out of the desktop facade. Stage a detached Windows relaunch waiter before shutdown, with bounded handshake and process-birth checks. Keep packaged lifecycle tests isolated from the installed app. Native verification exposed two production races: cron maintenance imported the interactive CLI and rewrote TERMINAL_CWD, and install-ID reads collided with first publication. Use the existing owners and locks. Plugin checks now run at startup and each due-gated housekeeping tick, not after 60 ticks. Share updater-test mutation boundaries and remove collection-root fixtures. Separate cold MCP startup from command latency and give the real HTTP drip test enough time to reach body handling. Root npm check passed, including packaging. The fixed-tree Windows Python run reported 44557 passed, one failed, and 1404 skipped, plus one retry-only HTTP test. Those final failures now pass in a 35-test bounded batch. A real isolated gateway wrote startup and periodic plugin-check receipts. Full final-tree CI, bundled Sandbox deployment, and actual App Installer relaunch remain unverified. docs/pm-audit-status.md records these limits.
120 lines
4.1 KiB
Python
120 lines
4.1 KiB
Python
"""Foreground terminal execute must return when the inner wait loop wedges.
|
|
|
|
#94285: a hung ``_wait_for_process`` (Windows pipe/poll, blocked loop thread)
|
|
silently disabled every asyncio timer in the process. ``execute()`` now
|
|
bounds spawn+wait with ``run_bounded_sync`` so the wall-clock deadline
|
|
survives a wedged wait, and ``on_timeout`` kills the process tree.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
from tools.environments.local import LocalEnvironment
|
|
import tools.environments.base as base_mod
|
|
|
|
|
|
def test_execute_returns_when_wait_loop_never_returns(monkeypatch):
|
|
"""A wedged inner wait cannot hold execute() past timeout + grace."""
|
|
monkeypatch.setattr(base_mod, "_EXECUTE_WAIT_BOUND_GRACE_S", 0.05)
|
|
|
|
env = LocalEnvironment()
|
|
fake_proc = SimpleNamespace(pid=424242)
|
|
monkeypatch.setattr(env, "_run_bash", lambda *a, **k: fake_proc)
|
|
|
|
def _hang(*_a, **_k):
|
|
time.sleep(30)
|
|
return {"output": "late", "returncode": 0}
|
|
|
|
monkeypatch.setattr(env, "_wait_for_process", _hang)
|
|
killed: list = []
|
|
monkeypatch.setattr(env, "_kill_process", lambda proc: killed.append(("kill", proc)))
|
|
monkeypatch.setattr(
|
|
"agent.deadline.kill_process_tree",
|
|
lambda pid, **_k: killed.append(("tree", pid)),
|
|
)
|
|
monkeypatch.setattr(env, "_update_cwd", lambda _result: None)
|
|
|
|
start = time.monotonic()
|
|
result = env.execute("sleep 30", timeout=1)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert elapsed < 4.0, f"execute hung {elapsed:.1f}s past the 1s bound"
|
|
assert result["returncode"] == 124
|
|
assert "timed out" in result["output"].lower()
|
|
assert ("kill", fake_proc) in killed
|
|
assert ("tree", 424242) in killed
|
|
|
|
|
|
def test_execute_parent_interrupt_still_kills_wait_on_deadline_worker(monkeypatch):
|
|
"""/stop targets the tool-worker tid; the deadline worker must honor it."""
|
|
from tools.interrupt import set_interrupt, is_interrupted
|
|
|
|
env = LocalEnvironment()
|
|
fake_proc = SimpleNamespace(pid=None, poll=lambda: None, stdout=None)
|
|
monkeypatch.setattr(env, "_run_bash", lambda *a, **k: fake_proc)
|
|
|
|
seen = {"parent": False}
|
|
|
|
def _wait(_proc, timeout=120, *, bounded_capture=False, watch_interrupt_tid=None, output=None):
|
|
deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < deadline:
|
|
from tools.interrupt import is_thread_interrupted
|
|
|
|
if is_interrupted() or is_thread_interrupted(watch_interrupt_tid):
|
|
seen["parent"] = True
|
|
return {"output": "[Command interrupted]", "returncode": 130}
|
|
time.sleep(0.02)
|
|
return {"output": "missed interrupt", "returncode": 0}
|
|
|
|
monkeypatch.setattr(env, "_wait_for_process", _wait)
|
|
monkeypatch.setattr(env, "_kill_process", lambda _proc: None)
|
|
monkeypatch.setattr(env, "_update_cwd", lambda _result: None)
|
|
|
|
parent_tid = __import__("threading").get_ident()
|
|
|
|
def _interrupt_soon():
|
|
time.sleep(0.05)
|
|
set_interrupt(True, thread_id=parent_tid)
|
|
|
|
import threading
|
|
|
|
interrupter = threading.Thread(target=_interrupt_soon, daemon=True)
|
|
interrupter.start()
|
|
try:
|
|
result = env.execute("sleep 30", timeout=5)
|
|
finally:
|
|
interrupter.join(timeout=5)
|
|
set_interrupt(False, thread_id=parent_tid)
|
|
|
|
assert seen["parent"] is True
|
|
assert result["returncode"] == 130
|
|
|
|
|
|
def test_execute_worker_sees_caller_activity_callback(monkeypatch):
|
|
"""Heartbeats must fire on the deadline worker, not only the tool thread."""
|
|
env = LocalEnvironment()
|
|
fake_proc = SimpleNamespace(pid=None)
|
|
monkeypatch.setattr(env, "_run_bash", lambda *a, **k: fake_proc)
|
|
seen = {"cb": "unset"}
|
|
|
|
def _wait(*_a, **_k):
|
|
seen["cb"] = base_mod.get_activity_callback()
|
|
return {"output": "ok", "returncode": 0}
|
|
|
|
monkeypatch.setattr(env, "_wait_for_process", _wait)
|
|
monkeypatch.setattr(env, "_update_cwd", lambda _r: None)
|
|
|
|
def _cb(_msg):
|
|
pass
|
|
|
|
base_mod.set_activity_callback(_cb)
|
|
try:
|
|
result = env.execute("echo ok", timeout=5)
|
|
finally:
|
|
base_mod.set_activity_callback(None)
|
|
|
|
assert result["returncode"] == 0
|
|
assert seen["cb"] is _cb
|