Skip to content

Commit 6014fa9

Browse files
ymansurozerclaude
andauthored
fix: serialize mutating routes behind an in-process mutex (#60)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 25dc1c9 commit 6014fa9

4 files changed

Lines changed: 370 additions & 213 deletions

File tree

src/mutex.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createSerializer } from "./mutex.js";
4+
5+
test("serialize runs tasks in enqueue order — a later task waits for an earlier slow one", async () => {
6+
const serialize = createSerializer();
7+
const order: string[] = [];
8+
// Enqueued synchronously, so enqueue order is guaranteed. The first task is deliberately slow;
9+
// the second resolves immediately. Without serialization the fast one would finish first — the
10+
// mutex forces completion order to match enqueue order regardless of duration.
11+
const slow = serialize(async () => {
12+
await new Promise((r) => setTimeout(r, 30));
13+
order.push("slow");
14+
return "slow";
15+
});
16+
const fast = serialize(async () => {
17+
order.push("fast");
18+
return "fast";
19+
});
20+
const results = await Promise.all([slow, fast]);
21+
assert.deepEqual(order, ["slow", "fast"]);
22+
assert.deepEqual(results, ["slow", "fast"]);
23+
});
24+
25+
test("a rejecting task does not poison the chain, and its rejection reaches the caller", async () => {
26+
const serialize = createSerializer();
27+
const order: string[] = [];
28+
const boom = serialize(async () => {
29+
order.push("boom");
30+
throw new Error("boom");
31+
});
32+
const after = serialize(async () => {
33+
order.push("after");
34+
return "ok";
35+
});
36+
// The failing task's own promise rejects (the caller sees its error)…
37+
await assert.rejects(boom, /boom/);
38+
// …but the chain keeps flowing — the next task still runs to completion, in order.
39+
assert.equal(await after, "ok");
40+
assert.deepEqual(order, ["boom", "after"]);
41+
});

src/mutex.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// A minimal in-process serialization queue: a promise-chain mutex. `serialize(fn)` runs `fn`
2+
// only after every previously enqueued task has settled, so tasks execute one at a time in
3+
// enqueue order. Enqueue order (not completion order) is what determines the run order, so the
4+
// caller must enqueue synchronously to get a guaranteed sequence.
5+
//
6+
// A rejected task settles the chain WITHOUT poisoning it: the internal chain swallows the error
7+
// (`.then(undefined-ish, undefined-ish)`), so the next task still runs, while the promise handed
8+
// back to the caller still rejects — the caller sees its own failure.
9+
export type Serializer = <T>(fn: () => Promise<T>) => Promise<T>;
10+
11+
export function createSerializer(): Serializer {
12+
let chain: Promise<unknown> = Promise.resolve();
13+
return <T>(fn: () => Promise<T>): Promise<T> => {
14+
const next = chain.then(fn, fn);
15+
chain = next.then(
16+
() => undefined,
17+
() => undefined,
18+
);
19+
return next;
20+
};
21+
}

src/server.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import http from "node:http";
66
import { tmpdir } from "node:os";
77
import path from "node:path";
88
import { startServer } from "./server.js";
9-
import { buildReviewState, writeGlobalSettings } from "./state.js";
9+
import { buildReviewState, hash, writeGlobalSettings } from "./state.js";
1010
import type { ReviewState } from "./types.js";
1111

1212
function 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+
745807
test("settings API round-trips editorCommand", async () => {
746808
await withServer(async (handle) => {
747809
await fetch(`${handle.url}api/settings`, {

0 commit comments

Comments
 (0)