Skip to content

Commit 757ec4c

Browse files
vanceingallsclaude
andcommitted
fix(sdk/http): serialize concurrent writes to same path
Add per-path promise queue so rapid successive writes to the same composition file cannot race at the server. Different paths still write concurrently. Two new tests (RED→GREEN verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 561d10e commit 757ec4c

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

packages/sdk/src/adapters/http.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ describe("flush()", () => {
140140
);
141141
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
142142
void adapter.write("comp.html", "x"); // intentionally not awaited
143+
await Promise.resolve(); // let path-queue microtask fire so doWrite starts
143144
let flushed = false;
144145
const flushDone = adapter.flush().then(() => {
145146
flushed = true;
@@ -167,6 +168,62 @@ describe("loadFrom()", () => {
167168
});
168169
});
169170

171+
// ── write() — per-path serialization ─────────────────────────────────────────
172+
173+
describe("write() — per-path serialization", () => {
174+
it("serializes concurrent writes to the same path (second waits for first)", async () => {
175+
const starts: number[] = [];
176+
let resolveFirst!: () => void;
177+
let callCount = 0;
178+
vi.stubGlobal(
179+
"fetch",
180+
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
181+
if (init?.method === "PUT") {
182+
const n = ++callCount;
183+
starts.push(n);
184+
if (n === 1) await new Promise<void>((r) => (resolveFirst = r));
185+
}
186+
return { ok: true, status: 200, json: async () => ({}) };
187+
}),
188+
);
189+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
190+
const write1 = adapter.write("comp.html", "v1");
191+
await Promise.resolve(); // let write1 start
192+
const write2 = adapter.write("comp.html", "v2");
193+
await Promise.resolve(); // let write2 attempt to start
194+
expect(starts).toEqual([1]); // write2 has NOT started yet
195+
resolveFirst();
196+
await write1;
197+
await write2;
198+
expect(starts).toEqual([1, 2]); // write2 started only after write1 finished
199+
});
200+
201+
it("does not block writes to different paths", async () => {
202+
const starts: string[] = [];
203+
let resolveFirst!: () => void;
204+
let callCount = 0;
205+
vi.stubGlobal(
206+
"fetch",
207+
vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
208+
if (init?.method === "PUT") {
209+
const n = ++callCount;
210+
starts.push(`${n}:${url.split("/").pop()}`);
211+
if (n === 1) await new Promise<void>((r) => (resolveFirst = r));
212+
}
213+
return { ok: true, status: 200, json: async () => ({}) };
214+
}),
215+
);
216+
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
217+
const write1 = adapter.write("a.html", "v1");
218+
await Promise.resolve();
219+
void adapter.write("b.html", "v2"); // different path — must not wait for write1
220+
await Promise.resolve();
221+
expect(starts.length).toBe(2); // both started concurrently
222+
resolveFirst();
223+
await write1;
224+
});
225+
});
226+
170227
// ── on() / unsubscribe ────────────────────────────────────────────────────────
171228

172229
describe("on() / unsubscribe", () => {

packages/sdk/src/adapters/http.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class HttpAdapter implements PersistAdapter {
1313
private readonly baseUrl: string;
1414
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
1515
private readonly inflightWrites = new Set<Promise<void>>();
16+
private readonly pathQueues = new Map<string, Promise<void>>();
1617

1718
constructor(opts: HttpAdapterOptions) {
1819
this.baseUrl = opts.projectFilesUrl;
@@ -27,7 +28,12 @@ class HttpAdapter implements PersistAdapter {
2728
}
2829

2930
async write(path: string, content: string): Promise<void> {
30-
const p = this.doWrite(path, content);
31+
const prev = this.pathQueues.get(path) ?? Promise.resolve();
32+
const p = prev.then(() => this.doWrite(path, content));
33+
this.pathQueues.set(
34+
path,
35+
p.catch(() => {}),
36+
);
3137
this.inflightWrites.add(p);
3238
try {
3339
await p;

0 commit comments

Comments
 (0)