@@ -56,6 +56,15 @@ async function createTempSessionFile(): Promise<string> {
5656 return sessionFile ;
5757}
5858
59+ function cloneBigIntStatWith (
60+ stat : Awaited < ReturnType < typeof fs . stat > > ,
61+ fields : Partial < Awaited < ReturnType < typeof fs . stat > > > ,
62+ ) : Awaited < ReturnType < typeof fs . stat > > {
63+ return Object . assign ( Object . create ( Object . getPrototypeOf ( stat ) ) , stat , fields ) as Awaited <
64+ ReturnType < typeof fs . stat >
65+ > ;
66+ }
67+
5968describe ( "embedded attempt session lock lifecycle" , ( ) => {
6069 it ( "serializes embedded attempts that share a session file owner" , async ( ) => {
6170 const sessionFile = await createTempSessionFile ( ) ;
@@ -834,6 +843,152 @@ describe("embedded attempt session lock lifecycle", () => {
834843 expect ( release ) . toHaveBeenCalledTimes ( 2 ) ;
835844 } ) ;
836845
846+ it ( "allows ctime-only fingerprint drift while the prompt lock is released" , async ( ) => {
847+ const sessionFile = await createTempSessionFile ( ) ;
848+ const release = vi . fn ( async ( ) => { } ) ;
849+ const acquireSessionWriteLockLocalCtimeDrift = vi . fn ( async ( ) => ( { release } ) ) ;
850+ const controller = await createEmbeddedAttemptSessionLockController ( {
851+ acquireSessionWriteLock : acquireSessionWriteLockLocalCtimeDrift ,
852+ lockOptions : { ...lockOptions , sessionFile } ,
853+ } ) ;
854+
855+ await controller . releaseForPrompt ( ) ;
856+
857+ const stableStat = await fs . stat ( sessionFile , { bigint : true } ) ;
858+ const driftedStat = cloneBigIntStatWith ( stableStat , {
859+ ctimeNs : stableStat . ctimeNs + 1_000_000n ,
860+ } ) ;
861+ const statSpy = vi . spyOn ( fs , "stat" ) . mockImplementation ( async ( target , options ) => {
862+ if ( target === sessionFile && options ?. bigint === true ) {
863+ return driftedStat ;
864+ }
865+ throw new Error ( `unexpected stat call for ${ String ( target ) } ` ) ;
866+ } ) ;
867+
868+ try {
869+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . resolves . toBe ( "finalize" ) ;
870+ } finally {
871+ statSpy . mockRestore ( ) ;
872+ }
873+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( false ) ;
874+ expect ( acquireSessionWriteLockLocalCtimeDrift ) . toHaveBeenCalledTimes ( 2 ) ;
875+ expect ( release ) . toHaveBeenCalledTimes ( 2 ) ;
876+ } ) ;
877+
878+ it ( "trusts owned writes after accepting ctime-only fingerprint drift" , async ( ) => {
879+ const sessionFile = await createTempSessionFile ( ) ;
880+ const release = vi . fn ( async ( ) => { } ) ;
881+ const acquireSessionWriteLockLocalOwnedAfterDrift = vi . fn ( async ( ) => ( { release } ) ) ;
882+ const controller = await createEmbeddedAttemptSessionLockController ( {
883+ acquireSessionWriteLock : acquireSessionWriteLockLocalOwnedAfterDrift ,
884+ lockOptions : { ...lockOptions , sessionFile } ,
885+ } ) ;
886+
887+ await controller . releaseForPrompt ( ) ;
888+
889+ const stableStat = await fs . stat ( sessionFile , { bigint : true } ) ;
890+ const driftedStat = cloneBigIntStatWith ( stableStat , {
891+ ctimeNs : stableStat . ctimeNs + 1_000_000n ,
892+ } ) ;
893+ const appendedText = '{"type":"message","id":"owned-after-drift"}\n' ;
894+ const changedStat = cloneBigIntStatWith ( stableStat , {
895+ ctimeNs : stableStat . ctimeNs + 2_000_000n ,
896+ mtimeNs : stableStat . mtimeNs + 1_000_000n ,
897+ size : stableStat . size + BigInt ( Buffer . byteLength ( appendedText ) ) ,
898+ } ) ;
899+ let currentStat = driftedStat ;
900+ const statSpy = vi . spyOn ( fs , "stat" ) . mockImplementation ( async ( target , options ) => {
901+ if ( target === sessionFile && options ?. bigint === true ) {
902+ return currentStat ;
903+ }
904+ throw new Error ( `unexpected stat call for ${ String ( target ) } ` ) ;
905+ } ) ;
906+
907+ try {
908+ await expect (
909+ controller . withSessionWriteLock (
910+ async ( ) => {
911+ currentStat = changedStat ;
912+ await fs . appendFile ( sessionFile , appendedText , "utf8" ) ;
913+ } ,
914+ { publishOwnedWrite : true } ,
915+ ) ,
916+ ) . resolves . toBeUndefined ( ) ;
917+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . resolves . toBe ( "finalize" ) ;
918+ } finally {
919+ statSpy . mockRestore ( ) ;
920+ }
921+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( false ) ;
922+ expect ( acquireSessionWriteLockLocalOwnedAfterDrift ) . toHaveBeenCalledTimes ( 3 ) ;
923+ expect ( release ) . toHaveBeenCalledTimes ( 3 ) ;
924+ } ) ;
925+
926+ it ( "allows ctime-only fingerprint drift for large transcript snapshots" , async ( ) => {
927+ const sessionFile = await createTempSessionFile ( ) ;
928+ await fs . writeFile ( sessionFile , Buffer . alloc ( 8 * 1024 * 1024 + 1 , "x" ) ) ;
929+ const release = vi . fn ( async ( ) => { } ) ;
930+ const acquireSessionWriteLockLocalLargeCtimeDrift = vi . fn ( async ( ) => ( { release } ) ) ;
931+ const controller = await createEmbeddedAttemptSessionLockController ( {
932+ acquireSessionWriteLock : acquireSessionWriteLockLocalLargeCtimeDrift ,
933+ lockOptions : { ...lockOptions , sessionFile } ,
934+ } ) ;
935+
936+ await controller . releaseForPrompt ( ) ;
937+
938+ const stableStat = await fs . stat ( sessionFile , { bigint : true } ) ;
939+ const driftedStat = cloneBigIntStatWith ( stableStat , {
940+ ctimeNs : stableStat . ctimeNs + 1_000_000n ,
941+ } ) ;
942+ const statSpy = vi . spyOn ( fs , "stat" ) . mockImplementation ( async ( target , options ) => {
943+ if ( target === sessionFile && options ?. bigint === true ) {
944+ return driftedStat ;
945+ }
946+ throw new Error ( `unexpected stat call for ${ String ( target ) } ` ) ;
947+ } ) ;
948+
949+ try {
950+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . resolves . toBe ( "finalize" ) ;
951+ } finally {
952+ statSpy . mockRestore ( ) ;
953+ }
954+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( false ) ;
955+ } ) ;
956+
957+ it ( "rejects same-size transcript rewrites with restored mtime" , async ( ) => {
958+ const sessionFile = await createTempSessionFile ( ) ;
959+ const release = vi . fn ( async ( ) => { } ) ;
960+ const acquireSessionWriteLockLocalSameSizeRewrite = vi . fn ( async ( ) => ( { release } ) ) ;
961+ const controller = await createEmbeddedAttemptSessionLockController ( {
962+ acquireSessionWriteLock : acquireSessionWriteLockLocalSameSizeRewrite ,
963+ lockOptions : { ...lockOptions , sessionFile } ,
964+ } ) ;
965+
966+ await controller . releaseForPrompt ( ) ;
967+
968+ const stableStat = await fs . stat ( sessionFile , { bigint : true } ) ;
969+ await fs . writeFile ( sessionFile , '{"type":"sessioN"}\n' , "utf8" ) ;
970+ const driftedStat = cloneBigIntStatWith ( stableStat , {
971+ ctimeNs : stableStat . ctimeNs + 1_000_000n ,
972+ } ) ;
973+ const statSpy = vi . spyOn ( fs , "stat" ) . mockImplementation ( async ( target , options ) => {
974+ if ( target === sessionFile && options ?. bigint === true ) {
975+ return driftedStat ;
976+ }
977+ throw new Error ( `unexpected stat call for ${ String ( target ) } ` ) ;
978+ } ) ;
979+
980+ try {
981+ await expect ( controller . withSessionWriteLock ( ( ) => "finalize" ) ) . rejects . toBeInstanceOf (
982+ EmbeddedAttemptSessionTakeoverError ,
983+ ) ;
984+ } finally {
985+ statSpy . mockRestore ( ) ;
986+ }
987+ expect ( controller . hasSessionTakeover ( ) ) . toBe ( true ) ;
988+ expect ( acquireSessionWriteLockLocalSameSizeRewrite ) . toHaveBeenCalledTimes ( 2 ) ;
989+ expect ( release ) . toHaveBeenCalledTimes ( 2 ) ;
990+ } ) ;
991+
837992 it ( "still rejects external edits after the prompt stream lock is reacquired" , async ( ) => {
838993 const sessionFile = await createTempSessionFile ( ) ;
839994 const release = vi . fn ( async ( ) => { } ) ;
0 commit comments