Skip to content

Commit 00a873a

Browse files
sanchitrkclaude
andauthored
feat(plugin): serialize file mutations per sandbox file (#54)
Add SandboxIO.Mutation, a per-file FIFO lock on every mount. A file is its sandbox instance plus canonical path, so mounts of one instance and symlinked spellings share a queue, and a file that does not exist yet keys on its canonical parent. File-mutating tools hold it for their whole read-modify-write while parallel tool calls run. SandboxIO.withMutation completes any hand-built mount with the queue. Daytona realpath now rejects with ENOENT like Vercel, and the shared realpath scripts try `realpath -e` first so a symlinked file resolves. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 648af39 commit 00a873a

9 files changed

Lines changed: 417 additions & 22 deletions

File tree

‎packages/harness/src/sandbox/control.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ export const make = Effect.fn("Sandbox.Controller.make")(function* (options: Opt
641641
Layer.succeed(SandboxIO.Current, identity),
642642
Layer.succeed(SandboxIO.FileSystem, SandboxFileSystem.withCwd(fs, identity.cwd)),
643643
Layer.succeed(SandboxIO.Shell, shellWithCwd(shell, identity.cwd)),
644-
);
644+
).pipe(SandboxIO.withMutation);
645645
}),
646646
);
647647

‎packages/harness/src/sandboxes/daytona/provider.ts‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -207,16 +207,13 @@ const providerFrom = (sandbox: RemoteSandbox, options: Options) => {
207207
}
208208
},
209209
realpath: async (path: string) => {
210-
const run = async (script: string) => {
210+
for (const script of SandboxFileSystem.realpathScripts) {
211211
const command = quoteArgv(["sh", "-c", script, "_", path]);
212212
const result = await sandbox.process.executeCommand(command, options.cwd, undefined, options.execTimeout);
213-
return { command, result };
214-
};
215-
const asDirectory = await run(SandboxFileSystem.realpathScripts[0]);
216-
if (asDirectory.result.exitCode === 0) return (asDirectory.result.result ?? "").trimEnd();
217-
const asFile = await run(SandboxFileSystem.realpathScripts[1]);
218-
assertCommandSucceeded(asFile.command, asFile.result);
219-
return (asFile.result.result ?? "").trimEnd();
213+
if (result.exitCode === 0) return (result.result ?? "").trimEnd();
214+
}
215+
// same shape `fs.stat` rejects with, so `isNotFoundError` recognises it
216+
throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { code: "ENOENT" });
220217
},
221218
};
222219

@@ -327,8 +324,9 @@ const identityLayer = (options: Options) =>
327324
* remote filesystems have no synchronous filesystem surface.
328325
*/
329326
export const layer = (options: Options = {}): Layer.Layer<SandboxIO.Provides | SandboxResource.Service, DaytonaError> =>
330-
Layer.mergeAll(filesystemLayer(options), shellLayer(options), identityLayer(options), resourceLayer).pipe(
331-
Layer.provide(remote(options)),
332-
);
327+
Layer.mergeAll(
328+
SandboxIO.withMutation(Layer.mergeAll(filesystemLayer(options), shellLayer(options), identityLayer(options))),
329+
resourceLayer,
330+
).pipe(Layer.provide(remote(options)));
333331

334332
export const services = layer;

‎packages/harness/src/sandboxes/vercel/provider.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,10 @@ export const layer = (
437437
options: Options = {},
438438
): Layer.Layer<SandboxIO.Provides | SandboxResource.Service, VercelError> => {
439439
const mounted = { ...options, cwd: SandboxIO.resolveMountCwd(DEFAULT_CWD, options.cwd) };
440-
return Layer.mergeAll(filesystemLayer(mounted), shellLayer(mounted), identityLayer(mounted), resourceLayer).pipe(
441-
Layer.provide(remote(mounted)),
442-
);
440+
return Layer.mergeAll(
441+
SandboxIO.withMutation(Layer.mergeAll(filesystemLayer(mounted), shellLayer(mounted), identityLayer(mounted))),
442+
resourceLayer,
443+
).pipe(Layer.provide(remote(mounted)));
443444
};
444445

