@@ -14,8 +14,12 @@ declare global {
1414 }
1515}
1616
17- function url ( ns : string , arena : string ) {
18- return `/testapp/index.html?ns=${ encodeURIComponent ( ns ) } &arena=${ encodeURIComponent ( arena ) } ` ;
17+ function url ( ns : string , arena : string , options ?: { timeout ?: number } ) {
18+ let u = `/testapp/index.html?ns=${ encodeURIComponent ( ns ) } &arena=${ encodeURIComponent ( arena ) } ` ;
19+ if ( options ?. timeout !== undefined ) {
20+ u += `&timeout=${ options . timeout } ` ;
21+ }
22+ return u ;
1923}
2024
2125async function setupPage ( page : Page ) {
@@ -216,3 +220,283 @@ test("leader failover: when leader stops, another becomes leader and RPCs contin
216220
217221 await context . close ( ) ;
218222} ) ;
223+
224+ test ( "leader crash: abrupt tab close triggers failover and RPCs recover" , async ( { browser } ) => {
225+ const context = await browser . newContext ( ) ;
226+ const [ a , b ] = await Promise . all ( [ context . newPage ( ) , context . newPage ( ) ] ) ;
227+ const ns = `ns-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
228+ const arena = `arena-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
229+
230+ [ a , b ] . forEach ( setupPage ) ;
231+ await Promise . all ( [ a . goto ( url ( ns , arena ) ) , b . goto ( url ( ns , arena ) ) ] ) ;
232+ await Promise . all ( [ waitForReady ( a ) , waitForReady ( b ) ] ) ;
233+ await Promise . all ( [ startCandidate ( a ) , startCandidate ( b ) ] ) ;
234+
235+ await expect . poll ( async ( ) => ( ( await isLeader ( a ) ) ? 1 : 0 ) + ( ( await isLeader ( b ) ) ? 1 : 0 ) , { timeout : 5000 } ) . toBe ( 1 ) ;
236+
237+ const leaderPage = ( await isLeader ( a ) ) ? a : b ;
238+ const followerPage = leaderPage === a ? b : a ;
239+
240+ // Write a file before crash
241+ await writeFile ( leaderPage , "/pre-crash.txt" , "before" ) ;
242+ await expect . poll ( ( ) => readFile ( followerPage , "/pre-crash.txt" ) , { timeout : 5000 } ) . toBe ( "before" ) ;
243+
244+ // Crash the leader (abrupt close, no graceful shutdown)
245+ await leaderPage . close ( ) ;
246+
247+ // Follower should become the new leader
248+ await expect . poll ( ( ) => isLeader ( followerPage ) , { timeout : 10000 } ) . toBe ( true ) ;
249+
250+ // RPCs should work on the new leader
251+ await writeFile ( followerPage , "/post-crash.txt" , "after" ) ;
252+ await expect . poll ( ( ) => readFile ( followerPage , "/post-crash.txt" ) , { timeout : 5000 } ) . toBe ( "after" ) ;
253+
254+ await context . close ( ) ;
255+ } ) ;
256+
257+ test ( "follower request timeout when no leader responds" , async ( { browser } ) => {
258+ const context = await browser . newContext ( ) ;
259+ const lockHolder = await context . newPage ( ) ;
260+ const follower = await context . newPage ( ) ;
261+ const ns = `ns-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
262+ const arena = `arena-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
263+
264+ setupPage ( lockHolder ) ;
265+ setupPage ( follower ) ;
266+
267+ // Navigate lock holder to same origin so it can hold the Web Lock
268+ await lockHolder . goto ( url ( ns , arena ) ) ;
269+ await waitForReady ( lockHolder ) ;
270+
271+ // Hold the ns-scoped Web Lock, preventing any broker from becoming leader
272+ const lockName = `opfs-worker-lock-${ ns } ` ;
273+ await lockHolder . evaluate ( ( name : string ) => {
274+ return new Promise < void > ( ( resolve ) => {
275+ navigator . locks . request ( name , ( ) => {
276+ resolve ( ) ; // signal that we have the lock
277+ return new Promise ( ( ) => { } ) ; // hold it forever
278+ } ) ;
279+ } ) ;
280+ } , lockName ) ;
281+
282+ // Start the follower with a short timeout (1s)
283+ await follower . goto ( url ( ns , arena , { timeout : 1000 } ) ) ;
284+ await waitForReady ( follower ) ;
285+ await startCandidate ( follower ) ;
286+
287+ // Follower should not be leader
288+ await expect . poll ( ( ) => isLeader ( follower ) , { timeout : 2000 } ) . toBe ( false ) ;
289+
290+ // Try to write — should fail since no leader exists to handle the request
291+ const error = await follower . evaluate ( async ( ) => {
292+ try {
293+ await window . thumbdriveTest . writeFile ( "/timeout-test.txt" , "should-fail" ) ;
294+ return null ;
295+ } catch ( e : any ) {
296+ return e . message || String ( e ) ;
297+ }
298+ } ) ;
299+
300+ expect ( error ) . toBeTruthy ( ) ;
301+ expect ( error ) . toContain ( "timeout" ) ;
302+
303+ await context . close ( ) ;
304+ } ) ;
305+
306+ test ( "restart cycle: stop and start preserves OPFS data" , async ( { page } ) => {
307+ const ns = `ns-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
308+ const arena = `arena-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
309+
310+ setupPage ( page ) ;
311+ await page . goto ( url ( ns , arena ) ) ;
312+ await waitForReady ( page ) ;
313+ await startCandidate ( page ) ;
314+ await expect . poll ( ( ) => isLeader ( page ) , { timeout : 5000 } ) . toBe ( true ) ;
315+
316+ // Write a file
317+ await writeFile ( page , "/restart-test.txt" , "before-restart" ) ;
318+ await expect . poll ( ( ) => readFile ( page , "/restart-test.txt" ) , { timeout : 5000 } ) . toBe ( "before-restart" ) ;
319+
320+ // Shutdown gracefully
321+ await shutdownLeader ( page ) ;
322+ await expect . poll ( ( ) => isLeader ( page ) , { timeout : 5000 } ) . toBe ( false ) ;
323+
324+ // Start again
325+ await startCandidate ( page ) ;
326+ await expect . poll ( ( ) => isLeader ( page ) , { timeout : 5000 } ) . toBe ( true ) ;
327+
328+ // New writes work after restart
329+ await writeFile ( page , "/restart-test-2.txt" , "after-restart" ) ;
330+ await expect . poll ( ( ) => readFile ( page , "/restart-test-2.txt" ) , { timeout : 5000 } ) . toBe ( "after-restart" ) ;
331+
332+ // Can shutdown again cleanly
333+ await shutdownLeader ( page ) ;
334+ await expect . poll ( ( ) => isLeader ( page ) , { timeout : 5000 } ) . toBe ( false ) ;
335+ } ) ;
336+
337+ test ( "concurrent writes from multiple tabs with overlapping JSONRPC IDs" , async ( { browser } ) => {
338+ const context = await browser . newContext ( ) ;
339+ const [ a , b , c ] = await Promise . all ( [ context . newPage ( ) , context . newPage ( ) , context . newPage ( ) ] ) ;
340+ const ns = `ns-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
341+ const arena = `arena-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
342+
343+ [ a , b , c ] . forEach ( setupPage ) ;
344+ await Promise . all ( [ a . goto ( url ( ns , arena ) ) , b . goto ( url ( ns , arena ) ) , c . goto ( url ( ns , arena ) ) ] ) ;
345+ await Promise . all ( [ waitForReady ( a ) , waitForReady ( b ) , waitForReady ( c ) ] ) ;
346+ await Promise . all ( [ startCandidate ( a ) , startCandidate ( b ) , startCandidate ( c ) ] ) ;
347+
348+ await expect
349+ . poll ( async ( ) => ( ( await isLeader ( a ) ) ? 1 : 0 ) + ( ( await isLeader ( b ) ) ? 1 : 0 ) + ( ( await isLeader ( c ) ) ? 1 : 0 ) , { timeout : 5000 } )
350+ . toBe ( 1 ) ;
351+
352+ // Fire concurrent writes from all three tabs simultaneously.
353+ // Each tab's vscode-jsonrpc connection assigns IDs starting from 0,
354+ // so the broker's ID rewriting must disambiguate them.
355+ await Promise . all ( [ writeFile ( a , "/c1.txt" , "from-a" ) , writeFile ( b , "/c2.txt" , "from-b" ) , writeFile ( c , "/c3.txt" , "from-c" ) ] ) ;
356+
357+ // All files should be readable from any tab
358+ await expect . poll ( ( ) => readFile ( b , "/c1.txt" ) , { timeout : 5000 } ) . toBe ( "from-a" ) ;
359+ await expect . poll ( ( ) => readFile ( c , "/c2.txt" ) , { timeout : 5000 } ) . toBe ( "from-b" ) ;
360+ await expect . poll ( ( ) => readFile ( a , "/c3.txt" ) , { timeout : 5000 } ) . toBe ( "from-c" ) ;
361+
362+ await context . close ( ) ;
363+ } ) ;
364+
365+ test ( "multiple sequential failovers across three tabs" , async ( { browser } ) => {
366+ const context = await browser . newContext ( ) ;
367+ const [ a , b , c ] = await Promise . all ( [ context . newPage ( ) , context . newPage ( ) , context . newPage ( ) ] ) ;
368+ const ns = `ns-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
369+ const arena = `arena-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
370+
371+ [ a , b , c ] . forEach ( setupPage ) ;
372+ await Promise . all ( [ a . goto ( url ( ns , arena ) ) , b . goto ( url ( ns , arena ) ) , c . goto ( url ( ns , arena ) ) ] ) ;
373+ await Promise . all ( [ waitForReady ( a ) , waitForReady ( b ) , waitForReady ( c ) ] ) ;
374+ await Promise . all ( [ startCandidate ( a ) , startCandidate ( b ) , startCandidate ( c ) ] ) ;
375+
376+ const pages = [ a , b , c ] ;
377+
378+ async function findLeaderIdx ( ) {
379+ const states = await Promise . all ( pages . map ( ( p ) => isLeader ( p ) . catch ( ( ) => false ) ) ) ;
380+ return states . findIndex ( ( x ) => x ) ;
381+ }
382+
383+ // Wait for initial leader
384+ let leaderIdx = - 1 ;
385+ await expect
386+ . poll (
387+ async ( ) => {
388+ leaderIdx = await findLeaderIdx ( ) ;
389+ return leaderIdx >= 0 ;
390+ } ,
391+ { timeout : 5000 }
392+ )
393+ . toBe ( true ) ;
394+
395+ // Write from first leader
396+ await writeFile ( pages [ leaderIdx ] , "/failover-1.txt" , "leader-1" ) ;
397+
398+ // --- First failover: crash the leader ---
399+ const firstLeaderIdx = leaderIdx ;
400+ await pages [ firstLeaderIdx ] . close ( ) ;
401+
402+ const remaining = [ 0 , 1 , 2 ] . filter ( ( i ) => i !== firstLeaderIdx ) ;
403+
404+ // Wait for a new leader among the remaining tabs
405+ await expect
406+ . poll (
407+ async ( ) => {
408+ const states = await Promise . all ( remaining . map ( ( i ) => isLeader ( pages [ i ] ) . catch ( ( ) => false ) ) ) ;
409+ return states . some ( ( x ) => x ) ;
410+ } ,
411+ { timeout : 10000 }
412+ )
413+ . toBe ( true ) ;
414+
415+ // Find new leader
416+ let secondLeaderIdx = - 1 ;
417+ for ( const i of remaining ) {
418+ if ( await isLeader ( pages [ i ] ) . catch ( ( ) => false ) ) {
419+ secondLeaderIdx = i ;
420+ break ;
421+ }
422+ }
423+
424+ // Write from second leader and verify cross-tab read works
425+ const lastIdx = remaining . find ( ( i ) => i !== secondLeaderIdx ) ! ;
426+ await writeFile ( pages [ secondLeaderIdx ] , "/failover-2.txt" , "leader-2" ) ;
427+ await expect . poll ( ( ) => readFile ( pages [ lastIdx ] , "/failover-2.txt" ) , { timeout : 5000 } ) . toBe ( "leader-2" ) ;
428+
429+ // --- Second failover: crash the second leader ---
430+ await pages [ secondLeaderIdx ] . close ( ) ;
431+
432+ // Last page standing should become leader
433+ await expect . poll ( ( ) => isLeader ( pages [ lastIdx ] ) , { timeout : 10000 } ) . toBe ( true ) ;
434+
435+ // RPCs still work after two consecutive failovers
436+ await writeFile ( pages [ lastIdx ] , "/failover-3.txt" , "leader-3" ) ;
437+ await expect . poll ( ( ) => readFile ( pages [ lastIdx ] , "/failover-3.txt" ) , { timeout : 5000 } ) . toBe ( "leader-3" ) ;
438+
439+ await context . close ( ) ;
440+ } ) ;
441+
442+ test ( "RPCs sent during failover transition eventually resolve" , async ( { browser } ) => {
443+ const context = await browser . newContext ( ) ;
444+ const [ a , b , c ] = await Promise . all ( [ context . newPage ( ) , context . newPage ( ) , context . newPage ( ) ] ) ;
445+ const ns = `ns-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
446+ const arena = `arena-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
447+
448+ // Use a short broker timeout so failed RPCs don't block the test for 20s
449+ [ a , b , c ] . forEach ( setupPage ) ;
450+ await Promise . all ( [
451+ a . goto ( url ( ns , arena , { timeout : 2000 } ) ) ,
452+ b . goto ( url ( ns , arena , { timeout : 2000 } ) ) ,
453+ c . goto ( url ( ns , arena , { timeout : 2000 } ) ) ,
454+ ] ) ;
455+ await Promise . all ( [ waitForReady ( a ) , waitForReady ( b ) , waitForReady ( c ) ] ) ;
456+ await Promise . all ( [ startCandidate ( a ) , startCandidate ( b ) , startCandidate ( c ) ] ) ;
457+
458+ const pages = [ a , b , c ] ;
459+
460+ async function findLeaderIdx ( ) {
461+ const states = await Promise . all ( pages . map ( ( p ) => isLeader ( p ) . catch ( ( ) => false ) ) ) ;
462+ return states . findIndex ( ( x ) => x ) ;
463+ }
464+
465+ let leaderIdx = - 1 ;
466+ await expect
467+ . poll (
468+ async ( ) => {
469+ leaderIdx = await findLeaderIdx ( ) ;
470+ return leaderIdx >= 0 ;
471+ } ,
472+ { timeout : 5000 }
473+ )
474+ . toBe ( true ) ;
475+
476+ // Identify the two survivors
477+ const survivors = [ 0 , 1 , 2 ] . filter ( ( i ) => i !== leaderIdx ) . map ( ( i ) => pages [ i ] ) ;
478+
479+ // Crash the leader
480+ await pages [ leaderIdx ] . close ( ) ;
481+
482+ // Immediately fire RPCs from both survivors — the first attempt may time out
483+ // if the broadcast arrives before the new leader is ready, so we retry once.
484+ async function writeWithRetry ( page : Page , path : string , content : string ) {
485+ try {
486+ await writeFile ( page , path , content ) ;
487+ } catch {
488+ // First attempt failed (likely timeout during transition). Retry after
489+ // giving the new leader time to finish booting.
490+ await page . waitForTimeout ( 500 ) ;
491+ await writeFile ( page , path , content ) ;
492+ }
493+ }
494+
495+ await Promise . all ( [ writeWithRetry ( survivors [ 0 ] , "/race-1.txt" , "race-1" ) , writeWithRetry ( survivors [ 1 ] , "/race-2.txt" , "race-2" ) ] ) ;
496+
497+ // Both writes should be readable from either survivor
498+ await expect . poll ( ( ) => readFile ( survivors [ 0 ] , "/race-2.txt" ) , { timeout : 5000 } ) . toBe ( "race-2" ) ;
499+ await expect . poll ( ( ) => readFile ( survivors [ 1 ] , "/race-1.txt" ) , { timeout : 5000 } ) . toBe ( "race-1" ) ;
500+
501+ await context . close ( ) ;
502+ } ) ;
0 commit comments