Skip to content

Commit 653c5dd

Browse files
andyclaude
andcommitted
tests/apps: bound the output drain, which hung CI for 26 minutes
This is what wedged the runners all day. The GUI apps job ran for exactly its wall-clock limit on every push; before that limit existed it held a runner slot for GitHub's 6-hour default, which is why nothing completed between 10:10 and the evening. It is not a slow app. _drain() ended with return proc.stdout.read().decode(...) which blocks until EVERY writer closes the pipe. _terminate() kills the app's process group, but the per-user services its wrapper starts -- apache2 for the data server, Xvfb, the JMS dispatcher -- outlive it and still hold the inherited write end, so there is no EOF to wait for. The runner's cleanup listed exactly those as orphans when it killed the job: python3, Xvfb, and six apache2 processes. The log pins it precisely: machregister finished at 17:16:11 and the next app never reported, for 26 minutes, until cancellation at 17:42. Never reproduced locally, and the reason matters -- on a developer box the data server is usually already running, so the wrapper starts nothing that could inherit the pipe. It needs a machine where the services come up fresh, which is every CI run and no interactive one. _drain now reads with a deadline: non-blocking, select-driven, stopping on real EOF or when the app itself is gone and only services still hold the pipe. A stuck app now costs its own timeout and gets reported instead of taking the run with it. All 21 apps still pass locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFMUNbw2UpRPmgsqviAowb
1 parent 2f66972 commit 653c5dd

1 file changed

Lines changed: 50 additions & 5 deletions

File tree

‎tests/apps/apps.py‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
services. Running the binary directly is explicitly not supported.
2626
"""
2727

28+
import fcntl
2829
import os
30+
import select
2931
import signal
3032
import subprocess
3133
import time
@@ -205,10 +207,53 @@ def _terminate(proc):
205207
pass
206208

207209

208-
def _drain(proc):
210+
def _drain(proc, timeout=15):
211+
"""Read whatever the app wrote, WITHOUT waiting forever for EOF.
212+
213+
This used to be a bare proc.stdout.read(), which blocks until every
214+
writer closes the pipe. _terminate() kills the app's process group,
215+
but the per-user services the wrapper starts -- apache2 for the data
216+
server, Xvfb, the JMS dispatcher -- can outlive it and still hold the
217+
inherited write end, so there is no EOF to wait for.
218+
219+
That hung the whole suite for 26 minutes on one app in CI, until the
220+
job's wall-clock limit killed it (orphaned apache2 processes were
221+
listed in the runner's cleanup). It never reproduced on a developer
222+
box, where the data server is usually already running and the wrapper
223+
therefore starts nothing that could inherit the pipe.
224+
225+
A bounded read means a stuck app costs its own timeout and is
226+
reported, instead of taking the run with it.
227+
"""
228+
if proc.stdout is None:
229+
return ""
230+
fd = proc.stdout.fileno()
209231
try:
210-
if proc.stdout is None:
211-
return ""
212-
return proc.stdout.read().decode("utf-8", "replace")
213-
except Exception:
232+
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
233+
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
234+
except OSError:
214235
return ""
236+
237+
chunks = []
238+
deadline = time.time() + timeout
239+
while time.time() < deadline:
240+
try:
241+
ready, _, _ = select.select([fd], [], [], 0.5)
242+
except (OSError, ValueError):
243+
break
244+
if not ready:
245+
# Nothing pending. If the app itself is gone, anything still
246+
# holding the pipe is a service we do not want to wait on.
247+
if proc.poll() is not None:
248+
break
249+
continue
250+
try:
251+
data = proc.stdout.read()
252+
except (OSError, ValueError):
253+
break
254+
if data is None: # non-blocking read with nothing ready
255+
continue
256+
if data == b"": # real EOF
257+
break
258+
chunks.append(data)
259+
return b"".join(chunks).decode("utf-8", "replace")

0 commit comments

Comments
 (0)