445446
export const services = layer;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"increments": 20,
3+
"order": [
4+
"a",
5+
"b",
6+
"c",
7+
"d",
8+
"e"
9+
],
10+
"interrupted": {
11+
"queuedBehind": {
12+
"beforeRelease": [
13+
"a"
14+
],
15+
"after": [
16+
"a",
17+
"c"
18+
]
19+
},
20+
"arrivedAfter": {
21+
"beforeRelease": [
22+
"a"
23+
],
24+
"after": [
25+
"a",
26+
"c"
27+
]
28+
}
29+
},
30+
"afterFailure": {
31+
"failed": "boom",
32+
"next": "ran"
33+
},
34+
"waits": {
35+
"relativeVsAbsolute": true,
36+
"throughSymlinkedDirectory": true,
37+
"throughSymlinkedFile": true,
38+
"missingThroughSymlink": true,
39+
"acrossMountsOfOneInstance": true,
40+
"unrelatedFiles": false
41+
}
42+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { Deferred, Effect, Fiber, Layer } from "effect";
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { describe, expect, it } from "vite-plus/test";
5+
import { EnvNodeJSDefault } from "../src/sandbox/fs/nodejs.ts";
6+
import { Local } from "../src/sandbox/fs/vfs.ts";
7+
import { SandboxIO } from "../src/sandbox/io.ts";
8+
import { HostExe } from "../src/sandbox/shell/host.ts";
9+
import { tmpdir } from "./fixtures/tempdir.ts";
10+
11+
/**
12+
* The mutation queue is concurrency code, so it is tested in isolation, one case
13+
* per way it can fail:
14+
* - concurrent read-modify-writes of one file lose updates
15+
* - two spellings of one file (relative, absolute, through a symlinked
16+
* directory) get separate queues
17+
* - a file that does not exist yet, under directories that do not either,
18+
* gets a different queue than later mutations of it
19+
* - queued calls run out of arrival order
20+
* - an interrupted waiter lets its successor overlap the running mutation
21+
* - a failed mutation never releases the file
22+
* - two mounts of one instance at different directories get separate queues
23+
* - unrelated files wait on each other
24+
*/
25+
26+
const transport = Layer.provideMerge(Layer.merge(Local.layer, HostExe.layer()), EnvNodeJSDefault.layer());
27+
28+
const at = (cwd: string) => SandboxIO.mount(SandboxIO.host(cwd)).pipe(Layer.provide(transport));
29+
30+
const settle = Effect.sleep("30 millis");
31+
32+
/**
33+
* Hold `first` open, start `second`, and report whether `second` ran while
34+
* `first` was still held. The two may come from different mounts.
35+
*/
36+
const overlaps = (first: { mount: string; path: string }, second: { mount: string; path: string }) =>
37+
Effect.gen(function* () {
38+
const gate = yield* Deferred.make<void>();
39+
const held = yield* Deferred.make<void>();
40+
let secondRan = false;
41+
const holder = yield* Effect.gen(function* () {
42+
const mutation = yield* SandboxIO.Mutation;
43+
yield* mutation.withFile(
44+
first.path,
45+
Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(gate))),
46+
);
47+
}).pipe(Effect.provide(at(first.mount)), Effect.forkChild);
48+
yield* Deferred.await(held);
49+
const other = yield* Effect.gen(function* () {
50+
const mutation = yield* SandboxIO.Mutation;
51+
yield* mutation.withFile(
52+
second.path,
53+
Effect.sync(() => {
54+
secondRan = true;
55+
}),
56+
);
57+
}).pipe(Effect.provide(at(second.mount)), Effect.forkChild);
58+
yield* settle;
59+
const ranWhileHeld = secondRan;
60+
yield* Deferred.succeed(gate, undefined);
61+
yield* Fiber.join(holder);
62+
yield* Fiber.join(other);
63+
return ranWhileHeld;
64+
});
65+
66+
describe("SandboxIO.Mutation", () => {
67+
it("serializes one file and nothing else", async () => {
68+
await using tmp = await tmpdir();
69+
// macOS resolves /var through a symlink; the queue must see through it too.
70+
const root = await fs.realpath(tmp.path);
71+
await fs.mkdir(path.join(root, "real"));
72+
await fs.mkdir(path.join(root, "sub"));
73+
await fs.symlink(path.join(root, "real"), path.join(root, "link"));
74+
await fs.symlink(path.join(root, "real", "file.txt"), path.join(root, "alias.txt"));
75+
await fs.writeFile(path.join(root, "real", "file.txt"), "0");
76+
await fs.writeFile(path.join(root, "counter.txt"), "0");
77+
78+
const report = await Effect.runPromise(
79+
Effect.gen(function* () {
80+
// 1. Twenty concurrent increments, each yielding between its read and write.
81+
const increments = yield* Effect.gen(function* () {
82+
const mutation = yield* SandboxIO.Mutation;
83+
const filesystem = yield* SandboxIO.FileSystem;
84+
const increment = mutation.withFile(
85+
"counter.txt",
86+
Effect.gen(function* () {
87+
const value = Number(yield* filesystem.readFile("counter.txt"));
88+
yield* Effect.sleep("1 millis");
89+
yield* filesystem.writeFile("counter.txt", String(value + 1));
90+
}),
91+
);
92+
yield* Effect.all(
93+
Array.from({ length: 20 }, () => increment),
94+
{ concurrency: "unbounded" },
95+
);
96+
return Number(yield* filesystem.readFile("counter.txt"));
97+
}).pipe(Effect.provide(at(root)));
98+
99+
// 4. Arrival order is run order.
100+
const order = yield* Effect.gen(function* () {
101+
const mutation = yield* SandboxIO.Mutation;
102+
const gate = yield* Deferred.make<void>();
103+
const ran: Array<string> = [];
104+
const run = (name: string, wait?: Deferred.Deferred<void>) =>
105+
mutation.withFile(
106+
"real/file.txt",
107+
Effect.sync(() => ran.push(name)).pipe(Effect.andThen(wait ? Deferred.await(wait) : Effect.void)),
108+
);
109+
const fibers = [yield* Effect.forkChild(run("a", gate))];
110+
for (const name of ["b", "c", "d", "e"]) {
111+
yield* settle;
112+
fibers.push(yield* Effect.forkChild(run(name)));
113+
}
114+
yield* settle;
115+
yield* Deferred.succeed(gate, undefined);
116+
yield* Effect.forEach(fibers, Fiber.join);
117+
return ran;
118+
}).pipe(Effect.provide(at(root)));
119+
120+
// 5. Interrupting a waiter must not release the file to a later caller,
121+
// whether that caller queued behind it or arrived after the interrupt.
122+
const interruptWaiter = (lastArrives: "beforeInterrupt" | "afterInterrupt") =>
123+
Effect.gen(function* () {
124+
const mutation = yield* SandboxIO.Mutation;
125+
const gate = yield* Deferred.make<void>();
126+
const ran: Array<string> = [];
127+
const run = (name: string, wait?: Deferred.Deferred<void>) =>
128+
mutation.withFile(
129+
"real/file.txt",
130+
Effect.sync(() => ran.push(name)).pipe(
131+
Effect.andThen(wait ? Deferred.await(wait) : Effect.void),
132+
),
133+
);
134+
const a = yield* Effect.forkChild(run("a", gate));
135+
yield* settle;
136+
const b = yield* Effect.forkChild(run("b"));
137+
yield* settle;
138+
let c: Fiber.Fiber<void, unknown> | undefined;
139+
if (lastArrives === "beforeInterrupt") c = yield* Effect.forkChild(run("c"));
140+
yield* settle;
141+
yield* Fiber.interrupt(b);
142+
if (lastArrives === "afterInterrupt") c = yield* Effect.forkChild(run("c"));
143+
yield* settle;
144+
const beforeRelease = [...ran];
145+
yield* Deferred.succeed(gate, undefined);
146+
yield* Fiber.join(a);
147+
yield* Fiber.join(c!);
148+
return { beforeRelease, after: ran };
149+
}).pipe(Effect.provide(at(root)));
150+
const interrupted = {
151+
queuedBehind: yield* interruptWaiter("beforeInterrupt"),
152+
arrivedAfter: yield* interruptWaiter("afterInterrupt"),
153+
};
154+
155+
// 6. A failed mutation releases the file.
156+
const afterFailure = yield* Effect.gen(function* () {
157+
const mutation = yield* SandboxIO.Mutation;
158+
const failed = yield* mutation.withFile("real/file.txt", Effect.fail("boom")).pipe(Effect.flip);
159+
const next = yield* mutation.withFile("real/file.txt", Effect.succeed("ran"));
160+
return { failed, next };
161+
}).pipe(Effect.provide(at(root)));
162+
163+
return {
164+
increments,
165+
order,
166+
interrupted,
167+
afterFailure,
168+
waits: {
169+
// 2. Spellings of one file share a queue.
170+
relativeVsAbsolute: !(yield* overlaps(
171+
{ mount: root, path: "real/file.txt" },
172+
{ mount: root, path: path.join(root, "real", "file.txt") },
173+
)),
174+
throughSymlinkedDirectory: !(yield* overlaps(
175+
{ mount: root, path: "real/file.txt" },
176+
{ mount: root, path: "link/file.txt" },
177+
)),
178+
throughSymlinkedFile: !(yield* overlaps(
179+
{ mount: root, path: "real/file.txt" },
180+
{ mount: root, path: "alias.txt" },
181+
)),
182+
// 3. A file and directories that do not exist yet.
183+
missingThroughSymlink: !(yield* overlaps(
184+
{ mount: root, path: "real/new/deep/file.txt" },
185+
{ mount: root, path: "link/new/deep/file.txt" },
186+
)),
187+
// 7. Two mounts of one instance.
188+
acrossMountsOfOneInstance: !(yield* overlaps(
189+
{ mount: root, path: "sub/shared.txt" },
190+
{ mount: path.join(root, "sub"), path: "shared.txt" },
191+
)),
192+
// 8. Unrelated files.
193+
unrelatedFiles: !(yield* overlaps(
194+
{ mount: root, path: "real/file.txt" },
195+
{ mount: root, path: "counter.txt" },
196+
)),
197+
},
198+
};
199+
}),
200+
);
201+
202+
expect(report.increments).toBe(20);
203+
expect(report.order).toEqual(["a", "b", "c", "d", "e"]);
204+
expect(report.interrupted).toEqual({
205+
queuedBehind: { beforeRelease: ["a"], after: ["a", "c"] },
206+
arrivedAfter: { beforeRelease: ["a"], after: ["a", "c"] },
207+
});
208+
expect(report.waits).toEqual({
209+
relativeVsAbsolute: true,
210+
throughSymlinkedDirectory: true,
211+
throughSymlinkedFile: true,
212+
missingThroughSymlink: true,
213+
acrossMountsOfOneInstance: true,
214+
unrelatedFiles: false,
215+
});
216+
await expect(JSON.stringify(report, null, "\t") + "\n").toMatchFileSnapshot(
217+
"./__artifacts__/sandbox.mutation.json",
218+
);
219+
});
220+
});

‎packages/plugin/src/sandbox/filesystem.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,15 @@ export const isNotFoundError = (cause: unknown) => {
6666
};
6767

6868
/**
69-
* Shell forms of `realpath` for backends that only expose a process API.
70-
* `pwd -P` is the portable primitive: directories resolve directly, anything
71-
* else resolves its parent and keeps the final name as given. Run as
72-
* `sh -c <script> _ <path>`; try the first, fall back to the second.
69+
* Shell forms of `realpath` for backends that only expose a process API. Run as
70+
* `sh -c <script> _ <path>`, trying each in turn. `realpath -e` (coreutils)
71+
* resolves every component, a symlinked file included, and fails on a missing
72+
* path. Where it is absent, `pwd -P` is the portable primitive: directories
73+
* resolve directly, anything else resolves its parent and keeps the final name
74+
* as given — so a symlinked *file* stays unresolved there.
7375
*/
7476
export const realpathScripts = [
77+
'realpath -e -- "$1"',
7578
'cd -- "$1" && pwd -P',
7679
'cd -- "$(dirname -- "$1")" && printf \'%s/%s\' "$(pwd -P)" "$(basename -- "$1")"',
7780
] as const;

0 commit comments

Comments
 (0)