fix(stream): health poll crashed on every start — parse the real healthStatus object shape

startup.log 2026-09-22 (via the user's run of the new build): every session
start logged 'Stream health poll failed: The requested operation requires an
element of type String, but the target element has type Object'.

Root cause: GetStreamHealthAsync did status.healthStatus.GetString(), but the
real API nests it as status.healthStatus = {status, lastUpdateTimeSeconds,
configurationIssues[]} — an OBJECT, per the liveStreams reference
https://developers.google.com/youtube/v3/live/docs/liveStreams (checked the
docs before fixing, per derivative-work rule). Our flat-string fixture was the
wrong assumption since TASK 9; the report-by-exception health banner had been
silently dead.

Fix: read the nested healthStatus.status + nested configurationIssues[], tolerating
the legacy flat-string shape so nothing else breaks. Tests flipped to the real
object shape (the Good Dog for this change). Full suite 315/315, build 0 warnings.

Also recorded in MyMistakes.md (API-shape recipe) and ai.md/Services index. End-of-session
transition(complete) 403 invalidTransition (enableAutoStop fallback) observed on all three
2026-09-22 stops — separate racy-design issue, carried, not in this change.
This commit is contained in:
2026-09-22 19:25:02 -07:00
parent e69db4d26c
commit 18ab553b0e
6 changed files with 94 additions and 50 deletions
+22 -19
View File
@@ -1,30 +1,18 @@
# HANDOFF — 2026-09-22 (TASK 42 top bar redesign implemented; commit + full-suite rerun owed)
# HANDOFF — 2026-09-22 (TASK 42 committed; health-poll parse fix in flight)
## Branch / Commit State
`main` HEAD = **`bb5dcb4` — feat(stream): TASK 41 Test Stream mode …** (TASK 41 committed).
Pushed state at `197ee81`. **Working tree DIRTY — TASK 42 implementation done, not yet committed**
(docs updated in this same pass; next step is scope-check + build + full tests + one commit):
`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):**
```
M Controls/BottomBar.xaml(.cs) (gear removed)
M Controls/TopBar.xaml(.cs) (rewrite + GearButton_Click)
M Themes/Controls.xaml (SegmentToggle/SegmentLabel)
M ViewModels/MainViewModel.Streaming.cs
M ViewModels/MainViewModel.Account.cs
M ViewModels/MainViewModel.Streaming.Operations.cs
M ytLive.Tests/PillRadioTests.cs / TestStreamTests.cs
?? ytLive.Tests/TopBarModeTests.cs (new Good Dog)
M TASKS.md + TASKS/task-42-top-bar-redesign.md (new)
M ai.md, ViewModels/index.md, Controls/index.md, TASKS/task-40-app-settings-round.md
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 HANDOFF.md (this rewrite)
```
Build impact: main + tests build clean, **0 warnings**; targeted suite **6/6 pass**; full-suite run
**314/315 pass** — the single abort is the known environment-dependent `RealMouseDrag` test (Path of
Exile 2 running, physical-mouse injection no-ops; see ⚠️ below and rerun with the game closed for a
clean 315/315).
## 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
@@ -53,6 +41,21 @@ 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
+15
View File
@@ -15,6 +15,21 @@
## 🔬 Recipes registry
### YOUTUBE liveStreams LIST: healthStatus IS AN OBJECT, NOT A STRING (RECIPE)
YouTube Data API v3 `liveStreams.list` nests health under
`status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` —
the `healthStatus` element is an **OBJECT**, and `configurationIssues[]` lives
INSIDE it, not directly under `status`. Reading `healthStatus.GetString()` throws
System.Text.Json's `The requested operation requires an element of type 'String',
but the target element has type 'Object'` — the exact log line seen 2026-09-22 on
every live health poll (two test sessions). The parse must read
`healthStatus["status"]` (and nested `healthStatus["configurationIssues"]`).
Something like REST-shape drift is a good reason to grep the API reference
(https://developers.google.com/youtube/v3/live/docs/liveStreams) before writing
parsers against a "remembered" shape — our own flat-string fixture was the wrong
assumption the whole time.
### WINRT RESOURCE-ALLOCATION CALLS: WRAP PER-CALL, DEGRADE TO NEXT OPTION (RECIPE)
Creator callout (2026-09-15): WinRT/COM calls that allocate or start a resource — `InitializeAsync`,
+36 -20
View File
@@ -231,29 +231,45 @@ public class YouTubeStreamService
if (items.GetArrayLength() == 0) return null;
var status = items[0].GetProperty("status");
var health = new StreamHealth
// The real API nests health as status.healthStatus = { status, lastUpdateTimeSeconds,
// configurationIssues[] }. The flat "healthStatus":"bad" shape is our old wrong
// assumption — accept both so neither crashes the report-by-exception poll.
var health = new StreamHealth();
var issueElements = new List<JsonElement>();
if (status.TryGetProperty("healthStatus", out var healthStatus))
{
HealthStatus = status.TryGetProperty("healthStatus", out var healthStatus)
? healthStatus.GetString()
: null,
};
if (status.TryGetProperty("configurationIssues", out var issues))
{
foreach (var issue in issues.EnumerateArray())
if (healthStatus.ValueKind == JsonValueKind.String)
{
var severity = issue.TryGetProperty("severity", out var sev) ? sev.GetString() : null;
var type = issue.TryGetProperty("type", out var t) ? t.GetString() : null;
health.ConfigurationIssues.Add(new StreamConfigurationIssue
{
Severity = severity switch
{
"error" => StreamIssueSeverity.Error,
"warning" => StreamIssueSeverity.Warning,
_ => StreamIssueSeverity.Info,
},
Type = type,
});
health.HealthStatus = healthStatus.GetString();
}
else if (healthStatus.TryGetProperty("status", out var inner) &&
inner.ValueKind == JsonValueKind.String)
{
health.HealthStatus = inner.GetString();
}
if (healthStatus.TryGetProperty("configurationIssues", out var nested))
issueElements.AddRange(nested.EnumerateArray());
}
if (status.TryGetProperty("configurationIssues", out var flat))
issueElements.AddRange(flat.EnumerateArray());
foreach (var issue in issueElements)
{
var severity = issue.TryGetProperty("severity", out var sev) && sev.ValueKind == JsonValueKind.String
? sev.GetString()
: null;
var type = issue.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString()
: null;
health.ConfigurationIssues.Add(new StreamConfigurationIssue
{
Severity = severity switch
{
"error" => StreamIssueSeverity.Error,
"warning" => StreamIssueSeverity.Warning,
_ => StreamIssueSeverity.Info,
},
Type = type,
});
}
return health;
}
+1 -1
View File
@@ -6,7 +6,7 @@ External-facing logic: YouTube API, persistence. See
| File | Purpose |
|------|---------|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) |
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
+6 -2
View File
@@ -208,7 +208,7 @@ The AI hallucinated through multiple commits that night on background/scene prop
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). Sign-out is **explicit only** (Logout / Change Account → `SignOutYouTubeAsync`): `StopStream()` does NOT clear the session — TASK 18's 2026-08-29 reversal ("stopping a recording leaves the creator signed in") — the older "graceful End signs out" line here was stale and is corrected against the code (2026-09-01). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state persists (LayoutStore Settings table — `LicenseKey`/`LicenseValidatedAt`/`IsPremium`, 14-day offline grace)
- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; banner decision in pure `StreamHealthReporter`
- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; the real API nests them under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the parse accepts that object shape and the legacy flat-string shape (fix 2026-09-22: the object shape crashed every poll with `requires an element of type 'String'`); banner decision in pure `StreamHealthReporter`
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED**, **frame-pipeline wiring (TASK 4 ship step 5) is SHIPPED**, **health stats (TASK 4 ship step 6) is SHIPPED**, **one-click go-live + private-only enforcement (TASK 4 ship step 7) is SHIPPED** — TASK 4 (RTMP Ingest) is fully done; full plan in `TASKS.md`
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
- **v1 task queue (2026-08-19):** tasks 19-23 added for v1 feature completeness; TASK 3.18 (chat box) shipped; TASK 16 (infinity display) shipped; TASK 10 (Polar billing) shipped — steps 1-7 (Velopack bootstrap, PolarLicenseService, license entry UI, LayoutStore entitlement, BrandFlash toggle, startup re-validation, PremiumUrl wired); Velopack update URL pending:
@@ -1520,7 +1520,11 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure
`Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls
every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and
clears on End via `ResetHealth`; poll failures log only. UI = a full-width banner strip under the
clears on End via `ResetHealth`; poll failures log only. **Parsing (fix 2026-09-22):** the real API
nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — reading it as
a string crashed every poll (`requires an element of type 'String'`, seen 2026-09-22 on both test
sessions); the parse now reads the nested `.status`/`configurationIssues` and tolerates the old
flat-string shape. UI = a full-width banner strip under the
top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red
`#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot
(clickable → dialog) is still queued.
+14 -8
View File
@@ -159,17 +159,22 @@ public class YouTubeStreamServiceTests
Assert.Empty(handler.Requests); // never reached the API
}
// TASK 5 item 3: liveStreams.status — healthStatus + configurationIssues[]
// parsed into the report-by-exception surface.
// TASK 5 item 3: liveStreams.status — status.healthStatus = {status,
// configurationIssues[]} (the REAL API shape — nested object, not a string;
// the flat-string shape was our own wrong fixture) parsed into the
// report-by-exception surface. This fixture is what a live test/real stream
// actually returns — it used to crash the poll with
// "requires an element of type 'String'" (2026-09-22 log, two sessions).
[Fact]
public async Task GetStreamHealthAsync_Parses_Status_And_Issue_Severities()
{
var handler = new RecordingHandler
{
ResponseBody = """
{"items":[{"status":{"healthStatus":"bad","configurationIssues":[
{"items":[{"status":{"healthStatus":{"status":"bad","lastUpdateTimeSeconds":1748000000,
"configurationIssues":[
{"type":"bitrateHigh","severity":"error"},
{"type":"reconnect","severity":"warning"}]}}]}
{"type":"reconnect","severity":"warning"}]}}}]}
"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
@@ -188,7 +193,7 @@ public class YouTubeStreamServiceTests
[Fact]
public async Task GetStreamHealthAsync_Good_Health_Yields_No_Issues()
{
var handler = new RecordingHandler { ResponseBody = """{"items":[{"status":{"healthStatus":"good"}}]}""" };
var handler = new RecordingHandler { ResponseBody = """{"items":[{"status":{"healthStatus":{"status":"good"}}}]}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
@@ -218,7 +223,7 @@ public class YouTubeStreamServiceTests
{
var handler = new RecordingHandler
{
ResponseBody = """{"items":[{"status":{"healthStatus":"good"}}]}"""
ResponseBody = """{"items":[{"status":{"healthStatus":{"status":"good"}}}]}"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
@@ -228,8 +233,9 @@ public class YouTubeStreamServiceTests
Assert.Null(StreamHealthReporter.BannerFor(healthy!.ConfigurationIssues).Text);
handler.ResponseBody = """
{"items":[{"status":{"healthStatus":"bad","configurationIssues":[
{"type":"bitrateHigh","severity":"error"}]}}]}
{"items":[{"status":{"healthStatus":{"status":"bad",
"configurationIssues":[
{"type":"bitrateHigh","severity":"error"}]}}}]}
""";
var degraded = await service.GetStreamHealthAsync("S456");
Assert.Equal("bad", degraded?.HealthStatus);