Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions src/api/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@
readonly network: boolean;
}

/** Sandbox metadata at open time, including the pinned epoch read from Postgres. */
type SandboxOpenMeta = SandboxMeta & { readonly version?: number };

const DEFAULT_RUNTIME_OPTIONS: RuntimeOptions = { python: false, javascript: false, network: false };

export function buildSandboxBaseEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
Expand Down Expand Up @@ -219,6 +222,8 @@
readonly scriptTx: SessionScopedFs | undefined;
lastUsed: number;
inFlight: number;
/** Pinned sandbox epoch exposed at script-open time. */
sandboxVersion: number;
/**
* Per-session async readers-writer lock. Writes (default exec path,
* destroy, reaper, shutdown) take exclusive mode; readOnly execs take
Expand Down Expand Up @@ -502,16 +507,20 @@
tenantId: string,
sandboxId: string,
owner = "",
): Promise<{ fs: IFileSystem; resolvedOwner: string; createdAt: string }> {
): Promise<{ fs: IFileSystem; resolvedOwner: string; createdAt: string; version: number }> {
if (this.createFsOverride !== undefined) {
const meta = this.getSandboxMetaFn !== undefined ? await this.getSandboxMetaFn(tenantId, sandboxId) : null;
return {
fs: await this.createFsOverride(tenantId, sandboxId),
resolvedOwner: owner,
createdAt: new Date().toISOString(),
version: meta?.version ?? 0,
};
}
const backend = this.getOrInitBackend(tenantId);
return createPostgresSandboxFs(
const meta = this.getSandboxMetaFn !== undefined ? await this.getSandboxMetaFn(tenantId, sandboxId) : null;
const sandboxVersion = meta?.version ?? 0;
const fs = await createPostgresSandboxFs(
{
connectionString: backend.connectionString,
tenantId,
Expand All @@ -521,7 +530,14 @@
},
sandboxId,
owner,
sandboxVersion,
);
return {
fs,

Check failure on line 536 in src/api/session-manager.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Type '{ fs: IFileSystem; resolvedOwner: string; createdAt: string; }' is missing the following properties from type 'IFileSystem': readFile, readFileBuffer, writeFile, appendFile, and 16 more.
resolvedOwner: owner,
createdAt: new Date().toISOString(),
version: sandboxVersion,
};
}

private estimatePathCacheBytes(fs: IFileSystem): number {
Expand Down Expand Up @@ -567,7 +583,7 @@
const creationPromise = (async (): Promise<Session> => {
let createdFs: IFileSystem | undefined;
try {
const { fs, resolvedOwner, createdAt: fsCreatedAt } = await this.buildFs(tenantId, sandboxId, owner);
const { fs, resolvedOwner, createdAt: fsCreatedAt, version: sandboxVersion } = await this.buildFs(tenantId, sandboxId, owner);
createdFs = fs;
const defenseInDepthConfig: DefenseInDepthConfig | false = this.defenseInDepth
? {
Expand Down Expand Up @@ -659,6 +675,7 @@
pathCacheBytes,
overBudget: pathCacheBytes > this.pathCacheMaxBytes,
lastSeenVersion: initialVersion,
sandboxVersion,
publishPending: false,
cwd: bash.getCwd(),
};
Expand Down Expand Up @@ -1179,9 +1196,9 @@
runtimeOptions?: RuntimeOptions,
lostSignal?: AbortSignal,
): Promise<T> {
let meta: SandboxMeta | null | undefined;
let meta: SandboxOpenMeta | null | undefined;
if (this.getSandboxMetaFn !== undefined) {
meta = await this.getSandboxMetaFn(tenantId, sandboxId);
meta = (await this.getSandboxMetaFn(tenantId, sandboxId)) as SandboxOpenMeta | null;
if (meta === null) {
throw Object.assign(new Error(`ENOENT: sandbox ${sandboxId} not found`), { code: "ENOENT" });
}
Expand All @@ -1195,6 +1212,7 @@
if (meta?.owner) session.owner = meta.owner;
if (meta?.name !== undefined) session.name = meta.name;
if (meta?.createdAt !== undefined) session.createdAt = meta.createdAt;
if (meta?.version !== undefined) session.sandboxVersion = meta.version;
return this.withSessionReadEntry(tenantId, sandboxId, session, fn, lostSignal);
}

Expand Down Expand Up @@ -1227,9 +1245,9 @@
runtimeOptions?: RuntimeOptions,
lostSignal?: AbortSignal,
): Promise<T> {
let meta: SandboxMeta | null | undefined;
let meta: SandboxOpenMeta | null | undefined;
if (this.getSandboxMetaFn !== undefined) {
meta = await this.getSandboxMetaFn(tenantId, sandboxId);
meta = (await this.getSandboxMetaFn(tenantId, sandboxId)) as SandboxOpenMeta | null;
if (meta === null) {
throw Object.assign(new Error(`ENOENT: sandbox ${sandboxId} not found`), { code: "ENOENT" });
}
Expand Down
12 changes: 11 additions & 1 deletion src/api/tests/unit/session-manager.rehydrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SandboxMeta } from "../../../sql-fs/types.js";
import { SessionManager } from "../../session-manager.js";

let pgSandboxes: Map<string, SandboxMeta>;
type SandboxMetaWithEpoch = SandboxMeta & { version?: number };

let pgSandboxes: Map<string, SandboxMetaWithEpoch>;

let createFsSpy: ReturnType<typeof vi.fn>;

Expand Down Expand Up @@ -121,6 +123,14 @@ describe("SessionManager.withSessionOrRehydrate()", () => {
expect(capturedRuntime).toEqual({ python: true, javascript: false, network: false });
});

it("pins sandbox version from PG metadata at open time", async () => {
pgSandboxes.set("sb-epoch", { owner: null, name: null, python: false, javascript: false, network: false, version: 41 });

await sm.withSessionOrRehydrate("default", "sb-epoch", async (session) => {
expect(session.sandboxVersion).toBe(41);
});
});

it("persistSandboxMeta writes to store and is readable on rehydration", async () => {
await sm.withSession("default", "sb-meta", async (session) => {
session.owner = "creator";
Expand Down
90 changes: 78 additions & 12 deletions src/sql-fs/dialects/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,46 @@
await tx`SELECT set_config('app.sandbox_id', ${sandboxId}, true)`;
}

async setSandboxContextWithLock(tx: PgTx, sandboxId: string): Promise<void> {
async setSandboxContextWithLock(tx: PgTx, sandboxId: string, sandboxVersion?: number): Promise<void> {
await tx`SELECT set_config('app.sandbox_id', ${sandboxId}, true), pg_advisory_xact_lock(hashtextextended(${sandboxId}, 0))`;
if (sandboxVersion !== undefined) await this.#assertSandboxVersion(tx, sandboxId, sandboxVersion);
}

async #acquireSandboxWriteFence(tx: PgTx, sandboxId: string): Promise<void> {
await tx`SELECT set_config('app.sandbox_id', ${sandboxId}, true), pg_advisory_xact_lock(hashtextextended(${sandboxId}, 0))`;
}

async #assertSandboxVersion(tx: PgTx, sandboxId: string, sandboxVersion?: number): Promise<void> {
if (sandboxVersion === undefined) return;
const rows = await tx<{ version: string | number }[]>`
SELECT version FROM sandboxes WHERE id = ${sandboxId}
`;
if (rows.length === 0 || Number(rows[0]!.version) !== sandboxVersion) {
throw Object.assign(new Error("ECOHERENCE: sandbox version changed; write suppressed"), { code: "ECOHERENCE" });

Check failure on line 113 in src/sql-fs/dialects/postgres.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

src/sql-fs/dialects/tests/unit/postgres.advisory-lock.test.ts > PostgresDialect composite write fencing > threads the pinned sandbox epoch into mkdirComposite's advisory-locked SQL

Error: ECOHERENCE: sandbox version changed; write suppressed ❯ PostgresDialect.#assertSandboxVersion src/sql-fs/dialects/postgres.ts:113:24 ❯ PostgresDialect.mkdirComposite src/sql-fs/dialects/postgres.ts:121:3 ❯ src/sql-fs/dialects/tests/unit/postgres.advisory-lock.test.ts:158:3 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ECOHERENCE' }
}
}

// ── Composite write operations ────────────────────────────────────────────────

async mkdirComposite(tx: PgTx, sandboxId: string, parentId: bigint, name: string, mode: number): Promise<bigint> {
async mkdirComposite(tx: PgTx, sandboxId: string, parentId: bigint, name: string, mode: number, sandboxVersion?: number): Promise<bigint> {
await this.#acquireSandboxWriteFence(tx, sandboxId);
await this.#assertSandboxVersion(tx, sandboxId, sandboxVersion);
const expectedVersion = sandboxVersion ?? null;
const rows = await tx<{ id: string }[]>`
WITH ctx AS (
SELECT set_config('app.sandbox_id', ${sandboxId}, true),
pg_advisory_xact_lock(hashtextextended(${sandboxId}, 0))
),
fence AS (
SELECT 1
FROM sandboxes
WHERE id = ${sandboxId}
AND (${expectedVersion} IS NULL OR version = ${expectedVersion})
AND (SELECT 1 FROM ctx) IS NOT NULL
),
new_inode AS (
INSERT INTO inodes (sandbox_id, kind, mode, size)
SELECT ${sandboxId}, 2, ${mode}, 0 FROM ctx
SELECT ${sandboxId}, 2, ${mode}, 0 FROM ctx, fence
RETURNING id
)
INSERT INTO dirents (parent_inode_id, name, inode_id, sandbox_id)
Expand All @@ -118,20 +143,29 @@
RETURNING inode_id AS id
`;
const row = rows[0];
if (!row) throw new Error("mkdirComposite: INSERT returned no rows");
if (!row) throw Object.assign(new Error("ECOHERENCE: sandbox version changed; write suppressed"), { code: "ECOHERENCE" });
return BigInt(row.id);
}

async rmComposite(tx: PgTx, sandboxId: string, parentId: bigint, name: string): Promise<bigint> {
async rmComposite(tx: PgTx, sandboxId: string, parentId: bigint, name: string, sandboxVersion?: number): Promise<bigint> {
await this.#acquireSandboxWriteFence(tx, sandboxId);
const expectedVersion = sandboxVersion ?? null;
const rows = await tx<{ removed_inode_id: string }[]>`
WITH ctx AS (
SELECT set_config('app.sandbox_id', ${sandboxId}, true),
pg_advisory_xact_lock(hashtextextended(${sandboxId}, 0))
),
fence AS (
SELECT 1
FROM sandboxes
WHERE id = ${sandboxId}
AND (${expectedVersion} IS NULL OR version = ${expectedVersion})
AND (SELECT 1 FROM ctx) IS NOT NULL
),
removed_dirent AS (
DELETE FROM dirents
WHERE parent_inode_id = ${String(parentId)} AND name = ${name}
AND (SELECT 1 FROM ctx) IS NOT NULL
AND (SELECT 1 FROM fence) IS NOT NULL
RETURNING inode_id
),
-- Delete and decrement are split into two mutually-exclusive CTEs
Expand All @@ -153,7 +187,7 @@
SELECT inode_id AS removed_inode_id FROM removed_dirent
`;
const row = rows[0];
if (!row) throw createEnoent(name);
if (!row) throw Object.assign(new Error("ECOHERENCE: sandbox version changed; write suppressed"), { code: "ECOHERENCE" });
return BigInt(row.removed_inode_id);
}

Expand All @@ -166,23 +200,34 @@
size: number,
sha256: Uint8Array,
data: Uint8Array,
sandboxVersion?: number,
): Promise<bigint> {
// F6: the CAS blob is committed by `commitBlob` in its own short tx BEFORE
// this composite runs, so there is no `blob_insert` CTE here — the
// script-tx must not hold the hot-blob `ON CONFLICT DO UPDATE` tuple lock.
await this.#acquireSandboxWriteFence(tx, sandboxId);
const expectedVersion = sandboxVersion ?? null;
const rows = await tx<{ new_inode_id: string }[]>`
WITH ctx AS (
SELECT set_config('app.sandbox_id', ${sandboxId}, true),
pg_advisory_xact_lock(hashtextextended(${sandboxId}, 0))
),
fence AS (
SELECT 1
FROM sandboxes
WHERE id = ${sandboxId}
AND (${expectedVersion} IS NULL OR version = ${expectedVersion})
AND (SELECT 1 FROM ctx) IS NOT NULL
),
new_inode AS (
INSERT INTO inodes (sandbox_id, kind, mode, size, content_sha256)
SELECT ${sandboxId}, 1, ${mode}, ${size}, ${sha256} FROM ctx
SELECT ${sandboxId}, 1, ${mode}, ${size}, ${sha256} FROM ctx, fence
RETURNING id
),
old_dirent AS (
SELECT inode_id FROM dirents
WHERE parent_inode_id = ${String(parentId)} AND name = ${name}
AND (SELECT 1 FROM fence) IS NOT NULL
),
upserted AS (
INSERT INTO dirents (parent_inode_id, name, inode_id, sandbox_id)
Expand All @@ -208,7 +253,7 @@
SELECT id AS new_inode_id FROM new_inode
`;
const row = rows[0];
if (!row) throw new Error("writeFileComposite: INSERT returned no rows");
if (!row) throw Object.assign(new Error("ECOHERENCE: sandbox version changed; write suppressed"), { code: "ECOHERENCE" });
if (this.#blobCache !== undefined) {
void this.#blobCache.set(sha256, data);
}
Expand All @@ -222,6 +267,7 @@
oldName: string,
newParentId: bigint,
newName: string,
sandboxVersion?: number,
): Promise<void> {
// Audit M10: a single wCTE that both DELETEs the destination dirent and
// UPDATEs the source dirent into that same (parent_inode_id, name) slot
Expand All @@ -231,15 +277,24 @@
// 1 frees the destination slot (and cleans its inode), statement 2 renames
// the source into it. Statement 1's effects are visible to statement 2, so
// the rename can no longer collide. Atomicity is preserved by the tx.
await this.#acquireSandboxWriteFence(tx, sandboxId);
const expectedVersion = sandboxVersion ?? null;
await tx`
WITH ctx AS (
SELECT set_config('app.sandbox_id', ${sandboxId}, true),
pg_advisory_xact_lock(hashtextextended(${sandboxId}, 0))
),
fence AS (
SELECT 1
FROM sandboxes
WHERE id = ${sandboxId}
AND (${expectedVersion} IS NULL OR version = ${expectedVersion})
AND (SELECT 1 FROM ctx) IS NOT NULL
),
old_dest AS (
DELETE FROM dirents
WHERE parent_inode_id = ${String(newParentId)} AND name = ${newName}
AND (SELECT 1 FROM ctx) IS NOT NULL
AND (SELECT 1 FROM fence) IS NOT NULL
RETURNING inode_id
),
-- Split delete/decrement by snapshot nlink so the overwritten
Expand All @@ -257,12 +312,19 @@
AND nlink > 1
`;
const rows = await tx<{ inode_id: string }[]>`
WITH fence AS (
SELECT 1
FROM sandboxes
WHERE id = ${sandboxId}
AND (${expectedVersion} IS NULL OR version = ${expectedVersion})
)
UPDATE dirents
SET parent_inode_id = ${String(newParentId)}, name = ${newName}
WHERE parent_inode_id = ${String(oldParentId)} AND name = ${oldName}
AND (SELECT 1 FROM fence) IS NOT NULL
RETURNING inode_id
`;
if (rows.length === 0) throw createEnoent(oldName);
if (rows.length === 0) throw Object.assign(new Error("ECOHERENCE: sandbox version changed; write suppressed"), { code: "ECOHERENCE" });
}

// ── Private helpers ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -337,10 +399,11 @@
python: boolean;
javascript: boolean;
network: boolean;
version: string | number;
created_at: Date;
}[]
>`
SELECT owner, name, python, javascript, network, created_at FROM sandboxes WHERE id = ${sandboxId}
SELECT owner, name, python, javascript, network, version, created_at FROM sandboxes WHERE id = ${sandboxId}
`;
if (rows.length === 0) return null;
const r = rows[0]!;
Expand All @@ -350,6 +413,7 @@
python: r.python,
javascript: r.javascript,
network: r.network,
version: Number(r.version) || 0,
createdAt: r.created_at.toISOString(),
};
} catch (err) {
Expand Down Expand Up @@ -582,6 +646,8 @@
`;

// Move the source dirent via a single UPDATE
await this.#acquireSandboxWriteFence(tx, sandboxId);

Check failure on line 649 in src/sql-fs/dialects/postgres.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Cannot find name 'sandboxId'.
await this.#assertSandboxVersion(tx, sandboxId, sandboxVersion);

Check failure on line 650 in src/sql-fs/dialects/postgres.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Cannot find name 'sandboxVersion'.

Check failure on line 650 in src/sql-fs/dialects/postgres.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Cannot find name 'sandboxId'.
const rows = await tx<{ inode_id: string }[]>`
UPDATE dirents
SET parent_inode_id = ${String(newParentId)}, name = ${newName}
Expand Down
Loading
Loading