Skip to content

v1.40.34: player resilience, boundary double-clutch fix, loopback health checks - #307

Merged
chrobione merged 6 commits into
mainfrom
ui/player-layout-and-http-default
Jul 27, 2026
Merged

v1.40.34: player resilience, boundary double-clutch fix, loopback health checks#307
chrobione merged 6 commits into
mainfrom
ui/player-layout-and-http-default

Conversation

@chrobione

Copy link
Copy Markdown
Member

Rolls 1.40.34. Five changes, all traced from prod logs on 1.40.30.

Boundary double-clutch (the one that survived #280)

#280 is deployed — f66dee7 is an ancestor of v1.40.30, and was not in v1.40.24. It fixed its case; a second one remained.

resolveEntryForNow keeps an entry resolvable for 2s past its own EndsAt so a boundary tick can still find it. At a hard boundary the entry ending now and its successor both resolve in one pass. #280 correctly stopped the ended entry from claiming the mount's launch slot, but nothing stopped it running handleEntry first and building a whole pipeline the successor then replaced.

Prod mount d4f41798, top of every hour:

entry is_instance window source
e343ce12 false, recurrence custom 23:00 → 00:00:00 (ended) Behind The Woodshed
983d7b83 true 00:00:00 → 02:00 different webstream row, same upstream

Builds at 00:00:00.329 and 00:00:01.138. The 0.81s gap is startWebstreamEntry's synchronous ICY FetchOnce running between them.

An ended entry now only launches when nothing still current covers the mount. The grace still covers dead air when there is no successor.

Detector note: "4 feed starts per mount per minute" is not the signature — it false-positives on short content (rlmradioxyz runs a 13-item promo playlist with items like "1.25 sec Hal AI - Promo"). Pair feed started with the following EOF and look at pipeline lifetime. 6h baseline: 422 builds, 14 under 10s, 3 under 1s.

Listener connections survive stalls

A single failed write disconnected a listener. Correct for a broken pipe or reset; wrong for a deadline exceeded, which only means the client stopped draining. Timeouts now run against a stall budget of 3 × writeTimeout (90s default); every other error still drops immediately.

The budget is elapsed time, not a count of failed writes — a client returning from a dip has a backlog to chew through and blows several deadlines in a row while catching up, so counting attempts would drop exactly the listener this protects.

Player auto-reconnect

reconnectLiveStream() was fully written — backoff, 20-attempt ceiling, LQ fallback — and called from nowhere. onError returned after a toast, onEnded returned early for live, onStalled/onWaiting only printed "Buffering...", and stalledTimer was cleared but never set. Every drop became a manual refresh. All four paths now feed it; stalled/waiting arm an 8s timer that fires only if playback hasn't recovered. close() cancels pending timers.

Also: transport defaults to HTTP instead of WebRTC, so a new listener starts on LQ over plain HTTP with no signaling websocket. Anyone with a stored grimnir-live-transport keeps their choice.

Loopback health checks

52 of 66 webstreams point at https://rlmradio.xyz/live/<mount> — mounts this same box serves. Playout already pulled those over loopback (14 rewrites / 11 playouts in 6h). The health checker did not: 28,249 checks in 6 hours, ~78/min, each leaving the box for the CDN edge and returning over the tunnel to ask whether a local mount is alive. A wobble there marks a healthy mount unhealthy and can trip a failover the source never needed.

Checks now use the same resolver. Rewrite logic is shared via webstream.LoopbackURL; the director keeps its guard that a mount is never fed from itself. Configured URLs in the DB are untouched — the rewrite is request-time only.

Pre-existing race fixed

make ci runs with -race and TestService_PlaybackLifecycle failed on a race that predates this branch (reproduced on main at bd63b09). CI runs go test -count=1 ./... with no -race, which is why it stayed green.

monitorProcess and Stop both called Wait on the same exec.Cmd. os/exec forbids that, and either could observe the wrong exit status. monitorProcess is now the sole caller and closes a waitDone channel; Stop selects on it. Force-kill path unchanged.

Player layout

Panel 400px → 480px, so a 46-char title fits instead of scrolling. Transport buttons move right via ms-auto instead of sitting centered between the elapsed time and the volume slider. Verified by rendering the template against the real CSS.

Verification

make ci passes with -race (it did not before this branch). Coverage 66.90%.

Two tests I threw away rather than keep: socket-level attempts at the hold-open path passed with the budget removed, because kernel send-buffer autotuning meant no write ever timed out. That logic is now classifyWriteErr, tested directly — all four cases fail with the budget disabled. Same discrimination check run on the double-clutch guard (2 builds without it) and the health-check redirect (public hits = 1, want 0).

🤖 Generated with Claude Code

chrobione and others added 6 commits July 27, 2026 09:09
Three changes to the floating global player.

The panel goes from 400px to 480px wide. A 46-character title like "Blue
Monday (1988 Remaster) - Extended Club Mix" previously cut off at
"Extende" and had to scroll; it now fits on one line. The marquee measures
clientWidth at runtime, so it simply scrolls less rather than needing any
change. max-width: calc(100vw - 40px) still protects narrow screens.

The transport buttons in the dashboard player were centred, because the row
was justify-content-between with three children (elapsed time, transport,
volume). The row is now a plain flex with ms-auto on the transport group,
so play/pause sits to the right next to the volume slider & the time stays
left.

The live transport now defaults to http instead of webrtc. WebRTC needs a
signaling WebSocket, and the websocket path to the edge is where every
observed listener drop happens: 8 days of link monitoring shows the
prod<->edge tunnel losing 0.10% of packets with p50 34.6ms and no relay
flaps, while the app still logs "websocket write failed ... broken pipe"
against 192.168.195.1. HTTP sidesteps that path.

WebRTC stays fully available from the transport menu. Anyone who has
already picked a transport keeps it, because the stored
grimnir-live-transport value still wins over the default; only clients with
no stored preference change behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #280 fixed one double-clutch and a second one survived it. This is that
second one, traced on prod 1.40.30 (which does contain f66dee7).

resolveEntryForNow keeps an entry resolvable for 2s past its own EndsAt so a
boundary tick can still find it. At a hard boundary that means the entry
ending exactly now and its successor both resolve in the same pass. #280's
per-tick guard deliberately does not let the ended entry claim the mount's
single launch, so its successor can still preempt. Nothing stopped the ended
entry reaching handleEntry FIRST and building a complete pipeline that the
successor then tore down and replaced.

On prod mount d4f41798 that fired at the top of every hour: a recurring
"custom" template ending at exactly 00:00:00 launched Behind The Woodshed,
and the 00:00 instance rebuilt the same upstream 0.81s later. Two builds,
one boundary, audible stutter. Measured over 6h of prod logs by pairing
"feed started" with the following EOF per mount: 422 builds, 14 under 10s,
3 under 1s, and the sub-second ones landed at 22:00:00.19, 23:00:00.36 &
00:00:00.33.

An entry that has already ended now only launches when nothing still current
covers the same mount. The grace still works as dead-air cover; it just
stops producing a throwaway build whenever a successor is ready.

Two tests. The first seeds an ended entry plus its successor on one mount and
asserts a single build, and that the mount is left on the successor rather
than the entry that ended; it reports 2 builds without the guard. The second
seeds an ended entry alone and asserts it still launches, so the fix cannot
turn a boundary with no successor into silence.

Note the counting trap for anyone re-checking this on air: "4 feed starts
per mount per minute" is NOT the signature. It false-positives on short
content, e.g. rlmradioxyz runs a 13-item promo playlist with items like
"1.25 sec Hal AI - Promo". Pair feed-start with the following EOF and look
at pipeline lifetime instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nect

Two halves of the same problem: a brief network wobble ended a listener's
stream and nothing brought it back, so an eight-second dip looked exactly
like dead air.

Server side. A single failed write disconnected a client. That is right for
a broken pipe or a connection reset, where the peer is already gone, and
wrong for a deadline exceeded, which only means the client stopped draining
and may well come back. Failed writes now run against a stall budget of
3x writeTimeout (90s at the default 30s) and every other error still drops
on the spot. The budget is elapsed time, not a count of failed writes: a
client returning from a dip has a backlog to work through and blows several
deadlines in a row while catching up, so counting attempts would disconnect
precisely the listener this is meant to keep. Same rule on the keepalive
flush path. A timed-out chunk is dropped and counted in skipped, so the
listener hears the same discontinuity Broadcast already produces for a slow
client rather than losing the stream.

Client side. reconnectLiveStream() was written, complete, with backoff and a
20-attempt ceiling, and never called from anywhere. onError returned after a
toast, onEnded returned early for live, onStalled and onWaiting only printed
"Buffering...", and stalledTimer was cleared in onPlaying but never set. Any
drop therefore became a manual page refresh. All four paths now feed it:
error and ended reconnect immediately, stalled and waiting arm an 8s timer
that reconnects only if playback has not resumed by itself, and close()
cancels anything pending so a deliberately closed player cannot resurrect.

The hold-open decision is extracted as classifyWriteErr and tested directly
rather than through a socket, because a socket-level version of this fights
kernel send-buffer autotuning and cannot reliably force the timeout it means
to test — two earlier attempts passed with the budget removed, which makes
them worse than no test. The table covers a first timeout, a timeout deep
inside the budget, a timeout past it, and a broken pipe both cold and during
a stall run; all four fail with the budget disabled. The existing
half-open-client reap test still passes, so a genuinely dead socket is still
collected rather than held forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
52 of the 66 webstreams on prod point at https://rlmradio.xyz/live/<mount>,
which is a mount this same box already serves. Playout already handled that:
rewriteSelfRelayURL pulls those over 127.0.0.1, and prod logged 14 loopback
rewrites against 11 webstream playouts in 6h, so the audio path never leaves
the machine.

The health checker did not. It probed whatever URL the row carried, so those
same 52 relays were checked by their public URL: 28,249 checks in 6 hours,
about 78 a minute, every one leaving the box for the CDN edge and coming back
over the tunnel to ask whether a local mount is alive. That makes an internal
relay's health depend on the least reliable link in the system, and a wobble
there marks a healthy mount unhealthy and can trip a failover the source
never needed. The 02:00 logs show exactly that shape: "failover threshold
reached, grace period started" alongside health-check clients arriving from
an external address.

Health checks now run through the same loopback resolver. The rewrite logic
moves into webstream.LoopbackURL so playout and the checker share one
implementation, and the director keeps its extra guard that a mount is never
fed from itself. The resolver is injected from server.go, where the broadcast
server is already in scope, and a nil resolver leaves every URL untouched.

Configured URLs in the database are not modified. The rewrite happens at
request time, so a webstream that later stops being local, or an operator
reading the row, still sees exactly what was configured.

Tests: LoopbackURL over our own mount, a non-default port, the 0-port
fallback, and four external-relay shapes that must never be redirected at our
own loopback, since that would silently serve one of our streams in place of
somebody else's. Two checker-level tests assert the probe hits loopback and
not the public URL, and that a checker with no resolver still probes the
configured URL; the first fails with the redirect removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
make ci runs the suite with -race and TestService_PlaybackLifecycle failed on
a data race that predates this branch (reproduced on main at bd63b09). CI
itself runs `go test -count=1 ./...` with no -race, which is why it has been
going green.

Two goroutines called Wait on the same exec.Cmd. monitorProcess, started by
Start, calls it to record the exit code and fire onExit. Stop then called it
again from its own goroutine purely to get a 5s graceful-shutdown timeout.
os/exec forbids calling Wait more than once, and the two calls raced on the
Cmd's internal state; either could also have observed the wrong exit status,
so an emergency insert during a crossfade could mis-report why a pipeline
died.

monitorProcess is now the only caller. It closes a waitDone channel once the
process is reaped and its exit state recorded, and Stop selects on that
instead of waiting itself, reading exitError under the lock for its log line.
The force-kill path is unchanged: on timeout the kill still lands and
monitorProcess reaps the process as before.

Verified with -count=5 -race on the failing test, the whole mediaengine tree
under -race, and a full make ci.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chrobione
chrobione merged commit 9d7a296 into main Jul 27, 2026
1 check passed
@chrobione
chrobione deleted the ui/player-layout-and-http-default branch July 27, 2026 15:12
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.

1 participant