@matt82198/aesop 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """proc_util -- bounded shell execution shared by the concrete AgentDriver adapters.
3
+
4
+ run_shell_bounded() backs run_command for ClaudeCodeDriver and CodexDriver
5
+ (OpenAICompatibleDriver inherits it). It exists because plain
6
+ ``subprocess.run(command, shell=True, timeout=N)`` does NOT bound wall-clock on
7
+ Windows: on timeout CPython kills only the shell (cmd.exe), then its cleanup
8
+ ``communicate()`` re-blocks until the orphaned grandchild -- the actual
9
+ test/git process -- closes the inherited pipe handles. A deadlocked grandchild
10
+ therefore stalled the caller indefinitely: the exact wave-hang class the
11
+ timeout was meant to prevent (RS-A F1; live-proven: timeout_s=0.5 vs a 6s
12
+ sleep returned exit 124 only after 6.1s).
13
+
14
+ Guarantees:
15
+ * The timeout truly bounds wall-clock (plus a small fixed drain allowance).
16
+ * The whole process TREE is killed on timeout: the child is spawned in its
17
+ own group/session, then ``taskkill /T /F`` on Windows (kills grandchildren
18
+ by parent-child walk) or ``os.killpg(SIGKILL)`` on POSIX.
19
+ * Exit code 124 on timeout (shell convention); 127 when spawn fails.
20
+ * Partial stdout/stderr captured before the kill is preserved (RS-A F7), so
21
+ a timed-out 119s suite still yields its printed diagnostics instead of a
22
+ blind rerun.
23
+
24
+ stdlib-only, ASCII-only, Windows + Linux safe.
25
+ """
26
+
27
+ import os
28
+ import signal
29
+ import subprocess
30
+
31
+ from agent_driver import CommandResult
32
+
33
+ # Post-kill drain bound. With the tree dead the pipes close and communicate()
34
+ # returns immediately; this cap only protects against a survivor that
35
+ # inherited the handles (e.g. a detached daemon) re-introducing the hang.
36
+ _DRAIN_TIMEOUT_S = 5.0
37
+
38
+ _TIMEOUT_NOTE = "Command timed out after {t}s; process tree killed (exit 124)"
39
+
40
+
41
+ def kill_process_tree(proc):
42
+ """Best-effort, bounded kill of proc and every descendant."""
43
+ if os.name == "nt":
44
+ try:
45
+ subprocess.run(
46
+ ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
47
+ capture_output=True,
48
+ timeout=_DRAIN_TIMEOUT_S,
49
+ )
50
+ except (OSError, subprocess.TimeoutExpired):
51
+ pass
52
+ else:
53
+ try:
54
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
55
+ except (ProcessLookupError, PermissionError, OSError):
56
+ pass
57
+ # Belt and braces: direct child, idempotent if already dead.
58
+ try:
59
+ proc.kill()
60
+ except OSError:
61
+ pass
62
+
63
+
64
+ def _as_text(value):
65
+ """Normalize partial-output payloads (None/bytes/str) to str."""
66
+ if value is None:
67
+ return ""
68
+ if isinstance(value, bytes):
69
+ return value.decode("utf-8", errors="replace")
70
+ return value
71
+
72
+
73
+ def run_shell_bounded(command, cwd=None, timeout_s=120.0):
74
+ """Run `command` through the platform shell, hard-bounded by timeout_s.
75
+
76
+ Returns CommandResult. On timeout: exit 124, partial output preserved,
77
+ stderr carries a "Command timed out" note. On spawn failure: exit 127.
78
+ """
79
+ popen_kwargs = {}
80
+ if os.name == "nt":
81
+ # Own process group so the child tree is a coherent kill target.
82
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
83
+ else:
84
+ # Own session -> killpg reaches every descendant.
85
+ popen_kwargs["start_new_session"] = True
86
+
87
+ try:
88
+ proc = subprocess.Popen(
89
+ command,
90
+ cwd=cwd,
91
+ shell=True,
92
+ stdout=subprocess.PIPE,
93
+ stderr=subprocess.PIPE,
94
+ text=True,
95
+ encoding="utf-8",
96
+ errors="replace",
97
+ **popen_kwargs,
98
+ )
99
+ except OSError as exc:
100
+ return CommandResult(exit_code=127, stdout="", stderr=str(exc))
101
+
102
+ try:
103
+ stdout, stderr = proc.communicate(timeout=timeout_s)
104
+ return CommandResult(
105
+ exit_code=proc.returncode,
106
+ stdout=stdout or "",
107
+ stderr=stderr or "",
108
+ )
109
+ except subprocess.TimeoutExpired as exc:
110
+ # POSIX populates partial output on the exception; Windows does not
111
+ # (its reader threads still hold it). Keep the exception's copy as a
112
+ # fallback, but prefer the post-kill drain below (complete on both).
113
+ partial_out = _as_text(exc.stdout)
114
+ partial_err = _as_text(exc.stderr)
115
+
116
+ # Kill the WHOLE tree first -- never re-block on live grandchildren.
117
+ kill_process_tree(proc)
118
+
119
+ # Drain what the pipe readers captured. Tree dead -> pipes closed ->
120
+ # this returns promptly; still bounded so a handle-holding survivor
121
+ # cannot re-introduce the unbounded hang.
122
+ try:
123
+ drained_out, drained_err = proc.communicate(timeout=_DRAIN_TIMEOUT_S)
124
+ except (subprocess.TimeoutExpired, ValueError, OSError):
125
+ drained_out, drained_err = None, None
126
+
127
+ out = _as_text(drained_out) or partial_out
128
+ err = _as_text(drained_err) or partial_err
129
+ note = _TIMEOUT_NOTE.format(t=timeout_s)
130
+ return CommandResult(
131
+ exit_code=124,
132
+ stdout=out,
133
+ stderr=(err + "\n" + note) if err else note,
134
+ )