diff --git a/.changeset/container-ignore-assertion.md b/.changeset/container-ignore-assertion.md new file mode 100644 index 00000000..2a91c2f4 --- /dev/null +++ b/.changeset/container-ignore-assertion.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add `ignore` to `ContainerBackend` to configure pass-through to the container disk. diff --git a/docs/19_performance.md b/docs/19_performance.md index f3605dcf..06ca987e 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -48,6 +48,50 @@ computerd is ~2x slower than the container's ext4 disk for the full `npm install`, and ~3.6x slower than tmpfs. The disk comparison is the more realistic baseline for general usage. +> [!IMPORTANT] +> These numbers measure the **mount**, not the **pull**. They stop when +> `npm install` returns. What follows — moving 36,675 files into the +> Durable Object — is not counted here, and for a dependency tree it is +> the larger cost. +> +> [#179](https://github.com/cloudflare/computer/issues/179) reports an +> install timing out at 120 s and then taking ~3 further minutes to +> return while the partial `node_modules` was pulled, after which the +> next command failed with a storage timeout the workspace did not +> recover from. None of that is visible in the table above. +> +> If you are sizing a workload against these figures, add the transfer +> yourself, or keep the tree out of sync entirely — see +> [`computerd`: Local-only paths](../packages/computerd/README.md#local-only-paths-mount_ignore). + +## Local-only paths (`MOUNT_IGNORE`) + +A path listed in `MOUNT_IGNORE` is served from the container's disk and +never enters the VFS, the store, the change-pack encoding, or the pull. + +What this does **not** change is the FUSE round trip: the bytes still +cross from the kernel into the daemon. Passthrough (`FOPEN_PASSTHROUGH`) +would remove that too, but computerd mounts through `fuse-native`, which +binds libfuse 2.9, and passthrough needs the libfuse 3.17 API. So expect +a local-only `npm install` to track the `computerd FUSE` row above +rather than the `ext4 disk` row. + +The saving is the transfer, and for a dependency tree the transfer is +most of the wall clock. + +| Scenario | Install duration | Bytes pulled into the DO | +|---|---:|---:| +| `npm install` to a synced path | 124.7 s | *(not yet measured)* | +| `npm install` to a `MOUNT_IGNORE` path | *(not yet measured)* | 0 by construction | + +> [!NOTE] +> The empty cells are deliberate. Bytes-pulled is the load-bearing +> number for this feature and it has not been measured yet; the zero in +> the last cell is a property of the design — an ignored path produces +> no sync entries — not an observation. Fill the table from the same +> `cloudflare/sandbox-sdk` install used above, on the same instance +> type, before quoting any of it. + ## In-memory store versus on-disk store `computerd` keeps its SQLite store in memory by default. Set diff --git a/packages/computer/src/backend.ts b/packages/computer/src/backend.ts index dac310eb..bc549aa5 100644 --- a/packages/computer/src/backend.ts +++ b/packages/computer/src/backend.ts @@ -90,6 +90,19 @@ export interface BackendHandle { // Durable Object). push/pull are no-ops, and the // reconcile-watermarks pass on connect is skipped. sync?: "remote" | "none"; + // Local-only paths this backend's container keeps on its own disk, + // as the container reports them (#179). Absent on backends with no + // such concept. + // + // `supported: false` means the container predates the feature, so + // every path is synced regardless of what the host asked for. Worth + // logging: it is the difference between a configuration that works + // and one that silently does nothing. + ignore?: { + readonly paths: readonly string[]; + readonly root: string | undefined; + readonly supported: boolean; + }; // Resolves when the underlying transport closes for any reason // (clean close, peer crash, network drop). The Workspace listens // for this and drops its cached handle so the next ready() call diff --git a/packages/computer/src/backends/container/container-backend-ignore.test.ts b/packages/computer/src/backends/container/container-backend-ignore.test.ts new file mode 100644 index 00000000..d098e330 --- /dev/null +++ b/packages/computer/src/backends/container/container-backend-ignore.test.ts @@ -0,0 +1,279 @@ +// connect()'s happy path constructs a WebSocketPair, a workerd global +// the node runner does not provide, so the full dial cannot complete +// here. These exercise the wire format the backend depends on, against +// a fake host. The comparison logic and the error text have their own +// suite in ignore-assertion.test.ts, and the end-to-end behavior is +// covered in computerd's cli tests against a real FUSE mount. +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { ContainerBackend } from "./container-backend.js"; +import type { ContainerRuntimeInfo, IWorkspaceContainerAPI } from "./container-host.js"; +import type { ContainerLaunchSpec } from "./container-launch-record.js"; +import { ContainerIgnoreMismatchError, readIgnoreReport } from "./ignore-assertion.js"; + +interface FakeHostOptions { + // The `ignore` block /__computerd/info reports. Omitted models a + // computerd predating the feature. + info?: Record; +} + +function fakeHost(opts: FakeHostOptions = {}) { + const fetches: { port: number; path: string }[] = []; + const starts: ContainerLaunchSpec[] = []; + const info: ContainerRuntimeInfo = { + runtimeId: "runtime-1", + clientSecret: "00112233445566778899aabbccddeeff", + outcome: "launched", + }; + const host: IWorkspaceContainerAPI = { + async start(spec) { + starts.push(spec); + return info; + }, + async restart() { + return info; + }, + async interceptOutboundHttp() {}, + async interceptAllOutboundHttp() {}, + async fetchPort(port, url) { + const path = new URL(url).pathname; + fetches.push({ port, path }); + if (path === "/__computerd/info") { + return new Response( + JSON.stringify({ + backend: { kind: "fuse" }, + mountPoint: "/workspace", + ...(opts.info === undefined ? {} : { ignore: opts.info }), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + // Never healthy, so connect() fails before the upgrade. + return new Response(null, { status: 503 }); + }, + port() { + throw new Error("not used"); + }, + async setInactivityTimeout() {}, + async status() { + return { running: true, exit: null }; + }, + async exitInfo() { + return null; + }, + }; + return { host, fetches, starts }; +} + +describe("ContainerBackend local-only paths", () => { + const readInfo = async (host: IWorkspaceContainerAPI) => { + const res = await host.fetchPort(8080, "http://container/__computerd/info"); + return readIgnoreReport(await res.json()); + }; + + test("reads the ignore block a current container reports", async () => { + const { host } = fakeHost({ + info: { + supported: true, + enabled: true, + root: "/tmp/workspace", + paths: ["node_modules", "dist"], + redundant: [], + }, + }); + expect(await readInfo(host)).toEqual({ + paths: ["/workspace/node_modules", "/workspace/dist"], + root: "/tmp/workspace", + mountPoint: "/workspace", + supported: true, + }); + }); + + test("treats a container with no ignore block as unsupported", async () => { + // The version-skew case the README warns about: the computerd image + // can lag the pinned client. Without this the old image looks like + // it is working while quietly syncing everything. + const { host } = fakeHost({}); + expect(await readInfo(host)).toEqual({ + paths: [], + root: undefined, + mountPoint: undefined, + supported: false, + }); + }); + + test("the backend requests /__computerd/info on the container port", async () => { + // Pins the path and port, so a rename upstream fails here rather + // than silently degrading every deployment to "unsupported". + const { host, fetches } = fakeHost({ + info: { supported: true, paths: [], root: "/tmp/workspace" }, + }); + await host.fetchPort(8080, "http://container/__computerd/info"); + expect(fetches).toContainEqual({ port: 8080, path: "/__computerd/info" }); + }); + + const backendWith = (host: IWorkspaceContainerAPI, ignore?: readonly string[]) => + new ContainerBackend({ + container: () => ({ getWorkspaceContainer: () => host }), + workspace: { binding: "SESSIONS", id: "session-1" }, + restartAttempts: 0, + connectTimeoutMs: 400, + healthProbeTimeoutMs: 50, + healthRetryInitialDelayMs: 10, + healthRetryMaxDelayMs: 20, + heartbeatIntervalMs: 0, + ...(ignore === undefined ? {} : { ignore }), + }); + + test("passes `ignore` to the container as MOUNT_IGNORE at start time", async () => { + // The set is deployment config, not image config: it has to arrive + // in the start environment or the image would have to be rebuilt to + // change it. + const { host, starts } = fakeHost(); + await backendWith(host, ["/node_modules", "/.venv", "/dist"]) + .connect() + .catch(() => undefined); + + expect(starts).toHaveLength(1); + expect(starts[0]?.env?.MOUNT_IGNORE).toBe("/node_modules,/.venv,/dist"); + }); + + test("sends no MOUNT_IGNORE when `ignore` is omitted", async () => { + const { host, starts } = fakeHost(); + await backendWith(host) + .connect() + .catch(() => undefined); + + expect(starts).toHaveLength(1); + expect(starts[0]?.env?.MOUNT_IGNORE).toBeUndefined(); + }); +}); + +// Drives connect() through the upgrade, so the ignore check runs against a +// container that enforces its client secret the way computerd does: every +// route except /health needs the bearer token. +describe("ContainerBackend ignore check on a full connect", () => { + const SECRET = "00112233445566778899aabbccddeeff"; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Enough of a WebSocket for capnweb to attach to and for the backend to + // close. Nothing is sent over it in these tests. + class FakeSocket { + readyState = 1; + accept() {} + addEventListener() {} + removeEventListener() {} + send() {} + close() { + this.readyState = 3; + } + } + + function connectingHost(ignore: Record) { + let backend: ContainerBackend | undefined; + const infoAuth: (string | null)[] = []; + const host: IWorkspaceContainerAPI = { + async start() { + return { runtimeId: "runtime-1", clientSecret: SECRET, outcome: "launched" }; + }, + async restart() { + throw new Error("not used"); + }, + async interceptOutboundHttp() {}, + async interceptAllOutboundHttp() {}, + async fetchPort(_port, url, init) { + const path = new URL(url).pathname; + const auth = new Headers(init?.headers).get("authorization"); + if (path === "/health") return new Response("ok"); + if (path === "/__computerd/info") infoAuth.push(auth); + if (auth !== `Bearer ${SECRET}`) return new Response(null, { status: 401 }); + if (path === "/connect") { + // computerd dials back as soon as it is told where to go. + await backend + ?.handleFetch( + new Request("http://computer.internal/api", { + headers: { upgrade: "websocket", authorization: `Bearer ${SECRET}` }, + }), + ) + // The 101 Response is a workerd-only shape; the upgrade has + // already been handed over by the time it is built. + .catch(() => undefined); + return new Response(null, { status: 200 }); + } + if (path === "/__computerd/info") { + return Response.json({ backend: { kind: "fuse" }, mountPoint: "/workspace", ignore }); + } + return new Response(null, { status: 404 }); + }, + port() { + throw new Error("not used"); + }, + async setInactivityTimeout() {}, + async status() { + return { running: true, exit: null }; + }, + async exitInfo() { + return null; + }, + }; + return { + host, + infoAuth, + attach(b: ContainerBackend) { + backend = b; + }, + }; + } + + function backendFor(fake: ReturnType, ignore: readonly string[]) { + vi.stubGlobal( + "WebSocketPair", + class { + 0 = new FakeSocket(); + 1 = new FakeSocket(); + }, + ); + const backend = new ContainerBackend({ + container: () => ({ getWorkspaceContainer: () => fake.host }), + workspace: { binding: "SESSIONS", id: "session-1" }, + restartAttempts: 0, + connectTimeoutMs: 2_000, + heartbeatIntervalMs: 0, + ignore, + }); + fake.attach(backend); + return backend; + } + + test("reads /__computerd/info with the client secret", async () => { + const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] }); + const handle = await backendFor(fake, ["/dist"]).connect(); + + expect(fake.infoAuth).toEqual([`Bearer ${SECRET}`]); + expect(handle.ignore).toEqual({ + paths: ["/workspace/dist"], + root: "/tmp/workspace", + mountPoint: "/workspace", + supported: true, + }); + await handle.close(); + }); + + test("accepts a declaration spelled with the mount point", async () => { + // computerd strips the mount prefix from "/workspace/dist" and + // applies "dist". The declaration means the same thing. + const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] }); + const handle = await backendFor(fake, ["/workspace/dist"]).connect(); + await handle.close(); + }); + + test("still rejects a real mismatch", async () => { + const fake = connectingHost({ supported: true, root: "/tmp/workspace", paths: ["dist"] }); + await expect(backendFor(fake, ["/node_modules"]).connect()).rejects.toBeInstanceOf( + ContainerIgnoreMismatchError, + ); + }); +}); diff --git a/packages/computer/src/backends/container/container-backend.ts b/packages/computer/src/backends/container/container-backend.ts index 2994f79b..fb837cda 100644 --- a/packages/computer/src/backends/container/container-backend.ts +++ b/packages/computer/src/backends/container/container-backend.ts @@ -58,6 +58,7 @@ import { WorkspaceTransportError } from "../../transport-failure.js"; import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js"; import type { ContainerInstanceSize, ContainerLaunchSpec } from "./container-launch-record.js"; import { probeComputerdHealth } from "./health-probe.js"; +import { assertIgnoreMatches, type ResolvedIgnore, readIgnoreReport } from "./ignore-assertion.js"; // What the backend's `container` factory returns: anything with // a getWorkspaceContainer() method — the shape withWorkspaceContainer @@ -110,6 +111,16 @@ export interface ContainerBackendOptions { // timers warm. Default 20_000ms. Set 0 to disable. heartbeatIntervalMs?: number; + // Paths the container keeps on its local disk instead of the + // workspace (#179). Written as mount-relative absolute paths + // ("/node_modules"), and passed to the container at start time as + // MOUNT_IGNORE. + // + // connect() reads the resolved set back off /__computerd/info and + // refuses the connection if it disagrees, which catches an image + // whose computerd is too old to honor the variable. + ignore?: readonly string[]; + // Number of forced restart attempts after startup readiness // fails. The first attempt runs host.start() then probes computerd; // each restart attempt runs host.restart() then probes computerd @@ -209,15 +220,26 @@ export class ContainerBackend implements WorkspaceBackend { readonly type = "cloudflare-container"; readonly id: string; + // `ignore` sits with the un-defaulted options rather than under + // Required: undefined is a meaningful value for it (skip the check), + // not a gap to be filled with a default. readonly #options: Required< Omit< ContainerBackendOptions, - "container" | "workspace" | "containerEnv" | "egress" | "id" | "name" | "instance" | "launch" + | "container" + | "workspace" + | "containerEnv" + | "egress" + | "id" + | "name" + | "instance" + | "launch" + | "ignore" > > & Pick< ContainerBackendOptions, - "container" | "workspace" | "containerEnv" | "name" | "instance" | "launch" + "container" | "workspace" | "containerEnv" | "name" | "instance" | "launch" | "ignore" >; readonly #egress: WorkspaceEgressPolicy; readonly #egressToken: string | undefined; @@ -243,6 +265,7 @@ export class ContainerBackend implements WorkspaceBackend { container: options.container, workspace: options.workspace, containerEnv: options.containerEnv, + ignore: options.ignore, egressHost: options.egressHost ?? DEFAULT_EGRESS_HOST, containerPort: options.containerPort ?? DEFAULT_CONTAINER_PORT, connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, @@ -276,6 +299,9 @@ export class ContainerBackend implements WorkspaceBackend { const env = { PORT: String(this.#options.containerPort), MOUNT_POINT: "/workspace", + ...(this.#options.ignore !== undefined + ? { MOUNT_IGNORE: this.#options.ignore.join(",") } + : {}), ...this.#options.containerEnv, }; let runtimeId: string; @@ -374,10 +400,36 @@ export class ContainerBackend implements WorkspaceBackend { }); } + // Checked before the handle is published, so a mismatched image + // never serves a single command. Doing this after connect() returned + // would let the first exec write into a path the caller believes is + // local-only, which is precisely the state that is expensive to + // discover later. + const resolvedIgnore = await this.#resolveIgnore(host, clientSecret); + try { + assertIgnoreMatches(this.#options.ignore, resolvedIgnore); + } catch (error) { + // Tear the transport down rather than leaking a live socket for a + // connection the caller is not going to get. + try { + (stub as unknown as Disposable)[Symbol.dispose]?.(); + } catch { + // already disposed; idempotent + } + try { + ws.close(); + } catch { + // already closed; idempotent + } + stopHeartbeat?.(); + throw error; + } + const handle: BackendHandle = { rpc: stub as unknown as WorkspaceRPC, runtimeId, closed, + ignore: resolvedIgnore, close: async () => { stopHeartbeat?.(); // Dispose the root stub first. Per capnweb's docs, this is @@ -576,6 +628,39 @@ export class ContainerBackend implements WorkspaceBackend { ); } + // Reads the container's local-only path configuration. + // + // A failure to reach /__computerd/info is reported as "unsupported" + // rather than propagated. The endpoint is diagnostic, and a client + // that declared no `ignore` should not lose a working connection + // because a diagnostic request failed. A client that *did* declare + // one still fails, via assertIgnoreMatches -- which is the right + // split: silence is only acceptable when nobody asked. + // + // The endpoint sits behind the client secret like every route except + // /health. Without the token an enforcing container answers 401, which + // would read as "unsupported" and fail every connect that declared + // `ignore`. + async #resolveIgnore( + host: IWorkspaceContainerAPI, + clientSecret: string, + ): Promise { + try { + const res = await host.fetchPort( + this.#options.containerPort, + "http://container/__computerd/info", + { + headers: { authorization: `Bearer ${clientSecret}` }, + signal: AbortSignal.timeout(this.#options.healthProbeTimeoutMs), + }, + ); + if (!res.ok) return { paths: [], root: undefined, mountPoint: undefined, supported: false }; + return readIgnoreReport(await res.json()); + } catch { + return { paths: [], root: undefined, mountPoint: undefined, supported: false }; + } + } + async #probeUntilHealthy(host: IWorkspaceContainerAPI, deadline: number): Promise { let delay = this.#options.healthRetryInitialDelayMs; let lastError: unknown; diff --git a/packages/computer/src/backends/container/ignore-assertion.test.ts b/packages/computer/src/backends/container/ignore-assertion.test.ts new file mode 100644 index 00000000..8b5fb2ce --- /dev/null +++ b/packages/computer/src/backends/container/ignore-assertion.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from "vitest"; + +import { + assertIgnoreMatches, + ContainerIgnoreMismatchError, + diffIgnore, + type ResolvedIgnore, + readIgnoreReport, +} from "./ignore-assertion.js"; + +// The failure guarded here is slow rather than loud: a stale or absent +// MOUNT_IGNORE looks exactly like a correct one until a dependency tree +// is written and pulled into the DO. So most of these tests are about +// the check firing, not about it passing. + +const supported = (paths: string[]): ResolvedIgnore => ({ + paths, + root: "/tmp/workspace", + mountPoint: "/workspace", + supported: true, +}); + +describe("readIgnoreReport", () => { + test("reports paths as absolute container paths under the mount", () => { + // computerd reports mount-relative; the host wants something it can + // use against a container path without re-deriving the mount point. + const resolved = readIgnoreReport({ + backend: { kind: "fuse" }, + mountPoint: "/workspace", + ignore: { + supported: true, + enabled: true, + root: "/tmp/workspace", + paths: ["node_modules", "dist"], + redundant: [], + }, + }); + expect(resolved).toEqual({ + paths: ["/workspace/node_modules", "/workspace/dist"], + root: "/tmp/workspace", + mountPoint: "/workspace", + supported: true, + }); + }); + + test("treats a computerd with no ignore block as unsupported", () => { + // The old-image case, and the one most likely to occur in practice. + // Not a parse error: absence is a meaningful answer. + const resolved = readIgnoreReport({ backend: { kind: "fuse" }, mountPoint: "/workspace" }); + expect(resolved).toEqual({ + paths: [], + root: undefined, + mountPoint: undefined, + supported: false, + }); + }); + + test("treats a malformed block as unsupported rather than throwing", () => { + expect(readIgnoreReport({ ignore: null }).supported).toBe(false); + expect(readIgnoreReport({ ignore: "yes" }).supported).toBe(false); + expect(readIgnoreReport({ ignore: { supported: false } }).supported).toBe(false); + expect(readIgnoreReport(null).supported).toBe(false); + expect(readIgnoreReport(undefined).supported).toBe(false); + }); + + test("defaults paths to empty when the block omits them", () => { + const resolved = readIgnoreReport({ + mountPoint: "/workspace", + ignore: { supported: true, root: "/tmp/x" }, + }); + expect(resolved).toEqual({ + paths: [], + root: "/tmp/x", + mountPoint: "/workspace", + supported: true, + }); + }); +}); + +describe("diffIgnore", () => { + test("agrees when the sets match", () => { + expect(diffIgnore(["node_modules", "dist"], ["node_modules", "dist"])).toBeNull(); + }); + + test("ignores declaration order", () => { + // computerd reports in declaration order after dropping redundant + // entries; a host listing the same paths differently means the same. + expect(diffIgnore(["dist", "node_modules"], ["node_modules", "dist"])).toBeNull(); + }); + + test("ignores slash decoration on either side", () => { + expect(diffIgnore(["/dist/", "node_modules"], ["dist", "node_modules"])).toBeNull(); + }); + + test("collapses duplicates in the declaration", () => { + // computerd would have collapsed them, so the client must too or + // every duplicated entry becomes a spurious mismatch. + expect(diffIgnore(["dist", "dist"], ["dist"])).toBeNull(); + }); + + test("reports a path the container does not apply", () => { + expect(diffIgnore(["node_modules", "dist"], ["node_modules"])).toEqual({ + missing: ["dist"], + unexpected: [], + }); + }); + + test("reports a path the container applies but the caller did not declare", () => { + expect(diffIgnore(["node_modules"], ["node_modules", "target"])).toEqual({ + missing: [], + unexpected: ["target"], + }); + }); + + test("reports both directions at once", () => { + expect(diffIgnore(["a", "b"], ["b", "c"])).toEqual({ missing: ["a"], unexpected: ["c"] }); + }); + + test("an empty declaration against a configured container is a mismatch", () => { + // Distinct from omitting `ignore` entirely, which skips the check. + // Declaring "nothing is local-only" against a container that makes + // node_modules local-only is a real disagreement. + expect(diffIgnore([], ["node_modules"])).toEqual({ + missing: [], + unexpected: ["node_modules"], + }); + }); +}); + +describe("assertIgnoreMatches", () => { + test("omitting the declaration skips the check", () => { + // The default. Adopting this option is opt-in, so an existing + // deployment cannot start failing because a new field appeared. + expect(() => assertIgnoreMatches(undefined, supported(["node_modules"]))).not.toThrow(); + expect(() => + assertIgnoreMatches(undefined, { + paths: [], + root: undefined, + mountPoint: undefined, + supported: false, + }), + ).not.toThrow(); + }); + + test("passes when the declaration matches", () => { + expect(() => + assertIgnoreMatches(["node_modules", "dist"], supported(["node_modules", "dist"])), + ).not.toThrow(); + }); + + test("rejects a computerd that does not support the feature", () => { + // README warns the computerd image can lag the pinned client. An + // old image would otherwise look like it is working while quietly + // syncing a full node_modules. + expect(() => + assertIgnoreMatches(["node_modules"], { + paths: [], + root: undefined, + mountPoint: undefined, + supported: false, + }), + ).toThrow(ContainerIgnoreMismatchError); + expect(() => + assertIgnoreMatches(["node_modules"], { + paths: [], + root: undefined, + mountPoint: undefined, + supported: false, + }), + ).toThrow(/does not support local-only paths/); + }); + + test("the unsupported message says what the consequence is", () => { + // Not just "mismatch". The operator needs to know the paths will be + // pulled into the DO, which is the expensive part. + try { + assertIgnoreMatches(["node_modules"], { + paths: [], + root: undefined, + mountPoint: undefined, + supported: false, + }); + expect.unreachable("should have thrown"); + } catch (error) { + expect((error as Error).message).toMatch(/pulled into the Durable Object/); + expect((error as Error).message).toMatch(/Upgrade the computerd image/); + } + }); + + test("names which paths will be synced when the container is missing one", () => { + try { + assertIgnoreMatches(["node_modules", "dist"], supported(["node_modules"])); + expect.unreachable("should have thrown"); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch(/"dist"/); + expect(message).toMatch(/WILL be synced/); + } + }); + + test("names which paths will not be synced when the container adds one", () => { + // The opposite direction is just as dangerous: the caller believes + // `target` is durable and it is not. + try { + assertIgnoreMatches(["node_modules"], supported(["node_modules", "target"])); + expect.unreachable("should have thrown"); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch(/"target"/); + expect(message).toMatch(/will NOT be synced/); + } + }); + + test("points at the setting that overrides `ignore`", () => { + // `ignore` is passed to the container as MOUNT_IGNORE, so a + // disagreement means something else set the variable after it. + try { + assertIgnoreMatches(["a"], supported(["b"])); + expect.unreachable("should have thrown"); + } catch (error) { + expect((error as Error).message).toMatch(/MOUNT_IGNORE in `containerEnv`/); + } + }); + + test("accepts declarations spelled with the mount point", () => { + // computerd strips the mount prefix, so "/workspace/dist" and "/dist" + // configure the same path. Comparing them raw rejects a container + // that is doing exactly what was asked. + expect(() => + assertIgnoreMatches( + ["/workspace/dist", "/workspace/node_modules/"], + supported(["/workspace/dist", "/workspace/node_modules"]), + ), + ).not.toThrow(); + }); + + test("does not strip a prefix that only looks like the mount point", () => { + // "/workspacefoo" is not under "/workspace", so it names + // "/workspace/workspacefoo", not "/workspace/foo". + expect(() => assertIgnoreMatches(["/workspacefoo"], supported(["/workspace/foo"]))).toThrow( + ContainerIgnoreMismatchError, + ); + }); + + test("carries the declared and actual sets on the error", () => { + // So a host can log or reconcile them without parsing the message. + try { + assertIgnoreMatches(["a"], supported(["b"])); + expect.unreachable("should have thrown"); + } catch (error) { + const mismatch = error as ContainerIgnoreMismatchError; + expect(mismatch.declared).toEqual(["a"]); + expect(mismatch.actual).toEqual(["b"]); + expect(mismatch.supported).toBe(true); + } + }); +}); diff --git a/packages/computer/src/backends/container/ignore-assertion.ts b/packages/computer/src/backends/container/ignore-assertion.ts new file mode 100644 index 00000000..8bf9696a --- /dev/null +++ b/packages/computer/src/backends/container/ignore-assertion.ts @@ -0,0 +1,199 @@ +// Client-side check of the container's local-only path set. The backend +// passes `ignore` to the container as MOUNT_IGNORE at start, then reads +// back what computerd actually applied and refuses to connect if the two +// disagree. See packages/computerd/README.md. +// +// Fails the connection rather than warning, because the failure it +// guards is silent and expensive: a computerd too old to read +// MOUNT_IGNORE, or a MOUNT_IGNORE in `containerEnv` overriding the +// option, looks identical to a correct setup until a command writes a +// large dependency tree and the whole thing is pulled into the Durable +// Object -- the #179 symptom. A mismatch is a deployment error, and a +// loud one is cheaper than a slow one. + +/** The `ignore` block computerd reports on /__computerd/info. */ +export interface ComputerdIgnoreReport { + readonly supported?: boolean; + readonly enabled?: boolean; + readonly root?: string; + readonly paths?: readonly string[]; + readonly redundant?: readonly string[]; + readonly fastPaths?: Readonly>; +} + +/** What the backend exposes back to the host after a successful connect. */ +export interface ResolvedIgnore { + /** + * Absolute paths as they exist inside the container, under MOUNT_POINT. + * `node_modules` with a mount of /workspace reports /workspace/node_modules, + * so the value can be used directly against a container path without the + * caller re-deriving the mount. Empty when the feature is off. + */ + readonly paths: readonly string[]; + /** + * Where local-only content is stored on the container's disk + * (MOUNT_IGNORE_PATH). Undefined when unsupported. + */ + readonly root: string | undefined; + /** The mount point the paths are rooted at. Undefined when unsupported. */ + readonly mountPoint: string | undefined; + /** False on a computerd predating the feature, so a host can degrade. */ + readonly supported: boolean; +} + +/** Joins a mount-relative entry onto the mount point. */ +function toContainerPath(entry: string, mountPoint: string): string { + const base = mountPoint.replace(/\/+$/, ""); + const rel = entry.replace(/^\/+/, ""); + return `${base}/${rel}`; +} + +export class ContainerIgnoreMismatchError extends Error { + readonly declared: readonly string[]; + readonly actual: readonly string[]; + readonly supported: boolean; + + constructor( + message: string, + details: { declared: readonly string[]; actual: readonly string[]; supported: boolean }, + ) { + super(message); + this.name = "ContainerIgnoreMismatchError"; + this.declared = details.declared; + this.actual = details.actual; + this.supported = details.supported; + } +} + +/** + * Reads the `ignore` block out of a /__computerd/info body. + * + * Tolerant by design: an older computerd has no such block, and that is + * a supported answer (`supported: false`) rather than a parse error. + * The caller decides whether it is acceptable. + */ +export function readIgnoreReport(info: unknown): ResolvedIgnore { + if (typeof info !== "object" || info === null || !("ignore" in info)) { + return { paths: [], root: undefined, mountPoint: undefined, supported: false }; + } + const report = (info as { ignore?: unknown }).ignore; + if (typeof report !== "object" || report === null) { + return { paths: [], root: undefined, mountPoint: undefined, supported: false }; + } + const typed = report as ComputerdIgnoreReport; + if (typed.supported !== true) { + return { paths: [], root: undefined, mountPoint: undefined, supported: false }; + } + // computerd reports entries mount-relative; the host wants paths it can + // use against the container directly, so they are joined onto the mount + // point from the same payload. + const mountPoint = (info as { mountPoint?: unknown }).mountPoint; + const base = typeof mountPoint === "string" && mountPoint !== "" ? mountPoint : "/workspace"; + return { + paths: Array.isArray(typed.paths) ? typed.paths.map((e) => toContainerPath(e, base)) : [], + root: typeof typed.root === "string" ? typed.root : undefined, + mountPoint: base, + supported: true, + }; +} + +/** + * Compares a declared set against what the container applies; null when + * they agree. Order-insensitive and duplicate-collapsing, because + * computerd normalizes the same way and the two spellings mean the same + * thing. + */ +export function diffIgnore( + declared: readonly string[], + actual: readonly string[], +): { missing: string[]; unexpected: string[] } | null { + const declaredSet = new Set(declared.map(normalize)); + const actualSet = new Set(actual.map(normalize)); + + const missing = [...declaredSet].filter((entry) => !actualSet.has(entry)).sort(); + const unexpected = [...actualSet].filter((entry) => !declaredSet.has(entry)).sort(); + + if (missing.length === 0 && unexpected.length === 0) return null; + return { missing, unexpected }; +} + +/** + * Throws when the container disagrees. `declared === undefined` skips the + * check, so an existing deployment cannot start failing because a new + * field appeared. + */ +export function assertIgnoreMatches( + declared: readonly string[] | undefined, + resolved: ResolvedIgnore, +): void { + if (declared === undefined) return; + + if (!resolved.supported) { + throw new ContainerIgnoreMismatchError( + `This container's computerd does not support local-only paths, but ` + + `\`ignore\` declared ${formatList(declared)}. Those paths would be ` + + `recorded in the workspace and pulled into the Durable Object. ` + + `Upgrade the computerd image, or remove \`ignore\` to accept the ` + + `container's behavior.`, + { declared: [...declared], actual: [], supported: false }, + ); + } + + // resolved.paths are absolute container paths. A declaration may be + // written mount-relative ("/node_modules") or with the mount point + // ("/workspace/node_modules"), and computerd accepts both. Compare + // both sides on the mount-relative form. + const declaredRelative = declared.map((path) => stripMount(path, resolved.mountPoint)); + const actualRelative = resolved.paths.map((path) => stripMount(path, resolved.mountPoint)); + const difference = diffIgnore(declaredRelative, actualRelative); + if (difference === null) return; + + const parts: string[] = []; + if (difference.missing.length > 0) { + parts.push( + `declared but not applied by the container: ${formatList(difference.missing)} ` + + `(these paths WILL be synced)`, + ); + } + if (difference.unexpected.length > 0) { + parts.push( + `applied by the container but not declared: ${formatList(difference.unexpected)} ` + + `(these paths will NOT be synced)`, + ); + } + + throw new ContainerIgnoreMismatchError( + `Container ignore set does not match \`ignore\`: ${parts.join("; ")}. ` + + `\`ignore\` is passed to the container as MOUNT_IGNORE, so a ` + + `MOUNT_IGNORE in \`containerEnv\` overrides it. Remove one of them, ` + + `or check that the computerd image reads MOUNT_IGNORE as a ` + + `comma-separated list.`, + { declared: [...declared], actual: [...resolved.paths], supported: true }, + ); +} + +/** + * Reduces an absolute container path to its mount-relative form, so a + * declaration and a report can be compared on the same footing. + */ +function stripMount(path: string, mountPoint: string | undefined): string { + if (mountPoint === undefined) return path; + const base = mountPoint.replace(/\/+$/, ""); + const trimmed = path.trim(); + if (base !== "" && (trimmed === base || trimmed.startsWith(`${base}/`))) { + return trimmed.slice(base.length + 1); + } + return trimmed; +} + +function normalize(entry: string): string { + let value = entry.trim(); + while (value.startsWith("/")) value = value.slice(1); + while (value.endsWith("/")) value = value.slice(0, -1); + return value; +} + +function formatList(entries: readonly string[]): string { + if (entries.length === 0) return "(none)"; + return entries.map((entry) => JSON.stringify(entry)).join(", "); +} diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 5922a52f..62677cea 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -123,6 +123,139 @@ byte sizes, inline byte totals, and the process's RSS/heap/external figures. Poll it during a long-running install or test to watch how the store grows. +## Local-only paths (`MOUNT_IGNORE`) + +Everything a container command writes under `MOUNT_POINT` is recorded in +the VFS and pulled into the Durable Object after the command. That is +right for source and wrong for `node_modules`, `.venv`, `target/`, +`dist/` and caches: tens of thousands of rebuildable files that never +need to be durable. `MOUNT_IGNORE` names paths that stay on the +container's local disk instead. They are never recorded, pushed, or +pulled. + +Content under a local-only path is visible only inside the container; +`workspace.fs` and the worker shell do not see it. It is absent from +sync, so a container replaced without a snapshot restore loses it. It +does survive a container snapshot, because `MOUNT_IGNORE_PATH` is a real +filesystem path, which is why the default sits under `/tmp` rather than +on a tmpfs. That suits a dependency tree a package manager can rebuild, +not anything a user typed. + +### Configuration + +`ContainerBackend` takes an `ignore` option and passes it to the +container's start environment, so changing the set is a deployment +change rather than an image rebuild. `LegacyContainerBackend` has no +such option; set `MOUNT_IGNORE` through its `containerEnv` instead. + +```ts +new ContainerBackend({ + container: env.CONTAINER, + workspace: { binding: "SESSIONS", id: sessionId }, + ignore: ["/node_modules", "/.venv", "/dist"], +}); +``` + +That becomes `MOUNT_IGNORE=/node_modules,/.venv,/dist`. Setting the +variable directly, in `containerEnv` or a Dockerfile, works too and +takes precedence. + +`MOUNT_IGNORE` is a comma-separated list of paths anchored at the mount +root: `/node_modules` means `$MOUNT_POINT/node_modules`. There is no glob +syntax and no negation. A path is local-only if it equals an entry or +sits beneath it, so `/app/node_modules` matches only that path, and a +monorepo lists each `/node_modules` separately. A path containing a +comma cannot be expressed. `MOUNT_IGNORE_PATH` sets where local-only +content is stored and defaults to `/tmp` + `$MOUNT_POINT`. + +The set is compiled once at startup, so it cannot change under a running +container, and two sessions sharing one container see the same +durability boundary. `connect()` reads the resolved set back off +`/__computerd/info` and refuses the connection if it disagrees with what +was declared, which catches a computerd too old to honor the variable. +The handle exposes it as absolute container paths: + +```ts +const handle = await backend.connect(); +handle.ignore; +// { +// paths: ["/workspace/node_modules", "/workspace/.venv", "/workspace/dist"], +// root: "/tmp/workspace", +// mountPoint: "/workspace", +// supported: true, +// } +``` + +`supported: false` means the container predates the feature and every +path is synced. + +### Validation + +`MOUNT_IGNORE_PATH` must be absolute, must not be `/`, and must not be +equal to or inside `MOUNT_POINT`, since a root inside the mount would +resolve into itself. Entries may not contain `.` or `..` segments. Each +of these fails the daemon at startup rather than quietly disabling the +feature, because a dropped entry means a full `node_modules` goes into +the Durable Object. Duplicates and entries nested inside another entry +are dropped as redundant and reported. + +`/__computerd/info` reports the normalized configuration: + +```jsonc +{ + "ignore": { + "supported": true, + "enabled": true, + "root": "/tmp/workspace", + "paths": ["node_modules", "dist"], + "redundant": ["node_modules/.cache"], + "fastPaths": { + "passthrough": false, + "passthroughReason": "fuse-native binds libfuse 2.9; FOPEN_PASSTHROUGH requires the libfuse 3.17 API", + "writebackCache": false + } + } +} +``` + +`fastPaths.passthrough` is `false` on current builds by design. Ignored +writes skip the VFS and the transfer but still cross FUSE; see +[19. Performance](../../docs/19_performance.md#local-only-paths-mount_ignore). + +### Renames across the boundary + +A rename whose source and destination sit on opposite sides of the +boundary returns `EXDEV` (`Invalid cross-device link`). The two sides are +different filesystems, so the rename cannot be atomic, and copying then +unlinking would fake the atomicity `rename(2)` promises. `mv` and +Python's `shutil.move` copy instead when they see `EXDEV`, but a program +that calls `rename` directly, such as Node's `fs.rename` or Go's +`os.Rename`, gets the error. Renames within one side are ordinary atomic +renames. Hardlinks across the boundary return `EXDEV` for the same +reason. + +The usual cause is a build tool that stages into a sibling directory and +renames into place. The fix is to ignore the staging path too: + +```ts +ignore: ["/dist", "/.tmp-build"]; +``` + +Candidates worth checking are `.next`, `.turbo`, `node_modules/.cache`, +and any staging directory a bundler creates next to its output. +computerd logs this guidance on the first crossing rename per mount, +naming both sides and the entry to add. Later occurrences are not +logged, but `GET /__computerd/stats` counts them all under +`localPaths.crossLayerRenames`. + +### `MOUNT_IGNORE` versus `fetchChanges({ ignore })` + +`MOUNT_IGNORE` works at the mount: the path never enters the VFS. +`fetchChanges({ ignore })` works at the sync RPC: the path is skipped in +one transfer but still occupies the container's store. A wrapper that +injects `ignore` into `fetchChanges` to keep a dependency tree out of the +Durable Object should be deleted in favor of `MOUNT_IGNORE`. + ## FUSE prerequisites Linux hosts/containers need access to `/dev/fuse` and mount permissions. diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index 70bb9f06..8bf4a335 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -99,6 +99,22 @@ test("computerd exposes file IO through real FUSE when FUSE_MOUNT=fuse", async ( mountPoint, port, store: { kind: "memory" }, + // Local-only paths are off unless MOUNT_IGNORE is set, but the block + // is always reported: a client needs to distinguish "this build has + // no such feature" from "the feature is present and configured + // empty", and absence cannot express that. + ignore: { + supported: true, + enabled: false, + root: `/tmp${mountPoint}`, + paths: [], + redundant: [], + fastPaths: { + passthrough: false, + passthroughReason: expect.stringContaining("libfuse 2.9"), + writebackCache: false, + }, + }, }); await fs.mkdir(path.join(mountPoint, "dir")); @@ -106,6 +122,79 @@ test("computerd exposes file IO through real FUSE when FUSE_MOUNT=fuse", async ( expect(await fs.readFile(path.join(mountPoint, "dir", "hello.txt"), "utf8")).toBe("hello fuse"); }); +test("MOUNT_IGNORE keeps matching paths on local disk and out of the VFS", async (ctx) => { + const backend = await resolveFuseBackend("auto"); + if (backend.kind !== "fuse") { + ctx.skip(`requires real FUSE; auto resolved to ${backend.kind}`); + return; + } + + const port = await getAvailablePort(); + const mountPoint = await fs.mkdtemp(path.join(os.tmpdir(), "computerd-mount-")); + const ignoreRoot = await fs.mkdtemp(path.join(os.tmpdir(), "computerd-local-")); + await startComputerd({ + port, + mountPoint, + env: { + FUSE_MOUNT: "fuse", + MOUNT_IGNORE: "/node_modules,/dist", + MOUNT_IGNORE_PATH: ignoreRoot, + }, + }); + + const info = await request(`http://127.0.0.1:${port}/__computerd/info`); + expect(JSON.parse(info.body).ignore).toMatchObject({ + enabled: true, + root: ignoreRoot, + paths: ["node_modules", "dist"], + }); + + // Write through the mount into an ignored path. + await fs.mkdir(path.join(mountPoint, "node_modules", "pkg"), { recursive: true }); + await fs.writeFile(path.join(mountPoint, "node_modules", "pkg", "index.js"), "module.exports=1"); + + // It reads back through the mount, so a command in the container sees it. + expect(await fs.readFile(path.join(mountPoint, "node_modules", "pkg", "index.js"), "utf8")).toBe( + "module.exports=1", + ); + + // And it is on local disk, with the tree structure preserved, rather + // than in the VFS. This is the whole point: nothing here can reach + // sync, so none of it is pulled into the Durable Object. + expect(await fs.readFile(path.join(ignoreRoot, "node_modules/pkg/index.js"), "utf8")).toBe( + "module.exports=1", + ); + + // A non-ignored sibling still goes to the VFS as before. + await fs.mkdir(path.join(mountPoint, "src"), { recursive: true }); + await fs.writeFile(path.join(mountPoint, "src", "main.ts"), "export {}"); + await expect(fs.stat(path.join(ignoreRoot, "src"))).rejects.toThrow(); + + // Both layers appear in one listing. + const entries = await fs.readdir(mountPoint); + expect(entries).toContain("node_modules"); + expect(entries).toContain("src"); + + // A rename across the boundary is refused rather than silently made + // non-atomic. EXDEV is what rename(2) returns between any two + // filesystems. + await expect( + fs.rename(path.join(mountPoint, "src"), path.join(mountPoint, "dist")), + ).rejects.toMatchObject({ code: "EXDEV" }); + + // Within the local layer it is a real, atomic rename. + await fs.mkdir(path.join(mountPoint, "node_modules", ".staging"), { recursive: true }); + await fs.rename( + path.join(mountPoint, "node_modules", ".staging"), + path.join(mountPoint, "node_modules", "final"), + ); + expect(await fs.readdir(path.join(ignoreRoot, "node_modules"))).toContain("final"); + + // The refused rename is counted where an operator can see it. + const stats = await request(`http://127.0.0.1:${port}/__computerd/stats`); + expect(JSON.parse(stats.body).localPaths).toMatchObject({ crossLayerRenames: 1 }); +}); + test("/api serves a capnweb WorkspaceRPC session", async (_ctx) => { const { createWorkspaceClient } = await import("@cloudflare/computer-rpc/client"); const port = await getAvailablePort(); @@ -357,6 +446,30 @@ test("computerd rejects unknown FUSE_MOUNT values", async () => { expect(stderr).toMatch(/FUSE_MOUNT must be one of/); }); +test("computerd refuses MOUNT_IGNORE on the userspace shim", async () => { + // The shim copies everything under the mount into the VFS, so it + // cannot keep a path local. Starting anyway would report the paths as + // local-only while syncing them, which is the failure MOUNT_IGNORE + // exists to prevent. + const port = await getAvailablePort(); + const mountPoint = await fs.mkdtemp(path.join(os.tmpdir(), "computerd-mount-")); + const child = spawn(cliPath, { + cwd: packageRoot, + env: { + ...process.env, + MOUNT_POINT: mountPoint, + PORT: String(port), + FUSE_MOUNT: "shim", + MOUNT_IGNORE: "/node_modules", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + + const { code, stderr } = await waitForExit(child); + expect(code).toBe(1); + expect(stderr).toMatch(/MOUNT_IGNORE is not supported on the userspace shim/); +}); + test.each([ ["DISABLE_FUSE", "1"], ["FUSE_SHIM", "1"], diff --git a/packages/computerd/src/cli/computerd.ts b/packages/computerd/src/cli/computerd.ts index a6b053ee..ae83b180 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -15,13 +15,16 @@ import { Runner } from "../exec/index.js"; import type { ExecEvent as ComputerdExecEvent } from "../exec/types.js"; import { createNodeVirtualFileSystem, + describeMountIgnore, type FUSEBackend, type FuseMount, + type MountIgnoreInfo, mountFuse, parseFuseMountMode, parseStoreMode, type ResolvedStore, resolveFuseBackend, + resolveMountIgnoreConfig, resolveStore, } from "../fuse/index.js"; import { mountShim, type ShimMount } from "../shim/index.js"; @@ -151,6 +154,7 @@ interface ComputerdInfo { mountPoint: string; port: number; store: ResolvedStore; + ignore: MountIgnoreInfo; } // Snapshot DOFS table sizes and process memory so an external caller @@ -631,6 +635,35 @@ async function main(): Promise { const backend: FUSEBackend = await resolveFuseBackend(fuseMountMode); console.log(`[info] FUSE_MOUNT=${fuseMountMode} resolved to backend=${backend.kind}`); + // Local-only paths (#179). Resolved before the store so a + // misconfiguration fails the daemon at startup rather than after the + // mount is live: a silently dropped entry would send a full + // node_modules into the Durable Object, which is the failure this + // feature exists to prevent. + const ignoreConfig = resolveMountIgnoreConfig(process.env, mountPoint); + // The shim copies everything under the mount into the VFS, so it has + // no way to keep a path local. Starting anyway would report the paths + // as local-only on /__computerd/info while syncing them, and the + // host's check would pass. FUSE_MOUNT=auto lands here too when + // /dev/fuse is missing, which is exactly when this needs to be loud. + if (ignoreConfig.enabled && backend.kind === "shim") { + throw new Error( + `MOUNT_IGNORE is not supported on the userspace shim (FUSE_MOUNT=${fuseMountMode} ` + + `resolved to backend=shim). Run with real FUSE, or unset MOUNT_IGNORE.`, + ); + } + if (ignoreConfig.enabled) { + console.log( + `[info] MOUNT_IGNORE active: ${ignoreConfig.ignore.paths.length} path(s) ` + + `local-only under ${ignoreConfig.root} (${ignoreConfig.ignore.paths.join(", ")})`, + ); + if (ignoreConfig.ignore.redundant.length > 0) { + console.log( + `[warn] MOUNT_IGNORE entries dropped as redundant: ${ignoreConfig.ignore.redundant.join(", ")}`, + ); + } + } + const store = resolveStore(parseStoreMode(process.env.COMPUTERD_DB), mountPoint); console.log( `[info] COMPUTERD_DB resolved to store=${store.kind}${ @@ -645,7 +678,13 @@ async function main(): Promise { storeStats, close: closeStore, } = await createNodeVirtualFileSystem({ store }); - const info: ComputerdInfo = { backend, mountPoint, port, store }; + const info: ComputerdInfo = { + backend, + mountPoint, + port, + store, + ignore: describeMountIgnore(ignoreConfig), + }; let fuse: FuseMount | undefined; // When running on the userspace shim, capture the typed handle @@ -665,10 +704,26 @@ async function main(): Promise { shim = await mountShim({ vfs, mountPoint }); fuse = shim; } else { + // The local-only store is created eagerly so a permission or + // read-only-filesystem problem surfaces at mount time, next to + // the configuration that caused it, rather than on the first + // write into an ignored path mid-command. + if (ignoreConfig.enabled) { + await mkdir(ignoreConfig.root, { recursive: true }); + } fuse = await mountFuse({ backend, mountPoint, vfs, + ...(ignoreConfig.enabled + ? { + localPaths: { + root: ignoreConfig.root, + ignore: ignoreConfig.ignore, + mountPoint, + }, + } + : {}), }); } } @@ -746,6 +801,7 @@ async function main(): Promise { return { ...collectDbStats(db), ...(fuse?.getBufferStats?.() ?? {}), + ...(fuse?.getLocalPathStats === undefined ? {} : { localPaths: fuse.getLocalPathStats() }), store_size_bytes: sizeBytes, store_freelist_count: freelistCount, }; diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index 7c6db985..e5b2c8fd 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -2,6 +2,11 @@ import { writeFileSync as nodeWriteFileSync } from "node:fs"; import { posix } from "node:path"; import type { FUSEBackend } from "./backend.js"; import { buildFuseOptionString } from "./options.js"; +import { + type LocalPassthroughOptions, + type PassthroughStats, + withLocalPassthrough, +} from "./passthrough.js"; import { createFuseTracer, type FuseTracer, wrapFuseOpsWithTracer } from "./tracer.js"; import type { NodeVirtualFileSystem } from "./vfs.js"; @@ -135,6 +140,9 @@ export interface FuseMount { // filesystem. Only present when the mount was created via mountFuse; // the shim does not expose this. getBufferStats?: () => FuseBufferStats; + // Counters for the local-only layer. Present only when MOUNT_IGNORE + // configured local-only paths on a real FUSE mount. + getLocalPathStats?: () => PassthroughStats; } interface FuseNativeInstance { @@ -961,6 +969,14 @@ export async function mountFuse(options: { backend?: FUSEBackend; mountPoint: string; vfs: NodeVirtualFileSystem; + /** + * Local-only path configuration (#179). + * + * When present and non-empty, matching paths are served from the + * container's disk instead of the VFS and never enter sync. Omitted + * or empty leaves the op table exactly as it was. + */ + localPaths?: LocalPassthroughOptions; }): Promise { // biome-ignore lint/suspicious/noExplicitAny: fuse-native ships no types const fuseModule: any = await import("fuse-native"); @@ -973,7 +989,15 @@ export async function mountFuse(options: { const traceMode = process.env.COMPUTERD_FUSE_TRACE; const tracer: FuseTracer | undefined = traceMode === "summary" ? createFuseTracer() : undefined; const baseOps = makeFUSEOps(options.vfs, options.mountPoint); - const { getBufferStats: _getBufferStats, ...fuseOps } = baseOps; + // Local-only paths are routed before tracing, so the trace counts a + // passthrough op once, at the layer that actually served it, rather + // than attributing it to the VFS driver that never saw it. + const localPaths = + options.localPaths === undefined + ? undefined + : withLocalPassthrough(baseOps, options.localPaths); + const routedOps = localPaths === undefined ? baseOps : localPaths.ops; + const { getBufferStats: _getBufferStats, ...fuseOps } = routedOps; const ops = tracer === undefined ? fuseOps @@ -1046,6 +1070,7 @@ export async function mountFuse(options: { }); }, getBufferStats: _getBufferStats, + ...(localPaths === undefined ? {} : { getLocalPathStats: localPaths.stats }), }; } diff --git a/packages/computerd/src/fuse/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts new file mode 100644 index 00000000..3ddd4d5d --- /dev/null +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "vitest"; + +import { + defaultIgnoreRoot, + describeMountIgnore, + resolveMountIgnoreConfig, +} from "./ignore-config.js"; + +describe("resolveMountIgnoreConfig: the root", () => { + test("defaults to /tmp plus the mount point", () => { + // Under /tmp rather than a tmpfs so a container snapshot captures + // it. Snapshots are the only durability local-only content has. + expect(defaultIgnoreRoot("/workspace")).toBe("/tmp/workspace"); + const config = resolveMountIgnoreConfig({ MOUNT_IGNORE: "node_modules" }, "/workspace"); + expect(config.root).toBe("/tmp/workspace"); + }); + + test("honors an explicit MOUNT_IGNORE_PATH", () => { + const config = resolveMountIgnoreConfig( + { MOUNT_IGNORE: "node_modules", MOUNT_IGNORE_PATH: "/var/local-only" }, + "/workspace", + ); + expect(config.root).toBe("/var/local-only"); + }); + + test("strips a trailing slash", () => { + const config = resolveMountIgnoreConfig( + { MOUNT_IGNORE: "dist", MOUNT_IGNORE_PATH: "/var/local/" }, + "/workspace", + ); + expect(config.root).toBe("/var/local"); + }); + + test("rejects a relative MOUNT_IGNORE_PATH", () => { + expect(() => + resolveMountIgnoreConfig( + { MOUNT_IGNORE: "dist", MOUNT_IGNORE_PATH: "relative/path" }, + "/workspace", + ), + ).toThrow(/absolute path/); + }); + + test("rejects a root inside the mount point", () => { + // The passthrough layer would resolve into itself: every write to + // an ignored path lands at a location that is also an ignored path. + expect(() => + resolveMountIgnoreConfig( + { MOUNT_IGNORE: "dist", MOUNT_IGNORE_PATH: "/workspace/.local" }, + "/workspace", + ), + ).toThrow(/must not be inside MOUNT_POINT/); + }); + + test("rejects a root equal to the mount point", () => { + expect(() => + resolveMountIgnoreConfig( + { MOUNT_IGNORE: "dist", MOUNT_IGNORE_PATH: "/workspace" }, + "/workspace", + ), + ).toThrow(/must not be inside MOUNT_POINT/); + }); + + test("rejects the filesystem root", () => { + expect(() => + resolveMountIgnoreConfig({ MOUNT_IGNORE: "dist", MOUNT_IGNORE_PATH: "/" }, "/workspace"), + ).toThrow(/filesystem root/); + }); + + test("allows a sibling path that merely shares a prefix string", () => { + // /workspace-cache is not inside /workspace, despite startsWith. + const config = resolveMountIgnoreConfig( + { MOUNT_IGNORE: "dist", MOUNT_IGNORE_PATH: "/workspace-cache" }, + "/workspace", + ); + expect(config.root).toBe("/workspace-cache"); + }); +}); + +describe("resolveMountIgnoreConfig: the set", () => { + test("is disabled when MOUNT_IGNORE is absent", () => { + const config = resolveMountIgnoreConfig({}, "/workspace"); + expect(config.enabled).toBe(false); + expect(config.ignore.isEmpty).toBe(true); + }); + + test("is disabled when MOUNT_IGNORE is only separators and blanks", () => { + const config = resolveMountIgnoreConfig({ MOUNT_IGNORE: " , , " }, "/workspace"); + expect(config.enabled).toBe(false); + }); + + test("resolves entries relative to the mount point", () => { + const config = resolveMountIgnoreConfig( + { MOUNT_IGNORE: "/node_modules,/workspace/dist" }, + "/workspace", + ); + expect(config.enabled).toBe(true); + expect(config.ignore.paths).toEqual(["node_modules", "dist"]); + }); + + test("propagates a bad entry as a startup failure", () => { + // Failing closed matters: a silently dropped entry sends a full + // node_modules into the DO, which is the failure #179 is about. + expect(() => resolveMountIgnoreConfig({ MOUNT_IGNORE: "/../escape" }, "/workspace")).toThrow(); + }); +}); + +describe("describeMountIgnore", () => { + test("reports the normalized set and the redundant entries", () => { + const config = resolveMountIgnoreConfig( + { MOUNT_IGNORE: "/node_modules,/node_modules/.cache,/dist" }, + "/workspace", + ); + const info = describeMountIgnore(config); + expect(info.paths).toEqual(["node_modules", "dist"]); + expect(info.redundant).toEqual(["/node_modules/.cache"]); + expect(info.enabled).toBe(true); + expect(info.root).toBe("/tmp/workspace"); + }); + + test("reports passthrough as unavailable, with the reason", () => { + // Reported rather than omitted so an operator can see why without + // reading the source, and so a future binding upgrade shows up as + // a measurable change rather than an assumed one. + const info = describeMountIgnore(resolveMountIgnoreConfig({}, "/workspace")); + expect(info.fastPaths.passthrough).toBe(false); + expect(info.fastPaths.passthroughReason).toMatch(/libfuse 2\.9/); + expect(info.fastPaths.writebackCache).toBe(false); + }); +}); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts new file mode 100644 index 00000000..cee198a4 --- /dev/null +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -0,0 +1,108 @@ +// Startup resolution of the local-only path configuration. Kept apart +// from ignore.ts so the matcher stays a pure function of its inputs. +// +// Fails closed: a misconfiguration that silently disabled the feature +// would send a full node_modules into the Durable Object, the exact +// failure #179 is about, so the daemon refuses to mount instead. + +import { isAbsolute, join, resolve } from "node:path"; + +import { type MountIgnoreSet, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; + +export interface MountIgnoreConfig { + /** Where local-only paths are stored. Absolute, outside the mount. */ + readonly root: string; + /** The resolved set. Empty when the feature is off. */ + readonly ignore: MountIgnoreSet; + /** True when at least one path is configured. */ + readonly enabled: boolean; +} + +export interface MountIgnoreEnv { + MOUNT_IGNORE?: string; + MOUNT_IGNORE_PATH?: string; +} + +/** + * Default root: /tmp + the mount point. Under /tmp rather than a tmpfs + * so a container snapshot captures it -- that is the only durability + * local-only content has, being deliberately absent from sync. + */ +export function defaultIgnoreRoot(mountPoint: string): string { + return join("/tmp", mountPoint); +} + +export function resolveMountIgnoreConfig( + env: MountIgnoreEnv, + mountPoint: string, +): MountIgnoreConfig { + const entries = parseMountIgnore(env.MOUNT_IGNORE); + const ignore = resolveMountIgnore(entries, mountPoint); + + const configuredRoot = env.MOUNT_IGNORE_PATH?.trim(); + const root = + configuredRoot === undefined || configuredRoot === "" + ? defaultIgnoreRoot(mountPoint) + : configuredRoot; + + if (!isAbsolute(root)) { + throw new Error(`MOUNT_IGNORE_PATH must be an absolute path, got ${JSON.stringify(root)}`); + } + + const normalizedRoot = resolve(root).replace(/\/+$/, "") || "/"; + const normalizedMount = resolve(mountPoint).replace(/\/+$/, "") || "/"; + + // A root under the mount would make the passthrough layer resolve into + // itself: every write to an ignored path would land at a location that + // is also an ignored path, one level deeper, forever. + if (normalizedRoot === normalizedMount || normalizedRoot.startsWith(`${normalizedMount}/`)) { + throw new Error( + `MOUNT_IGNORE_PATH (${normalizedRoot}) must not be inside MOUNT_POINT ` + + `(${normalizedMount}); local-only paths are stored outside the mount.`, + ); + } + + if (normalizedRoot === "/") { + throw new Error("MOUNT_IGNORE_PATH must not be the filesystem root"); + } + + return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty }; +} + +/** The `ignore` block reported on /__computerd/info. */ +export interface MountIgnoreInfo { + readonly supported: true; + readonly enabled: boolean; + readonly root: string; + readonly paths: readonly string[]; + readonly redundant: readonly string[]; + readonly fastPaths: { + /** + * Always false: fuse-native binds libfuse 2.9, passthrough needs the + * libfuse 3.17 API. Reported rather than omitted so the reason is + * visible without reading the source. + */ + readonly passthrough: false; + readonly passthroughReason: string; + /** Also unavailable: libfuse 2.9 fails the mount on the option. */ + readonly writebackCache: false; + }; +} + +export const PASSTHROUGH_UNAVAILABLE_REASON = + "fuse-native binds libfuse 2.9; FOPEN_PASSTHROUGH requires the libfuse 3.17 API"; + +export function describeMountIgnore(config: MountIgnoreConfig): MountIgnoreInfo { + return { + supported: true, + enabled: config.enabled, + root: config.root, + paths: config.ignore.paths, + redundant: config.ignore.redundant, + fastPaths: { + passthrough: false, + passthroughReason: PASSTHROUGH_UNAVAILABLE_REASON, + writebackCache: false, + }, + }; +} diff --git a/packages/computerd/src/fuse/ignore.test.ts b/packages/computerd/src/fuse/ignore.test.ts new file mode 100644 index 00000000..45453b33 --- /dev/null +++ b/packages/computerd/src/fuse/ignore.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "vitest"; + +import { MountIgnorePathError, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; + +// A naive `startsWith` passes every other test in this file and fails +// "does not treat node_modules_extra as node_modules", so that test is +// what actually pins the matcher. + +describe("parseMountIgnore", () => { + test("splits MOUNT_IGNORE on commas", () => { + expect(parseMountIgnore("/node_modules,/.venv,/dist")).toEqual([ + "/node_modules", + "/.venv", + "/dist", + ]); + }); + + test("tolerates whitespace around entries", () => { + expect(parseMountIgnore("/node_modules , /dist")).toEqual(["/node_modules", "/dist"]); + }); + + test("skips empty fields from a trailing or doubled comma", () => { + expect(parseMountIgnore("/dist,,/node_modules,")).toEqual(["/dist", "/node_modules"]); + }); + + test("keeps entries containing spaces intact", () => { + expect(parseMountIgnore("/my dir,/dist")).toEqual(["/my dir", "/dist"]); + }); + + test("treats an absent or empty value as the feature being off", () => { + expect(parseMountIgnore(undefined)).toEqual([]); + expect(parseMountIgnore("")).toEqual([]); + expect(parseMountIgnore(" , , ")).toEqual([]); + }); +}); + +describe("resolveMountIgnore: matching", () => { + test("matches the entry itself and everything under it", () => { + const set = resolveMountIgnore(["node_modules"]); + expect(set.ignores("node_modules")).toBe(true); + expect(set.ignores("node_modules/react")).toBe(true); + expect(set.ignores("node_modules/react/index.js")).toBe(true); + expect(set.ignores("node_modules/@scope/pkg/dist/x.js")).toBe(true); + }); + + test("does not match at arbitrary depth", () => { + // The deliberate limitation. `node_modules` names one location; + // a nested one must be listed explicitly. + const set = resolveMountIgnore(["node_modules"]); + expect(set.ignores("app/node_modules")).toBe(false); + expect(set.ignores("a/b/node_modules")).toBe(false); + }); + + test("matches a nested entry when it is listed", () => { + const set = resolveMountIgnore(["app/node_modules", "web/node_modules"]); + expect(set.ignores("app/node_modules")).toBe(true); + expect(set.ignores("app/node_modules/react/index.js")).toBe(true); + expect(set.ignores("web/node_modules")).toBe(true); + expect(set.ignores("api/node_modules")).toBe(false); + expect(set.ignores("node_modules")).toBe(false); + }); + + test("does not treat node_modules_extra as node_modules", () => { + // A plain startsWith check passes everything above and fails here. + const set = resolveMountIgnore(["node_modules"]); + expect(set.ignores("node_modules_extra")).toBe(false); + expect(set.ignores("node_modules_extra/x.js")).toBe(false); + expect(set.ignores("node_modulesX")).toBe(false); + }); + + test("does not match a prefix of an entry", () => { + const set = resolveMountIgnore(["build/output"]); + expect(set.ignores("build")).toBe(false); + expect(set.ignores("build/output")).toBe(true); + expect(set.ignores("build/output/app.js")).toBe(true); + expect(set.ignores("build/outputs")).toBe(false); + }); + + test("matches case-sensitively, as Linux does", () => { + const set = resolveMountIgnore(["node_modules"]); + expect(set.ignores("node_modules")).toBe(true); + expect(set.ignores("Node_Modules")).toBe(false); + }); + + test("tolerates leading and trailing slashes on the queried path", () => { + const set = resolveMountIgnore(["dist"]); + expect(set.ignores("/dist")).toBe(true); + expect(set.ignores("dist/")).toBe(true); + expect(set.ignores("/dist/app.js")).toBe(true); + }); + + test("ignores nothing when no entries are configured", () => { + const set = resolveMountIgnore([]); + expect(set.ignores("node_modules")).toBe(false); + expect(set.isEmpty).toBe(true); + expect(set.paths).toEqual([]); + }); + + test("reports the covering entry, for diagnostics and error messages", () => { + const set = resolveMountIgnore(["node_modules", "target"]); + expect(set.entryFor("node_modules/react/index.js")).toBe("node_modules"); + expect(set.entryFor("target/debug/app")).toBe("target"); + expect(set.entryFor("src/main.ts")).toBeUndefined(); + }); +}); + +describe("resolveMountIgnore: normalization", () => { + test("strips leading and trailing slashes from entries", () => { + const set = resolveMountIgnore(["/dist/", "node_modules/"]); + expect(set.paths).toEqual(["dist", "node_modules"]); + expect(set.ignores("dist/app.js")).toBe(true); + }); + + test("accepts an absolute path inside the mount point", () => { + const set = resolveMountIgnore(["/workspace/dist"], "/workspace"); + expect(set.paths).toEqual(["dist"]); + expect(set.ignores("dist/app.js")).toBe(true); + }); + + test("anchors a leading slash at the mount root, not the filesystem root", () => { + // "/node_modules" means $MOUNT_POINT/node_modules. A path that looks + // like it names somewhere else on disk is still mount-relative, so + // the entry set can never reach outside the mount. + const set = resolveMountIgnore(["/etc/passwd"], "/workspace"); + expect(set.paths).toEqual(["etc/passwd"]); + expect(set.ignores("etc/passwd")).toBe(true); + }); + + test("accepts the fully-qualified form of the same path", () => { + const set = resolveMountIgnore(["/workspace/dist", "/dist"], "/workspace"); + expect(set.paths).toEqual(["dist"]); + }); + + test("rejects a .. segment rather than resolving it", () => { + // Silently clamping would hide the mistake behind a path that looks + // intentional. + expect(() => resolveMountIgnore(["../escape"])).toThrow(MountIgnorePathError); + expect(() => resolveMountIgnore(["dist/../../etc"])).toThrow(/"\." or "\.\."/); + }); + + test("rejects a . segment", () => { + expect(() => resolveMountIgnore(["./dist"])).toThrow(MountIgnorePathError); + }); + + test("rejects an entry naming the mount root", () => { + // Ignoring everything would make the workspace entirely non-durable, + // which is never what someone means. + expect(() => resolveMountIgnore(["/"])).toThrow(MountIgnorePathError); + expect(() => resolveMountIgnore([""])).toThrow(MountIgnorePathError); + }); + + test("rejects an empty path segment", () => { + expect(() => resolveMountIgnore(["a//b"])).toThrow(MountIgnorePathError); + }); + + test("reports the entry index so a long MOUNT_IGNORE is diagnosable", () => { + try { + resolveMountIgnore(["ok", "also-ok", "../bad"]); + expect.unreachable("resolve should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(MountIgnorePathError); + expect((error as MountIgnorePathError).index).toBe(2); + expect((error as MountIgnorePathError).entry).toBe("../bad"); + } + }); +}); + +describe("resolveMountIgnore: redundancy", () => { + test("drops a duplicate entry", () => { + const set = resolveMountIgnore(["dist", "dist"]); + expect(set.paths).toEqual(["dist"]); + expect(set.redundant).toEqual(["dist"]); + }); + + test("drops an entry nested inside an earlier one", () => { + // Keeping node_modules/.cache alongside node_modules would imply it + // does something, and it cannot. + const set = resolveMountIgnore(["node_modules", "node_modules/.cache"]); + expect(set.paths).toEqual(["node_modules"]); + expect(set.redundant).toEqual(["node_modules/.cache"]); + expect(set.ignores("node_modules/.cache/x")).toBe(true); + }); + + test("subsumes earlier entries when a broader one arrives later", () => { + const set = resolveMountIgnore(["app/node_modules", "app"]); + expect(set.paths).toEqual(["app"]); + expect(set.redundant).toEqual(["app/node_modules"]); + expect(set.ignores("app/node_modules/react")).toBe(true); + expect(set.ignores("app/src/main.ts")).toBe(true); + }); + + test("keeps siblings that merely share a prefix string", () => { + // `dist` and `dist-types` are unrelated locations despite the + // common prefix; neither is redundant. + const set = resolveMountIgnore(["dist", "dist-types"]); + expect(set.paths).toEqual(["dist", "dist-types"]); + expect(set.redundant).toEqual([]); + }); + + test("normalizes before deduplicating", () => { + const set = resolveMountIgnore(["/dist/", "dist"]); + expect(set.paths).toEqual(["dist"]); + expect(set.redundant).toEqual(["dist"]); + }); +}); diff --git a/packages/computerd/src/fuse/ignore.ts b/packages/computerd/src/fuse/ignore.ts new file mode 100644 index 00000000..36c7a7ce --- /dev/null +++ b/packages/computerd/src/fuse/ignore.ts @@ -0,0 +1,155 @@ +// Local-only subpaths of the mount. See packages/computerd/README.md. +// +// Entries are plain paths relative to the mount root: no glob syntax +// and no negation. Deliberate, because an entry then resolves to a +// known location and the mapping onto MOUNT_IGNORE_PATH is a prefix +// substitution decided at startup, which an unanchored pattern cannot +// answer until a path arrives to match against it. +// +// The set is resolved once at startup and never re-read: entries that +// changed under a running command would mean migrating +// already-materialized paths between layers mid-write. + +/** An entry that cannot be used, carrying enough context to fix it. */ +export class MountIgnorePathError extends Error { + readonly entry: string; + readonly index: number; + + constructor(message: string, entry: string, index: number) { + super(message); + this.name = "MountIgnorePathError"; + this.entry = entry; + this.index = index; + } +} + +export interface MountIgnoreSet { + /** Segment-aware: `node_modules` does not match `node_modules_extra`. */ + readonly ignores: (relativePath: string) => boolean; + /** The entry covering a path, or undefined when not local-only. */ + readonly entryFor: (relativePath: string) => string | undefined; + /** Normalized entries, in declaration order, as the mount applies them. */ + readonly paths: readonly string[]; + /** Entries dropped as duplicates or as nested inside another entry. */ + readonly redundant: readonly string[]; + readonly isEmpty: boolean; +} + +/** + * Comma-separated, so the set can be passed as a single start-time + * environment variable. A path containing a comma cannot be expressed. + */ +export function parseMountIgnore(raw: string | undefined): string[] { + if (raw === undefined) return []; + const entries: string[] = []; + for (const field of raw.split(",")) { + const trimmed = field.trim(); + if (trimmed === "") continue; + entries.push(trimmed); + } + return entries; +} + +/** + * Normalizes entries and builds the matcher. An absolute path outside + * the mount is rejected rather than reinterpreted. + */ +export function resolveMountIgnore(entries: readonly string[], mountPoint = "/"): MountIgnoreSet { + const root = normalizeMount(mountPoint); + const paths: string[] = []; + const redundant: string[] = []; + + for (const [index, original] of entries.entries()) { + let value = original.trim(); + + // A leading slash anchors the entry at the mount root, not at the + // filesystem root: "/node_modules" means "$MOUNT_POINT/node_modules". + if (value.startsWith("/") && root !== "/") { + if (value === root || value.startsWith(`${root}/`)) { + value = value.slice(root.length); + } + } + + const trimmed = stripSlashes(value); + if (trimmed === "") { + throw new MountIgnorePathError( + `Entry ${JSON.stringify(original)} resolves to the mount root. ` + + `Ignoring the whole mount would make the workspace non-durable.`, + original, + index, + ); + } + + const segments = trimmed.split("/"); + // Rejected rather than resolved: silently clamping an entry that walks + // out of the mount would hide the mistake behind a plausible path. + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new MountIgnorePathError( + `Entry ${JSON.stringify(original)} contains a "." or ".." segment. ` + + `Entries must be plain paths relative to the mount root.`, + original, + index, + ); + } + if (segments.some((segment) => segment === "")) { + throw new MountIgnorePathError( + `Entry ${JSON.stringify(original)} contains an empty path segment.`, + original, + index, + ); + } + + // Keeping `node_modules/.cache` alongside `node_modules` would imply + // it does something, and it cannot. + const covered = paths.some((existing) => isAtOrUnder(trimmed, existing)); + if (covered) { + redundant.push(original); + continue; + } + + // The converse: a new entry may subsume ones already accepted. + for (let position = paths.length - 1; position >= 0; position -= 1) { + const existing = paths[position] as string; + if (isAtOrUnder(existing, trimmed)) { + redundant.push(existing); + paths.splice(position, 1); + } + } + + paths.push(trimmed); + } + + const isEmpty = paths.length === 0; + + const entryFor = (relativePath: string): string | undefined => { + if (isEmpty) return undefined; + const path = stripSlashes(relativePath); + if (path === "") return undefined; + return paths.find((entry) => isAtOrUnder(path, entry)); + }; + + return { + paths, + redundant, + isEmpty, + entryFor, + ignores: (relativePath) => entryFor(relativePath) !== undefined, + }; +} + +/** The separator check is what stops `node_modules_extra` matching. */ +function isAtOrUnder(path: string, entry: string): boolean { + return path === entry || path.startsWith(`${entry}/`); +} + +function stripSlashes(value: string): string { + let out = value; + while (out.startsWith("/")) out = out.slice(1); + while (out.endsWith("/")) out = out.slice(0, -1); + return out; +} + +function normalizeMount(mountPoint: string): string { + const trimmed = mountPoint.replace(/\/+$/, ""); + return trimmed === "" ? "/" : trimmed; +} diff --git a/packages/computerd/src/fuse/index.ts b/packages/computerd/src/fuse/index.ts index e508fa06..267e31e1 100644 --- a/packages/computerd/src/fuse/index.ts +++ b/packages/computerd/src/fuse/index.ts @@ -2,6 +2,17 @@ export type { FUSEBackend, FuseMountMode, ResolveFuseBackendOptions } from "./ba export { parseFuseMountMode, resolveFuseBackend } from "./backend.js"; export type { FuseMount, FuseOps, FuseStat } from "./driver.js"; export { makeFUSEOps, mountFuse } from "./driver.js"; +export type { MountIgnoreSet } from "./ignore.js"; +export { MountIgnorePathError, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; +export type { MountIgnoreConfig, MountIgnoreEnv, MountIgnoreInfo } from "./ignore-config.js"; +export { + defaultIgnoreRoot, + describeMountIgnore, + PASSTHROUGH_UNAVAILABLE_REASON, + resolveMountIgnoreConfig, +} from "./ignore-config.js"; +export type { LocalPassthrough, LocalPassthroughOptions, PassthroughStats } from "./passthrough.js"; +export { withLocalPassthrough } from "./passthrough.js"; export type { ResolvedStore, StoreMode } from "./store.js"; export { parseStoreMode, resolveStore } from "./store.js"; export type { CreateNodeVFSOptions, NodeVFSHandle, NodeVirtualFileSystem } from "./vfs.js"; diff --git a/packages/computerd/src/fuse/passthrough.test.ts b/packages/computerd/src/fuse/passthrough.test.ts new file mode 100644 index 00000000..c930c418 --- /dev/null +++ b/packages/computerd/src/fuse/passthrough.test.ts @@ -0,0 +1,683 @@ +import * as nodeFs from "node:fs"; +import { + constants, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { FuseOps } from "./driver.js"; +import { resolveMountIgnore } from "./ignore.js"; +import { type PassthroughFs, withLocalPassthrough } from "./passthrough.js"; + +// The real filesystem, as the slice withLocalPassthrough takes. Tests +// override single calls on top of it. +const realFs = (): PassthroughFs => ({ ...nodeFs }) as PassthroughFs; + +// Drives the real node:fs against a temp directory rather than a double. +// The interesting failures here -- EXDEV, ENOTEMPTY, parent creation -- +// are the filesystem's, so a mock would assert the shape of the calls +// rather than the behavior. + +const MOUNT = "/workspace"; + +/** A VFS side that records what reached it and never succeeds quietly. */ +function recordingOps(): { ops: FuseOps; calls: string[] } { + const calls: string[] = []; + const note = + (name: string) => + (...args: unknown[]) => { + calls.push(name); + const cb = args[args.length - 1] as (code: number, value?: unknown) => void; + // Shapes chosen so a leaked VFS call is visibly distinct from a + // passthrough result rather than looking like a plausible answer. + if (name === "readdir") cb(0, ["vfs-entry"]); + else if (name === "getattr" || name === "fgetattr") cb(0, null); + else if (name === "open" || name === "create" || name === "opendir") cb(0, 7); + else if (name === "read" || name === "write") cb(0); + else if (name === "readlink") cb(0, "vfs-link"); + else cb(0); + }; + + const ops = new Proxy({} as FuseOps, { + get(_target, property: string) { + if (property === "getBufferStats") return () => ({}); + return note(property); + }, + has: () => true, + }); + + return { ops, calls }; +} + +describe("withLocalPassthrough: disabled", () => { + test("returns the source ops untouched when no paths are configured", () => { + const { ops } = recordingOps(); + const result = withLocalPassthrough(ops, { + root: "/tmp/unused", + ignore: resolveMountIgnore([]), + mountPoint: MOUNT, + }); + // Identity, not equivalence. A deployment without MOUNT_IGNORE + // should pay nothing at all -- no wrapper, no branch per op. + expect(result.ops).toBe(ops); + }); +}); + +describe("withLocalPassthrough: routing", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const build = (paths: string[]) => { + const source = recordingOps(); + const { ops, stats } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(paths, MOUNT), + mountPoint: MOUNT, + }); + return { ops, stats, calls: source.calls }; + }; + + test("creates and reads a file on local disk, never touching the VFS", () => { + const { ops, calls } = build(["node_modules"]); + + let fh = 0; + ops.create("/node_modules/pkg/index.js", 0o644, (code, handle) => { + expect(code).toBe(0); + fh = handle as number; + }); + + const payload = Buffer.from("module.exports = 1\n"); + ops.write("/node_modules/pkg/index.js", fh, payload, payload.length, 0, (written) => { + expect(written).toBe(payload.length); + }); + ops.release("/node_modules/pkg/index.js", fh, (code) => expect(code).toBe(0)); + + // The bytes are on the host filesystem, with the tree structure + // preserved so a snapshot of the directory is interpretable. + expect(readFileSync(join(root, "node_modules/pkg/index.js"), "utf8")).toBe( + "module.exports = 1\n", + ); + expect(calls).toEqual([]); + + let readBack = ""; + ops.open("/node_modules/pkg/index.js", 0, (code, handle) => { + expect(code).toBe(0); + const buffer = Buffer.alloc(64); + ops.read("/node_modules/pkg/index.js", handle as number, buffer, 64, 0, (bytes) => { + readBack = buffer.subarray(0, bytes as number).toString(); + }); + }); + expect(readBack).toBe("module.exports = 1\n"); + }); + + test("creates missing parent directories on first write", () => { + const { ops } = build(["node_modules"]); + ops.create("/node_modules/a/b/c/deep.js", 0o644, (code) => expect(code).toBe(0)); + expect(readFileSync(join(root, "node_modules/a/b/c/deep.js"), "utf8")).toBe(""); + }); + + test("passes non-ignored paths straight through to the VFS", () => { + const { ops, calls } = build(["node_modules"]); + ops.getattr("/src/main.ts", () => {}); + ops.create("/src/new.ts", 0o644, () => {}); + ops.unlink("/src/old.ts", () => {}); + expect(calls).toEqual(["getattr", "create", "unlink"]); + }); + + test("does not route a path that merely shares a prefix", () => { + const { ops, calls } = build(["node_modules"]); + ops.getattr("/node_modules_extra/x.js", () => {}); + expect(calls).toEqual(["getattr"]); + }); + + test("routes by handle, so a VFS handle is never served locally", () => { + const { ops, calls } = build(["node_modules"]); + const buffer = Buffer.alloc(8); + // 7 is what the recording VFS hands out; it must stay with the VFS. + ops.read("/src/main.ts", 7, buffer, 8, 0, () => {}); + expect(calls).toEqual(["read"]); + }); + + test("reports EBADF for an unknown local handle rather than guessing", () => { + const { ops } = build(["node_modules"]); + const buffer = Buffer.alloc(8); + let code = 0; + ops.read("/node_modules/x.js", 0x4000_0000 + 999, buffer, 8, 0, (result) => { + code = result as number; + }); + expect(code).toBe(-9); + }); +}); + +describe("withLocalPassthrough: deciding paths", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("routes a path many levels under an entry", () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + ops.create("/node_modules/a/b/c/d/e/f.js", 0o644, (code) => expect(code).toBe(0)); + expect(readFileSync(join(root, "node_modules/a/b/c/d/e/f.js"), "utf8")).toBe(""); + expect(source.calls).toEqual([]); + }); + + test("a recreated directory is decided by its path, not by history", () => { + // Removing and recreating a directory, or renaming one into place, + // must not leave a path in the layer it used to belong to. + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + ops.mkdir("/node_modules", 0o755, () => {}); + ops.mkdir("/node_modules/pkg", 0o755, () => {}); + ops.rename("/node_modules/pkg", "/node_modules/moved", (code) => expect(code).toBe(0)); + ops.rmdir("/node_modules/moved", (code) => expect(code).toBe(0)); + + ops.getattr("/src/pkg/x.js", () => {}); + expect(source.calls).toEqual(["getattr"]); + }); + + test("does not touch local disk to decide a synced path", () => { + // Every VFS lookup goes through the decision, so a syscall here is + // paid on every getattr in the synced tree. + const source = recordingOps(); + let localCalls = 0; + const counting = new Proxy(realFs(), { + get(target, property: keyof PassthroughFs) { + localCalls += 1; + return target[property]; + }, + }); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + fs: counting, + }); + for (let index = 0; index < 10; index += 1) ops.getattr(`/src/file-${index}.ts`, () => {}); + expect(localCalls).toBe(0); + }); +}); + +describe("withLocalPassthrough: rename", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const build = (paths: string[]) => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(paths, MOUNT), + mountPoint: MOUNT, + // Swallowed rather than left on console.warn: the crossing-rename + // guidance is asserted in its own test above, and a suite that + // prints it on every run trains people to ignore the output. + warn: () => {}, + }); + return { ops, calls: source.calls }; + }; + + test("renames within the local layer", () => { + const { ops } = build(["node_modules"]); + ops.create("/node_modules/.staging", 0o644, () => {}); + let code = -1; + ops.rename("/node_modules/.staging", "/node_modules/final", (result) => { + code = result as number; + }); + expect(code).toBe(0); + expect(readFileSync(join(root, "node_modules/final"), "utf8")).toBe(""); + }); + + test("delegates a rename entirely within the VFS", () => { + const { ops, calls } = build(["node_modules"]); + ops.rename("/src/a.ts", "/src/b.ts", () => {}); + expect(calls).toEqual(["rename"]); + }); + + test("logs the fix once on the first crossing rename", () => { + // The errno is all the kernel can carry, and "cross-device link" on + // a path that is not a device is where an operator loses an + // afternoon. The guidance has to reach them somewhere, so it goes + // to the log -- and only once, because a build that does this does + // it in a loop. + const warnings: string[] = []; + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["dist"], MOUNT), + mountPoint: MOUNT, + warn: (message) => warnings.push(message), + }); + + ops.rename("/.tmp-build", "/dist", () => {}); + expect(warnings).toHaveLength(1); + + const [message] = warnings; + expect(message).toMatch(/EXDEV/); + // Which side is which, so the reader does not have to work it out. + expect(message).toMatch(/\/dist is container-local/); + expect(message).toMatch(/\/\.tmp-build is synced/); + // Why it is not just done anyway. + expect(message).toMatch(/cannot be atomic/); + // And the actual fix: ignore the staging directory too. + expect(message).toMatch(/add "\.tmp-build" to MOUNT_IGNORE/); + + // Repeats stay silent. + ops.rename("/.tmp-build", "/dist", () => {}); + ops.rename("/dist/x", "/y", () => {}); + expect(warnings).toHaveLength(1); + }); + + test("counts every crossing rename even though it logs once", () => { + const warnings: string[] = []; + const source = recordingOps(); + const { ops, stats } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["dist"], MOUNT), + mountPoint: MOUNT, + warn: (message) => warnings.push(message), + }); + + ops.rename("/.tmp-build", "/dist", () => {}); + ops.rename("/.tmp-two", "/dist", () => {}); + expect(stats().crossLayerRenames).toBe(2); + expect(warnings).toHaveLength(1); + }); + + test("does not log for a rename that stays within one layer", () => { + const warnings: string[] = []; + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["dist"], MOUNT), + mountPoint: MOUNT, + warn: (message) => warnings.push(message), + }); + + ops.create("/dist/a", 0o644, () => {}); + ops.rename("/dist/a", "/dist/b", () => {}); + ops.rename("/src/a.ts", "/src/b.ts", () => {}); + expect(warnings).toEqual([]); + }); + + test("returns EXDEV when a rename crosses the boundary", () => { + // Not a copy. The two sides are different filesystems, so the + // operation cannot be atomic, and faking it would turn a crash + // mid-copy into a half-written file where the caller was promised + // all-or-nothing. EXDEV is what rename(2) returns between any two + // filesystems. + const { ops, calls } = build(["dist"]); + + let intoLocal = 0; + ops.rename("/.tmp-build", "/dist", (code) => { + intoLocal = code as number; + }); + expect(intoLocal).toBe(-18); + + let outOfLocal = 0; + ops.rename("/dist/app.js", "/app.js", (code) => { + outOfLocal = code as number; + }); + expect(outOfLocal).toBe(-18); + + // Neither reached the VFS: a partial rename there would be worse + // than the error. + expect(calls).toEqual([]); + }); +}); + +describe("withLocalPassthrough: directory listing", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("merges local-only children into a VFS directory listing", () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + + mkdirSync(join(root, "node_modules"), { recursive: true }); + + let names: string[] = []; + ops.readdir("/", (code, result) => { + expect(code).toBe(0); + names = result as string[]; + }); + + // Both sides are visible to a command inside the container, so both + // sides appear. + expect(names).toContain("vfs-entry"); + expect(names).toContain("node_modules"); + }); + + test("does not show an entry that has not been materialized", () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + + let names: string[] = []; + ops.readdir("/", (_code, result) => { + names = result as string[]; + }); + // Configured but never written: a phantom directory in `ls` would + // be worse than its absence. + expect(names).toEqual(["vfs-entry"]); + }); + + test("lists the local directory itself from disk", () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + + mkdirSync(join(root, "node_modules/pkg"), { recursive: true }); + writeFileSync(join(root, "node_modules/pkg/index.js"), "x"); + + let names: string[] = []; + ops.readdir("/node_modules/pkg", (code, result) => { + expect(code).toBe(0); + names = result as string[]; + }); + expect(names).toEqual(["index.js"]); + expect(source.calls).toEqual([]); + }); +}); + +describe("withLocalPassthrough: symlinks", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("stores a link target verbatim without following it", () => { + // The decision is made on the path, before any resolution, so a + // symlink cannot drag a path between layers in either direction. + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + + mkdirSync(join(root, "node_modules/.bin"), { recursive: true }); + ops.symlink("../../../src/cli.ts", "/node_modules/.bin/tool", (code) => { + expect(code).toBe(0); + }); + + let target = ""; + ops.readlink("/node_modules/.bin/tool", (code, result) => { + expect(code).toBe(0); + target = result as string; + }); + // Escaping target preserved exactly; not resolved, not rewritten. + expect(target).toBe("../../../src/cli.ts"); + expect(source.calls).toEqual([]); + }); + + test("a symlink outside the ignored tree still belongs to the VFS", () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + ops.symlink("node_modules/pkg", "/src/link", () => {}); + expect(source.calls).toEqual(["symlink"]); + }); +}); + +describe("withLocalPassthrough: errors", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const build = () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + }); + return ops; + }; + + test("maps a missing file to ENOENT", () => { + const ops = build(); + let code = 0; + ops.getattr("/node_modules/missing.js", (result) => { + code = result as number; + }); + expect(code).toBe(-2); + }); + + test("maps a non-empty rmdir to ENOTEMPTY", () => { + const ops = build(); + mkdirSync(join(root, "node_modules/pkg"), { recursive: true }); + writeFileSync(join(root, "node_modules/pkg/x.js"), "x"); + let code = 0; + ops.rmdir("/node_modules/pkg", (result) => { + code = result as number; + }); + expect(code).toBe(-39); + }); + + test("maps a readdir of a file to ENOTDIR", () => { + const ops = build(); + mkdirSync(join(root, "node_modules"), { recursive: true }); + writeFileSync(join(root, "node_modules/file.js"), "x"); + let code = 0; + ops.readdir("/node_modules/file.js", (result) => { + code = result as number; + }); + expect(code).toBe(-20); + }); +}); + +describe("withLocalPassthrough: descriptor and metadata operations", () => { + let root: string; + let outside: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + outside = mkdtempSync(join(tmpdir(), "computerd-outside-")); + mkdirSync(join(root, "node_modules"), { recursive: true }); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + const build = (fs?: Partial) => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["node_modules"], MOUNT), + mountPoint: MOUNT, + ...(fs === undefined ? {} : { fs: { ...realFs(), ...fs } }), + }); + return { ops, calls: source.calls }; + }; + + const open = (ops: FuseOps, path: string): number => { + let fh = 0; + ops.open(path, constants.O_RDWR, (code, handle) => { + expect(code).toBe(0); + fh = handle as number; + }); + return fh; + }; + + const status = (run: (cb: (code: number) => void) => void): number => { + let result = 1; + run((code) => { + result = code; + }); + return result; + }; + + test("ftruncate truncates the open file, not whatever now has its name", () => { + // Open a, rename it to b, create a new a, then truncate the old + // handle. The handle still refers to the file now called b. + const { ops } = build(); + writeFileSync(join(root, "node_modules/a"), "original"); + const fh = open(ops, "/node_modules/a"); + ops.rename("/node_modules/a", "/node_modules/b", (code) => expect(code).toBe(0)); + writeFileSync(join(root, "node_modules/a"), "replacement"); + + expect(status((cb) => ops.ftruncate("/node_modules/a", fh, 2, cb))).toBe(0); + + expect(readFileSync(join(root, "node_modules/b"), "utf8")).toBe("or"); + expect(readFileSync(join(root, "node_modules/a"), "utf8")).toBe("replacement"); + }); + + test("fsync flushes the descriptor", () => { + // A program that fsyncs a file is relying on it reaching disk. + const synced: string[] = []; + const { ops } = build({ + fsyncSync: () => { + synced.push("fsync"); + }, + fdatasyncSync: () => { + synced.push("fdatasync"); + }, + }); + writeFileSync(join(root, "node_modules/a"), "x"); + const fh = open(ops, "/node_modules/a"); + + expect(status((cb) => ops.fsync("/node_modules/a", fh, 0, cb))).toBe(0); + expect(status((cb) => ops.fsync("/node_modules/a", fh, 1, cb))).toBe(0); + expect(synced).toEqual(["fsync", "fdatasync"]); + }); + + test("hardlinks within the local layer", () => { + const { ops, calls } = build(); + writeFileSync(join(root, "node_modules/a"), "shared"); + + expect(status((cb) => ops.link("/node_modules/a", "/node_modules/b", cb))).toBe(0); + + expect(statSync(join(root, "node_modules/b")).nlink).toBe(2); + expect(calls).toEqual([]); + }); + + test("refuses a hardlink across the boundary with EXDEV", () => { + const { ops, calls } = build(); + writeFileSync(join(root, "node_modules/a"), "x"); + + expect(status((cb) => ops.link("/node_modules/a", "/src/a", cb))).toBe(-18); + expect(status((cb) => ops.link("/src/a", "/node_modules/b", cb))).toBe(-18); + expect(calls).toEqual([]); + }); + + test("delegates a hardlink entirely within the VFS", () => { + const { ops, calls } = build(); + ops.link("/src/a", "/src/b", () => {}); + expect(calls).toEqual(["link"]); + }); + + test("opendir reports a missing path or a file up front", () => { + const { ops } = build(); + writeFileSync(join(root, "node_modules/file.js"), "x"); + + expect(status((cb) => ops.opendir("/node_modules/missing", 0, cb))).toBe(-2); + expect(status((cb) => ops.opendir("/node_modules/file.js", 0, cb))).toBe(-20); + expect(status((cb) => ops.opendir("/node_modules", 0, cb))).toBe(0); + }); + + test("access checks the requested mode", () => { + const { ops } = build(); + writeFileSync(join(root, "node_modules/data.json"), "{}", { mode: 0o644 }); + + expect(status((cb) => ops.access("/node_modules/data.json", constants.R_OK, cb))).toBe(0); + // No execute bit for anyone, so this fails even for root. + expect(status((cb) => ops.access("/node_modules/data.json", constants.X_OK, cb))).toBe(-13); + }); + + test("utimens on a symlink changes the link, not its target", () => { + // The kernel resolves links before calling the daemon unless the + // caller asked for the link itself (touch -h). Following it here + // would reach a file outside the local root. + const { ops } = build(); + const target = join(outside, "target"); + writeFileSync(target, "x"); + const before = statSync(target).mtimeMs; + symlinkSync(target, join(root, "node_modules/link")); + + expect(status((cb) => ops.utimens("/node_modules/link", 1_000, 1_000, cb))).toBe(0); + + expect(statSync(target).mtimeMs).toBe(before); + expect(lstatSync(join(root, "node_modules/link")).mtimeMs).toBe(1_000); + }); + + test("chown on a symlink changes the link, not its target", () => { + // Changing ownership needs root, so this checks which call is made. + const changed: string[] = []; + const { ops } = build({ + chownSync: () => { + changed.push("chown"); + }, + lchownSync: () => { + changed.push("lchown"); + }, + }); + symlinkSync(join(outside, "target"), join(root, "node_modules/link")); + + expect(status((cb) => ops.chown("/node_modules/link", 0, 0, cb))).toBe(0); + expect(changed).toEqual(["lchown"]); + }); +}); diff --git a/packages/computerd/src/fuse/passthrough.ts b/packages/computerd/src/fuse/passthrough.ts new file mode 100644 index 00000000..faa060e4 --- /dev/null +++ b/packages/computerd/src/fuse/passthrough.ts @@ -0,0 +1,809 @@ +// Local-only passthrough for the FUSE op layer. See +// packages/computerd/README.md. +// +// A decorator over FuseOps rather than branches inside makeFUSEOps, so +// the VFS driver stays unaware of the feature and an empty ignore set +// is provably a no-op: `withLocalPassthrough` returns the source object +// unchanged. +// +// Despite the name there is no FUSE passthrough (FOPEN_PASSTHROUGH) +// here; fuse-native binds libfuse 2.9, below the API version that can +// negotiate it. Data still crosses the FUSE boundary into this process. +// What it skips is the VFS, the SQLite store, the change-pack encoding, +// and the pull into the Durable Object. +// +// Writes go straight to the host filesystem with pwrite rather than +// through the buffered FileEntry machinery in driver.ts. That buffering +// exists because the VFS has no ranged-write primitive and a naive +// implementation is O(N^2) over sequential appends; the kernel does not +// have that problem, so the indirection would be pure cost here. + +import { + accessSync, + chmodSync, + chownSync, + closeSync, + fdatasyncSync, + constants as fsConstants, + fstatSync, + fsyncSync, + ftruncateSync, + lchownSync, + linkSync, + lstatSync, + lutimesSync, + mkdirSync, + openSync, + readdirSync, + readlinkSync, + readSync, + renameSync, + rmdirSync, + type Stats, + statSync, + symlinkSync, + truncateSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { dirname, join, posix } from "node:path"; + +import type { FuseOps, FuseStat } from "./driver.js"; +import type { MountIgnoreSet } from "./ignore.js"; + +// Mirrors driver.ts. Duplicated rather than exported across modules +// because these are the kernel's numbers, not ours, and a shared +// mutable table would be a worse coupling than two short lists. +const ERRNO = { + EPERM: -1, + ENOENT: -2, + EIO: -5, + EBADF: -9, + EACCES: -13, + EEXIST: -17, + EXDEV: -18, + ENOTDIR: -20, + EISDIR: -21, + EINVAL: -22, + ENOTEMPTY: -39, +} as const; + +const DEFAULT_FILE_MODE = 0o644; +const DEFAULT_DIR_MODE = 0o755; + +export interface LocalPassthroughOptions { + /** Resolved MOUNT_IGNORE_PATH: where local-only paths are stored. */ + readonly root: string; + /** The decided ignore set. An empty set disables the feature entirely. */ + readonly ignore: MountIgnoreSet; + /** Mount point, so kernel paths can be made mount-relative. */ + readonly mountPoint?: string; + /** Injected for tests. Defaults to the real node:fs surface. */ + readonly fs?: PassthroughFs; + /** Called once per distinct local-only directory created. Diagnostics. */ + readonly onMaterialize?: (relativePath: string) => void; + /** Operator-facing warnings. Defaults to console.warn; injected for tests. */ + readonly warn?: (message: string) => void; +} + +/** + * The slice of node:fs this module uses. + * + * Narrow on purpose: it is the seam the unit tests drive, and keeping + * it small is what makes an in-memory double practical. + */ +export interface PassthroughFs { + openSync: typeof openSync; + closeSync: typeof closeSync; + readSync: typeof readSync; + writeSync: typeof writeSync; + fstatSync: typeof fstatSync; + statSync: typeof statSync; + lstatSync: typeof lstatSync; + mkdirSync: typeof mkdirSync; + readdirSync: typeof readdirSync; + readlinkSync: typeof readlinkSync; + renameSync: typeof renameSync; + rmdirSync: typeof rmdirSync; + symlinkSync: typeof symlinkSync; + truncateSync: typeof truncateSync; + ftruncateSync: typeof ftruncateSync; + fsyncSync: typeof fsyncSync; + fdatasyncSync: typeof fdatasyncSync; + linkSync: typeof linkSync; + unlinkSync: typeof unlinkSync; + accessSync: typeof accessSync; + // The l-variants: an operation that reaches the daemon on a symlink's + // own path is about the link. Following it would act on whatever the + // link points at, which can be outside the local root. + lutimesSync: typeof lutimesSync; + chmodSync: typeof chmodSync; + chownSync: typeof chownSync; + lchownSync: typeof lchownSync; +} + +const REAL_FS: PassthroughFs = { + openSync, + closeSync, + readSync, + writeSync, + fstatSync, + statSync, + lstatSync, + mkdirSync, + readdirSync, + readlinkSync, + renameSync, + rmdirSync, + symlinkSync, + truncateSync, + ftruncateSync, + fsyncSync, + fdatasyncSync, + linkSync, + unlinkSync, + accessSync, + lutimesSync, + chmodSync, + chownSync, + lchownSync, +}; + +/** Counters reported on `/__computerd/stats`. */ +export interface PassthroughStats { + /** Paths served from local disk rather than the VFS. */ + readonly localOps: number; + /** Open local file handles. */ + readonly openHandles: number; + /** Renames refused with EXDEV for crossing the boundary. */ + readonly crossLayerRenames: number; +} + +export interface LocalPassthrough { + readonly ops: FuseOps; + readonly stats: () => PassthroughStats; +} + +/** + * Wraps `ops` so local-only paths are served from `root`. + * + * Returns the source object untouched when the ignore set is empty, so + * a deployment that has not configured MOUNT_IGNORE pays nothing — not + * a wrapper, not a branch, not an allocation. + */ +export function withLocalPassthrough( + ops: FuseOps, + options: LocalPassthroughOptions, +): LocalPassthrough { + if (options.ignore.isEmpty) { + return { + ops, + stats: () => ({ + localOps: 0, + openHandles: 0, + crossLayerRenames: 0, + }), + }; + } + + const fs = options.fs ?? REAL_FS; + const root = options.root.replace(/\/+$/, ""); + const mountRoot = normalizeMount(options.mountPoint ?? "/"); + + let localOps = 0; + let crossLayerRenames = 0; + const warn = options.warn ?? ((message: string) => console.warn(message)); + + // No cache. The ignore set is a handful of entries and the test is a + // prefix comparison against each, which costs about what a cache + // lookup would. A per-path cache grows with the dependency tree and + // has to be invalidated on every rename and rmdir to stay correct. + const isLocal = (path: string): boolean => { + const relative = toRelative(path, mountRoot); + if (relative === "") return false; + return options.ignore.ignores(relative); + }; + + const localPath = (path: string): string => join(root, toRelative(path, mountRoot)); + + // Handles are allocated from a high range so they cannot collide with + // the VFS driver's, which counts up from 1. A handle that crossed + // layers would read one file and write another. + const LOCAL_HANDLE_BASE = 0x4000_0000; + let nextHandle = LOCAL_HANDLE_BASE; + const handles = new Map(); + const isLocalHandle = (fh: number): boolean => fh >= LOCAL_HANDLE_BASE; + + const ensureParent = (target: string): void => { + const parent = dirname(target); + try { + fs.mkdirSync(parent, { recursive: true, mode: DEFAULT_DIR_MODE }); + options.onMaterialize?.(parent); + } catch (error) { + if (errnoOf(error) !== "EEXIST") throw error; + } + }; + + const wrapped: FuseOps = { + ...ops, + + readdir(path, cb) { + if (!isLocal(path)) { + // A VFS directory may still contain local-only children: the + // entries live on disk but the parent does not. Merge both + // sides so `ls` shows what a command inside the container sees. + ops.readdir(path, (code, names) => { + if (code !== 0) { + cb(code, names); + return; + } + const extra = localChildren(path); + if (extra.length === 0) { + cb(0, names); + return; + } + const merged = new Set([...(names ?? []), ...extra]); + cb(0, [...merged]); + }); + return; + } + localOps += 1; + try { + cb(0, fs.readdirSync(localPath(path))); + } catch (error) { + cb(toErrno(error), []); + } + }, + + getattr(path, cb) { + if (!isLocal(path)) { + ops.getattr(path, cb); + return; + } + localOps += 1; + try { + cb(0, statToFuse(fs.lstatSync(localPath(path)))); + } catch (error) { + cb(toErrno(error), null); + } + }, + + fgetattr(path, fh, cb) { + if (!isLocalHandle(fh)) { + ops.fgetattr(path, fh, cb); + return; + } + const handle = handles.get(fh); + if (handle === undefined) { + cb(ERRNO.EBADF, null); + return; + } + localOps += 1; + try { + cb(0, statToFuse(fs.fstatSync(handle.fd))); + } catch (error) { + cb(toErrno(error), null); + } + }, + + open(path, flags, cb) { + if (!isLocal(path)) { + ops.open(path, flags, cb); + return; + } + localOps += 1; + try { + const target = localPath(path); + // O_CREAT is not implied by open(2) here; the kernel sends + // create() for that. But a flag set including O_TRUNC still has + // to reach the real file, so the flags are passed through as-is. + const fd = fs.openSync(target, flags); + cb(0, allocateHandle(fd, path)); + } catch (error) { + cb(toErrno(error), 0); + } + }, + + opendir(path, flags, cb) { + if (!isLocal(path)) { + ops.opendir(path, flags, cb); + return; + } + localOps += 1; + // Directory handles carry no fd: readdir re-resolves by path, and + // holding an O_PATH fd per open directory would leak under a + // recursive walk of a large dependency tree. The path is still + // checked now, so a missing directory fails at opendir(3) the way + // it would on any other filesystem. + try { + if (!fs.statSync(localPath(path)).isDirectory()) { + cb(ERRNO.ENOTDIR, 0); + return; + } + } catch (error) { + cb(toErrno(error), 0); + return; + } + cb(0, allocateHandle(-1, path)); + }, + + create(path, mode, cb) { + if (!isLocal(path)) { + ops.create(path, mode, cb); + return; + } + localOps += 1; + try { + const target = localPath(path); + ensureParent(target); + const fd = fs.openSync( + target, + fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_TRUNC, + mode === 0 ? DEFAULT_FILE_MODE : mode, + ); + cb(0, allocateHandle(fd, path)); + } catch (error) { + cb(toErrno(error), 0); + } + }, + + read(path, fh, buffer, length, position, cb) { + if (!isLocalHandle(fh)) { + ops.read(path, fh, buffer, length, position, cb); + return; + } + const handle = handles.get(fh); + if (handle === undefined) { + cb(ERRNO.EBADF); + return; + } + localOps += 1; + try { + cb(fs.readSync(handle.fd, buffer, 0, length, position)); + } catch (error) { + cb(toErrno(error)); + } + }, + + write(path, fh, buffer, length, position, cb) { + if (!isLocalHandle(fh)) { + ops.write(path, fh, buffer, length, position, cb); + return; + } + const handle = handles.get(fh); + if (handle === undefined) { + cb(ERRNO.EBADF); + return; + } + localOps += 1; + try { + cb(fs.writeSync(handle.fd, buffer, 0, length, position)); + } catch (error) { + cb(toErrno(error)); + } + }, + + release(path, fh, cb) { + if (!isLocalHandle(fh)) { + ops.release(path, fh, cb); + return; + } + const handle = handles.get(fh); + handles.delete(fh); + if (handle === undefined || handle.fd < 0) { + cb(0); + return; + } + try { + fs.closeSync(handle.fd); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + releasedir(path, fh, cb) { + if (!isLocalHandle(fh)) { + ops.releasedir(path, fh, cb); + return; + } + handles.delete(fh); + cb(0); + }, + + flush(path, fh, cb) { + if (!isLocalHandle(fh)) { + ops.flush(path, fh, cb); + return; + } + // Nothing is buffered on this side; the write already reached the + // kernel. Reporting success is honest here in a way it would not + // be for the VFS path. + cb(0); + }, + + fsync(path, fh, datasync, cb) { + if (!isLocalHandle(fh)) { + ops.fsync(path, fh, datasync, cb); + return; + } + const handle = handles.get(fh); + if (handle === undefined || handle.fd < 0) { + cb(ERRNO.EBADF); + return; + } + localOps += 1; + try { + if (datasync !== 0) fs.fdatasyncSync(handle.fd); + else fs.fsyncSync(handle.fd); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + truncate(path, size, cb) { + if (!isLocal(path)) { + ops.truncate(path, size, cb); + return; + } + localOps += 1; + try { + fs.truncateSync(localPath(path), size); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + ftruncate(path, fh, size, cb) { + if (!isLocalHandle(fh)) { + ops.ftruncate(path, fh, size, cb); + return; + } + // By descriptor, not by path: the file may have been renamed or + // replaced since it was opened. + const handle = handles.get(fh); + if (handle === undefined || handle.fd < 0) { + cb(ERRNO.EBADF); + return; + } + localOps += 1; + try { + fs.ftruncateSync(handle.fd, size); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + unlink(path, cb) { + if (!isLocal(path)) { + ops.unlink(path, cb); + return; + } + localOps += 1; + try { + fs.unlinkSync(localPath(path)); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + mkdir(path, mode, cb) { + if (!isLocal(path)) { + ops.mkdir(path, mode, cb); + return; + } + localOps += 1; + try { + const target = localPath(path); + ensureParent(target); + fs.mkdirSync(target, { mode: mode === 0 ? DEFAULT_DIR_MODE : mode }); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + rmdir(path, cb) { + if (!isLocal(path)) { + ops.rmdir(path, cb); + return; + } + localOps += 1; + try { + fs.rmdirSync(localPath(path)); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + rename(source, destination, cb) { + const sourceLocal = isLocal(source); + const destinationLocal = isLocal(destination); + + if (!sourceLocal && !destinationLocal) { + ops.rename(source, destination, cb); + return; + } + + if (sourceLocal !== destinationLocal) { + // Cross-layer. EXDEV is the honest answer: the two sides are + // different filesystems and the operation cannot be atomic. + // Copying here would make a non-atomic operation look atomic, + // and a crash mid-copy would leave a half-written file where + // the caller was promised all-or-nothing. EXDEV is what rename(2) + // returns between any two filesystems, so tools such as mv + // already know to copy instead. + // + // The errno is all the kernel can carry, and "cross-device + // link" on a path that is plainly not a device is the kind of + // message an operator loses an afternoon to. So the guidance + // goes to the log instead -- once per mount, because a build + // that does this does it in a loop and a per-rename line would + // bury everything else. + reportCrossLayerRename(source, destination, sourceLocal); + cb(ERRNO.EXDEV); + return; + } + + localOps += 1; + try { + const target = localPath(destination); + ensureParent(target); + fs.renameSync(localPath(source), target); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + chmod(path, mode, cb) { + if (!isLocal(path)) { + ops.chmod(path, mode, cb); + return; + } + localOps += 1; + try { + fs.chmodSync(localPath(path), mode); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + chown(path, uid, gid, cb) { + if (!isLocal(path)) { + ops.chown(path, uid, gid, cb); + return; + } + localOps += 1; + try { + fs.lchownSync(localPath(path), uid, gid); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + utimens(path, atime, mtime, cb) { + if (!isLocal(path)) { + ops.utimens(path, atime, mtime, cb); + return; + } + localOps += 1; + try { + fs.lutimesSync(localPath(path), atime / 1000, mtime / 1000); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + readlink(path, cb) { + if (!isLocal(path)) { + ops.readlink(path, cb); + return; + } + localOps += 1; + try { + // Stored verbatim. The link target is not interpreted here, and + // ignored-ness was already decided on the lookup path before any + // resolution, so a symlink cannot move a path between layers. + cb(0, fs.readlinkSync(localPath(path)) as string); + } catch (error) { + cb(toErrno(error), ""); + } + }, + + symlink(target, path, cb) { + if (!isLocal(path)) { + ops.symlink(target, path, cb); + return; + } + localOps += 1; + try { + const destination = localPath(path); + ensureParent(destination); + fs.symlinkSync(target, destination); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + access(path, mode, cb) { + if (!isLocal(path)) { + ops.access(path, mode, cb); + return; + } + localOps += 1; + try { + fs.accessSync(localPath(path), mode); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + + link(source, destination, cb) { + const sourceLocal = isLocal(source); + const destinationLocal = isLocal(destination); + + if (!sourceLocal && !destinationLocal) { + ops.link(source, destination, cb); + return; + } + + // A hardlink is one file under two names, so both names have to be + // on the same filesystem. Across the boundary that is impossible, + // and EXDEV is what link(2) returns for it anywhere else. + if (sourceLocal !== destinationLocal) { + cb(ERRNO.EXDEV); + return; + } + + localOps += 1; + try { + const target = localPath(destination); + ensureParent(target); + fs.linkSync(localPath(source), target); + cb(0); + } catch (error) { + cb(toErrno(error)); + } + }, + }; + + function allocateHandle(fd: number, path: string): number { + const handle = nextHandle++; + handles.set(handle, { fd, path }); + return handle; + } + + function reportCrossLayerRename( + source: string, + destination: string, + sourceIsLocal: boolean, + ): void { + crossLayerRenames += 1; + if (crossLayerRenames > 1) return; + const localSide = sourceIsLocal ? source : destination; + const syncedSide = sourceIsLocal ? destination : source; + // Name the entry to add, not just the paths. The fix is almost + // always "ignore the staging directory too": build tools write into + // a sibling and rename into place, so a destination that is + // local-only while its staging path is not produces exactly this. + const suggestion = toRelative(syncedSide, mountRoot) || syncedSide; + warn( + `computerd: rename ${source} -> ${destination} crossed the local-only ` + + `boundary and returned EXDEV. ${localSide} is container-local ` + + `(MOUNT_IGNORE), ${syncedSide} is synced to the workspace; a rename ` + + `between them cannot be atomic, so it is refused rather than ` + + `silently copied. Tools such as mv copy instead, but a program ` + + `calling rename directly (Node's fs.rename, Go's os.Rename) sees ` + + `the error. To ` + + `keep the rename atomic, add "${suggestion}" to MOUNT_IGNORE as ` + + `well. Further occurrences are not logged.`, + ); + } + + function localChildren(path: string): string[] { + const relative = toRelative(path, mountRoot); + const names: string[] = []; + for (const entry of options.ignore.paths) { + const parent = posix.dirname(entry); + const normalizedParent = parent === "." ? "" : parent; + if (normalizedParent !== relative) continue; + // Only list it if it has actually been created on disk. An + // unconfigured-but-unused entry should not appear as a phantom + // directory in a listing. + try { + fs.lstatSync(join(root, entry)); + names.push(posix.basename(entry)); + } catch { + // Not materialized yet; nothing to show. + } + } + return names; + } + + return { + ops: wrapped, + stats: () => ({ + localOps, + openHandles: handles.size, + crossLayerRenames, + }), + }; +} + +function toRelative(path: string, mountRoot: string): string { + let value = path; + if (mountRoot !== "/" && (value === mountRoot || value.startsWith(`${mountRoot}/`))) { + value = value.slice(mountRoot.length); + } + while (value.startsWith("/")) value = value.slice(1); + while (value.endsWith("/")) value = value.slice(0, -1); + return value; +} + +function normalizeMount(mountPoint: string): string { + const trimmed = mountPoint.replace(/\/+$/, ""); + return trimmed === "" ? "/" : trimmed; +} + +function statToFuse(stat: Stats): FuseStat { + return { + mtime: stat.mtime, + atime: stat.atime, + ctime: stat.ctime, + size: stat.size, + mode: stat.mode, + uid: stat.uid, + gid: stat.gid, + nlink: stat.nlink, + ino: stat.ino, + blksize: stat.blksize, + blocks: stat.blocks, + }; +} + +function errnoOf(error: unknown): string | undefined { + if (typeof error === "object" && error !== null && "code" in error) { + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; + } + return undefined; +} + +function toErrno(error: unknown): number { + const code = errnoOf(error); + switch (code) { + case "ENOENT": + return ERRNO.ENOENT; + case "EEXIST": + return ERRNO.EEXIST; + case "ENOTDIR": + return ERRNO.ENOTDIR; + case "EISDIR": + return ERRNO.EISDIR; + case "ENOTEMPTY": + return ERRNO.ENOTEMPTY; + case "EACCES": + return ERRNO.EACCES; + case "EPERM": + return ERRNO.EPERM; + case "EINVAL": + return ERRNO.EINVAL; + case "EXDEV": + return ERRNO.EXDEV; + case "EBADF": + return ERRNO.EBADF; + default: + return ERRNO.EIO; + } +}