feat: close STREAMING.md production readiness gaps
- 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:
1 parent
f6ca89747e
commit
18459a9811
12 files changed
+699
-48
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user