Skip to content

fix(diagnostics): bound the CEC subprocess reap and report its states distinctly - #3272

Merged
vpetersson merged 2 commits into
Screenly:masterfrom
vpetersson-bot:split/cec-bounded-reap
Aug 7, 2026
Merged

fix(diagnostics): bound the CEC subprocess reap and report its states distinctly#3272
vpetersson merged 2 commits into
Screenly:masterfrom
vpetersson-bot:split/cec-bounded-reap

Conversation

@vpetersson-bot

Copy link
Copy Markdown
Contributor

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, because communicate() keeps draining it — even when the direct child has already exited.

Measured on three architectures, fast-exiting child with a grandchild holding stdout:

armhf / arm64 / x86_64
subprocess.run 8.0s
_run_bounded 0.07–0.12s

On 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 from subprocess.run in 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, because Popen.__exit__'s unbounded wait() 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/False and the str | bool return are deliberately unchanged: they are the data the v2 System Info API surfaces for display_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.py and system_info.html all pass it straight through.

A bonus fix in the same function

The no-CEC branch's r.set('display_power', 'Not available', ...) sat outside the SoftTimeLimitExceeded handler, 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 defaults socket_timeout=5), but the default Retry(retries=10) costs 58.83s measured, which blows the 30s soft limit and clears the 60s hard limit by only ~1.2s. Outside the try, 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

Board Result
Pi 3-64 (arm64) worst-case overrun +0.0027s; 40 real dispatches, fd delta 0, zombie delta 0, temp-file delta 0. Clean #3267 A/B: 'CEC error''No CEC adapter', end to end through redis and /api/v2/info
Pi 4 (arm64) genuine D-state child (O_DIRECT to the SD card, 98.7% D, wchan mmc_blk_rw_wait) — reap still held, killpg reaping in 31.5ms. Differential: 0.115s vs TimeoutExpired at 8.008s pre-fix
Pi 3 A+ (armv7l, 361 MB) the timeout path is the normal path there (libcec hangs), so every tick exercises it: 10.07–10.32s against a 30s soft limit, no SIGKILL. Bound held under swap exhaustion (10.016s vs a 12s ceiling)
Pi 2 (armhf) bounded reap in the real worker; grandchild killed; tempfile leaves nothing over 40 invocations
x86 200 fast + 20 killed invocations: zero fd/temp/zombie delta. Supplied the redis-py measurement above

What this does NOT fix, stated plainly

The root cause of ANTHIAS-A/9/B/31 is still open. My original theory — that SoftTimeLimitExceeded re-enters an unbounded wait() so the task sails past the hard limit — does not reproduce on CPython 3.13, which calls process.kill() before every wait(). 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 the killpg, 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/cec0 through 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 returns None rather 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 the subprocess.run seam to _run_bounded, preserving every behavioural assertion — message formatting, length capping, stderr fallback, returncode fallback.

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices.
  • I have tested my changes for x86 devices.
  • I added a documentation for the changes I have made (when necessary).

… 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

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.50000% with 7 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@82c5def). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/anthias_server/lib/diagnostics.py 81.08% 7 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/anthias_server/lib/diagnostics.py Outdated
Comment thread src/anthias_server/celery_tasks.py Outdated
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.
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/cec0 and 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).
            #

@vpetersson
vpetersson merged commit 936e7d8 into Screenly:master Aug 7, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

get_display_power still SIGKILLs the celery worker despite the soft-limit guard

3 participants