fix(diagnostics): bound the CEC subprocess reap and report its states distinctly - #3272
Conversation
… distinctly Screenly#3264 — subprocess.run(capture_output=True, timeout=N) burns its ENTIRE timeout when a descendant still holds the inherited stdout pipe, because communicate() keeps draining it. Measured on armhf, arm64 and x86_64 with a fast-exiting child whose grandchild holds stdout: 0.07-0.12s here versus 8.0s for subprocess.run. On a task that runs every 5 minutes against a 30s soft limit, repeatedly spending the whole budget for nothing is the real cost. New _run_bounded(): start_new_session=True + killpg(SIGKILL) so a grandchild cannot survive or stall the reap, output to temp files instead of pipes so there is nothing to drain, a timeout on every wait, and deliberately not used as a context manager since Popen.__exit__'s unbounded wait() is one of the hazards being avoided. Honest scope: the theory that SoftTimeLimitExceeded re-enters an unbounded wait and lets the task sail past the hard limit does NOT reproduce on CPython 3.13, which kills before every wait — 0.00s overshoot, agreed by three boards. A genuine D-state child was produced on arm64 (O_DIRECT to the SD card, 98.7% D) and the reap still held in 31.5ms. So this is a robustness and latency fix; the root cause of ANTHIAS-A/9/B/31 remains open. Screenly#3267 (partial) — 'CEC error' covered three different situations and reported the most common one as a fault. Now: 'No CEC adapter' (libcec raised), 'No CEC display detected' (adapter works, no peer answered — the normal case for a plain monitor), 'CEC adapter unresponsive' (libcec hung and was killed, which is what actually happens on vchiq-only boards), and 'CEC error' for genuinely unexpected. True/False and the str|bool return are left alone: they are the v2 API's data values. Also moves the no-CEC r.set() inside the SoftTimeLimitExceeded handler. It is not unbounded — redis-py 8.0.1 defaults socket_timeout=5 — but the default Retry(retries=10) costs 58.83s measured, which blows the 30s soft limit; outside the try that escaped uncaught and filed a Sentry event on exactly the no-CEC boards.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #3272 +/- ##
=========================================
Coverage ? 90.65%
=========================================
Files ? 76
Lines ? 8467
Branches ? 898
=========================================
Hits ? 7676
Misses ? 570
Partials ? 221 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR improves Anthias’ display-power diagnostics by introducing a bounded subprocess runner to prevent Celery soft/hard time-limit overruns during CEC probing, and by splitting the previously overloaded 'CEC error' result into more actionable CEC states while preserving the existing str | bool return semantics for API compatibility.
Changes:
- Add
_run_bounded()(process-group kill + temp-file capture + bounded reap) and migrate CEC query/set paths to use it. - Refine CEC query outcomes into distinct operator-facing states (e.g., no adapter vs no display vs unresponsive).
- Update and extend unit tests to validate bounded reaping behavior and the new CEC status strings.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/anthias_server/lib/diagnostics.py |
Introduces _run_bounded() and updates CEC query/set logic plus CEC status differentiation. |
src/anthias_server/celery_tasks.py |
Adjusts the periodic display-power task flow and related commentary around Redis writes and gating. |
tests/test_diagnostics.py |
Repivots existing tests to _run_bounded() and adds behavioral tests for bounded reap and process-group kills. |
Suppressed comments (2)
src/anthias_server/lib/diagnostics.py:280
- get_display_power() treats a None result from _run_bounded() as a timeout/kill, but _run_bounded() also returns None when the subprocess cannot be spawned. The inline comment currently claims it was "timed out and was killed", which is not always true.
if completed is None:
# Timed out and was killed — libcec hung rather than raising.
# Verified on the vchiq-only Pi 3 A+, where this is the normal
# outcome on every tick, not an exceptional one.
return 'CEC adapter unresponsive'
src/anthias_server/lib/diagnostics.py:306
- set_display_power() returns a "timed out" message whenever _run_bounded() returns None, but None can also mean fork/exec failed. The user-facing message should not imply a timeout-only failure mode.
completed = _run_bounded([sys.executable, '-c', script], _CEC_TIMEOUT_S)
if completed is None:
return (
False,
f'Display turn-{verb} timed out — CEC adapter unresponsive.',
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Both were factually wrong, not stylistic: - _run_bounded's docstring said None means "the child had to be killed". It also returns None when the fork/exec never happened (OSError), so a caller reading None as timeout-specific would be misled. Documented both, and noted at each call site that "no answer" covers the two. - the celery comment claimed get_display_power() "now always returns str". It does not — it still returns bool for a clean on/off reading, which is exactly why the str() coercion there is load-bearing rather than decorative (redis-py rejects a bool: Sentry ANTHIAS-2C). The comment was left over from a change I reverted for API stability.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/test_diagnostics.py:436
- The grandchild-kill assertion treats a surviving PID as “still alive”, but after SIGKILL the grandchild can legitimately remain as a zombie (especially in containers without an init reaper).
os.kill(pid, 0)returns success for zombies too, so this test can fail even though the process group kill worked. Consider accepting either “PID gone” or “PID is a zombie” as success.
try:
os.kill(grandchild, 0)
except ProcessLookupError:
return
time.sleep(0.1)
src/anthias_server/celery_tasks.py:371
- The inline comment says this
not cec_available()branch is taken on “x86, Pi 5”, but the preceding comment block (and the rationale for removing Pi 5 as an example) states Pi 5 does get/dev/cec0and therefore should not take this branch. This is internally inconsistent and could confuse future debugging.
# This SET used to sit outside the handler below, which made
# it the one unprotected blocking call in the task — on
# exactly the boards that take this branch (x86, Pi 5).
#



Issues Fixed
Fixes #3264. Addresses the user-visible half of #3267 (the passthrough half is a separate PR — see below).
Description
The bounded reap (#3264)
subprocess.run(..., capture_output=True, timeout=N)burns its entire timeout when a descendant still holds the inherited stdout pipe, becausecommunicate()keeps draining it — even when the direct child has already exited.Measured on three architectures, fast-exiting child with a grandchild holding stdout:
subprocess.run_run_boundedOn a task that runs every 5 minutes against a 30s soft limit, repeatedly spending the whole budget for nothing is the real cost.
_run_bounded()differs fromsubprocess.runin three deliberate ways:start_new_session=True+killpg(SIGKILL)so a grandchild cannot survive the kill or stall the reap; output to temp files rather than pipes, so there is nothing to drain; and a timeout on every wait, walking away rather than blocking if the group somehow persists. It is deliberately not used as a context manager, becausePopen.__exit__'s unboundedwait()is one of the very hazards it exists to avoid.Three distinct CEC states (#3267, partial)
'CEC error'covered three different situations and reported the most common one — a plain monitor with no CEC support — to the operator as a fault. Now:'No CEC adapter'— libcec raised; it found nothing usable.'No CEC display detected'— the adapter works and nothing answered. Expected for a monitor without CEC, which is a large share of signage installs. Not an error.'CEC adapter unresponsive'— libcec neither answered nor raised inside the timeout, so it was killed. This is what actually happens on vchiq-only boards.'CEC error'— genuinely unexpected.True/Falseand thestr | boolreturn are deliberately unchanged: they are the data the v2 System Info API surfaces fordisplay_power, so renaming them would be a field-semantics change for external clients. Verified no in-tree consumer branches on the string —api/views/v2.py,api/views/mixins.py,app/page_context.pyandsystem_info.htmlall pass it straight through.A bonus fix in the same function
The no-CEC branch's
r.set('display_power', 'Not available', ...)sat outside theSoftTimeLimitExceededhandler, making it the one unprotected blocking call in the task — on exactly the boards that take that branch (x86, Pi 5). It is not unbounded (redis-py 8.0.1 defaultssocket_timeout=5), but the defaultRetry(retries=10)costs 58.83s measured, which blows the 30s soft limit and clears the 60s hard limit by only ~1.2s. Outside thetry, that soft limit escaped uncaught, failed the task, and filed a Sentry event — the very noise this work removes. Now inside the handler.Hardware validation
'CEC error'→'No CEC adapter', end to end through redis and/api/v2/infommc_blk_rw_wait) — reap still held, killpg reaping in 31.5ms. Differential: 0.115s vsTimeoutExpiredat 8.008s pre-fixtempfileleaves nothing over 40 invocationsWhat this does NOT fix, stated plainly
The root cause of ANTHIAS-A/9/B/31 is still open. My original theory — that
SoftTimeLimitExceededre-enters an unboundedwait()so the task sails past the hard limit — does not reproduce on CPython 3.13, which callsprocess.kill()before everywait(). Overshoot measured 0.00s for both implementations, agreed independently by three boards. And a genuine uninterruptible child still got reaped in 31.5ms.So this is a robustness and latency fix, not a demonstrated cure for the hard-limit SIGKILL. A reap that actually overruns the grace period needs a driver that never returns, which was not safely inducible. #3264 should stay open after this merges.
Two documented limits of a process-group kill, neither reachable from the CEC scripts (which spawn no child processes — libcec uses threads), are recorded in the docstring: a grandchild calling
setpgid(0,0)escapes thekillpg, and a fast double-forking child leaves an orphan because no timeout fires at all. Also corrected there: container PID 1 is the celery worker, not an init that reaps orphans — so a killed grandchild would persist. Three boards caught that claim.Related
The other half of #3267 — passing
/dev/cec0through on pi2/pi3/pi3-64/pi4-64, which is the root cause of display power never working there — is a separate PR, because it changes device passthrough during real OTA upgrades and deserves its own risk review.Testing
1552 passed, 3 skipped(-m "not integration"), mypy clean across 169 files, ruff check + format clean.New tests verify the reap behaviourally rather than by inspection: a hanging child dies inside
timeout + grace; a grandchild does not survive the process-group kill; an unspawnable argv returnsNonerather than raising (this runs inside both an HTTP request and a celery beat); stdout and stderr stay separate so libcec chatter cannot corrupt the single-token contract. The 16 pre-existing CEC tests were re-pointed from thesubprocess.runseam to_run_bounded, preserving every behavioural assertion — message formatting, length capping, stderr fallback, returncode fallback.Checklist