@@ -380,6 +380,116 @@ describe("GatewayRelayRealtimeTalkTransport", () => {
380380 expect ( onInputLevel ) . toHaveBeenLastCalledWith ( 0 ) ;
381381 } ) ;
382382
383+ it ( "bounds stalled microphone appends and aborts every owner on stop" , async ( ) => {
384+ const onStatus = vi . fn ( ) ;
385+ const client = createClient ( ) ;
386+ let activeAppends = 0 ;
387+ let peakActiveAppends = 0 ;
388+ const appendSignals : AbortSignal [ ] = [ ] ;
389+ vi . mocked ( client [ "request" ] ) . mockImplementation ( ( method , _params , options ) => {
390+ if ( method !== "talk.session.appendAudio" ) {
391+ return Promise . resolve ( { } ) ;
392+ }
393+ const signal = options ?. signal ;
394+ if ( ! signal ) {
395+ return Promise . reject ( new Error ( "missing append abort signal" ) ) ;
396+ }
397+ appendSignals . push ( signal ) ;
398+ activeAppends += 1 ;
399+ peakActiveAppends = Math . max ( peakActiveAppends , activeAppends ) ;
400+ return new Promise ( ( _ , reject ) => {
401+ signal . addEventListener (
402+ "abort" ,
403+ ( ) => {
404+ activeAppends -= 1 ;
405+ reject ( new Error ( "append aborted" ) ) ;
406+ } ,
407+ { once : true } ,
408+ ) ;
409+ } ) ;
410+ } ) ;
411+ const transport = createTransport ( { callbacks : { onStatus } , client } ) ;
412+
413+ await transport . start ( ) ;
414+ const samples = new Float32Array ( 4096 ) ;
415+ for ( let index = 0 ; index < 10_000 ; index += 1 ) {
416+ pumpMicrophone ( samples ) ;
417+ }
418+
419+ const appendCalls = requestCallsFor ( client , "talk.session.appendAudio" ) ;
420+ expect ( appendCalls ) . toHaveLength ( 4 ) ;
421+ expect ( peakActiveAppends ) . toBe ( 4 ) ;
422+ expect ( activeAppends ) . toBe ( 4 ) ;
423+ expect ( new Set ( appendSignals ) . size ) . toBe ( 1 ) ;
424+ expect (
425+ appendCalls . every (
426+ ( call ) => call [ 2 ] ?. signal === appendSignals [ 0 ] && call [ 2 ] ?. timeoutMs === 8_000 ,
427+ ) ,
428+ ) . toBe ( true ) ;
429+
430+ transport . stop ( ) ;
431+ transport . stop ( ) ;
432+ await Promise . resolve ( ) ;
433+
434+ expect ( activeAppends ) . toBe ( 0 ) ;
435+ expect ( appendSignals . every ( ( signal ) => signal . aborted ) ) . toBe ( true ) ;
436+ expect ( requestCallsFor ( client , "talk.session.close" ) ) . toHaveLength ( 1 ) ;
437+ expect ( onStatus ) . not . toHaveBeenCalled ( ) ;
438+ } ) ;
439+
440+ it ( "preserves accepted microphone frame order" , async ( ) => {
441+ const client = createClient ( ) ;
442+ const transport = createTransport ( { client } ) ;
443+
444+ await transport . start ( ) ;
445+ for ( const timestamp of [ 10 , 20 , 30 , 40 ] ) {
446+ audioCurrentTime = timestamp / 1_000 ;
447+ pumpMicrophone ( new Float32Array ( 4096 ) ) ;
448+ }
449+
450+ expect (
451+ requestCallsFor ( client , "talk.session.appendAudio" ) . map (
452+ ( call ) => ( call [ 1 ] as { timestamp : number } ) . timestamp ,
453+ ) ,
454+ ) . toEqual ( [ 10 , 20 , 30 , 40 ] ) ;
455+ transport . stop ( ) ;
456+ } ) ;
457+
458+ it ( "ignores a stale append rejection after a replacement starts" , async ( ) => {
459+ const oldStatus = vi . fn ( ) ;
460+ const oldClient = createClient ( ) ;
461+ let rejectOldAppend : ( error : Error ) => void = ( ) => undefined ;
462+ vi . mocked ( oldClient [ "request" ] ) . mockImplementation ( ( method ) => {
463+ if ( method !== "talk.session.appendAudio" ) {
464+ return Promise . resolve ( { } ) ;
465+ }
466+ return new Promise ( ( _ , reject ) => {
467+ rejectOldAppend = reject ;
468+ } ) ;
469+ } ) ;
470+ const oldTransport = createTransport ( { callbacks : { onStatus : oldStatus } , client : oldClient } ) ;
471+
472+ await oldTransport . start ( ) ;
473+ pumpMicrophone ( new Float32Array ( 4096 ) ) ;
474+ oldTransport . stop ( ) ;
475+
476+ const replacementStatus = vi . fn ( ) ;
477+ const replacementClient = createClient ( ) ;
478+ const replacement = createTransport ( {
479+ callbacks : { onStatus : replacementStatus } ,
480+ client : replacementClient ,
481+ } ) ;
482+ await replacement . start ( ) ;
483+ pumpMicrophone ( new Float32Array ( 4096 ) ) ;
484+ rejectOldAppend ( new Error ( "late stale append failure" ) ) ;
485+ await Promise . resolve ( ) ;
486+
487+ expect ( requestCallsFor ( replacementClient , "talk.session.appendAudio" ) ) . toHaveLength ( 1 ) ;
488+ expect ( oldStatus ) . not . toHaveBeenCalled ( ) ;
489+ expect ( replacementStatus ) . not . toHaveBeenCalled ( ) ;
490+ replacement . stop ( ) ;
491+ } ) ;
492+
383493 it ( "stops microphone pumping when the relay rejects appended audio" , async ( ) => {
384494 const onStatus = vi . fn ( ) ;
385495 const client = createClient ( ) ;
0 commit comments