diff --git a/docs/tv-player.md b/docs/tv-player.md index 300c10f..7ed6316 100644 --- a/docs/tv-player.md +++ b/docs/tv-player.md @@ -45,8 +45,10 @@ once eight seconds are decoded while the remaining headroom is fetched; ordinary track changes do not reload the player. A waiting decoder may nudge across at most two seconds of already-buffered empty time, only at the end of the preceding range; it never jumps directly over the older programme to a -newer buffered range. Transient playlist 404/429/503 responses and network errors use at most four -retries within the same ten-second request budget, and Stop cancels the backoff. It cannot recover +newer buffered range. Transient playlist 404/429/503 responses, network errors and attempts that hang +for four seconds use at most four retries with bounded backoff, about thirty +seconds in the worst case, so one stalled read cannot reset a buffered engine. +Stop cancels the attempt and the backoff. It cannot recover quality already lost during preparation or compensate for an upload that stays slower than the encoded programme bitrate. diff --git a/tests/tv-playlist.test.mjs b/tests/tv-playlist.test.mjs index 228a891..67afae0 100644 --- a/tests/tv-playlist.test.mjs +++ b/tests/tv-playlist.test.mjs @@ -63,6 +63,21 @@ test('a transient playlist update retries within the existing request budget',as globalThis.fetch=async()=>++calls===1?new Response('Busy',{status:503}):new Response(master); try{assert.equal(await engine.text('http://127.0.0.1/media/index.m3u8'),master);assert.equal(calls,2);}finally{engine.destroy();globalThis.fetch=original;} }); +test('a hung playlist attempt is retried instead of resetting the engine',async()=>{ + const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine(); + globalThis.fetch=async(_url,{signal})=>++calls===1?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):new Response(master); + try{assert.equal(await engine.text('http://127.0.0.1/media/index.m3u8'),master);assert.equal(calls,2);}finally{engine.destroy();globalThis.fetch=original;} +}); +test('a busy playlist followed by a slow reply still succeeds',async()=>{ + const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine(); + globalThis.fetch=async()=>++calls===1?new Response('Busy',{status:503}):new Promise(resolve=>setTimeout(()=>resolve(new Response(master)),3000)); + try{assert.equal(await engine.text('http://127.0.0.1/media/index.m3u8'),master);assert.equal(calls,2);}finally{engine.destroy();globalThis.fetch=original;} +}); +test('Stop during a hung playlist attempt ends the read without retrying',async()=>{ + const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine(); + globalThis.fetch=async(_url,{signal})=>{calls++;setTimeout(()=>engine.destroy(),50);return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));}; + try{await assert.rejects(()=>engine.text('http://127.0.0.1/media/index.m3u8'));assert.equal(calls,1);}finally{engine.destroy();globalThis.fetch=original;} +}); test('Stop during playlist backoff prevents another request',async()=>{ const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine(); globalThis.fetch=async()=>{calls++;queueMicrotask(()=>engine.destroy());return new Response('Busy',{status:503});}; diff --git a/web/tv/cmaf-player.mjs b/web/tv/cmaf-player.mjs index 757ad2e..d70a7d3 100644 --- a/web/tv/cmaf-player.mjs +++ b/web/tv/cmaf-player.mjs @@ -10,14 +10,16 @@ export async function boundedBytes(response,limit) { const output=new Uint8Array(size);let offset=0;for(const chunk of chunks){output.set(chunk,offset);offset+=chunk.length;}return output; } // Retry complete reads after a route change; never append partial media. -async function retryBytes(url,{signal,limit,retry404=false,onAttempt=()=>{},onResponse=()=>{}}){ +async function retryBytes(url,{signal,limit,retry404=false,attemptMs=0,onAttempt=()=>{},onResponse=()=>{}}){ for(let attempt=0;attempt<5;attempt++){ signal.throwIfAborted();onAttempt(attempt); - try{const response=await fetch(url,{signal,redirect:'error',cache:'no-store',credentials:'omit'});onResponse(response); + // A hung attempt is retried; only the caller's signal ends the read. + const attemptSignal=attemptMs?AbortSignal.any([signal,AbortSignal.timeout(attemptMs)]):signal; + try{const response=await fetch(url,{signal:attemptSignal,redirect:'error',cache:'no-store',credentials:'omit'});onResponse(response); if(!response.ok){await response.body?.cancel();throw Object.assign(Error(`Media HTTP ${response.status}`),{status:response.status});} return await boundedBytes(response,limit); }catch(error){ - if(signal.aborted||attempt===4||!(error instanceof TypeError||[429,503,...(retry404?[404]:[])].includes(error.status)))throw error; + if(signal.aborted||attempt===4||!(error instanceof TypeError||error?.name==='TimeoutError'||[429,503,...(retry404?[404]:[])].includes(error.status)))throw error; await new Promise(resolve=>{const done=()=>{clearTimeout(timer);signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,[500,1500,3000,5000][attempt]);signal.addEventListener('abort',done,{once:true});if(signal.aborted)done();}); } } @@ -77,8 +79,8 @@ export default class CmafPlayer { get liveSyncPosition(){return this.offset===null||this.edge===null?NaN:this.offset+this.edge-this.config.liveSyncDuration;} get latency(){return this.offset===null||this.edge===null?0:Math.max(0,this.offset+this.edge-this.media.currentTime);} async text(url){ - const signal=AbortSignal.any([this.controller.signal,AbortSignal.timeout(10000)]); - return new TextDecoder('ascii',{fatal:true}).decode(await retryBytes(url,{signal,limit:65536,retry404:true})); + // Five 4-second attempts plus backoff stay inside the 20-30 second buffer. + return new TextDecoder('ascii',{fatal:true}).decode(await retryBytes(url,{signal:this.controller.signal,limit:65536,retry404:true,attemptMs:4000})); } begin(){if(this.started||!this.media||!this.url)return;this.started=true;void this.run().catch(error=>{if(!this.controller.signal.aborted)this.trigger(CmafPlayer.Events.ERROR,{fatal:true,details:error.message});});} async append(bytes){if(this.buffer.updating)await waitEvent(this.buffer,'updateend',this.controller.signal);await waitEvent(this.buffer,'updateend',this.controller.signal,()=>this.buffer.appendBuffer(bytes));} diff --git a/web/tv/version.mjs b/web/tv/version.mjs index 2308127..c377506 100644 --- a/web/tv/version.mjs +++ b/web/tv/version.mjs @@ -1,3 +1,3 @@ -export const PLAYER_VERSION='2026.10.27.7'; +export const PLAYER_VERSION='2026.10.27.8'; export function newerPlayerVersion(value){return typeof value==='string'&&/^[0-9.]{1,32}$/.test(value)&&value.localeCompare(PLAYER_VERSION,'en',{numeric:true})>0;}