@@ -4449,6 +4449,78 @@ describe("XtermHost", () => {
44494449 } ) ;
44504450 } ) ;
44514451
4452+ it ( "replays terminal history when websocket recovery succeeds without a status transition" , async ( ) => {
4453+ const store = createStore ( ) ;
4454+ const initialReplayChunk = new TextEncoder ( ) . encode ( "initial replay\n" ) ;
4455+ const recoveredReplayChunk = new TextEncoder ( ) . encode ( "recovered after probe\n" ) ;
4456+ let recoveryHandler :
4457+ | ( ( trigger : "visibility_resume" | "network_online" | "manual_retry" | "reconnected" ) => void )
4458+ | undefined ;
4459+ let replayCount = 0 ;
4460+ const sendCommand = vi
4461+ . fn ( )
4462+ . mockImplementation ( ( op : string , args : { terminalId ?: string ; lastSeq ?: number } ) => {
4463+ if ( op !== "terminal.replay" ) {
4464+ return Promise . resolve ( { status : "ok" } ) ;
4465+ }
4466+
4467+ replayCount += 1 ;
4468+ if ( replayCount === 1 ) {
4469+ expect ( args . lastSeq ) . toBe ( 0 ) ;
4470+ return Promise . resolve ( {
4471+ status : "ok" ,
4472+ transport : "binary" ,
4473+ streamId : 980 ,
4474+ size : initialReplayChunk . byteLength ,
4475+ seq : 100 ,
4476+ bytes : initialReplayChunk ,
4477+ } satisfies TerminalReplayPayload ) ;
4478+ }
4479+
4480+ expect ( args . lastSeq ) . toBe ( 100 ) ;
4481+ return Promise . resolve ( {
4482+ status : "ok" ,
4483+ transport : "binary" ,
4484+ streamId : 981 ,
4485+ size : recoveredReplayChunk . byteLength ,
4486+ seq : 126 ,
4487+ bytes : recoveredReplayChunk ,
4488+ } satisfies TerminalReplayPayload ) ;
4489+ } ) ;
4490+
4491+ store . set ( wsClientAtom , {
4492+ sendCommand,
4493+ subscribe : vi . fn ( ( ) => vi . fn ( ) ) ,
4494+ getStatus : vi . fn ( ( ) => "connected" ) ,
4495+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
4496+ onRecovery : vi . fn ( ( handler : typeof recoveryHandler ) => {
4497+ recoveryHandler = handler ;
4498+ return ( ) => { } ;
4499+ } ) ,
4500+ } as never ) ;
4501+
4502+ render (
4503+ < Provider store = { store } >
4504+ < XtermHost terminalId = "probe-recovery-terminal" workspaceId = "test-workspace" />
4505+ </ Provider >
4506+ ) ;
4507+
4508+ await waitFor ( ( ) => {
4509+ expectTerminalWriteData ( initialReplayChunk ) ;
4510+ } ) ;
4511+
4512+ await act ( async ( ) => {
4513+ recoveryHandler ?.( "network_online" ) ;
4514+ await Promise . resolve ( ) ;
4515+ await Promise . resolve ( ) ;
4516+ } ) ;
4517+
4518+ await waitFor ( ( ) => {
4519+ expect ( replayCount ) . toBe ( 2 ) ;
4520+ expectTerminalWriteData ( recoveredReplayChunk ) ;
4521+ } ) ;
4522+ } ) ;
4523+
44524524 it ( "waits for reconnect replay bytes to finish rendering before starting another reconnect recovery" , async ( ) => {
44534525 const store = createStore ( ) ;
44544526 const initialReplayChunk = new TextEncoder ( ) . encode ( "initial replay\n" ) ;
@@ -4573,6 +4645,125 @@ describe("XtermHost", () => {
45734645 } ) ;
45744646 } ) ;
45754647
4648+ it ( "queues probe-triggered recovery that arrives while historical recovery writes are still in flight" , async ( ) => {
4649+ const store = createStore ( ) ;
4650+ const initialReplayChunk = new TextEncoder ( ) . encode ( "initial replay\n" ) ;
4651+ const delayedReconnectReplay = new TextEncoder ( ) . encode ( "delayed reconnect replay\n" ) ;
4652+ const queuedProbeReplay = new TextEncoder ( ) . encode ( "queued probe recovery\n" ) ;
4653+ let recoveryHandler :
4654+ | ( ( trigger : "visibility_resume" | "network_online" | "manual_retry" | "reconnected" ) => void )
4655+ | undefined ;
4656+ let replayCount = 0 ;
4657+ let releaseDelayedReconnectWrite : ( ( ) => void ) | undefined ;
4658+
4659+ mockTerminal . write . mockImplementation ( ( data : Uint8Array | string , callback ?: ( ) => void ) => {
4660+ if ( data === delayedReconnectReplay ) {
4661+ releaseDelayedReconnectWrite = callback ;
4662+ return ;
4663+ }
4664+ callback ?.( ) ;
4665+ } ) ;
4666+
4667+ const sendCommand = vi
4668+ . fn ( )
4669+ . mockImplementation ( ( op : string , args : { terminalId ?: string ; lastSeq ?: number } ) => {
4670+ if ( op !== "terminal.replay" ) {
4671+ return Promise . resolve ( { status : "ok" } ) ;
4672+ }
4673+
4674+ replayCount += 1 ;
4675+ if ( replayCount === 1 ) {
4676+ expect ( args . lastSeq ) . toBe ( 0 ) ;
4677+ return Promise . resolve ( {
4678+ status : "ok" ,
4679+ transport : "binary" ,
4680+ streamId : 982 ,
4681+ size : initialReplayChunk . byteLength ,
4682+ seq : 100 ,
4683+ bytes : initialReplayChunk ,
4684+ } satisfies TerminalReplayPayload ) ;
4685+ }
4686+
4687+ if ( replayCount === 2 ) {
4688+ expect ( args . lastSeq ) . toBe ( 100 ) ;
4689+ return Promise . resolve ( {
4690+ status : "ok" ,
4691+ transport : "binary" ,
4692+ streamId : 983 ,
4693+ size : delayedReconnectReplay . byteLength ,
4694+ seq : 200 ,
4695+ bytes : delayedReconnectReplay ,
4696+ } satisfies TerminalReplayPayload ) ;
4697+ }
4698+
4699+ expect ( args . lastSeq ) . toBe ( 200 ) ;
4700+ return Promise . resolve ( {
4701+ status : "ok" ,
4702+ transport : "binary" ,
4703+ streamId : 984 ,
4704+ size : queuedProbeReplay . byteLength ,
4705+ seq : 240 ,
4706+ bytes : queuedProbeReplay ,
4707+ } satisfies TerminalReplayPayload ) ;
4708+ } ) ;
4709+
4710+ mockTerminal . cols = 132 ;
4711+ mockTerminal . rows = 36 ;
4712+
4713+ store . set ( wsClientAtom , {
4714+ sendCommand,
4715+ subscribe : vi . fn ( ( ) => vi . fn ( ) ) ,
4716+ getStatus : vi . fn ( ( ) => "connected" ) ,
4717+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
4718+ onRecovery : vi . fn ( ( handler : typeof recoveryHandler ) => {
4719+ recoveryHandler = handler ;
4720+ return ( ) => { } ;
4721+ } ) ,
4722+ } as never ) ;
4723+
4724+ render (
4725+ < Provider store = { store } >
4726+ < XtermHost terminalId = "queued-probe-recovery-terminal" workspaceId = "test-workspace" />
4727+ </ Provider >
4728+ ) ;
4729+
4730+ await waitFor ( ( ) => {
4731+ expectTerminalWriteData ( initialReplayChunk ) ;
4732+ } ) ;
4733+
4734+ await act ( async ( ) => {
4735+ recoveryHandler ?.( "network_online" ) ;
4736+ await Promise . resolve ( ) ;
4737+ await Promise . resolve ( ) ;
4738+ } ) ;
4739+
4740+ await waitFor ( ( ) => {
4741+ expect ( replayCount ) . toBe ( 2 ) ;
4742+ expectTerminalWriteData ( delayedReconnectReplay ) ;
4743+ } ) ;
4744+
4745+ expect ( typeof releaseDelayedReconnectWrite ) . toBe ( "function" ) ;
4746+
4747+ await act ( async ( ) => {
4748+ recoveryHandler ?.( "visibility_resume" ) ;
4749+ await Promise . resolve ( ) ;
4750+ await Promise . resolve ( ) ;
4751+ } ) ;
4752+
4753+ expect ( replayCount ) . toBe ( 2 ) ;
4754+
4755+ await act ( async ( ) => {
4756+ releaseDelayedReconnectWrite ?.( ) ;
4757+ await Promise . resolve ( ) ;
4758+ await Promise . resolve ( ) ;
4759+ } ) ;
4760+
4761+ await waitFor ( ( ) => {
4762+ expect ( replayCount ) . toBe ( 3 ) ;
4763+ expectTerminalWriteData ( queuedProbeReplay ) ;
4764+ } ) ;
4765+ } ) ;
4766+
45764767 it ( "uses the flushed pending chunk seq for a later reconnect after a successful recovery" , async ( ) => {
45774768 const store = createStore ( ) ;
45784769 const initialReplayChunk = new TextEncoder ( ) . encode ( "initial replay\n" ) ;
0 commit comments