@@ -6,7 +6,7 @@ import http from "node:http";
66import { tmpdir } from "node:os" ;
77import path from "node:path" ;
88import { startServer } from "./server.js" ;
9- import { buildReviewState , writeGlobalSettings } from "./state.js" ;
9+ import { buildReviewState , hash , writeGlobalSettings } from "./state.js" ;
1010import type { ReviewState } from "./types.js" ;
1111
1212function state ( root : string ) : ReviewState {
@@ -742,6 +742,68 @@ test("origin guard accepts a same-origin POST and a POST with no Origin", async
742742 } ) ;
743743} ) ;
744744
745+ test ( "concurrent /api/reload and /api/save leave the desk internally consistent (issue 05)" , async ( ) => {
746+ const root = await mkdtemp ( path . join ( tmpdir ( ) , "galley-mutex-" ) ) ;
747+ const oldHome = process . env . HOME ;
748+ process . env . HOME = root ;
749+ const g = ( args : string [ ] ) => execFileSync ( "git" , args , { cwd : root } ) . toString ( ) ;
750+ g ( [ "init" , "-q" ] ) ;
751+ g ( [ "config" , "user.email" , "t@t.co" ] ) ;
752+ g ( [ "config" , "user.name" , "tester" ] ) ;
753+ await writeFile ( path . join ( root , "a.ts" ) , "one\ntwo\nthree\n" ) ;
754+ g ( [ "add" , "." ] ) ;
755+ g ( [ "commit" , "-qm" , "init" ] ) ;
756+ // A real working-tree diff so /api/reload does its full (git-bound) rebuild each round.
757+ await writeFile ( path . join ( root , "a.ts" ) , "one\nCHANGED\nthree\n" ) ;
758+ const st = await buildReviewState ( root , { session : "s" } ) ;
759+ assert . ok ( st , "built a review state for the working diff" ) ;
760+ const handle = await startServer ( { state : st ! , open : false , idleTimeoutMs : 0 } ) ;
761+ try {
762+ // Fire the two mutating routes concurrently, many rounds. The queue's FIFO/non-poisoning
763+ // contract is unit-tested in mutex.test.ts; here we assert the end-to-end invariant it exists
764+ // to protect — that a reload's Object.assign never lands mid-save to stitch a half-applied
765+ // snapshot. After every round the desk stays internally consistent: baseDiffHash is exactly
766+ // the hash of the rawDiff it reports, never a value carried over from a different reload's diff.
767+ for ( let i = 0 ; i < 20 ; i ++ ) {
768+ const [ reloadRes , saveRes ] = await Promise . all ( [
769+ post ( handle . url , "api/reload" , { } ) ,
770+ post ( handle . url , "api/save" , { reviewedFiles : [ "a.ts" ] } ) ,
771+ ] ) ;
772+ assert . equal ( reloadRes . status , 200 , `reload ${ i } ok` ) ;
773+ assert . equal ( saveRes . status , 200 , `save ${ i } ok` ) ;
774+ const snap = await getState ( handle . url ) ;
775+ assert . equal (
776+ snap . baseDiffHash ,
777+ hash ( snap . rawDiff ) ,
778+ `baseDiffHash matches rawDiff (round ${ i } )` ,
779+ ) ;
780+ }
781+ } finally {
782+ handle . server . close ( ) ;
783+ process . env . HOME = oldHome ;
784+ await rm ( root , { recursive : true , force : true } ) ;
785+ }
786+ } ) ;
787+
788+ test ( "a throwing wrapped route settles the mutex without poisoning the chain (issue 05)" , async ( ) => {
789+ await withServer ( async ( handle , _root , st ) => {
790+ // Invalid JSON reaches the serialized /api/save body, where JSON.parse throws — the wrapped
791+ // fn rejects. The chain must swallow that rejection (not wedge behind a permanently-rejected
792+ // promise) while the caller still sees the 500.
793+ const bad = await fetch ( `${ handle . url } api/save` , {
794+ method : "POST" ,
795+ headers : { "content-type" : "application/json" } ,
796+ body : "{ not valid json" ,
797+ } ) ;
798+ assert . equal ( bad . status , 500 ) ;
799+ assert . equal ( ( ( await bad . json ( ) ) as { code ?: string } ) . code , "INTERNAL" ) ;
800+ // A following mutation still runs to completion — proof the queue kept flowing.
801+ const ok = await post ( handle . url , "api/save" , { reviewedFiles : [ "a.ts" ] } ) ;
802+ assert . equal ( ok . status , 200 ) ;
803+ assert . deepEqual ( st . reviewedFiles , [ "a.ts" ] ) ;
804+ } ) ;
805+ } ) ;
806+
745807test ( "settings API round-trips editorCommand" , async ( ) => {
746808 await withServer ( async ( handle ) => {
747809 await fetch ( `${ handle . url } api/settings` , {
0 commit comments