Repository navigation
fix(pty): stop the write loop spinning, without introducing the shutdown hang (#17355) - #17367
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (2)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
|
Correcting the provenance of my own evidence, before a reviewer has to. The Verification table originally presented The body now separates the two explicitly. Nothing in the fix changes: the argument was always built on the source ( |
…own hang (#17355) _write_loop called input_queue.get_nowait() and `continue`d on queue.Empty with no sleep, no timeout and no blocking get anywhere in its body. An idle session re-entered the loop immediately and burned 100% of a core -- one per LIVE terminal, with no leak required. Measured on the pre-fix loop against a counting queue: 845,748 calls in 0.4 seconds. After: under 50. Its docstring said "Wait for input without timeout". That is what the loop was meant to do and the exact opposite of what get_nowait does, which is why the defect survived review -- a reader checking "does this block properly?" found a comment saying it did. THE OBVIOUS FIX IS A TRAP, and the tests here exist mostly to catch it. A plain blocking get() looks correct against cleanup(), which does put a None sentinel. It parks forever on three paths that stop this loop without one: * the sentinel's put sits inside a try/except that swallows to logger.debug, AFTER running = False is already set; * an abandoned session never calls cleanup() at all; * the loop's second condition, `master_fd is not None`, is signalled by nothing, and a parked thread re-checks neither term. So the wait is BOUNDED: get(timeout=_WRITE_WAIT_TIMEOUT_S), from TimingConstants.MICRO_DELAY rather than a literal. It is not a latency budget -- get() returns the moment input is queued -- it bounds only how often an idle loop wakes and how long after running=False the thread notices. cleanup()'s two `pass` statements claimed the threads exit on the running flag. That was true ONLY because the writer spun: the spin was load-bearing for a documented shutdown contract nobody had decided to rely on. The comments now say what actually happens -- both threads exit within their own bounded wait, and cleanup() does not join them. No join is added deliberately: cleanup() is reached from async request paths and a blocking join there would stall an event loop to save milliseconds of thread lifetime. Three tests, each mutation-tested: * idle loop does not spin -- fails at 845,748 calls with get_nowait restored; * loop stops on running=False with NO sentinel -- fails with a blocking get(); * loop stops on master_fd going None -- fails with a blocking get(). Restored, 3 pass. The fake queue models a real blocking get() (never returns when nothing is queued) so the blocking-fix mutation runs against the committed fixture rather than being described in a comment.
89afddb to
854c93a
Compare
Thinking Path
Shard 10 of the backend suite was hitting the 60-minute CI ceiling. Per-shard
durations showed it at ~11 minutes of real work -- third-slowest, not an
outlier -- so the ceiling was being hit by a hang, not by contention.
_write_loopcalledinput_queue.get_nowait()andcontinued onqueue.Emptywith no sleep, no timeout, and no blockinggetanywhere in itsbody. An idle session re-enters the loop immediately and burns 100% of a core
-- one per LIVE terminal, no leak required. Measured on the pre-fix loop
against a counting queue: 845,748 calls in 0.4 seconds.
Its docstring said "Wait for input without timeout" -- what the loop was
meant to do, and the exact opposite of
get_nowait. That is why the defectsurvived review: a reader checking "does this block properly?" found a comment
saying it did.
The obvious fix is a trap. A plain blocking
get()looks correct againstcleanup(), which does put aNonesentinel -- but it parks forever on threepaths that stop this loop without one:
putsits inside atry/exceptthat swallows tologger.debug, afterrunning = Falseis already set;cleanup()at all;master_fd is not None, is signalled bynothing, and a parked thread re-checks neither term.
So the wait is bounded, not blocking.
What Changed
autobot-backend/services/simple_pty.pyget_nowait()→get(timeout=_WRITE_WAIT_TIMEOUT_S); new module constant fromTimingConstants.MICRO_DELAY, not a literal;cleanup()'s twopassstatements now describe what actually happensautobot-backend/services/simple_pty_write_loop_17355_test.pyget()_WRITE_WAIT_TIMEOUT_Sis not a latency budget --get()returns themoment input is queued. It bounds only how often an idle loop wakes, and how
long after
running = Falsethe thread notices.cleanup()'spassstatements claimed the threads exit on therunningflag.That was true only because the writer spun: the spin was load-bearing for a
documented shutdown contract nobody had decided to rely on. No
join()isadded, deliberately --
cleanup()is reached from async request paths, and ablocking join there would stall an event loop to save milliseconds of thread
lifetime.
Verification
Three tests, each mutation-tested against the committed fixture:
get_nowait()running = False, no sentinelget()master_fdgoingNoneget()Restored: 3 passed. The fake queue's
get(timeout=None)never returns whennothing is queued, so the blocking-fix mutation runs against real code rather
than being asserted in a comment.
Provenance of the numbers above
Separating what is authoritative here from what is not, because this repo does
not accept a local run as verification and the table above would otherwise read
as if it did:
get_nowait()+continueonEmpty, with nosleep, notimeout=and no blockingget(anywhere in the loop bodyget()parksThe two unauthoritative rows are why the fix is argued from the source rather
than from the measurement: the call count tells you the spin is fast, the
source is what tells you it spins at all. Treat 845,748 as an illustration of
scale, not as evidence. CI is the authority on whether the suite passes.
The number the bound is worth, stated rather than implied
The PR said the constant comes from
TimingConstants.MICRO_DELAY"rather than aliteral" and never said what it is. That is the one figure a reviewer needs,
because a bound that is too small makes this change cosmetic:
get_nowait()+continue)get(timeout=0.1))Roughly a 200,000x reduction, not a smaller spin.
TimingConstantscarries noenv-var backing for any of its members, so an SSOT constant is the class's own
convention here; the env-var rule in CLAUDE.md is scoped to cache TTLs, and this
is a poll bound, not a TTL.
Model Used
Claude Opus 5 (1M context).
Refs #17355
Deliberately
Refs, notCloses. #17355 asks for two things: the spin, and"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." This PR fixes
the spin only. An idle loop now costs nothing, so the leak stops being
expensive — but it is still a leak, and it is still unfixed. #17355 stays open
for that half rather than being closed on a partial delivery.