|
| 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 | +}); |
0 commit comments