@pgxsinkit/pgwasm
Version:
523 lines (487 loc) • 20.4 kB
text/typescript
/**
* `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;
}
}