@@ -98,6 +98,51 @@ struct SubprocessRunnerTests {
9898 #expect( elapsed < 3 , " Timeout should fire in ~1s, not wait for process to exit naturally " )
9999 }
100100
101+ @Test
102+ func `timeout kills descendants that escape the process group`() async throws {
103+ let root = FileManager . default. temporaryDirectory
104+ . appendingPathComponent ( " codexbar-subprocess-tree- \( UUID ( ) . uuidString) " , isDirectory: true )
105+ let childPIDFile = root. appendingPathComponent ( " child.pid " )
106+ try FileManager . default. createDirectory ( at: root, withIntermediateDirectories: true )
107+ defer { try ? FileManager . default. removeItem ( at: root) }
108+
109+ var environment = ProcessInfo . processInfo. environment
110+ environment [ " CODEXBAR_TEST_CHILD_PID_FILE " ] = childPIDFile. path
111+ let script = """
112+ import os
113+ import subprocess
114+ import sys
115+ import time
116+
117+ child = subprocess.Popen(
118+ [sys.executable, " -c " , " import time; time.sleep(30) " ],
119+ start_new_session=True,
120+ )
121+ with open(os.environ[ " CODEXBAR_TEST_CHILD_PID_FILE " ], " w " ) as handle:
122+ handle.write(str(child.pid))
123+ time.sleep(30)
124+ """
125+
126+ await #expect( throws: SubprocessRunnerError . self) {
127+ try await SubprocessRunner . run (
128+ binary: " /usr/bin/python3 " ,
129+ arguments: [ " -c " , script] ,
130+ environment: environment,
131+ timeout: 0.5 ,
132+ label: " escaped-descendant " )
133+ }
134+
135+ let text = try String ( contentsOf: childPIDFile, encoding: . utf8)
136+ let childPID = try #require( pid_t ( text. trimmingCharacters ( in: . whitespacesAndNewlines) ) )
137+ defer { _ = kill ( childPID, SIGKILL) }
138+
139+ let deadline = Date ( ) . addingTimeInterval ( 1 )
140+ while kill ( childPID, 0 ) == 0 , Date ( ) < deadline {
141+ try await Task . sleep ( for: . milliseconds( 20 ) )
142+ }
143+ #expect( kill ( childPID, 0 ) == - 1 )
144+ }
145+
101146 /// Multiple concurrent hung subprocesses must all time out independently, proving that
102147 /// one blocked subprocess does not starve the timeout mechanism of others.
103148 /// This is the core scenario that caused the original permanent-refresh-stall bug.
0 commit comments