Skip to content

fix(viewer): anchor the per-asset wait on the tick start, not a flat duration - #3328

Open
DiasonD wants to merge 2 commits into
Screenly:masterfrom
DiasonD:fix/asset-loop-deadline-wait
Open

DiasonD wants to merge 2 commits into
Screenly:masterfrom
DiasonD:fix/asset-loop-deadline-wait

Conversation

@DiasonD

@DiasonD DiasonD commented Sep 14, 2026

Copy link
Copy Markdown

Issues Fixed

Related to the discussion in issue 3319 (wall-clock playlist alignment). This does not implement that feature and does not close the issue. It fixes the accumulating part of the drift described there.

Description

asset_loop waited a flat duration after everything the tick did first (scheduler refresh, DB reads, the D-Bus load call), so every rotation took duration plus a device-specific overhead. Two players cycling the same playlist drifted apart by seconds within a few hours. The docs already promise an asset "is displayed for the specified duration", and the D-Bus loadImage / loadPage calls return before rendering, so the flat wait never bought on-screen time; it only lengthened the cycle.

  • tick_start = monotonic() is now the first statement of asset_loop, and both wait sites (image/web in asset_loop, video in view_video) wait until tick_start + duration through one helper, _seconds_until.
  • Clamped at zero: a mid-tick browser respawn can outlast a short duration, and the asset then just moves on. Not logged separately, since both wait sites log the computed wait next to the configured duration and a zero-duration asset would otherwise warn every tick.
  • The "Sleeping for" line (image/web) and the new "Playing video for" line (video) keep the configured integer duration next to the computed wait, at info level, so a zero clamp is visible without debug logging.

Video semantics. For a video asset, duration is the clip length probed at upload, so the wait is the playback window rather than a display timer. Anchoring the wait on the tick start therefore trims the tick's pre-play overhead off the end of every clip: a few tens of ms on x86, proportionally more on a slow board or when a browser respawn lands mid-tick. Master already truncates a clip's fractional tail through clamp_duration's int(), so this is a small extension of an existing cut. It is the intended trade: a fixed cycle time for the playlist over the last few frames of a clip.

Not covered here. view_video still calls media_player.stop() after the wait returns, so player teardown falls outside the deadline. On x86 that is a cheap D-Bus stopVideo; on the Qt5 linuxfb boards GstFbdevMediaPlayer.stop() is a killpg plus up to two 3 s waits. That cost lands in the next tick, where the next asset's deadline absorbs it, so it only leaks into the cycle when teardown exceeds the following slot. Not a regression against master. Moving or budgeting the teardown needs a Pi 3 measurement and is a separate change.

What this does not do: it does not phase-align players (each still starts its counter wherever it was at boot) and it does not remove the residual crystal-rate drift between devices. It makes the cycle time equal to duration instead of duration plus overhead.

Tests: three new cases drive asset_loop on a fake monotonic clock (image, video, and the zero clamp), and the existing max-duration test pins monotonic so its exact-timeout assertion stays valid. ruff check, ruff format --check, mypy and the viewer test files pass.

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

https://claude.ai/code/session_01Gos7GxdfJQkjapBvmXzGQS

…duration

The image/web wait and the video wait were both a flat `duration` on
top of everything the tick did first (scheduler refresh, DB reads, the
D-Bus load call), so every rotation ran `duration` plus a device-
specific overhead. Co-located players cycling the same playlist drifted
apart by seconds within hours.

- Take `tick_start` as the first statement of `asset_loop` and wait
  until `tick_start + duration` in both wait sites via `_seconds_until`
- Clamp at zero so a mid-tick browser respawn just moves on
- Keep the configured integer duration in the "Sleeping for" log line
- Tests drive `asset_loop` on a fake monotonic clock for image and
  video, plus the zero clamp; pin `monotonic` in the max-duration test

Claude-Session: https://claude.ai/code/session_015NGKXmXtrjLX2M3Uv7CaKy
Copilot AI lite review requested due to automatic review settings September 14, 2026 06:59
@DiasonD
DiasonD requested a review from a team as a code owner September 14, 2026 06:59

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.

🔵 Needs a closer look

Video teardown can still add substantial rotation drift; its cost needs to be accounted for with regression coverage.

Pull request overview

This PR anchors per-asset waits to each tick’s monotonic start to reduce playlist drift.

Changes:

  • Adds deadline-based waits for image, webpage, and video assets.
  • Clamps overdue waits to zero.
  • Adds fake-clock timing regression tests.
File summaries
File Summary
tests/test_viewer.py Tests deadline timing, video behavior, and zero-duration clamping.
src/anthias_viewer/__init__.py Implements tick-anchored asset timing and shared remaining-time waits.
Review details

Suppressed comments (1)

