@@ -361,23 +361,43 @@ describe("sync driver — bidirectional convergence", () => {
361361 }
362362 } ) ;
363363
364- it ( "an upstream entry does not get re-pushed (loopback suppression)" , async ( ) => {
364+ it ( "an upstream entry stops circulating within two ticks" , async ( ) => {
365+ // Before the pushRev-locality fix, the loopback suppression
366+ // advanced B's pushRev to currentRev on the apply, so the
367+ // immediate pushOnce was a no-op. After the fix, B's pushRev
368+ // stays put after the pull, so the first pushOnce after a
369+ // pull ships the apply's rev bumps back to A; A's
370+ // alreadyApplied() drops them, the push response advances B's
371+ // pushRev, and the *next* tick is the no-op. The echo is
372+ // bounded at one extra round trip and the system converges
373+ // without an unbounded ping-pong.
365374 const a = makePeer ( ) ;
366375 const b = makePeer ( ) ;
367376 try {
368377 const providerA = new SQLiteWorkspaceProvider ( a . db , { now : ( ) => 1 } ) ;
369378 providerA . writeFileSync ( "/from-a.txt" , "alpha" ) ;
370379
371- // First tick: B pulls from A.
372- await tick ( b . db , a . rpc ) ;
380+ // Tick 1: B pulls A's write. The apply on B bumps B's rev,
381+ // so B's coalesce window contains entries; pushed reports
382+ // however many entries got coalesced (typically 1 for the
383+ // file alone, more if directory entries get touched).
384+ const first = await tick ( b . db , a . rpc ) ;
373385 expect ( fileEntries ( b . db ) ) . toContain ( "from-a.txt" ) ;
374-
375- // Second tick: B has nothing new to push back. If the
376- // loopback suppression is broken, applyChanges bumped
377- // vfs_meta.rev on the apply, and the push side would re-ship
378- // the same entry.
379- const result = await tick ( b . db , a . rpc ) ;
380- expect ( result . pushed ) . toBe ( 0 ) ;
386+ expect ( first . pulled . applied ) . toBeGreaterThan ( 0 ) ;
387+ expect ( first . pushed ) . toBeGreaterThanOrEqual ( 1 ) ;
388+
389+ // Tick 2: A's alreadyApplied() dropped the redundant entries
390+ // shipped in tick 1, and B's pushRev advanced past them. So
391+ // tick 2 has nothing to push and nothing to pull.
392+ const second = await tick ( b . db , a . rpc ) ;
393+ expect ( second . pulled . applied ) . toBe ( 0 ) ;
394+ expect ( second . pushed ) . toBe ( 0 ) ;
395+
396+ // Tick 3: still settled. Pins that convergence is durable,
397+ // not just "the next tick happens to be empty."
398+ const third = await tick ( b . db , a . rpc ) ;
399+ expect ( third . pulled . applied ) . toBe ( 0 ) ;
400+ expect ( third . pushed ) . toBe ( 0 ) ;
381401 } finally {
382402 a . close ( ) ;
383403 b . close ( ) ;
@@ -415,7 +435,7 @@ describe("sync driver — cross-side invariant", () => {
415435
416436 // Wrap B's rpc to lie about appliedPushRev. Simulates a
417437 // regression in the suppress-dirty-tracking apply path.
418- const lyingRpc = new Proxy ( b . rpc as object , {
438+ const lyingRPC = new Proxy ( b . rpc as object , {
419439 get ( target , prop , receiver ) {
420440 if ( prop === "push" ) {
421441 return async ( input : { senderRev : number ; changes : ReadableStream < unknown > } ) => {
@@ -427,44 +447,90 @@ describe("sync driver — cross-side invariant", () => {
427447 } ,
428448 } ) as typeof b . rpc ;
429449
430- await expect ( pushOnce ( a . db , lyingRpc ) ) . rejects . toThrow ( / c r o s s - s i d e i n v a r i a n t v i o l a t e d / i) ;
450+ await expect ( pushOnce ( a . db , lyingRPC ) ) . rejects . toThrow ( / c r o s s - s i d e i n v a r i a n t v i o l a t e d / i) ;
431451 } finally {
432452 a . close ( ) ;
433453 b . close ( ) ;
434454 }
435455 } ) ;
436456
437- it ( "pullOnce throws when fetchChanges echoes back a lower appliedPushRev" , async ( ) => {
438- // Symmetric to the push case. fetchChanges returns the remote's
439- // appliedPushRev alongside the entry stream; the DO asserts
440- // appliedPushRev >= pushRev before draining, so a regression in
441- // the remote's apply path that loses applied state trips the
442- // invariant on the next pull instead of corrupting fetchRev.
457+ it ( "pullOnce resets pushRev and retries when fetchChanges echoes a lower appliedPushRev" , async ( ) => {
458+ // The remote reporting an appliedPushRev below our localPushRev
459+ // means the remote forgot what we pushed — typically a process-
460+ // lifetime wsd restart while the WebSocket stayed up, so the
461+ // reconcileWatermarks pass we run on connect never re-ran. The
462+ // pull path now treats this inline: cancel the in-flight
463+ // stream, reset pushRev to 0, and retry. The next pushOnce
464+ // re-ships everything from the rev-0 baseline.
465+ const remote = makePeer ( ) ;
466+ try {
467+ const local = new Database ( new SQLiteTestStorage ( ) ) ;
468+ initializeSchema ( local , ( ) => 1000 ) ;
469+ writeWatermark ( local , "pushRev" , 42 ) ;
470+ const providerR = new SQLiteWorkspaceProvider ( remote . db , { now : ( ) => 1 } ) ;
471+ providerR . writeFileSync ( "/seed.txt" , "x" ) ;
472+
473+ // The proxy lies once: on the first fetchChanges, swap the
474+ // remote's real appliedPushRev for 0. The pull path detects
475+ // the divergence and retries; on the retry the real RPC
476+ // runs (because lied flips) and pullOnce drains normally.
477+ let lied = false ;
478+ const flakyRPC = new Proxy ( remote . rpc as object , {
479+ get ( target , prop , receiver ) {
480+ if ( prop === "fetchChanges" && ! lied ) {
481+ return async ( input : { sinceRev ?: number ; ignore ?: string [ ] } ) => {
482+ lied = true ;
483+ const real = await Reflect . get ( target , prop , receiver ) . call ( target , input ) ;
484+ return { ...real , appliedPushRev : 0 } ;
485+ } ;
486+ }
487+ return Reflect . get ( target , prop , receiver ) ;
488+ } ,
489+ } ) as typeof remote . rpc ;
490+
491+ const result = await pullOnce ( local , flakyRPC ) ;
492+ // The retry succeeded: the seeded /seed.txt landed locally.
493+ expect ( result . applied ) . toBeGreaterThan ( 0 ) ;
494+ // pushRev was reset to 0 on the divergence and stays at 0
495+ // (we didn't run a successful pushOnce); the next pushOnce
496+ // tick will re-ship from the baseline.
497+ expect ( readWatermark ( local , "pushRev" ) ) . toBe ( 0 ) ;
498+ } finally {
499+ remote . close ( ) ;
500+ }
501+ } ) ;
502+
503+ it ( "pullOnce surfaces an invariant violation that survives the inline retry" , async ( ) => {
504+ // A persistently-lying remote (returns appliedPushRev=0 on
505+ // every call) trips the assertion after the inline reset.
506+ // The retry resets localPushRev to 0; the assertion then sees
507+ // appliedPushRev=0, localPushRev=0 and passes. So a permanent
508+ // lie now degrades to baseline re-sync rather than a hard
509+ // error. Pin that: the test passes (not throws), and the
510+ // caller's watermarks are zeroed.
443511 const remote = makePeer ( ) ;
444512 try {
445- // Seed the local pushRev so it's higher than what the lying
446- // remote will echo. The remote is otherwise fresh — nothing
447- // to fetch.
448513 const local = new Database ( new SQLiteTestStorage ( ) ) ;
449514 initializeSchema ( local , ( ) => 1000 ) ;
450515 writeWatermark ( local , "pushRev" , 42 ) ;
451- // Make the remote return *something* so the puller drains it.
452516 const providerR = new SQLiteWorkspaceProvider ( remote . db , { now : ( ) => 1 } ) ;
453517 providerR . writeFileSync ( "/seed.txt" , "x" ) ;
454518
455- const lyingRpc = new Proxy ( remote . rpc as object , {
519+ const lyingRPC = new Proxy ( remote . rpc as object , {
456520 get ( target , prop , receiver ) {
457521 if ( prop === "fetchChanges" ) {
458- return ( input : { sinceRev ?: number ; ignore ?: string [ ] } ) => {
459- const real = Reflect . get ( target , prop , receiver ) . call ( target , input ) ;
522+ return async ( input : { sinceRev ?: number ; ignore ?: string [ ] } ) => {
523+ const real = await Reflect . get ( target , prop , receiver ) . call ( target , input ) ;
460524 return { ...real , appliedPushRev : 0 } ;
461525 } ;
462526 }
463527 return Reflect . get ( target , prop , receiver ) ;
464528 } ,
465529 } ) as typeof remote . rpc ;
466530
467- await expect ( pullOnce ( local , lyingRpc ) ) . rejects . toThrow ( / c r o s s - s i d e i n v a r i a n t v i o l a t e d / i) ;
531+ const result = await pullOnce ( local , lyingRPC ) ;
532+ expect ( result . applied ) . toBeGreaterThan ( 0 ) ;
533+ expect ( readWatermark ( local , "pushRev" ) ) . toBe ( 0 ) ;
468534 } finally {
469535 remote . close ( ) ;
470536 }
@@ -653,8 +719,8 @@ describe("sync driver — reconcileWatermarks", () => {
653719 it ( "resets pushRev when the remote hasn't applied what we shipped" , async ( ) => {
654720 const remote = makePeer ( ) ;
655721 try {
656- // Local pushRev = 17, but the remote is fresh: its pushRev,
657- // which doubles as appliedPushRev on the wire, is 0.
722+ // Local pushRev = 17, but the remote is fresh: its fetchRev
723+ // (echoed back as appliedPushRev on the wire) is 0. Reset .
658724 const local = new Database ( new SQLiteTestStorage ( ) ) ;
659725 initializeSchema ( local , ( ) => 1000 ) ;
660726 writeWatermark ( local , "fetchRev" , 0 ) ;
@@ -667,6 +733,33 @@ describe("sync driver — reconcileWatermarks", () => {
667733 }
668734 } ) ;
669735
736+ it ( "leaves pushRev alone when the remote has applied our pushes but never initiated its own" , async ( ) => {
737+ // Topology: DO ↔ container. The container applies pushes (so
738+ // its fetchRev = our pushRev) but never initiates outbound
739+ // pushes (so its pushRev stays at 0). reconcileWatermarks must
740+ // not interpret remote.pushRev = 0 as "remote forgot our
741+ // pushes" — that would trigger a full re-push on every
742+ // reconnect even when nothing is broken.
743+ const remote = makePeer ( ) ;
744+ try {
745+ // Pretend the container's apply path has accepted our pushes
746+ // up to rev 17 (= what fetchChanges would echo back as
747+ // appliedPushRev). Its own pushRev stays at 0 because it has
748+ // not shipped anything outbound.
749+ writeWatermark ( remote . db , "fetchRev" , 17 ) ;
750+ const local = new Database ( new SQLiteTestStorage ( ) ) ;
751+ initializeSchema ( local , ( ) => 1000 ) ;
752+ writeWatermark ( local , "fetchRev" , 0 ) ;
753+ writeWatermark ( local , "pushRev" , 17 ) ;
754+
755+ const result = await reconcileWatermarks ( local , remote . rpc ) ;
756+ expect ( result . pushRevReset ) . toBe ( false ) ;
757+ expect ( readWatermark ( local , "pushRev" ) ) . toBe ( 17 ) ;
758+ } finally {
759+ remote . close ( ) ;
760+ }
761+ } ) ;
762+
670763 it ( "leaves watermarks alone when remote is at least caught up" , async ( ) => {
671764 const remote = makePeer ( ) ;
672765 try {
0 commit comments