Skip to content

audio: release the cached output stream when idle — HDMI output blocks macOS sleep - #23

Merged
vstrelnikof merged 3 commits into
Just-Code-NET:mainfrom
shohart:fix/macos-hdmi-audio-sleep
Aug 6, 2026
Merged

audio: release the cached output stream when idle — HDMI output blocks macOS sleep#23
vstrelnikof merged 3 commits into
Just-Code-NET:mainfrom
shohart:fix/macos-hdmi-audio-sleep

Conversation

@shohart

@shohart shohart commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

On macOS, with audio routed to an HDMI / DisplayPort display, PolterType prevents the display from turning off and the system from sleeping: pmset -g assertions shows a com.apple.audio.* power assertion held for the process indefinitely.

Root cause

The audio worker caches its rodio OutputStream once a sound has played, and the 'stale refresh' in WorkerState::handle() only drops it when another play arrives after STREAM_IDLE_REFRESH. With no further plays the stream stays open for the life of the process. An open CoreAudio output stream on an HDMI device keeps coreaudiod's power assertion alive, so macOS behaves as if audio were playing forever.

Fix

The worker loop now waits with recv_timeout(STREAM_IDLE_REFRESH) (30 s) and releases the cached stream on timeout. The next play reopens it lazily — the ~20–50 ms reopen cost was the reason the stream was cached in the first place, and it is hidden under the synth tone's lead silence (and is imperceptible for theme files, which were already opened fresh per play).

The existing stale-refresh and error-invalidation paths are unchanged, so default-device tracking (BT headphones, HDMI plug/unplug) behaves as before.

Validation

  • cargo test -p poltertype-core passes.
  • On macOS 15 (Intel) with HDMI audio: before the fix the assertion persisted indefinitely after a correction sound; after the fix it disappears ~30 s after the last sound and the display sleeps on schedule.

@vstrelnikof

Copy link
Copy Markdown
Member

Thank you — this is a good catch, and the write-up made it reviewable in one pass. I checked the two things that could have gone wrong and both hold up:

  • A sound can't be cut off. Playback is synchronous — play_file / play_tone both end in sink.sleep_until_end(), so the worker is only ever back in recv_timeout after the sink has drained. The idle drop cannot land mid-play.
  • No latency regression. The pre-existing stale refresh in WorkerState::handle() keys off last_opened, i.e. stream age, not last use — so any play more than 30 s after the stream was opened already paid the reopen. Releasing at idle just hands the device back in between.

One nit, not a blocker: the worker now wakes every 30 s for the life of the process, including when there is no stream to release. For a tray app that runs all day that's ~2880 no-op wakeups a day — pennies, but trivially removed by waiting with recv() (no timeout) while state.stream.is_none(), and with recv_timeout only while a stream is cached. Happy to take it as a follow-up if you'd rather not touch this PR again.

shohart added a commit to shohart/PolterType that referenced this pull request Aug 6, 2026
Addressing review on Just-Code-NET#23: waiting with
recv_timeout unconditionally woke the worker every 30 s for the life
of the process even when no stream was cached — ~2880 no-op wakeups a
day for a tray app. Block with recv() while state.stream is None and
only poll on a timeout while a stream is cached.
shohart added a commit to shohart/PolterType that referenced this pull request Aug 6, 2026
Addressing review on Just-Code-NET#23: waiting with
recv_timeout unconditionally woke the worker every 30 s for the life
of the process even when no stream was cached — ~2880 no-op wakeups a
day for a tray app. Block with recv() while state.stream is None and
only poll on a timeout while a stream is cached.
@shohart

shohart commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Good point — taken in this PR rather than as a follow-up. The loop now blocks with plain recv() while state.stream is None and only arms the recv_timeout(STREAM_IDLE_REFRESH) while a stream is actually cached, so an idle process never wakes for the release path (commit 3d0b474). Behaviour is otherwise unchanged: a timeout still only fires when there is a stream to drop.

@shohart
shohart marked this pull request as draft August 6, 2026 12:37
shohart added 3 commits August 6, 2026 14:39
…S sleep

The worker cached its OutputStream for the life of the process once a
sound had played: the 'stale refresh' in handle() only dropped it when
another play arrived after the idle window, and with no further plays
the stream stayed open forever. On macOS an open CoreAudio output on
an HDMI / DisplayPort device keeps coreaudiod's power assertion alive,
so the system would neither turn the display off nor sleep.

The worker loop now waits with recv_timeout(STREAM_IDLE_REFRESH) and
releases the cached stream on timeout; the next play reopens it lazily
(~20-50 ms, hidden under the synth lead silence).
Addressing review on Just-Code-NET#23: waiting with
recv_timeout unconditionally woke the worker every 30 s for the life
of the process even when no stream was cached — ~2880 no-op wakeups a
day for a tray app. Block with recv() while state.stream is None and
only poll on a timeout while a stream is cached.
@shohart
shohart force-pushed the fix/macos-hdmi-audio-sleep branch from 3d0b474 to 3592ac6 Compare August 6, 2026 12:41
@vstrelnikof

Copy link
Copy Markdown
Member

Checked in on this because we were about to merge — then noticed you moved it to draft right after the comment, so I am asking rather than assuming.

For what it is worth, I reviewed the current head (3592ac6, after the force-push) and it looks finished from here:

  • The loop now blocks on plain recv() while state.stream is None and only arms recv_timeout(STREAM_IDLE_REFRESH) while a stream is cached — so an idle process never wakes for the release path, which is exactly what the nit asked for.
  • invalidate() sets stream = None (audio/types.rs:69), so after a timeout the next iteration necessarily falls into the blocking branch. No hot loop, no missed wakeup.
  • AudioCmd::Shutdown => break survived the rewrite of the match — that is the arm these refactors usually drop.
  • All four checks are green, and the rebase onto main cleared the conflict; I test-merged it locally and it is clean.

So: is the draft flag there because something else is still in flight — something that does not show up in the diff, hardware behaviour on a real HDMI output, say — or did it just get flipped by habit? No rush from our side, and I would rather wait than merge something you consider unfinished.

Context so you can judge the timing: this is the last thing between us and cutting a release. Nothing else is waiting on you, and if you would rather it soak, that is a fine answer — the release can wait. Just mark it ready for review when you are happy with it and we will merge.

@shohart
shohart marked this pull request as ready for review August 6, 2026 13:50
@vstrelnikof
vstrelnikof merged commit dc61a55 into Just-Code-NET:main Aug 6, 2026
4 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.

2 participants