src/anthias_viewer/init.py:1704

  • This deadline only covers the wait; view_video still calls media_player.stop() after the wait returns. In particular, GstFbdevMediaPlayer.stop() can block up to 3 seconds waiting for SIGTERM and another 3 seconds after SIGKILL, and the next asset_loop tick cannot start until that teardown completes. Video playlists can therefore retain substantial per-rotation drift despite this change; the teardown needs to be accounted for or moved in the timing design, with a regression test for its cost.
        if skip_event.wait(timeout=_seconds_until(deadline)):
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@vpetersson-bot vpetersson-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this — the diagnosis is correct, the fix is minimal and well-targeted, and the three new tests are genuine regression tests (I confirmed each one fails against master's viewer and passes with the change). I ran the suite locally and also measured the change on real hardware, since the device-testing boxes are unticked. Details below; three non-blocking comments at the end.

Local verification

Branch is current with master (17e30b91).

  • uv run pytest -m "not integration" — 2058 passed, 3 skipped
  • uv run ruff check . — clean
  • uv run ruff format --check . — 249 files already formatted
  • uv run mypy . — no issues in 196 source files

Hardware measurement (x86 testbed)

The board's pinned image carried a viewer file byte-identical to origin/master, so the baseline is exactly master; I then overlaid only src/anthias_viewer/__init__.py extracted from commit 911152de (md5 verified in-container) and re-measured the same playlist. Three assets — video 20s, image 10s, web 10s — timed from Showing asset log timestamps.

segment master this PR drift/hour, before → after
image (10s) 10.0152s (+15.2ms) 10.0017s (+1.7ms) 5.5s → 0.6s
web (10s) 10.0221s (+22.1ms) 10.0007s (+0.7ms) 8.0s → 0.3s
video (20s) 20.0410s (+41.0ms) 20.0025s (+2.5ms) 7.4s → 0.5s
full cycle (40s) 40.0784s (+78.4ms) 40.0049s (+4.9ms)

n = 9–12 segments per cell. Per-cycle overhead drops ~94%, which is the accumulating component the PR set out to remove. No tracebacks or behaviour changes in the viewer log across the run, and skip still works at both wait sites under the new deadline — assets/control/next was honoured in 20ms during video playback and 9ms during an image.

Note this board is close to the best case: x86 on Wayland with the QtMultimedia player. The absorbed overhead is device-specific and larger on the SBCs — this board alone emits a wlr-randr could not apply rotation retry at the top of every tick, which is exactly the kind of work the anchor now soaks up.

Comments (none blocking)

1. The video path still carries unabsorbed per-cycle overhead. view_video calls media_player.stop() after the deadline wait, so that cost falls outside the deadline and still accumulates. It shows up in the numbers above: video's residual (+2.5ms) is the largest of the three despite having twice the nominal slot. On x86 that's a cheap D-Bus stopVideo, but GstFbdevMediaPlayer.stop() on the Qt5 linuxfb boards is killpg + wait(timeout=3), with a second 3s budget after SIGKILL — a process teardown that can dwarf everything this PR absorbs. Copilot's review flagged the same thing independently. Not a regression (master has it too), and I don't think it should hold up the merge, but it does mean "the rotation takes exactly duration" holds for image/web and only approximately for video. The PR's own video test can't catch it because the player is mocked.

2. For video, duration is the clip length, so the deadline trims the clip's tail. AssetCreationMixin rejects a caller-supplied duration for video and sets it from get_video_duration (src/anthias_server/api/serializers/mixins.py:210), so a video slot is exactly one pass of the clip. The rationale in the description — the D-Bus load call returns before rendering, so the flat wait never bought on-screen time — holds for loadImage/loadPage, but not for playVideo, where the wait is the playback window. Absorbing the pre-play() work therefore cuts the last N ms off every clip: tens of ms on this board, proportionally more on a slow board or when a browser respawn lands mid-tick. I think it's the right trade, but it's a user-visible semantic change worth stating explicitly in the description.

3. _seconds_until's docstring over-claims on observability. It justifies not logging the zero-clamp on the grounds that the caller's Sleeping for 0.0 line already shows it. That's true for image/web, but view_video has no such line — it logs only Displaying video %s for %s at debug level, and with the configured duration rather than the computed wait. A video asset whose wait clamps to zero therefore leaves no diagnostic at all at the default log level. Either soften the docstring or give view_video an equivalent line.

Nit: view_video's duration: int | str annotation is now stale — the value only feeds logging and set_asset, and the sole caller passes a clamp_duration-ed int.

One thing I could not exercise on hardware: the zero-clamp path, where tick overhead exceeds a short duration. No device reproduced it naturally, so it rests on the new unit test.

Leaving the formal approval to a maintainer, but from my side this looks good to merge.

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (master@17e30b9). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3328   +/-   ##
=========================================
  Coverage          ?   90.47%           
=========================================
  Files             ?       85           
  Lines             ?    10075           
  Branches          ?     1126           
=========================================
  Hits              ?     9115           
  Misses            ?      707           
  Partials          ?      253           

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

- view_video now logs the deadline-clamped wait next to the configured
  duration, matching the image/web "Sleeping for" line, so a zero-clamp
  during video playback is visible at the default log level
- drop the stale `int | str` annotation on view_video; the only caller
  passes a clamped int
- _seconds_until docstring no longer claims the image/web log line
  covers the video path

Claude-Session: https://claude.ai/code/session_01Gos7GxdfJQkjapBvmXzGQS
Copilot AI review requested due to automatic review settings September 15, 2026 07:26
@sonarqubecloud

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.

🔵 Needs a closer look

Timing-sensitive playback changes warrant final human review and device validation.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

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.

3 participants