Skip to content

fix(viewer): wait for a connected display on eglfs so headless pi4 doesn't crash-loop - #2962

Merged
vpetersson merged 1 commit into
masterfrom
fix/viewer-eglfs-headless-wait
Jun 1, 2026
Merged

fix(viewer): wait for a connected display on eglfs so headless pi4 doesn't crash-loop#2962
vpetersson merged 1 commit into
masterfrom
fix/viewer-eglfs-headless-wait

Conversation

@vpetersson

Copy link
Copy Markdown
Contributor

Issues Fixed

Headless Pi 4 devices crash-loop the viewer on 2026.5.2. The headless-display guard from #2959 only covers the linuxfb /dev/fb0 path (pi2/pi3); Pi 4 renders under QT_QPA_PLATFORM=eglfs (since #2904), which has no /dev/fb0 and so was never guarded. With no panel attached, Qt's eglfs plugin finds no connected DRM connector, logs no screens available, and exits before the viewer emits its D-Bus handshake — load_browser() then raises RuntimeError and the container restart-loops. Observed live on 4 pi4 devices.

Description

  • Add wait_for_eglfs_display(): before the KMS-card detection, wait until a DRM connector reads connected (or the occasional bridge that reports unknown); only an all-disconnected board waits. A genuinely headless board idles quietly and self-heals on hotplug, mirroring the existing /dev/fb0 wait.
  • Correct stale comments that still described Pi 4 as running under linuxfb.

No change for boards with a display attached at boot (the check returns immediately); a board whose TV is slow to negotiate HDMI now waits a few seconds instead of crash-looping.

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).

🤖 Generated with Claude Code

…esn't crash-loop

