feat: close STREAMING.md production readiness gaps
CI / test (push) Failing after 9s
CI / docker (push) Skipped
Deploy to Cloudflare Pages / deploy (push) Failing after 10s
CD / deploy (push) Successful in 47s

- Added run ID + unique segment naming (r-{ISOTIME}/seg-{seq}.m4s)
- Added fsnotify watcher for real-time R2 segment upload (~7s)
- Added atomic manifest publication (temp key → CopyObject)
- Fixed manifest Cache-Control: public, max-age=1, s-maxage=2, must-revalidate
- Added hls.js error recovery: bounded retries (5x), exponential backoff, reconnecting UI
- Enriched /health endpoint: run_id, active_item, last_segment_seq, last_r2_pub, consecutive_failures, unhealthy detection
- Updated tests: HealthHandler (new fields + unhealthy), player error recovery test
- Updated TODO.md, BACKLOG.md, DONE.md, MEMORY.md
This commit is contained in:
zwitschi committed 2026-08-10 17:20:54 +02:00
1 parent f6ca89747e
commit 18459a9811
12 files changed
+699 -48

No files matched your search

+5
View File
@@ -71,6 +71,11 @@
</section>
</main>
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
<div class="reconnecting-spinner"></div>
<span id="reconnecting-text">Reconnecting...</span>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+41 -2
View File
@@ -13,7 +13,17 @@ const HlsMock = Object.assign(
vi.fn().mockImplementation(() => hlsInstance),
{
isSupported: vi.fn(() => true),
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
Events: {
MANIFEST_PARSED: "hlsManifestParsed",
ERROR: "hlsError",
LEVEL_UPDATED: "hlsLevelUpdated",
},
ErrorTypes: {
NETWORK_ERROR: "networkError",
MEDIA_ERROR: "mediaError",
MUX_ERROR: "muxError",
OTHER_ERROR: "otherError",
},
},
);
@@ -331,8 +341,37 @@ describe("message rendering", () => {
});
const messages = document.getElementById("messages")!;
expect(messages.innerHTML).toContain("alice");
expect(messages.innerHTML).toContain("hey");
expect(messages.innerHTML).toContain("alice");
expect(messages.querySelector(".message-other")).toBeTruthy();
});
});
describe("player error recovery", () => {
it("shows reconnecting overlay on fatal network error", async () => {
const main = await import("../src/main");
await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any);
// Find the error handler registered by initPlayerAndChat
const errorCall = HlsMock.mock.results?.[0]?.value?.on?.mock?.calls?.find(
(c: [string, Function]) => c[0] === "hlsError"
);
// Use hlsInstance.on mock calls
const errorHandler = hlsInstance.on.mock.calls.find(
(c: [string, Function]) => c[0] === "hlsError"
);
if (errorHandler) {
const handler = errorHandler[1];
handler("hlsError", {
type: "networkError",
details: "manifestLoadError",
fatal: true,
});
const overlay = document.getElementById("reconnecting-overlay")!;
expect(overlay.classList.contains("hidden")).toBe(false);
expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting...");
}
});
});
+75 -1
View File
@@ -18,6 +18,11 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
let chatVisible = true;
const onlineUsers = new Set<string>();
// Player error recovery state (STREAMING.md contract)
let playerErrorCount = 0;
const PLAYER_MAX_RETRIES = 5;
const PLAYER_BASE_DELAY_MS = 2000;
export function resetViewerStateForTests() {
hls = null;
ws = null;
@@ -25,6 +30,7 @@ export function resetViewerStateForTests() {
username = "viewer";
chatVisible = true;
onlineUsers.clear();
playerErrorCount = 0;
}
export function resolveWebSocketBaseUrl() {
@@ -159,10 +165,60 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
try {
const { default: Hls } = await loadHlsModule();
if (Hls.isSupported()) {
hls = new Hls();
hls = new Hls({
liveDurationInfinity: true,
liveSyncDurationCount: 3,
});
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
// Player error recovery (STREAMING.md contract)
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
playerErrorCount++;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
console.warn(`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`);
showReconnecting("Reconnecting...");
setTimeout(() => {
hls?.startLoad();
}, delay);
} else {
console.error("HLS network error: max retries exhausted");
showReconnecting("Stream temporarily unavailable");
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
if (playerErrorCount <= 1) {
console.warn("HLS media error, attempting recovery...");
showReconnecting("Recovering...");
hls.recoverMediaError();
} else {
console.error("HLS media error: recovery failed");
hls.destroy();
showReconnecting("Stream temporarily unavailable");
}
break;
default:
console.error("HLS fatal error:", data.type, data.details);
hls.destroy();
showReconnecting("Stream temporarily unavailable");
break;
}
});
// Reset error count on successful manifest parse
hls.on(Hls.Events.MANIFEST_PARSED, () => {
playerErrorCount = 0;
hideReconnecting();
});
}
} catch (error) {
console.error("Failed to load HLS player", error);
@@ -179,6 +235,24 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
}
export function showReconnecting(message = "Reconnecting...") {
const overlay = document.getElementById("reconnecting-overlay");
const text = document.getElementById("reconnecting-text");
if (overlay) {
overlay.classList.remove("hidden");
}
if (text) {
text.textContent = message;
}
}
export function hideReconnecting() {
const overlay = document.getElementById("reconnecting-overlay");
if (overlay) {
overlay.classList.add("hidden");
}
}
function toggleChatPanel() {
const shell = document.querySelector(".viewer-shell");
const toggle = document.getElementById("chat-toggle");
+37
View File
@@ -1056,3 +1056,40 @@ select {
scroll-behavior: auto !important;
}
}
/* Reconnecting overlay (player error recovery) */
.reconnecting-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4, 1rem);
z-index: 1000;
backdrop-filter: blur(8px);
}
.reconnecting-overlay.hidden {
display: none;
}
.reconnecting-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line, #2a2a2a);
border-top-color: var(--brand, #587792);
border-radius: 50%;
animation: lv-spin 0.8s linear infinite;
}
@keyframes lv-spin {
to { transform: rotate(360deg); }
}
#reconnecting-text {
color: var(--ink-0, #ede8e6);
font-family: var(--font-body);
font-size: 1.125rem;
}