11import { mkdir , writeFile } from "node:fs/promises" ;
22import { join } from "node:path" ;
3+ import { backendRelay } from "../../../apps/desktop/src/dev/durable-spike/backend-relay.ts" ;
34import { describe , expect , it } from "vitest" ;
45// Pace's backend relay, the hop between a host and Pace's renderer.
56import { createDurableSpikeBridge , type DurableSpikeBridge } from "../../../packages/backend/src/spikes/durable-bridge.ts" ;
@@ -10,6 +11,10 @@ import { connectTo, freePort, startFauxHost, tempDir, useCleanups, waitForView }
1011
1112const defer = useCleanups ( ) ;
1213
14+ function envelope ( type : string , payload : Record < string , unknown > ) {
15+ return { type : "event" as const , event : { id : "evt-1" , seq : 0 , sessionId : "" , piSessionId : "" , type, ts : "2026-10-02T00:00:00Z" , payload } } ;
16+ }
17+
1318/** An agent dir holding a host token where the bridge looks for it. */
1419async function agentDirWithToken ( token : string ) : Promise < string > {
1520 const dir = await tempDir ( ) ;
@@ -36,6 +41,69 @@ async function connectThroughBridge(bridge: DurableSpikeBridge, url: string): Pr
3641}
3742
3843describe ( "Pace backend relay" , ( ) => {
44+ it . each ( [ "lifecycle event" , "lost socket" ] ) ( "recovers a live client after a backend restart (%s) and delivers its next command" , async ( failure ) => {
45+ const host = await startFauxHost ( defer , { answers : [ "after-restart-answer" ] } ) ;
46+ const listeners = new Set < ( event : ReturnType < typeof envelope > ) => void > ( ) ;
47+ const emit = ( event : ReturnType < typeof envelope > ) => {
48+ for ( const listener of listeners ) listener ( event ) ;
49+ } ;
50+ const startBackend = ( ) => {
51+ const bridge = createDurableSpikeBridge ( { agentDir : "/unused" , defaultUrl : host . url } ) ;
52+ const detach = bridge . onEvent ( ( event ) => emit ( envelope ( `durable_spike.${ event . kind } ` , event ) ) ) ;
53+ defer ( ( ) => bridge . dispose ( ) ) ;
54+ return { bridge, detach } ;
55+ } ;
56+ let backend : ReturnType < typeof startBackend > | undefined = startBackend ( ) ;
57+ const invoke = ( async ( command : string , args : Record < string , unknown > = { } ) => {
58+ if ( backend === undefined ) throw new Error ( "Pace backend is not connected" ) ;
59+ const connectionId = args . connectionId as string ;
60+ if ( command === "durable_spike_connect" ) await backend . bridge . connect ( { connectionId, token : host . token } ) ;
61+ else if ( command === "durable_spike_send" ) backend . bridge . send ( { connectionId, data : args . data as string } ) ;
62+ else if ( command === "durable_spike_disconnect" ) backend . bridge . disconnect ( { connectionId } ) ;
63+ else throw new Error ( `Unexpected command: ${ command } ` ) ;
64+ return null ;
65+ } ) as < T > ( command : string , args ?: Record < string , unknown > ) => Promise < T > ;
66+ const relay = backendRelay ( { invoke, onBackendEvent : ( listener ) => {
67+ listeners . add ( listener ) ;
68+ return ( ) => { listeners . delete ( listener ) ; } ;
69+ } } ) ;
70+ const client = await connectRemoteDurable ( { transport : relayTransport ( relay , "pace" ) , reconnectDelayMs : { min : 1 , max : 5 } } ) ;
71+ defer ( ( ) => client . close ( ) ) ;
72+
73+ // A crashed process cannot forward the sockets' close events.
74+ backend . detach ( ) ;
75+ backend . bridge . dispose ( ) ;
76+ backend = undefined ;
77+ if ( failure === "lifecycle event" ) {
78+ const disconnected = envelope ( "error" , { lifecycle : "disconnected" , generation : 1 } ) ;
79+ disconnected . event . sessionId = "__backend__" ;
80+ emit ( disconnected ) ;
81+ expect ( client . view . current ( ) . connection ) . toBe ( "reconnecting" ) ;
82+ } else {
83+ backend = startBackend ( ) ;
84+ }
85+ await client . controller . submit ( "offline-command" , "followUp" ) ;
86+ expect ( client . view . current ( ) . connection ) . toBe ( "reconnecting" ) ;
87+ expect ( client . view . current ( ) . notices . at ( - 1 ) ?. message ) . toMatch ( / n o t c o n n e c t e d | d i s c o n n e c t e d / i) ;
88+
89+ backend ??= startBackend ( ) ;
90+ const connected = envelope ( "status" , { lifecycle : "connected" , generation : 2 } ) ;
91+ connected . event . sessionId = "__backend__" ;
92+ emit ( connected ) ;
93+ await waitForView ( client . view , ( view ) => view . connection === "connected" ) ;
94+ await client . controller . submit ( "after-restart-command" , "followUp" ) ;
95+ await waitForView ( client . view , ( view ) => transcript ( view . conversation ) . at ( - 1 ) ?. text === "after-restart-answer" ) ;
96+ expect ( transcript ( client . view . current ( ) . conversation ) . map ( ( item ) => item . text ) ) . toEqual ( [
97+ "after-restart-command" , "after-restart-answer" ,
98+ ] ) ;
99+ } ) ;
100+
101+ it ( "rejects a command for a connection the restarted backend no longer owns" , ( ) => {
102+ const bridge = createDurableSpikeBridge ( { agentDir : "/unused" } ) ;
103+ defer ( ( ) => bridge . dispose ( ) ) ;
104+ expect ( ( ) => bridge . send ( { connectionId : "before-restart" , data : "command" } ) ) . toThrow ( / c o n n e c t i o n .* n o t o p e n / i) ;
105+ } ) ;
106+
39107 it ( "carries a client that sees and drives the same conversation as a direct one" , async ( ) => {
40108 const host = await startFauxHost ( defer , { answers : [ "answer-through-the-relay" ] } ) ;
41109 const bridge = createDurableSpikeBridge ( { agentDir : await agentDirWithToken ( host . token ) } ) ;
0 commit comments