fix: close workspace ownership and API escapes
This commit is contained in:
@@ -1,34 +1,37 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { mkdir, open as openFile, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join, dirname, isAbsolute, relative } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
WorkspaceFsAtV1,
|
||||
type OwnedWorkspaceFsAtRegularFile,
|
||||
type OwnedWorkspaceFsAtDirectory,
|
||||
} from "./workspace-fs-at.js";
|
||||
import type { RuntimeConfigLease } from "./runtime-config-lease.js";
|
||||
import {
|
||||
BorrowedVerifiedWorkspaceLockRootLease,
|
||||
VerifiedWorkspaceLockRootLease,
|
||||
type CanonicalWorkspaceId,
|
||||
type WorkspaceLockRootIdentityV1,
|
||||
WorkspaceRootLock,
|
||||
Revision40,
|
||||
} from "./workspace-lock-root-lease.js";
|
||||
|
||||
export interface ArtifactIdentity { readonly kind: string; readonly digest: string; readonly bytes: number; }
|
||||
export interface PreprocessingRunStateV1 {
|
||||
readonly schemaVersion: 1; readonly runId: string; readonly workspaceId: CanonicalWorkspaceId;
|
||||
readonly revision: string; readonly operation: string; readonly phase: string;
|
||||
readonly revision: Revision40; readonly operation: string; readonly phase: string;
|
||||
readonly artifacts: readonly ArtifactIdentity[]; readonly createdAt: string; readonly updatedAt: string;
|
||||
}
|
||||
export interface CreateRunInput { readonly runId?: string; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly operation: string; }
|
||||
export interface ResumeRunInput { readonly runId: string; readonly workspaceId: string; readonly revision: string; readonly operation: string; }
|
||||
export interface CreateRunInput { readonly runId?: string; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly operation: string; }
|
||||
export interface ResumeRunInput { readonly runId: string; readonly workspaceId: string; readonly revision: Revision40; readonly operation: string; }
|
||||
/** A transition is deliberately closed: identity and artifacts are never caller-writable. */
|
||||
export interface RunTransition { readonly phase: string; }
|
||||
export interface FkReviewInput { readonly candidate: ArtifactIdentity; readonly reviewSha256: string; readonly annotationSha256?: string; }
|
||||
export interface FkReviewRecordV1 extends FkReviewInput { readonly runId: string; readonly recordedAt: string; }
|
||||
|
||||
const digest = (x: Uint8Array | string) => createHash("sha256").update(x).digest("hex");
|
||||
const INTERNAL_STATE = Symbol("preprocessing-state-internal");
|
||||
const RUN_ID = /^[0-9a-f]{32}$/;
|
||||
const SHA256 = /^(?:sha256:)?[0-9a-f]{64}$/;
|
||||
const REVISION = /^[0-9a-f]{40}$/;
|
||||
@@ -39,15 +42,9 @@ const MAX_FILE_BYTES = 1 << 20;
|
||||
const MAX_AGGREGATE_BYTES = 64 << 20;
|
||||
const MAX_ENTRIES = 4096;
|
||||
function fail(msg = "preprocessing_conflict"): Error { const e = new Error(msg); e.name = "PreprocessingConflictError"; return e; }
|
||||
type WorkspaceRootLock = { assertPath(): void; close(): void; flock(kind: "shared" | "exclusive", wait: "blocking" | "nonblocking"): void; spawn(root: OwnedWorkspaceFsAtDirectory, executable: string, args: readonly string[], environment?: NodeJS.ProcessEnv): Promise<WorkspaceLockedChildResult>; };
|
||||
function id(v: string): void { if (typeof v !== "string" || !RUN_ID.test(v)) throw fail("invalid run id"); }
|
||||
function checkSha(v: string): void { if (typeof v !== "string" || !SHA256.test(v)) throw fail("invalid digest"); }
|
||||
function checkedRoot(root: string | undefined): string {
|
||||
if (!root || typeof root !== "string" || !isAbsolute(root) || root.includes("\0")) throw fail();
|
||||
const resolved = realpathSync(root);
|
||||
const original = lstatSync(root); const st = lstatSync(resolved);
|
||||
if (!original.isDirectory() || original.dev !== st.dev || original.ino !== st.ino || !st.isDirectory() || (st.mode & 0o777) !== 0o700 || st.nlink < 2) throw fail();
|
||||
return resolved;
|
||||
}
|
||||
function strictObject(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const got = Object.keys(value as object).sort();
|
||||
@@ -75,9 +72,8 @@ async function durableJson(path: string, value: unknown): Promise<void> {
|
||||
}
|
||||
|
||||
export class PreprocessingStateStore {
|
||||
private readonly configuredRoot: string;
|
||||
constructor(configuredRoot: string) { this.configuredRoot = checkedRoot(configuredRoot); }
|
||||
private root(): string { return this.configuredRoot; }
|
||||
constructor(private readonly rootLease: VerifiedWorkspaceLockRootLease) {}
|
||||
private root(): string { this.rootLease.assertLive(); return this.rootLease.anchoredPath(); }
|
||||
private paths(input: { runId: string }) {
|
||||
id(input.runId); const root = this.root(); const base = join(root, "preprocessing");
|
||||
return { base, jobs: join(base, "jobs"), path: join(base, "jobs", `${input.runId}.json`), candidate: join(base, "fk-candidates", `${input.runId}.yaml`), review: join(base, "fk-reviews", `${input.runId}.json`) };
|
||||
@@ -138,15 +134,15 @@ export class PreprocessingStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
export interface DwhLockedChildRequest { readonly kind: "dwh_preprocess"; readonly stage: "introspect" | "lsh"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: unknown; readonly childRunId: string; }
|
||||
export interface SchemaLockedChildRequest { readonly kind: "schema_preprocess"; readonly stage: "fk_suggest" | "fk_check" | "schema_index"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: unknown; readonly childRunId: string; readonly reviewedArtifact: ArtifactIdentity | null; }
|
||||
export interface EvidenceLockedChildRequest { readonly kind: "evidence_preprocess"; readonly stage: "http_publish"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: string; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: unknown; readonly childRunId: string; }
|
||||
export interface DwhLockedChildRequest { readonly kind: "dwh_preprocess"; readonly stage: "introspect" | "lsh"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; }
|
||||
export interface SchemaLockedChildRequest { readonly kind: "schema_preprocess"; readonly stage: "fk_suggest" | "fk_check" | "schema_index"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; readonly reviewedArtifact: ArtifactIdentity | null; }
|
||||
export interface EvidenceLockedChildRequest { readonly kind: "evidence_preprocess"; readonly stage: "http_publish"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; }
|
||||
export type WorkspaceLockedChildRequest = DwhLockedChildRequest | SchemaLockedChildRequest | EvidenceLockedChildRequest;
|
||||
export interface WorkspaceLockedChildResult { readonly exitCode: number; readonly stdout: Uint8Array; readonly stderr: Uint8Array; }
|
||||
|
||||
export class BorrowedWorkspaceSessionReadersExclusiveLockLease {
|
||||
private live = true; private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1) {}
|
||||
static from(id: CanonicalWorkspaceId, root: WorkspaceLockRootIdentityV1) { return new BorrowedWorkspaceSessionReadersExclusiveLockLease(id, root); }
|
||||
static [INTERNAL_STATE](id: CanonicalWorkspaceId, root: WorkspaceLockRootIdentityV1) { return new BorrowedWorkspaceSessionReadersExclusiveLockLease(id, root); }
|
||||
assertLive(): void { if (!this.live) throw fail(); }
|
||||
invalidate(): void { this.live = false; }
|
||||
}
|
||||
@@ -154,13 +150,14 @@ export class WorkspaceWriterLockCapability {
|
||||
private live = true; private settled = false; private readerExclusive = false; private spawnActive = false; private poisoned = false;
|
||||
private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1, private readonly root: VerifiedWorkspaceLockRootLease, private readonly writer: WorkspaceRootLock) {}
|
||||
private assertLive(): void { if (!this.live || this.settled || this.poisoned) throw fail(); }
|
||||
assertWriterPath(): void { this.assertLive(); this.root.assertLive(); this.writer.assertPath(); }
|
||||
invalidateForSettlement(): void { this.settled = true; }
|
||||
static create(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) { return new WorkspaceWriterLockCapability(id, identity, root, writer); }
|
||||
static [INTERNAL_STATE](id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) { return new WorkspaceWriterLockCapability(id, identity, root, writer); }
|
||||
async runUnderSessionReadersExclusive<T>(action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise<T>): Promise<T> {
|
||||
this.assertLive(); if (this.readerExclusive || this.spawnActive) throw fail(); this.readerExclusive = true;
|
||||
let lock: WorkspaceRootLock;
|
||||
try { lock = await this.root.acquireSessionReadersExclusive(); } catch { this.readerExclusive = false; this.poisoned = true; throw fail(); }
|
||||
const borrowed = BorrowedWorkspaceSessionReadersExclusiveLockLease.from(this.workspaceId, this.rootIdentity);
|
||||
const borrowed = BorrowedWorkspaceSessionReadersExclusiveLockLease[INTERNAL_STATE](this.workspaceId, this.rootIdentity);
|
||||
try { return await action(borrowed); } catch (error) { throw error; }
|
||||
finally {
|
||||
borrowed.invalidate(); let cleanupError: unknown; try { lock.close(); } catch (error) { cleanupError = error; }
|
||||
@@ -171,22 +168,27 @@ export class WorkspaceWriterLockCapability {
|
||||
async spawnChild(request: WorkspaceLockedChildRequest): Promise<WorkspaceLockedChildResult> {
|
||||
this.assertLive(); if (!this.readerExclusive || this.spawnActive || !request || request.workspaceId !== this.workspaceId) throw fail();
|
||||
if (!RUN_ID.test(request.childRunId) || request.rootIdentity.device !== this.rootIdentity.device || request.rootIdentity.inode !== this.rootIdentity.inode || request.rootIdentity.workspaceId !== this.workspaceId) throw fail();
|
||||
const cfg = request.runtimeConfig as { workspaceId?: string; revision?: string; configPath?: string; path?: string } | null;
|
||||
if (!cfg || cfg.workspaceId !== this.workspaceId || cfg.revision !== request.revision || typeof (cfg.configPath ?? cfg.path) !== "string") throw fail();
|
||||
const cfg = request.runtimeConfig;
|
||||
if (cfg.workspaceId !== this.workspaceId || cfg.workspaceRevision !== request.revision || typeof cfg.path !== "string") throw fail();
|
||||
this.spawnActive = true;
|
||||
try {
|
||||
const configPath = (cfg.configPath ?? cfg.path)!; let argv: string[];
|
||||
const configPath = cfg.path; let argv: string[];
|
||||
switch (request.kind) { case "dwh_preprocess": argv = ["-m", "tht.cli", "preprocess", "dwh", "--steps", request.stage, "--json", "-c", configPath]; break; case "schema_preprocess": argv = ["-m", "tht.cli", "schema", request.stage === "fk_suggest" ? "suggest-fks" : request.stage === "fk_check" ? "check" : "index", "--json", "-c", configPath]; break; case "evidence_preprocess": argv = ["-m", "tht.cli", "preprocess", "evidence", "--json", "-c", configPath]; break; default: throw fail(); }
|
||||
return await this.root.spawnChild(this.writer, process.env.THT_PYTHON ?? "python3", argv, { ...process.env, THOTH_WORKSPACE_ID: this.workspaceId, THOTH_WORKSPACE_REVISION: request.revision, THOTH_WORKSPACE_DEVICE: String(this.rootIdentity.device), THOTH_WORKSPACE_INODE: String(this.rootIdentity.inode) });
|
||||
} finally { this.spawnActive = false; }
|
||||
}
|
||||
async close(): Promise<void> { if (!this.live) return; if (this.readerExclusive || this.spawnActive) throw fail(); this.live = false; let error: unknown; try { this.writer.close(); } catch (e) { error = e; } try { await this.root.close(); } catch (e) { error ??= e; } if (error) throw fail(); }
|
||||
}
|
||||
function makeWriterCapability(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) { return WorkspaceWriterLockCapability.create(id, identity, root, writer); }
|
||||
function makeWriterCapability(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) { return WorkspaceWriterLockCapability[INTERNAL_STATE](id, identity, root, writer); }
|
||||
export interface BorrowedOrderedWorkspaceWriterLeaseV1 {
|
||||
readonly workspaceId: CanonicalWorkspaceId;
|
||||
readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease;
|
||||
readonly writerCapability: WorkspaceWriterLockCapability;
|
||||
}
|
||||
export interface OrderedWorkspaceCapability { readonly workspaceId: CanonicalWorkspaceId; readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease; readonly writerCapability: WorkspaceWriterLockCapability; }
|
||||
export class OrderedWorkspaceWriterCapabilitySet {
|
||||
private live = true; private constructor(private readonly caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) {}
|
||||
static make(caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) { return new OrderedWorkspaceWriterCapabilitySet(caps); }
|
||||
static [INTERNAL_STATE](caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) { return new OrderedWorkspaceWriterCapabilitySet(caps); }
|
||||
invalidate(): void { this.live = false; for (const cap of this.caps.values()) cap.invalidateForSettlement(); }
|
||||
get workspaceIds(): readonly CanonicalWorkspaceId[] { if (!this.live) throw fail(); return [...this.caps.keys()]; }
|
||||
async forWorkspace<T>(workspaceId: CanonicalWorkspaceId, action: (lease: OrderedWorkspaceCapability) => Promise<T>): Promise<T> { if (!this.live) throw fail(); const cap = this.caps.get(workspaceId); if (!cap) throw fail(); return action({ workspaceId, rootLease: BorrowedVerifiedWorkspaceLockRootLease.make(cap.rootIdentity, () => { if (!this.live) throw fail(); }), writerCapability: cap }); }
|
||||
@@ -197,8 +199,11 @@ export async function runUnderOrderedWorkspaceWriterLocks<T>(rootLeases: readonl
|
||||
const caps: WorkspaceWriterLockCapability[] = []; let set: OrderedWorkspaceWriterCapabilitySet | undefined; let result: T | undefined; let callbackError: unknown;
|
||||
try {
|
||||
for (const source of sorted) { const root = source.transfer(); let writer: WorkspaceRootLock | undefined; try { writer = await root.acquireWriterLock(); caps.push(makeWriterCapability(root.identity.workspaceId, root.identity, root, writer)); } catch (error) { try { writer?.close(); } catch {} try { await root.close(); } catch {} throw error; } }
|
||||
set = OrderedWorkspaceWriterCapabilitySet.make(new Map(caps.map(c => [c.workspaceId, c]))); result = await action(set);
|
||||
} catch (error) { callbackError = error; }
|
||||
set = OrderedWorkspaceWriterCapabilitySet[INTERNAL_STATE](new Map(caps.map(c => [c.workspaceId, c])));
|
||||
for (const capability of caps) capability.assertWriterPath();
|
||||
try { result = await action(set); for (const capability of caps) capability.assertWriterPath(); }
|
||||
catch (error) { callbackError = error; }
|
||||
} catch (error) { callbackError ??= error; }
|
||||
set?.invalidate();
|
||||
for (const cap of [...caps].reverse()) await cap.close().catch(() => undefined);
|
||||
if (callbackError) throw callbackError; return result as T;
|
||||
|
||||
Reference in New Issue
Block a user