diff --git a/compose.yaml b/compose.yaml index 1395c3c3..318a2d17 100644 --- a/compose.yaml +++ b/compose.yaml @@ -170,8 +170,13 @@ services: restart: unless-stopped kokoro: - image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1 # PIN — do not use latest + image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0 # PIN — do not use latest networks: [core] # internal only; control port 8880 NEVER published + # kokoro-fastapi leaks RSS under load (upstream #453 — root cause fixed after v0.2.x; #262 + # still open). Fresh baseline is ~1.2GiB; this cap is the fail-closed backstop: leak past it + # and the kernel OOM-kills the container, `restart: unless-stopped` brings it back clean in + # seconds. Blast radius is one failed TTS render — the broadcast keeps playing music. + mem_limit: 3g healthcheck: test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/8880'"] interval: 5s diff --git a/src/GenWave.Tts/KokoroVoiceLister.cs b/src/GenWave.Tts/KokoroVoiceLister.cs index fd5a2b41..7040dd15 100644 --- a/src/GenWave.Tts/KokoroVoiceLister.cs +++ b/src/GenWave.Tts/KokoroVoiceLister.cs @@ -24,6 +24,6 @@ public async Task> ListVoicesAsync(CancellationToken ct) response.EnsureSuccessStatusCode(); // throws HttpRequestException on non-2xx var payload = await response.Content.ReadFromJsonAsync(ct); - return payload?.Voices ?? []; + return payload?.VoiceIds() ?? []; } } diff --git a/src/GenWave.Tts/KokoroVoicesResponse.cs b/src/GenWave.Tts/KokoroVoicesResponse.cs index c2a965c7..c201ecf9 100644 --- a/src/GenWave.Tts/KokoroVoicesResponse.cs +++ b/src/GenWave.Tts/KokoroVoicesResponse.cs @@ -1,10 +1,40 @@ namespace GenWave.Tts; +using System.Text.Json; using System.Text.Json.Serialization; /// -/// Wire shape of Kokoro's GET /v1/audio/voices response: { "voices": [...] } -/// (confirmed against ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1, R2). Internal — callers only -/// ever see the flattened id list via . +/// Wire shape of Kokoro's GET /v1/audio/voices response: { "voices": [...] }, where +/// each entry is a bare id string (kokoro-fastapi ≤ v0.2.x) or an { "id": ..., "name": ... } +/// object (v0.6.0, confirmed against ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0). Entries are +/// held as raw s so one record deserializes both generations — +/// Tts:Endpoint is operator-repointable at runtime (F36.4), so the wire shape on the other +/// end is not fixed at build time. Internal — callers only ever see the flattened id list via +/// . /// -sealed record KokoroVoicesResponse([property: JsonPropertyName("voices")] List? Voices); +sealed record KokoroVoicesResponse([property: JsonPropertyName("voices")] List? Voices) +{ + public IReadOnlyList VoiceIds() + { + if (Voices is null) + return []; + + var ids = new List(Voices.Count); + foreach (var entry in Voices) + { + var id = entry.ValueKind switch + { + JsonValueKind.String => entry.GetString(), + JsonValueKind.Object when entry.TryGetProperty("id", out var idProperty) + && idProperty.ValueKind == JsonValueKind.String + => idProperty.GetString(), + _ => null, + }; + + if (!string.IsNullOrEmpty(id)) + ids.Add(id); + } + + return ids; + } +} diff --git a/tests/GenWave.Host.Tests/Specs/Story141_AcceptanceGateRotationConsole.cs b/tests/GenWave.Host.Tests/Specs/Story141_AcceptanceGateRotationConsole.cs index 82f2cba7..99b29de9 100644 --- a/tests/GenWave.Host.Tests/Specs/Story141_AcceptanceGateRotationConsole.cs +++ b/tests/GenWave.Host.Tests/Specs/Story141_AcceptanceGateRotationConsole.cs @@ -66,8 +66,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain // STORY-179): the api service gained Icecast__StatsUrl/Icecast__AdminPassword env vars for // the spectator listener-count poll — another intentional edit from a LATER epic, still not // a regression of Epic V's own F41–F45 zero-diff promise. EngineScriptSha256 unchanged. + // ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to + // kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak + // (remsky/Kokoro-FastAPI#453) — an intentional ops edit from outside this epic, not a + // regression of its zero-diff promise. EngineScriptSha256 is untouched. + // const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6"; - const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446"; + const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25"; [Fact] public void EngineScriptByteMatchesMain() diff --git a/tests/GenWave.Host.Tests/Specs/Story147_AcceptanceGateEnrichment2.cs b/tests/GenWave.Host.Tests/Specs/Story147_AcceptanceGateEnrichment2.cs index b3df9758..80f5a9e4 100644 --- a/tests/GenWave.Host.Tests/Specs/Story147_AcceptanceGateEnrichment2.cs +++ b/tests/GenWave.Host.Tests/Specs/Story147_AcceptanceGateEnrichment2.cs @@ -59,8 +59,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain // STORY-179): the api service gained Icecast__StatsUrl/Icecast__AdminPassword env vars for // the spectator listener-count poll — another intentional edit from a LATER epic, not a // regression of F46–F51 touching neither file. EngineScriptSha256 unchanged. + // ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to + // kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak + // (remsky/Kokoro-FastAPI#453) — an intentional ops edit from outside this epic, not a + // regression of its zero-diff promise. EngineScriptSha256 is untouched. + // const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6"; - const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446"; + const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25"; [Fact] public void EngineScriptByteMatchesMain() diff --git a/tests/GenWave.Host.Tests/Specs/Story153_AcceptanceGateCurationSettings.cs b/tests/GenWave.Host.Tests/Specs/Story153_AcceptanceGateCurationSettings.cs index accd9138..8d04d1a2 100644 --- a/tests/GenWave.Host.Tests/Specs/Story153_AcceptanceGateCurationSettings.cs +++ b/tests/GenWave.Host.Tests/Specs/Story153_AcceptanceGateCurationSettings.cs @@ -69,8 +69,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain // STORY-179): the api service gained Icecast__StatsUrl/Icecast__AdminPassword env vars for // the spectator listener-count poll — another intentional edit from a LATER epic, not a // regression of F52–F56 touching neither file. EngineScriptSha256 unchanged. + // ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to + // kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak + // (remsky/Kokoro-FastAPI#453) — an intentional ops edit from outside this epic, not a + // regression of its zero-diff promise. EngineScriptSha256 is untouched. + // const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6"; - const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446"; + const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25"; [Fact] public void EngineScriptByteMatchesMain() diff --git a/tests/GenWave.Host.Tests/Specs/Story162_AcceptanceGateRankingRobustness.cs b/tests/GenWave.Host.Tests/Specs/Story162_AcceptanceGateRankingRobustness.cs index 3f48db2f..8d4fc457 100644 --- a/tests/GenWave.Host.Tests/Specs/Story162_AcceptanceGateRankingRobustness.cs +++ b/tests/GenWave.Host.Tests/Specs/Story162_AcceptanceGateRankingRobustness.cs @@ -73,8 +73,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain // STORY-179): a third intentional edit that DOES touch compose.yaml — the api service // gained Icecast__StatsUrl/Icecast__AdminPassword env vars for the spectator listener-count // poll. EngineScriptSha256 unchanged — T21 does not touch engine/genwave.liq. + // ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to + // kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak + // (remsky/Kokoro-FastAPI#453) — an intentional ops edit from outside this epic, not a + // regression of its zero-diff promise. EngineScriptSha256 is untouched. + // const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6"; - const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446"; + const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25"; [Fact] public void EngineScriptByteMatchesMain() diff --git a/tests/GenWave.Host.Tests/kokoro-compose.yaml b/tests/GenWave.Host.Tests/kokoro-compose.yaml index 7fa74c95..335a7e82 100644 --- a/tests/GenWave.Host.Tests/kokoro-compose.yaml +++ b/tests/GenWave.Host.Tests/kokoro-compose.yaml @@ -5,7 +5,7 @@ name: genwave-kokorotest services: kokoro: - image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1 + image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0 ports: - "127.0.0.1:18880:8880" healthcheck: diff --git a/tests/GenWave.Tts.Tests/Specs/Story097_KokoroVoicesWireShapes.cs b/tests/GenWave.Tts.Tests/Specs/Story097_KokoroVoicesWireShapes.cs new file mode 100644 index 00000000..25fadc45 --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Story097_KokoroVoicesWireShapes.cs @@ -0,0 +1,63 @@ +// STORY-097 — KokoroVoicesResponse deserializes both generations of the voices wire shape +// +// BDD specification — xUnit. kokoro-fastapi ≤ v0.2.x served GET /v1/audio/voices as +// { "voices": ["af_heart", ...] }; v0.6.0 serves { "voices": [{ "id": "af_heart", +// "name": "af_heart" }, ...] } (captured from the real ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0 +// container during the image bump). Tts:Endpoint is operator-repointable at runtime (F36.4), so +// the client must flatten EITHER shape to the same id list rather than assume the pinned image's. + +namespace GenWave.Tts.Tests.Specs; + +using System.Text.Json; + +public static class FeatureKokoroVoicesWireShapes +{ + static KokoroVoicesResponse Parse(string json) + { + var parsed = JsonSerializer.Deserialize(json); + Assert.NotNull(parsed); + return parsed; + } + + public sealed class ScenarioLegacyStringArrayShape + { + // The ≤ v0.2.x shape — the one KokoroStubServer and every shipped repoint spec emulate. + readonly IReadOnlyList ids = + Parse("""{"voices":["af_heart","am_adam","zm_yunyang"]}""").VoiceIds(); + + [Fact] + public void YieldsEveryIdInOrder() => + Assert.Equal(["af_heart", "am_adam", "zm_yunyang"], ids); + } + + public sealed class ScenarioObjectShape + { + // The v0.6.0 shape, verbatim from the real container's response. + readonly IReadOnlyList ids = + Parse("""{"voices":[{"id":"af_alloy","name":"af_alloy"},{"id":"af_heart","name":"af_heart"}]}""") + .VoiceIds(); + + [Fact] + public void YieldsTheIdOfEveryObjectInOrder() => + Assert.Equal(["af_alloy", "af_heart"], ids); + } + + public sealed class ScenarioDegenerateEntries + { + [Fact] + public void ANullVoicesPropertyYieldsAnEmptyList() => + Assert.Empty(Parse("""{"voices":null}""").VoiceIds()); + + [Fact] + public void AMissingVoicesPropertyYieldsAnEmptyList() => + Assert.Empty(Parse("""{}""").VoiceIds()); + + [Fact] + public void AnEntryOfAnUnrecognizedKindIsSkippedNotThrown() => + Assert.Equal(["af_heart"], Parse("""{"voices":[42,{"name":"no-id"},"af_heart"]}""").VoiceIds()); + + [Fact] + public void AnEmptyStringEntryIsSkipped() => + Assert.Empty(Parse("""{"voices":[""]}""").VoiceIds()); + } +}