fix(stream): end close-out pre-flights lifeCycleStatus — no more 403 invalidTransition noise on every stop

startup.log 2026-09-22 (user runs of the new build): ALL THREE session stops
logged 'Broadcast transition(complete) failed (403) invalidTransition → End
close-out: enableAutoStop will finish'. The old ai.md assumption ('invalidTransition
when autoStop already fired') was wrong — the blind complete POST races
YouTube/autoStop marking the broadcast complete (enableAutoStop=true is always
set, tests included), so a blind POST earned only the 403 + log noise. Per the
transition errors table, invalidTransition is a current-status problem and
complete is not gated on streamStatus (only testing/live are):
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition

Fix: EndBroadcastAsync pre-flights liveBroadcasts.list -> status.lifeCycleStatus
and skips the POST when the broadcast is already complete/revoked; an inconclusive
pre-check still posts (old behavior) rather than silently stranding a live
broadcast; still never throws (enableAutoStop finishes every skip).

Good Dog: EndBroadcast test rewritten to the one contract — live -> GET pre-check
+ POST transition (URL shape asserted); already complete -> zero transition POSTs;
inconclusive pre-check + 403 POST -> error string, never a throw. Full suite
315/315 (one-run audio/mouse timing flakes cleared), build 0 warnings.

Recipe recorded in MyMistakes.md; ai.md / Services index / TASK-9 note corrected.
This commit is contained in:
2026-09-23 08:28:59 -07:00
parent 18ab553b0e
commit ac6e67aff0
7 changed files with 129 additions and 53 deletions
+39 -31
View File
@@ -1,18 +1,41 @@
# HANDOFF — 2026-09-22 (TASK 42 committed; health-poll parse fix in flight)
# HANDOFF — 2026-09-22 (TASK 42 committed; health-poll + end-race fixes tracked)
## Branch / Commit State
`main` HEAD = **`e69db4d` — feat(ui): TASK 42 top bar redesign …** (committed).
Pushed state at `197ee81` (verified from the app-build the user ran after the commit). **Working
tree DIRTY — health-poll parse bug fix, one small change (code + tests + docs):**
`main` HEAD = **`e69db4d`** (TASK 42 top bar) → **`18ab553`** (health-poll fix, committed).
Pushed state at `197ee81`. **Working tree DIRTY — end-of-stream race fix (one small change):**
```
M Services/YouTubeStreamService.cs (parse fix)
M ytLive.Tests/YouTubeStreamServiceTests.cs (fixtures flipped to REAL api shape)
M ai.md, Services/index.md, MyMistakes.md
M Services/YouTubeStreamService.cs (EndBroadcastAsync pre-flight)
M ytLive.Tests/YouTubeStreamServiceTests.cs (end-close-out test rewritten)
M ai.md, Services/index.md, TASKS/task-09-live-stream-management.md, MyMistakes.md
M HANDOFF.md (this rewrite)
```
## Shipped since last handoff
- **TASK 42 top bar redesign** `e69db4d` — see below.
- **Health-poll parse fix** `18ab553` — `GetStreamHealthAsync` read `healthStatus` as
a string; the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds,
configurationIssues[]}` (an OBJECT) → every 2026-09-22 session start logged
`requires an element of type 'String'`. Fixed to read the nested shape, tolerating the
old flat shape; the report-by-exception health banner is alive again.
## In flight — end-of-stream race fix (end close-out, 2026-09-22)
`startup.log` showed `Broadcast transition(complete) failed (403) invalidTransition →
enableAutoStop will finish` on ALL THREE 2026-09-22 stops (17:09 crash + both test
sessions). NOT the old "autoStop already fired" assumption (ai.md had it wrong): the
blind `transition(complete)` POST races YouTube/autoStop marking the broadcast complete
(via `enableAutoStop=true`, always set — tests included). Fix in the dirty tree:
`EndBroadcastAsync` pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only
POSTs complete from `live`/`testing`, skipping silently when already complete
(`enableAutoStop` finishes every skip); an inconclusive pre-check still posts (old
behavior, backstopped); still never throws. Cite:
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition (errors
table: invalidTransition = current-status problem, and complete is not gated on
streamStatus — only testing/live are).
## What shipped — TASK 42: top bar redesign (2026-09-22)
Full record: **`TASKS/task-42-top-bar-redesign.md`**. Creator directive: "forget this one dog plan
@@ -41,30 +64,15 @@ changing + running reality values. Also folded in: TASK 41's latent **Test pipel
`BeginTestStream` now arms `OnAirPillOn` explicitly (unarmed → encoder booted with zero outputs →
"At least one output is required" → forced stop cascade). The TestStreamTests gate updated to match.
## In flight — health-poll parse fix (2026-09-22, from startup.log)
The user ran the new build and the app's `startup.log` showed `Stream health poll failed: The
requested operation requires an element of type 'String', but the target element has type 'Object'.`
on EVERY session start (2/2 test sessions). Root cause: `YouTubeStreamService.GetStreamHealthAsync`
called `healthStatus.GetString()` but the real API nests `status.healthStatus = {status,
lastUpdateTimeSeconds, configurationIssues[]}` — an OBJECT, not a string (docs:
https://developers.google.com/youtube/v3/live/docs/liveStreams). Our own flat-string fixture was the
wrong assumption from day one; the report-by-exception health banner had been silently dead. Fix in
the dirty tree reads the nested `.status` and nested `configurationIssues[]`, tolerating the old flat
shape; fixtures flipped to the real shape (the Good Dog). ALSO logged: end-of-session
`transition(complete)` 403 `invalidTransition` → enableAutoStop fallback hit all three 2026-09-22
stops (17:09 crash + both tests) — known racy design (transition AFTER encoder EOF), NOT in this fix's
scope, carried.
## ⚠️ Around the task
- **Full-suite run today: 314/315** — the one abort is the pre-existing
- **Full-suite run today: 314/315** (first pass) — the one abort is the pre-existing
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which injects
REAL physical mouse input (`SetCursorPos` + `mouse_event`, see its own docstring) and no-ops while
another window interferes. Cause observed 2026-09-22: **Path of Exile 2 + NVIDIA Overlay were
running** (fullscreen-game window hides the test window's rows; the drag never lands → reorder
assert fails). Close the game before running that test; it is unrelated to TASK 42 and has passed
on clean reruns previously.
assert fails). `AudioPipelineTests.Mix_HonorsProviderGains…` also flaked one run (timing under
load); both cleared on the 315/315 rerun. Close games before running those tests; unrelated to the
recent changes.
- **Manual verification owed** (needs a real run on Windows): the app is a new build number when
next launched — check the new one-surface bar (switch ↔ worlds, gear menu, reality line).
- The app may be **RUNNING** — an in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
@@ -80,11 +88,11 @@ scope, carried.
## Next step
Scope-check the declared list → build (0 warnings) → full test suite rerun → **one commit** for
TASK 42 (docs + code same change). Push only on the user's say-so. Then **TASK 40 App Settings
round** is queued (`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D
(accent)** remain (B/gear shipped early); Unit A's SharedReadOnly control-write question is the one
genuinely uncertain technical point — run its mitigation ladder before a third guess.
Commit this end-of-stream fix (scope-check first, then build 0-warnings + full suite, then ONE
commit). Push only on the user's say-so. Then **TASK 40 App Settings round** is queued
(`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D (accent)** remain
(B/gear shipped early); Unit A's SharedReadOnly control-write question is the one genuinely
uncertain technical point — run its mitigation ladder before a third guess.
## Critical working rules (unchanged, still binding)