UNPKG

@pgxsinkit/pgwasm

Version:
326 lines • 16.2 kB
/** * The synchronous broker wire protocol. * * ## Why a wire protocol at all * * A repacked store is a single-owner object: it holds four exclusive file handles and one in-memory * metadata generation, and it is not shareable across JavaScript agents. A multi-backend engine needs * several worker threads to reach ONE store, and a worker running wasm parks in futexes, so it cannot * await a promise to get a file answered. The store therefore lives alone in a coordinator worker and * every other thread asks for file operations over a `SharedArrayBuffer`, blocking in `Atomics.wait` * until the coordinator answers. Nothing here knows about the engine, wasm, or OPFS. * * ## Topology * * One CHANNEL per client, plus one DOORBELL shared by every channel of one broker: * * client A ─ channel A ─┐ * client B ─ channel B ─┼─→ doorbell ─→ RepackedSyncBroker (owns the RepackedVfs) * client C ─ channel C ─┘ * * A client publishes its request into its own channel and then bumps the doorbell, so one blocking * server loop can wait on a single word and still service any number of clients. * * ## Channel layout * * One `SharedArrayBuffer`, `CHANNEL_HEADER_BYTES` (64) of `Int32Array` header followed by a byte * payload region (`DEFAULT_PAYLOAD_BYTES`, 64 KiB, chosen at channel creation): * * offset size as name * ------ ---- -------- ------------------------------------------------------------------ * 0 4 int32 HEADER_STATE the word both sides `Atomics.wait` / `notify` on * 4 4 int32 HEADER_OPCODE `OPCODE_*`, written by the client * 8 4 int32 HEADER_REQUEST request bytes valid in the payload region * 12 4 int32 HEADER_RESPONSE response bytes valid in the payload region * 16 4 int32 HEADER_ERRNO 0, or the WASI preview1 errno of a file rejection * 20 4 int32 HEADER_RESULT_LO low 32 bits of the unsigned 64-bit result * 24 4 int32 HEADER_RESULT_HI high 32 bits of the unsigned 64-bit result * 28 4 int32 HEADER_SEQUENCE the client's monotonic request number * 32 4 int32 HEADER_FAULT `FAULT_*` — a transport/store failure, not a file one * 36 4 int32 HEADER_PAYLOAD payload capacity in bytes (written once at creation) * 40 24 int32[6] reserved, always zero * 64 … bytes payload region * * `HEADER_STATE` is the whole handshake: * * STATE_IDLE (0) ──client publishes──→ STATE_REQUEST (1) ──server answers──→ STATE_RESPONSE (2) * ↑ │ │ * └─────────────client consumes─────────┼─────────────────────────────────────┘ * └──server rejects the client──→ STATE_DETACHED (3) * * `STATE_DETACHED` is terminal: the server has closed the client's descriptors and dropped it, and * every later client call throws instead of blocking forever. * * ## Doorbell layout * * A small separate `SharedArrayBuffer`, `DOORBELL_BYTES` (16) of `Int32Array`: * * index name meaning * ----- ---------------- ---------------------------------------------------------------- * 0 DOORBELL_TICKET incremented by any client that publishes a request; the word the * server's blocking loop waits on * 1 DOORBELL_RUNNING 1 while the server should keep looping, 0 to ask it to return * 2 reserved * 3 reserved * * The server reads `DOORBELL_TICKET` BEFORE it scans the channels and waits on that observed value, * so a request published during the scan makes the wait return `not-equal` immediately rather than * being missed. * * ## Ordering * * Nothing in the payload region is atomic, and nothing needs to be. Each side writes its payload with * ordinary stores and then publishes with `Atomics.store` on `HEADER_STATE`; the other side observes * with `Atomics.load` on the same word before touching the payload. That store/load pair is the * release/acquire edge, so every payload byte written before the publish is visible after the observe. * * ## Encoding * * Payloads are little-endian. Paths — and a symbolic link's TARGET, which is a path the store never * walks — are `u32` byte length followed by UTF-8 bytes; sizes, offsets and * timestamps are unsigned 64-bit (`bigint`) because a virtual file may exceed 2^53 in principle and * the core store speaks `bigint` throughout. Byte counts that cannot exceed the payload region stay * `u32`. The single numeric answer of an operation travels in the header's 64-bit result pair, not in * the payload, so a read reply is nothing but its bytes. */ import type { FsErrorName } from "../core/errors"; /** Int32 slot indices of the channel header. */ export declare const HEADER_STATE = 0; export declare const HEADER_OPCODE = 1; export declare const HEADER_REQUEST = 2; export declare const HEADER_RESPONSE = 3; export declare const HEADER_ERRNO = 4; export declare const HEADER_RESULT_LO = 5; export declare const HEADER_RESULT_HI = 6; export declare const HEADER_SEQUENCE = 7; export declare const HEADER_FAULT = 8; export declare const HEADER_PAYLOAD = 9; export declare const CHANNEL_HEADER_SLOTS = 16; export declare const CHANNEL_HEADER_BYTES: number; /** `HEADER_STATE` values. */ export declare const STATE_IDLE = 0; export declare const STATE_REQUEST = 1; export declare const STATE_RESPONSE = 2; export declare const STATE_DETACHED = 3; /** Int32 slot indices of the doorbell. */ export declare const DOORBELL_TICKET = 0; export declare const DOORBELL_RUNNING = 1; export declare const DOORBELL_SLOTS = 4; export declare const DOORBELL_BYTES: number; /** The default payload region: large enough that a Postgres 8 KiB page round-trips in one request. */ export declare const DEFAULT_PAYLOAD_BYTES: number; /** A payload region below this cannot hold the fixed part of every request. */ export declare const MIN_PAYLOAD_BYTES = 1024; /** * `HEADER_FAULT` values. A fault is NOT a file rejection: `HEADER_ERRNO` carries those and leaves the * client working. A fault means the transport or the store itself failed. */ export declare const FAULT_NONE = 0; /** The client violated the protocol (unknown opcode, impossible length, truncated payload). */ export declare const FAULT_PROTOCOL = 1; /** The store threw something that is not an `FsError` — it is very likely poisoned. */ export declare const FAULT_STORE = 2; /** The server rejected or dropped the client for a reason of its own (explicit `detach`). */ export declare const FAULT_DETACHED = 3; /** Every operation the broker speaks. */ export declare const OPCODE_OPEN = 1; export declare const OPCODE_CLOSE = 2; export declare const OPCODE_READ = 3; export declare const OPCODE_WRITE = 4; export declare const OPCODE_FSYNC = 5; export declare const OPCODE_FSTAT = 6; export declare const OPCODE_STAT = 7; export declare const OPCODE_LSTAT = 8; export declare const OPCODE_READDIR = 9; export declare const OPCODE_MKDIR = 10; export declare const OPCODE_RMDIR = 11; export declare const OPCODE_UNLINK = 12; export declare const OPCODE_RENAME = 13; export declare const OPCODE_TRUNCATE = 14; export declare const OPCODE_SIZE = 15; export declare const OPCODE_SYMLINK = 16; export declare const OPCODE_READLINK = 17; export declare function isKnownOpcode(opcode: number): boolean; /** * POSIX/WASI-shaped open bits. The wire carries these, never the core's node-style flag string: a WASI * `path_open` maps its `oflags`/`fdflags`/`fs_rights_base` onto them directly, and the server is the * only place that has to know the core's string vocabulary. */ export declare const O_RDONLY = 0; export declare const O_WRONLY = 1; export declare const O_RDWR = 2; export declare const O_ACCMODE = 3; export declare const O_CREAT = 64; export declare const O_EXCL = 128; export declare const O_TRUNC = 512; export declare const O_APPEND = 1024; /** * Refuse to follow a symbolic link on the FINAL component. Linux's own bit value, and the wire form * of a WASI `path_open` whose lookup flags omit `SYMLINK_FOLLOW`. */ export declare const O_NOFOLLOW = 131072; /** The stat shape on the wire: `kind` + mode + size + the three timestamps. */ export declare const STAT_KIND_FILE = 0; export declare const STAT_KIND_DIRECTORY = 1; export declare const STAT_KIND_SYMLINK = 2; export declare const STAT_BYTES: number; /** A `readdir` reply that ran out of payload room reports the cursor to resume from; -1 means done. */ export declare const READDIR_DONE = -1; /** * How the server should satisfy one open request with the core's fixed flag-string vocabulary * (`r`, `r+`, `w`, `w+`, `wx`, `wx+`, `a`, `a+`, `ax`, `ax+`). * * `readable`/`writable` are the access the CLIENT asked for, which is not always what `coreFlags` * grants: the core has no "create without truncating and without appending" and no write-only * non-truncating mode, so those requests open a wider core descriptor and the broker enforces the * requested access itself on every later read/write. */ export interface OpenPlan { /** The core flag string to try first. */ readonly coreFlags: string; /** Used only when `coreFlags` fails with ENOENT — the create-without-truncate two-step. */ readonly fallbackFlags: string | undefined; /** `O_TRUNC` without `O_CREAT`: truncate the existing path to zero before opening. */ readonly truncateFirst: boolean; /** No `O_CREAT`, but `coreFlags` would create: the server must prove the path exists first. */ readonly requireExisting: boolean; /** The access the client asked for, enforced by the broker on top of the core descriptor. */ readonly readable: boolean; readonly writable: boolean; /** `false` for `O_NOFOLLOW`: a final component that IS a symbolic link must answer `ELOOP`. */ readonly follow: boolean; } /** * Translate POSIX open bits into the core's vocabulary, or reject the combination with `EINVAL`. * * Rejected because they are meaningless rather than merely unsupported: an unknown bit, `O_EXCL` * without `O_CREAT`, `O_TRUNC` with `O_APPEND`, and a read-only request that also creates, * truncates, or appends. */ export declare function planOpen(flags: number): OpenPlan; /** Thrown when a payload cannot hold what a caller is trying to put in it. */ export declare class PayloadOverflowError extends Error { constructor(needed: number, capacity: number); } /** Thrown when a payload does not decode — always a protocol violation by the peer that wrote it. */ export declare class PayloadDecodeError extends Error { constructor(message: string); } /** Sequential little-endian writer over a channel's payload region. */ export declare class PayloadWriter { #private; constructor(payload: Uint8Array); get length(): number; get remaining(): number; u8(value: number): void; u32(value: number): void; i32(value: number): void; u64(value: bigint): void; bytes(source: Uint8Array): void; string(value: string): void; /** * Discard everything written after `to`, backing out a field that did not fit. A `string` reserves * its length prefix before its bytes, so a name that overflows can leave four bytes behind. */ rewind(to: number): void; /** * A mutable view over `count` bytes already written at `at` — for a counter that has to be reserved * before its value is known (a `readdir` page fills until the payload runs out). */ patch(at: number, count: number): DataView; } /** Sequential little-endian reader over a channel's payload region. */ export declare class PayloadReader { #private; constructor(payload: Uint8Array, length: number); get remaining(): number; u8(): number; u32(): number; i32(): number; u64(): bigint; bytes(count: number): Uint8Array; string(): string; } /** Split an unsigned 64-bit result into the header's low/high `int32` pair. */ export declare function splitResult(value: bigint): { readonly lo: number; readonly hi: number; }; /** Rejoin the header's low/high `int32` pair into an unsigned 64-bit result. */ export declare function joinResult(lo: number, hi: number): bigint; /** The stat shape both sides exchange; `bigint` everywhere the core is `bigint`. */ export interface BrokerStat { /** `symlink` only ever comes back from `lstat`; `size` is then the target's UTF-8 byte length. */ readonly kind: "directory" | "file" | "symlink"; readonly mode: number; readonly size: bigint; readonly atimeMs: bigint; readonly mtimeMs: bigint; readonly ctimeMs: bigint; } export declare function writeStat(writer: PayloadWriter, stat: BrokerStat): void; export declare function readStat(reader: PayloadReader): BrokerStat; /** A channel's two shared buffers, in the form that survives `postMessage`. */ export interface RepackedChannelTransfer { readonly id: number; readonly channel: SharedArrayBuffer; readonly doorbell: SharedArrayBuffer; } /** * The shared word one blocking server loop waits on. Both sides hold the same buffer; a client only * ever rings it, and only the host that created it may ask the loop to stop. */ export declare class RepackedDoorbell { #private; readonly buffer: SharedArrayBuffer; private constructor(); static create(): RepackedDoorbell; /** Rebuild the doorbell around a buffer that arrived over `postMessage`. */ static attach(buffer: SharedArrayBuffer): RepackedDoorbell; /** The value a server must observe BEFORE it scans, so a concurrent request cannot be missed. */ ticket(): number; /** Announce that some channel now holds a request. */ ring(): void; running(): boolean; /** * Ask a blocking `serveForever()` to return. This is the only way to stop it from another thread: * once inside the loop the server never reaches its own event loop, so `postMessage` cannot reach it. */ requestStop(): void; /** Undo a `requestStop()` so the same doorbell can drive another loop. */ resume(): void; /** Block until the ticket leaves `observed`, or the timeout elapses. */ wait(observed: number, timeoutMs: number): "ok" | "not-equal" | "timed-out"; /** The non-blocking form, for a host that must not park its thread. */ waitAsync(observed: number, timeoutMs: number): Promise<"ok" | "not-equal" | "timed-out">; } /** * One client's request/response channel. Both the client and the server hold an instance over the * same `SharedArrayBuffer`; neither owns the memory, and nothing but the header words is atomic. */ export declare class RepackedChannel { readonly id: number; readonly buffer: SharedArrayBuffer; readonly doorbell: RepackedDoorbell; readonly header: Int32Array; readonly payload: Uint8Array; private constructor(); static create(options: { id: number; doorbell: RepackedDoorbell; payloadBytes?: number; }): RepackedChannel; /** Rebuild a channel around buffers that arrived over `postMessage`. */ static attach(transfer: RepackedChannelTransfer): RepackedChannel; /** The shape to hand to `postMessage`. */ transfer(): RepackedChannelTransfer; get payloadBytes(): number; state(): number; } /** The errno of a rejection, or `undefined` when it is not a plain file rejection. */ export declare function errnoOf(cause: unknown): number | undefined; /** The core error name behind a wire errno, or `undefined` for a code the core never produces. */ export declare function fsErrorNameOf(code: number): FsErrorName | undefined; /** Readable form of a wire errno, for a message. */ export declare function errnoName(code: number): string; //# sourceMappingURL=protocol.d.ts.map