Repository navigation
bug(pty): simple_pty's write loop spins with no wait, so every leaked session burns a core #17355
Description
What
autobot-backend/services/simple_pty.py runs two background threads per session. They handle waiting very differently, and only one of them waits at all:
def _read_loop(self) -> None:
while self.running and self.master_fd is not None:
...
if poll_readable(fd, _READ_POLL_TIMEOUT_MS): # bounded waitdef _write_loop(self) -> None:
"""Background thread to write to PTY"""
while self.running and self.master_fd is not None:
try:
text = self.input_queue.get_nowait() # returns immediately
...
except queue.Empty:
continue # ... straight back roundThe read loop waits up to _READ_POLL_TIMEOUT_MS per iteration, so an idle session costs almost nothing. The write loop calls get_nowait() and continues on queue.Empty with no sleep, no timeout and no blocking get anywhere in the loop body — verified: zero occurrences of sleep, timeout= or a blocking get( between the two loop definitions. An idle write loop therefore spins at 100% of a core for as long as the thread lives.
The docstring says "Wait for input without timeout". get_nowait() is the opposite of waiting — the intent is stated and not implemented.
Why it matters beyond one core
Threads that outlive their session multiply it. A faulthandler dump from a CI shard that hit its 60-minute ceiling (job 107398043182, three dumps at 23:20:48, 23:48:48, 00:00:30) shows a population of these threads still alive:
Thread-N (_read_loop): autobot_shared/fd_poll.py:59 in poll_readable
autobot-backend/services/simple_pty.py:211 in _read_loop
Thread-N (_write_loop): autobot-backend/services/simple_pty.py:233/245 in _write_loop
Each surviving _write_loop is a spinning thread. On a 2–4 core GitHub-hosted runner, a handful of them starves whatever else shares the VM.
What this does and does not explain
Supports: that shard's 6× slowdown. Measured on a healthy completed run, shard 10 takes 11m37s; the timed-out job ran past 60 minutes. These are GitHub-hosted runners and timeout-minutes counts from job start, so cross-PR contention shows up as queueing, not as slower execution on a dedicated VM. Spinning threads inside the job do explain slower execution.
Does not establish: that the collection frames in the same dump (repo_tests/collected_test_model.py) were blocked on these threads. A dump shows state, not a dependency edge. CPU starvation does not need a dependency edge, which is why the mechanism above stands on the source rather than on the dump.
Suggested fix
Replace the spin with a blocking wait that still notices shutdown:
text = self.input_queue.get(timeout=_WRITE_WAIT_TIMEOUT_S)queue.Empty then means "nothing arrived within the budget" instead of "nothing was there this instant", the loop re-checks self.running each budget, and an idle session costs nothing. Whoever takes this should also look at why the threads outlive their session at all — a spin is only expensive because the leak keeps it alive, and the leak is the other half.
Provenance
Surfaced from #16516's faulthandler diagnostic during a CI hang investigation on PR #17341; the dump was obtained by autobot-ai-f8, the source analysis is mine. Not caused by that PR — its head was unchanged and five other runs on the same commit completed.
Activity
mrveiss commentedon Sep 24, 2026
This is a production defect, not a CI one — and it does not need a leak to cost anything
Verified after autobot-ai-f8 traced reachability. Two corrections to how the original report framed it, both making the issue sharper rather than milder.
1. It is reachable from live paths, not test scaffolding. Six production modules construct it:
and
simple_pty.py:187-190starts onewriter_threadper session,daemon=True:2. The cost is unconditional, and the original framing under-stated it by hanging it on leaked threads.
cleanup()(:369-371) does setself.running = False, so a properly closed session's threads exit at their next loop check. That means:cleanup()not to run — an abandoned session, an exception on the close path, or a test that constructs a PTY and never cleans up. That is the likely shape of the population seen in the CI dump, but it is a second, separate problem.So the severity does not rest on the leak at all: the steady-state cost of an idle terminal session is one saturated core. The leak only multiplies something already wrong.
One irony worth recording for whoever fixes it: the spin is what makes shutdown responsive. Because the loop never blocks, it notices
self.running = Falseon its very next iteration. A naive fix that swapsget_nowait()for an unboundedget()would remove the burn and introduce a shutdown hang. The fix has to beget(timeout=...)— bounded — so the loop keeps re-checkingself.runningeach budget. That is precisely why the docstring's stated intent ("Wait for input without timeout") is not merely wrong but dangerous as a specification: implemented literally, it hangs close.Unchanged from the original report: the mechanism for the CI 6× slowdown still rests on the source rather than on the dump, and the dump still does not establish that collection was blocked on these threads. Two independent facts pointing the same way, not one overclaimed chain.
mrveiss commentedon Sep 24, 2026
For whoever fixes this: the obvious fix passes the obvious test and hangs in the field
cleanup()does two things, and the second one is what makes a naive fix look correct:So a plain blocking
self.input_queue.get()would work whencleanup()runs — theNonesentinel wakes it. A fixer who readscleanup(), sees the sentinel and concludes "shutdown is signalled, a blocking get is fine" has consulted a real mechanism that honestly answers "does cleanup wake the thread?" while the question that matters is "does every path that stops this loop wake the thread?".It does not, for three reasons — and each one hangs a thread forever under a plain
get():putsits inside atry/exceptthat swallows the failure atlogger.debuglevel.running = Falseis already set by then, so on that path the loop is told to stop and never woken to notice.cleanup()at all. Nothing else sends the sentinel. This is also the shape that produced the thread population in the CI faulthandler dump.while self.running and self.master_fd is not None, so fd invalidation is also meant to stop it — and a thread parked inget()never returns to re-evaluate either term.That is why the fix must be bounded:
The loop then re-checks both conditions every budget regardless of what the caller did or failed to do, and
queue.Emptymeans "nothing arrived in that budget" rather than "nothing was there this instant".And the docstring has to change with it. "Wait for input without timeout" is not merely inaccurate about the present — implemented faithfully it produces exactly the hang described above. It is a specification that breaks close. A fix that leaves it in place hands the next reader the same trap in the same words.
Credit where due: the sentinel's role in making the naive fix look safe was spotted by autobot-ai-f8; the swallowing
try/exceptand the second loop term are the parts that make it hang anyway.mrveiss commentedon Sep 24, 2026
The spin is load-bearing for a documented shutdown contract — the complete hang set
cleanup()ends like this (simple_pty.py:403-409):Two
passstatements whose entire justification is a comment asserting the thread will noticerunning = False. There is nojoin()anywhere — the section is headed "Wait for threads to complete naturally" and does no waiting at all;cleanup()returns immediately and the threads are left to notice on their own.That assertion is true today only because the write loop never blocks. The spin is not an incidental inefficiency — it is what makes a documented shutdown contract hold. And the writer's comment says the thread exits "when
running=Falseand queue is processed", whichget_nowait()satisfies trivially and a blockingget()does not.So the trap has a fourth lure, and it is the worst-placed one: a fixer who asks "is shutdown handled?" finds an explicit comment at the exact site their change breaks, saying yes.
The complete hang set under a naive blocking
get()cleanup()'sinput_queue.put(None)sits in atry/except → logger.debug;running = Falseis already set, so the loop is told to stop and never wokencleanup(), so no sentinel is ever sent — the shape that produced the thread population in the CI dumpwhile self.running and self.master_fd is not None; a thread parked inget()re-evaluates neither termpasscontract abovecleanup()does notjoin(), and its comments promise an exit that only a non-blocking loop deliversOnly
get(timeout=...)survives all four, because it re-checks both loop conditions every budget regardless of what the caller did, failed to do, or documented.What a complete fix therefore includes
self.input_queue.get(timeout=_WRITE_WAIT_TIMEOUT_S)— bounded, not unbounded.passcomments, or replacing them with an actualjoin(timeout=...)— a documented wait that does not wait is how the spin became load-bearing without anyone deciding it should be.Verified sites: only two
running = Falseexist —:90(__init__) and:371(cleanup), socleanup()is the sole deliberate stop. Thepassblock was found by autobot-ai-f8.fix(pty): stop the write loop spinning, without introducing the shutd…
mrveiss commentedon Sep 24, 2026
The spin half is fixed; the leak half is now located, not hypothetical
PR #17367 fixes the spin only, so this issue stays open — it asks for two things
and the second was "why the threads outlive their session at all".
Review of that PR turned the second half from a suspicion into a coordinate.
The abandoned-session path is real, and here it is
autobot-backend/services/agent_terminal/session_manager.py:429-453—close_session()deletes theAgentTerminalSessionbookkeeping entry and theRedis key, and never calls
simple_pty_manager.close_session()orpty.cleanup(). It is reached fromservice.py:123-126.The call it is missing exists elsewhere in the same package:
command_executor.py:107-110(_force_close_pty_session) does call it. So thisis not a facility that was never built — it is one call path that does not use
it.
Consequence: the session's bookkeeping disappears while its PTY threads keep
running, with nothing left holding a reference that would ever set
running = False. That is the leak.What #17367 changed about it, precisely
The thread still lives forever. It is now almost free instead of ruinous, which
is why the spin was worth fixing separately — but a leak that got cheap is still
a leak, and a long-lived process accumulates them.
Also verified while there
master_fdis assigned in exactly two places:simple_pty.py:88(__init__,to
None) and:408(cleanup(), toNone). Nothing else ever sets it. Sothe loop's
master_fd is not Nonecondition is signalled only by acleanup()that the abandoned path never calls — the two halves of this issue are the same
gap seen from two directions.
Remaining work for this issue
session_manager.close_session()releases the PTY, not just thebookkeeping — via the existing
_force_close_pty_sessionpath rather thana second implementation.
close_session()was called is not the same claim.AgentTerminalSessionteardown paths for the sameomission, rather than fixing only the one the review happened to reach.
mrveiss commentedon Sep 24, 2026
Line-number pin, so the two readings above do not look like a disagreement.
My earlier comment cited
simple_pty.py:88and:408; those are on PR #17367's branch, which adds lines above them. On mergedmainat554a15b9cdthe same two assignments are:Verified with
git show origin/main:autobot-backend/services/simple_pty.py. Two assignments, both toNone, both in code the abandoned-session teardown path never reaches — the claim is unchanged, only the coordinates move with the branch. Cite the SHA alongside a line number in this file until #17367 lands, after whichmain's numbers become the PR branch's.fix(pty): stop the write loop spinning, without introducing the shutd…
mrveiss commentedon Sep 24, 2026
Spin half delivered — PR #17367, squash-merged as
d8561e36c4This issue stays open. It asks for two things and this delivers one, which
is why the PR carried
Refsrather thanClosesand why no merge keyword closedit.
Verified against merged
main@d8561e36c4MICRO_DELAYis0.1(autobot_shared/ssot_constants.py:580), so:The wait is bounded rather than blocking, deliberately: three paths stop this
loop without a sentinel, and a plain
get()parks forever on all three. It addsno keystroke latency —
get(timeout=...)returns the instant an item is queued;the 100ms bounds only the idle wake and how long after
running = Falsethethread notices.
Three tests landed with it (
simple_pty_write_loop_17355_test.py), eachmutation-checked against both regressions: restoring
get_nowait()fails thespin test at ~845k calls, and switching to a plain blocking
get()fails bothexit tests by parking past
join(timeout=2.0).Not a leftover:
output_queue.get_nowait()survives at:322. Differentqueue, different job — a non-blocking poll for output, correct as it stands.
Only the input loop was spinning.
What remains, and it is now a coordinate rather than a suspicion
The thread leak.
services/agent_terminal/session_manager.pyclose_session()drops the bookkeeping entry and the Redis key and never releases the PTY, so
nothing is left to set
running = False. Full detail and criteria in thecomment above; the line numbers there are pinned to
554a15b9cdand shift bymain's current offset.The spin fix changes the cost of that leak, not its existence:
A leak that got cheap is still a leak, and a long-lived process accumulates them.
Filed separately from the same review
#17381 — three pre-existing defects in
simple_pty.py, none introducedhere:
os.writeon a non-blocking fd raising a retryableBlockingIOErroriscaught by a broad
exceptand permanently ends the write loop;os.write'sreturn value is discarded so a large paste truncates silently, possibly
mid-UTF-8; plus a TOCTOU in
cleanup()andMICRO_DELAYserving two unrelatedtimings.
mrveiss commentedon Oct 3, 2026
Closed — fixed with a bounded wait and a dedicated guard, verified at
ba03b68c74autobot-backend/services/simple_pty.py:304_write_loopnow waits:The
get_nowait()hot spin is gone — the defect was one saturated core per liveterminal on an idle session, whether or not anything ever leaked.
The docstring at
:305-318is the part worth preserving, because it rules out theobvious alternative fix. A plain blocking
get()looks correct againstcleanup(),which does send a
Nonesentinel — and it would park forever on three paths thatstop this loop without one: the sentinel's
putsits inside a swallowingtry/exceptafter
running = Falseis already set; an abandoned session never callscleanup();and the loop's second condition,
master_fd is not None, is signalled by nothing. Thebounded timeout is what lets both loop conditions be re-checked regardless of what the
caller did or failed to do.
That is the tonight-recurring shape — the plausible repair destroys the property — caught
before it shipped and written down at the site rather than in a review thread.
Guarded:
autobot-backend/services/simple_pty_write_loop_17355_test.pyexists andnames the issue, so the spin cannot return unnoticed. Every issue closed cleanly in this
pass shipped its fix with a standing check; the ones that shipped a fix alone are the
ones still open.
No remainder.