UNPKG

@pgxsinkit/pgwasm

Version:
523 lines (487 loc) • 20.4 kB
/** * `RepackedSyncBroker` — the single owner of one `RepackedVfs`, answering synchronous file requests * that arrive over `SharedArrayBuffer` channels. * * The broker is meant to be the only thing on its thread. A backend running wasm parks in futexes and * cannot await anything, so it publishes a request and blocks; the broker must therefore never block on * a promise between reading a request and writing its answer. Every handler here is synchronous, and * the store's own API is synchronous end to end. * * Three loop shapes are offered: * * - `serveForever()` parks the thread in `Atomics.wait` between requests. It is the shape for a * dedicated coordinator worker, and while it runs the thread never reaches its event loop — so * `postMessage` cannot reach it and every channel must be attached BEFORE entering it. Ask it to * return with `doorbell.requestStop()` from any thread holding the doorbell. * - `serve()` is the same loop built on `Atomics.waitAsync`, for a host that must keep its event loop * alive. Channels may be attached and detached at any time. * - `serveOnce()` scans every attached channel once and returns without waiting, for a host that * drives its own loop. * * Failure separation is the point of the class. A file rejection (`FsError`) becomes an errno in the * reply header and nothing else happens. A store failure becomes `FAULT_STORE` in the reply, and the * loop keeps running because every later request will fail the same way and the client decides what to * do. A protocol violation detaches only the offending client, with a logged reason. */ import { FS_ERRNO, FsError } from "../core/errors"; import type { RepackedFileSystem, RepackedStat } from "../core/repacked-vfs"; import { FAULT_DETACHED, FAULT_NONE, FAULT_PROTOCOL, FAULT_STORE, HEADER_ERRNO, HEADER_FAULT, HEADER_OPCODE, HEADER_REQUEST, HEADER_RESPONSE, HEADER_RESULT_HI, HEADER_RESULT_LO, HEADER_STATE, OPCODE_CLOSE, OPCODE_FSTAT, OPCODE_FSYNC, OPCODE_LSTAT, OPCODE_MKDIR, OPCODE_OPEN, OPCODE_READ, OPCODE_READDIR, OPCODE_READLINK, OPCODE_RENAME, OPCODE_RMDIR, OPCODE_SIZE, OPCODE_STAT, OPCODE_SYMLINK, OPCODE_TRUNCATE, OPCODE_UNLINK, OPCODE_WRITE, PayloadDecodeError, PayloadOverflowError, PayloadReader, PayloadWriter, READDIR_DONE, STATE_DETACHED, STATE_REQUEST, STATE_RESPONSE, isKnownOpcode, planOpen, splitResult, writeStat, } from "./protocol"; import type { RepackedChannel, RepackedDoorbell } from "./protocol"; /** A descriptor the broker opened on behalf of one client. */ interface BrokerFd { /** The core's descriptor number. */ readonly fd: number; /** The access the CLIENT asked for, which may be narrower than what the core descriptor grants. */ readonly readable: boolean; readonly writable: boolean; } interface AttachedClient { readonly channel: RepackedChannel; readonly fds: Map<number, BrokerFd>; } /** What a handler produced: an errno-only rejection, or a result plus optional response bytes. */ interface Answer { readonly errno: number; readonly result: bigint; readonly responseBytes: number; } const OK: Answer = { errno: 0, result: 0n, responseBytes: 0 }; function ok(result: bigint | number = 0n, responseBytes = 0): Answer { return { errno: 0, result: typeof result === "bigint" ? result : BigInt(result), responseBytes }; } /** A client's request could not be understood; the client is detached, the loop survives. */ class ProtocolViolation extends Error { constructor(message: string) { super(message); this.name = "ProtocolViolation"; } } export interface RepackedSyncBrokerOptions { /** * The store this broker owns. Nothing else may hold it. A `RepackedVfs` is the one-store form; a * `MountedRepackedVfs` serves several stores as one tree, and the broker cannot tell them apart. */ readonly vfs: RepackedFileSystem; /** The shared doorbell every attached channel rings. */ readonly doorbell: RepackedDoorbell; /** The timestamp the broker supplies to every core call. Defaults to the wall clock. */ readonly now?: () => bigint; /** Where a detach reason goes. Defaults to `console.warn`. */ readonly log?: (message: string) => void; /** * How long one blocking iteration parks before re-scanning anyway. Defaults to 250 ms. The protocol * has no missed-wakeup window — the server observes the doorbell ticket BEFORE it scans, so a * request published during the scan makes the wait return `not-equal` at once — so this is purely a * heartbeat: it costs four idle wakeups a second and turns any future slip into a latency blip * instead of a permanently parked backend. `Infinity` makes the loop a pure park. */ readonly pollIntervalMs?: number; } export class RepackedSyncBroker { readonly doorbell: RepackedDoorbell; readonly #vfs: RepackedFileSystem; readonly #now: () => bigint; readonly #log: (message: string) => void; readonly #pollIntervalMs: number; readonly #clients = new Map<number, AttachedClient>(); #serving = false; constructor(options: RepackedSyncBrokerOptions) { this.#vfs = options.vfs; this.doorbell = options.doorbell; this.#now = options.now ?? (() => BigInt(Date.now())); this.#log = options.log ?? ((message) => console.warn(message)); this.#pollIntervalMs = options.pollIntervalMs ?? 250; } /** Channel ids the broker currently serves. */ attachedIds(): number[] { return [...this.#clients.keys()].sort((left, right) => left - right); } /** Descriptors the broker currently holds for one client — the leak check after a detach. */ openFdCount(channelId?: number): number { if (channelId === undefined) { let total = 0; for (const client of this.#clients.values()) total += client.fds.size; return total; } return this.#clients.get(channelId)?.fds.size ?? 0; } attach(channel: RepackedChannel): void { if (this.#clients.has(channel.id)) throw new Error(`broker channel ${channel.id} is already attached`); if (channel.doorbell.buffer !== this.doorbell.buffer) { throw new Error(`broker channel ${channel.id} rings a different doorbell`); } this.#clients.set(channel.id, { channel, fds: new Map() }); } /** * Drop a client and close every descriptor it still holds. A backend that dies mid-query never leaks * a descriptor into the store, and the store's exclusive handles are released the moment the LAST * owner goes away rather than whenever a stale fd happens to be noticed. */ detach(channel: RepackedChannel | number, reason = "detached by the host"): void { this.#detach(typeof channel === "number" ? channel : channel.id, reason, FAULT_DETACHED); } #detach(id: number, reason: string, fault: number): void { const client = this.#clients.get(id); if (client === undefined) return; this.#clients.delete(id); this.#releaseFds(client); const header = client.channel.header; Atomics.store(header, HEADER_FAULT, fault); Atomics.store(header, HEADER_ERRNO, 0); Atomics.store(header, HEADER_RESPONSE, 0); Atomics.store(header, HEADER_STATE, STATE_DETACHED); Atomics.notify(header, HEADER_STATE); this.#log(`repacked broker detached channel ${id}: ${reason}`); } /** Detach every client, closing all descriptors. The store itself is NOT closed. */ detachAll(reason = "broker shutting down"): void { for (const id of this.attachedIds()) this.detach(id, reason); } /** * Scan every attached channel once and answer whatever is pending. Never blocks, never throws for a * file or store error. Returns how many requests were answered. */ serveOnce(): number { let served = 0; for (const client of [...this.#clients.values()]) { if (Atomics.load(client.channel.header, HEADER_STATE) !== STATE_REQUEST) continue; this.#answer(client); served += 1; } return served; } /** * The blocking coordinator loop. Parks the thread in `Atomics.wait` on the doorbell between * requests, so this thread's event loop never runs: attach every channel before calling it, and stop * it with `doorbell.requestStop()` from another thread. */ serveForever(): void { this.#enterLoop(); try { while (this.doorbell.running()) { const observed = this.doorbell.ticket(); if (this.serveOnce() > 0) continue; this.doorbell.wait(observed, this.#pollIntervalMs); } } finally { this.#serving = false; } } /** * The same loop on `Atomics.waitAsync`, for a host that must not park its thread — the tab's main * thread, or a worker that also has to receive `postMessage`. Channels may be attached and detached * while it runs. Resolves when `doorbell.requestStop()` is called. */ async serve(): Promise<void> { this.#enterLoop(); try { while (this.doorbell.running()) { const observed = this.doorbell.ticket(); if (this.serveOnce() > 0) continue; await this.doorbell.waitAsync(observed, this.#pollIntervalMs); } } finally { this.#serving = false; } } #enterLoop(): void { if (this.#serving) throw new Error("the repacked broker is already serving"); this.#serving = true; } #releaseFds(client: AttachedClient): void { for (const entry of client.fds.values()) { try { this.#vfs.close(entry.fd); } catch { // A descriptor the store already dropped (or a poisoned store) must not stop the release of // the rest — the client is going away either way. } } client.fds.clear(); } /** Read one pending request, run it, and publish the reply. Never throws. */ #answer(client: AttachedClient): void { const header = client.channel.header; const opcode = Atomics.load(header, HEADER_OPCODE); const requestBytes = Atomics.load(header, HEADER_REQUEST); let fault = FAULT_NONE; let answer = OK; try { if (!isKnownOpcode(opcode)) throw new ProtocolViolation(`unknown opcode ${opcode}`); if (requestBytes < 0 || requestBytes > client.channel.payloadBytes) { throw new ProtocolViolation(`request length ${requestBytes} is outside the payload region`); } const reader = new PayloadReader(client.channel.payload, requestBytes); const writer = new PayloadWriter(client.channel.payload); answer = this.#dispatch(client, opcode, reader, writer); } catch (cause) { if (cause instanceof ProtocolViolation || cause instanceof PayloadDecodeError) { // Only the offending client goes; every other backend keeps its descriptors and its store. this.#detach(client.channel.id, cause.message, FAULT_PROTOCOL); return; } if (cause instanceof FsError) { answer = { errno: cause.code, result: 0n, responseBytes: 0 }; } else { // Everything else — a poisoned store, a limit, a corrupt store, or a reply that did not fit // the client's own channel — is not a file rejection and not this client's fault. Report it // as a fault and keep the loop alive; a poisoned store answers every later request the same // way, and the client decides whether to give up. fault = FAULT_STORE; answer = this.#faultAnswer(client, cause); } } const { lo, hi } = splitResult(answer.result); Atomics.store(header, HEADER_ERRNO, answer.errno); Atomics.store(header, HEADER_RESULT_LO, lo); Atomics.store(header, HEADER_RESULT_HI, hi); Atomics.store(header, HEADER_RESPONSE, answer.responseBytes); Atomics.store(header, HEADER_FAULT, fault); Atomics.store(header, HEADER_STATE, STATE_RESPONSE); Atomics.notify(header, HEADER_STATE); } /** Put a fault message in the payload, truncating rather than overflowing a small channel. */ #faultAnswer(client: AttachedClient, cause: unknown): Answer { const message = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause); const writer = new PayloadWriter(client.channel.payload); const room = client.channel.payloadBytes - 4; try { writer.string(message.length > room ? message.slice(0, Math.max(0, room)) : message); } catch { return { errno: 0, result: 0n, responseBytes: 0 }; } return { errno: 0, result: 0n, responseBytes: writer.length }; } #dispatch(client: AttachedClient, opcode: number, reader: PayloadReader, writer: PayloadWriter): Answer { switch (opcode) { case OPCODE_OPEN: return this.#open(client, reader); case OPCODE_CLOSE: return this.#close(client, reader); case OPCODE_READ: return this.#read(client, reader); case OPCODE_WRITE: return this.#write(client, reader); case OPCODE_FSYNC: return this.#fsync(client, reader); case OPCODE_FSTAT: return this.#stat(writer, this.#vfs.fstat(this.#fd(client, reader.u32()).fd)); case OPCODE_STAT: return this.#stat(writer, this.#vfs.stat(reader.string())); case OPCODE_LSTAT: return this.#stat(writer, this.#vfs.lstat(reader.string())); case OPCODE_READDIR: return this.#readdir(reader, writer); case OPCODE_MKDIR: return this.#mkdir(reader); case OPCODE_RMDIR: this.#vfs.rmdir(reader.string(), this.#now()); return OK; case OPCODE_UNLINK: this.#vfs.unlink(reader.string(), this.#now()); return OK; case OPCODE_RENAME: { const oldPath = reader.string(); this.#vfs.rename(oldPath, reader.string(), this.#now()); return OK; } case OPCODE_TRUNCATE: { const path = reader.string(); this.#vfs.truncate(path, reader.u64(), this.#now()); return OK; } case OPCODE_SIZE: return this.#size(reader); case OPCODE_SYMLINK: { const target = reader.string(); this.#vfs.symlink(target, reader.string(), this.#now()); return OK; } case OPCODE_READLINK: return this.#readlink(reader, writer); default: throw new ProtocolViolation(`unhandled opcode ${opcode}`); } } #open(client: AttachedClient, reader: PayloadReader): Answer { const path = reader.string(); const flags = reader.u32(); const mode = reader.u32(); const plan = planOpen(flags); const nowMs = this.#now(); // `O_NOFOLLOW`: the core always follows a final link, so the refusal is proved here first. A // path that does not exist is not a link, and `O_CREAT` must still be free to create it. if (!plan.follow) { try { if (this.#vfs.lstat(path).kind === "symlink") { throw new FsError("ELOOP", "path is a symbolic link and O_NOFOLLOW was requested", { path }); } } catch (cause) { if (!(cause instanceof FsError) || cause.code !== FS_ERRNO.ENOENT) throw cause; } } if (plan.requireExisting) this.#vfs.lstat(path); if (plan.truncateFirst) this.#vfs.truncate(path, 0n, nowMs); let fd: number; try { fd = this.#vfs.open(path, plan.coreFlags, mode, nowMs); } catch (cause) { if (plan.fallbackFlags === undefined || !(cause instanceof FsError) || cause.code !== FS_ERRNO.ENOENT) { throw cause; } fd = this.#vfs.open(path, plan.fallbackFlags, mode, nowMs); } client.fds.set(fd, { fd, readable: plan.readable, writable: plan.writable }); return ok(fd); } #close(client: AttachedClient, reader: PayloadReader): Answer { const entry = this.#fd(client, reader.u32()); this.#vfs.close(entry.fd); client.fds.delete(entry.fd); return OK; } #read(client: AttachedClient, reader: PayloadReader): Answer { const entry = this.#fd(client, reader.u32()); if (!entry.readable) throw new FsError("EBADF", "descriptor was not opened for reading"); const hasPosition = reader.u8() === 1; const position = reader.u64(); const length = reader.u32(); if (length > client.channel.payloadBytes) { throw new ProtocolViolation(`read length ${length} exceeds the payload region`); } const target = client.channel.payload.subarray(0, length); const count = hasPosition ? this.#vfs.read(entry.fd, target, 0, length, position) : this.#vfs.read(entry.fd, target, 0, length); return ok(count, count); } #write(client: AttachedClient, reader: PayloadReader): Answer { const entry = this.#fd(client, reader.u32()); if (!entry.writable) throw new FsError("EBADF", "descriptor was not opened for writing"); const hasPosition = reader.u8() === 1; const position = reader.u64(); const length = reader.u32(); const source = reader.bytes(length); const count = this.#vfs.write(entry.fd, source, 0, length, hasPosition ? position : undefined, this.#now()); return ok(count); } /** * `fsync` is store-wide. The core's only durability primitive is `strictSync()`, which flushes the * arena and then the active metadata log, so a successful `fsync` promises that EVERY byte written * through this broker by ANY client before the call returned is recoverable — not just this * descriptor's. WASI `fd_sync` and `fd_datasync` both map to it; the fd is carried only so the call * still fails with `EBADF` on a descriptor the client does not own, exactly as `fd_sync` must. */ #fsync(client: AttachedClient, reader: PayloadReader): Answer { if (reader.u8() === 1) this.#fd(client, reader.u32()); this.#vfs.strictSync(); return OK; } #stat(writer: PayloadWriter, stat: RepackedStat): Answer { writeStat(writer, stat); return ok(stat.size, writer.length); } /** * One page of a directory listing. The cursor is an index into the store's own sorted listing, which * the store recomputes on every call — so, exactly as POSIX `readdir` allows, a listing spread over * several pages is not an atomic snapshot of the directory. */ #readdir(reader: PayloadReader, writer: PayloadWriter): Answer { const path = reader.string(); const cursor = reader.u32(); const names = this.#vfs.readdir(path); if (cursor > names.length) throw new ProtocolViolation(`readdir cursor ${cursor} is past the listing`); // Reserve the two counters written after the entries are known to fit. const countAt = writer.length; writer.u32(0); writer.i32(READDIR_DONE); let emitted = 0; let next = READDIR_DONE; for (let index = cursor; index < names.length; index += 1) { const before = writer.length; try { writer.string(names[index]!); } catch (cause) { if (!(cause instanceof PayloadOverflowError)) throw cause; writer.rewind(before); if (emitted === 0) throw cause; // one name alone cannot fit: the channel is too small next = index; break; } emitted += 1; } const counters = writer.patch(countAt, 8); counters.setUint32(0, emitted, true); counters.setInt32(4, next, true); return ok(emitted, writer.length); } #mkdir(reader: PayloadReader): Answer { const path = reader.string(); const recursive = reader.u8() === 1; const mode = reader.u32(); this.#vfs.mkdir(path, { recursive, mode, nowMs: this.#now() }); return OK; } /** The target of one symbolic link. The result is the target's byte length; the payload is it. */ #readlink(reader: PayloadReader, writer: PayloadWriter): Answer { writer.string(this.#vfs.readlink(reader.string())); return ok(writer.length, writer.length); } #size(reader: PayloadReader): Answer { const stat = this.#vfs.stat(reader.string()); if (stat.kind !== "file") throw new FsError("EISDIR", "size is a file query"); return ok(stat.size); } /** * Resolve a descriptor the CLIENT owns. A descriptor that belongs to another client is `EBADF`, not * a protocol violation: fd numbers are per-store, so a client guessing one is an ordinary bad-fd * mistake and never a reason to reach into another backend's files. */ #fd(client: AttachedClient, fd: number): BrokerFd { const entry = client.fds.get(fd); if (entry === undefined) throw new FsError("EBADF", `descriptor ${fd} is not owned by this client`); return entry; } }