Pi 4 renders under QT_QPA_PLATFORM=eglfs (since #2904), but the
headless-display guard added in #2959 only covers the linuxfb /dev/fb0
path (pi2/pi3). On a headless eglfs board Qt's eglfs plugin finds no
connected DRM connector, logs "no screens available", and exits before
the viewer emits its D-Bus handshake — so load_browser() raises and the
viewer container crash-loops. Confirmed on 4 live pi4 devices running
2026.5.2.

- Add wait_for_eglfs_display(): before the KMS-card detection, wait
  until a DRM connector reads "connected" (or "unknown"); a genuinely
  headless board idles quietly and self-heals on hotplug, mirroring the
  linuxfb /dev/fb0 wait.
- Correct stale comments that still claimed Pi 4 runs under linuxfb.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@vpetersson
vpetersson requested a review from a team as a code owner June 1, 2026 04:42
@vpetersson vpetersson self-assigned this Jun 1, 2026
@vpetersson
vpetersson requested a review from Copilot June 1, 2026 04:43

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.

Copilot wasn't able to review any files in this pull request.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@vpetersson
vpetersson requested a review from Copilot June 1, 2026 04:43
@sonarqubecloud

sonarqubecloud Bot commented Jun 1, 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.

Copilot wasn't able to review any files in this pull request.

@vpetersson
vpetersson merged commit d417074 into master Jun 1, 2026
7 checks passed
vpetersson added a commit that referenced this pull request Jun 1, 2026
…#2968)

#2962 added wait_for_eglfs_display so a screenless eglfs (Pi 4) board waits
for a display instead of crash-looping on Qt's "no screens available".
eglfs_has_display() treated any connector status other than "disconnected"
as a present display (to tolerate bridges that report "unknown").

balenaOS 2026.x exposes a KMS `card0-Writeback-1` virtual connector that
ALWAYS reports "unknown". On a headless Pi 4 (both HDMI ports
"disconnected") the writeback connector's "unknown" satisfied the guard, so
it skipped the wait, launched eglfs, and the viewer crash-looped on
"no screens available" / "AnthiasViewer exited before emitting D-Bus
handshake" — exactly the failure #2962 was meant to prevent. Confirmed on
multiple live pi4 on 2026.1.0 (card0-HDMI-A-1/-2 = disconnected,
card0-Writeback-1 = unknown).

Skip `*Writeback*` connectors so only real display outputs (HDMI/DSI/DP/…)
count. A genuinely headless board now waits gracefully; the bridge-"unknown"
hedge is preserved for real connectors. Verified locally for headless,
connected, and bridge-unknown layouts.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
vpetersson added a commit that referenced this pull request Jun 2, 2026
…self-heals (#2969)

* fix(viewer): skip the Writeback connector in the eglfs headless guard

#2962 added wait_for_eglfs_display so a screenless eglfs (Pi 4) board waits
for a display instead of crash-looping on Qt's "no screens available".
eglfs_has_display() treated any connector status other than "disconnected"
as a present display (to tolerate bridges that report "unknown").

balenaOS 2026.x exposes a KMS `card0-Writeback-1` virtual connector that
ALWAYS reports "unknown". On a headless Pi 4 (both HDMI ports
"disconnected") the writeback connector's "unknown" satisfied the guard, so
it skipped the wait, launched eglfs, and the viewer crash-looped on
"no screens available" / "AnthiasViewer exited before emitting D-Bus
handshake" — exactly the failure #2962 was meant to prevent. Confirmed on
multiple live pi4 on 2026.1.0 (card0-HDMI-A-1/-2 = disconnected,
card0-Writeback-1 = unknown).

Skip `*Writeback*` connectors so only real display outputs (HDMI/DSI/DP/…)
count. A genuinely headless board now waits gracefully; the bridge-"unknown"
hedge is preserved for real connectors. Verified locally for headless,
connected, and bridge-unknown layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(viewer): retry AnthiasViewer spawn so armv7 WebEngine-init crash self-heals

- Wrap the AnthiasViewer launch in a capped exponential-backoff retry
  loop (BROWSER_SPAWN_MAX_ATTEMPTS) instead of raising on the first
  failed handshake
- Convert the tight container restart loop on Pi 2/Pi 3 into an
  in-process retry that self-heals on a later launch
- Publish viewer:webview_status to Redis (retrying/failed) so a stuck
  board is distinguishable from an empty playlist
- Add WebviewLaunchError + _spawn_webview_once helper; throttle repeat
  warnings to avoid flooding journald
- Cover retry-then-succeed and exhaust-then-raise paths in tests
- Document the armv7 WebEngine-init crash + retry stop-gap in
  docs/board-enablement.md

The 32-bit Qt5 viewer intermittently aborts during Chromium/WebEngine
init (malloc(): unaligned tcache chunk detected) ~75-90% of launches;
reproduced on a 64-bit Pi 3B+. No userspace mitigation fixes the
corruption, but a fresh launch clears it ~10-25% of the time, so
retrying catches a good launch within a few attempts (validated
on-device: handshake on attempt 6). Clean fix is arm64/Qt6 on 64-bit OS.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(viewer): address review — bound status-beacon Redis, funnel CommandNotFound

- Give the webview health beacon a dedicated Redis client with short
  socket timeouts (connect_to_redis gains opt-in timeout params,
  defaulting to the historical blocking behaviour) so a Redis stall
  can't hang viewer startup inside the spawn-retry loop
- Wrap sh.CommandNotFound into WebviewLaunchError in _spawn_webview_once
  so a missing binary is reported + handled on the same path as every
  other launch failure instead of escaping the retry loop
- Reword the board-enablement note so it describes the WebEngine-init
  observation without referencing a --no-sandbox flag the viewer
  doesn't receive
- conftest: accept the new connect_to_redis kwargs in the fake factory

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(viewer): chain final launch error; harden second redis test patch

- Chain the exhausted-retries WebviewLaunchError from last_error so the
  traceback preserves the underlying failure (timeout / early-exit /
  wrapped CommandNotFound)
- conftest: the autouse _mock_redis fixture's connect_to_redis patch now
  accepts *args, **kwargs too (matches the import-time patch)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(viewer): scope spawn-retry by call site; drop write-only status beacon

Addresses self-review findings on the retry mechanism:

- Mid-playback respawn (view_image/view_webpage, on the asset_loop
  thread) now uses a small, short budget (BROWSER_SPAWN_INLINE_*) so a
  persistent crash can't freeze the loop (no rotations/skips/standby,
  watchdog starved) for minutes; startup keeps the generous budget. A
  persistent mid-run failure raises and the container restart re-rolls.
- Permanent failures (missing binary) raise WebviewBinaryMissingError
  and short-circuit the retry instead of burning the full backoff budget.
- _spawn_webview_once now reaps the terminated process (SIGTERM, wait,
  SIGKILL) on the handshake-timeout path so a retry can't overlap two
  AnthiasViewers contending for the framebuffer / D-Bus name.
- Reset the stale `browser` global before re-spawning.
- Poll spawned process every 0.25s (was 1s) so a fast init crash is
  noticed promptly in the retry loop.
- Drop the write-only viewer:webview_status Redis beacon (no reader
  existed) and revert the connect_to_redis timeout-param widening +
  conftest churn; operator-visible status is the throttled log output.
- Tests: cover early-exit, terminate-on-timeout, missing-binary
  short-circuit, backoff growth, and the inline budget cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(viewer): clamp max_attempts to >=1; correct retry-logging comment

- Guard load_browser against a non-positive max_attempts (would skip the
  loop and raise a confusing "0 attempts; last error: None")
- Reword the comment: the first failure logs its reason AND a retry
  line, so it's not literally "one log line per attempt"

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(viewer): clamp backoff_cap and startup_timeout in load_browser

- A backoff_cap below 1s would devolve into a tight retry loop; a
  negative one would make sleep() raise ValueError mid-retry and mask
  the real launch error
- Clamp a negative startup_timeout to 0 (immediate-timeout attempt)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
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.

2 participants