@pgxsinkit/pgwasm
Version:
228 lines • 14.4 kB
TypeScript
/**
* A WASI preview1 FILESYSTEM adapter over `RepackedSyncClient`.
*
* ## Why this file exists
*
* The broker gives a futex-parked thread synchronous access to ONE repacked store. A wasm engine does
* not speak that API — it speaks `wasi_snapshot_preview1`, thirty-odd i32-returning imports over its
* own linear memory. This module is the seam: every WASI file call a guest makes is translated into a
* broker request, and the errno the broker already speaks (the protocol's numbers ARE WASI preview1
* errnos) is handed straight back. Nothing here knows about pgwasm, OPFS, or any particular engine —
* it needs a client, a way to reach the guest's memory, and nothing else.
*
* ## What it owns and what it refuses to touch
*
* The adapter owns fd `preopenFd` (3 by default — the "/" preopen) and every fd at or above `fdBase`
* (4 by default). It NEVER touches fds 0–2: a host keeps its own stdin/stdout/stderr, and
* {@link WasiPreview1Fs.compose} builds the merged import object that routes each call to whichever
* side owns the fd. `owns()` is public so a host can make that decision itself.
*
* ## The fd table
*
* One entry per open descriptor, keyed by the WASI fd the guest sees:
*
* { fd, path, isDir, clientFd, offset, fdflags, rightsBase, rightsInheriting, readable, writable }
*
* - `path` is the normalized absolute path the fd was opened with. It is kept for the whole life of
* the descriptor because the store resizes by PATH, not by descriptor: `fd_filestat_set_size` and
* `fd_allocate` resolve through it.
* - `clientFd` is the broker's descriptor for a FILE. A DIRECTORY has none: the store cannot open a
* directory at all (its `open` answers `EISDIR`), so a directory fd is adapter-local — a remembered
* path plus a listing snapshot — and is still usable for `fd_readdir`, `fd_filestat_get`, `fd_sync`
* and as a dirfd for every path operation.
* - `offset` is tracked HERE, not in the store. Every read and write the adapter issues carries an
* explicit position (the store's `pread`/`pwrite` form), so the store's own cursor is never used and
* `fd_seek`/`fd_tell` are exact even though several threads share one store.
*
* ## The mapping, decision by decision
*
* - **rights → access.** `RIGHTS_FD_READ` grants read, `RIGHTS_FD_WRITE` grants write; a request that
* asks for neither is read-only. That access is enforced by the BROKER (per its own fd) and again
* here, because a POSIX `open(O_RDONLY|O_CREAT)` has to open the store descriptor wider than the
* guest asked for — see below.
* - **oflags → POSIX bits.** `CREAT`→`O_CREAT`, `EXCL`→`O_EXCL`, `TRUNC`→`O_TRUNC`, and the access
* mode from the rights. `planOpen` (server side) rejects `O_CREAT`/`O_TRUNC` without write access,
* so a create/truncate request always adds write to the POSIX access mode while the adapter keeps
* the NARROWER access the rights asked for and rejects a later `fd_write` with `ENOTCAPABLE`.
* - **`O_DIRECTORY` and directories.** With `DIRECTORY` set the adapter never calls `open` (that would
* create a file); it stats and builds a directory fd. Without it, `open` is attempted and an
* `EISDIR` answer is recognised as "this is a directory" and turned into a directory fd too — which
* costs nothing on the common file path.
* - **`O_APPEND` is emulated here, never passed to the broker.** An append write resolves end-of-file
* with `fstat` and then `pwrite`s there, so the adapter's own offset stays exact and
* `fd_fdstat_set_flags` can turn `APPEND` on and off on a descriptor that was not opened with it.
* - **`fd_seek`.** `SET`/`CUR` are pure arithmetic on the adapter's offset; `END` resolves the size
* with `fstat` on the broker fd (never `size(path)`, which would race a rename).
* - **`fd_readdir` cookies.** A cookie is an index into a listing SNAPSHOT taken on cookie 0, exactly
* as a POSIX `readdir` may. The snapshot is built out of `readdirPage` calls, and it exists for a
* protocol reason as much as a semantic one: the broker treats a cursor past the end of the listing
* as a protocol violation and DETACHES the client, so a stale cookie from a directory that shrank
* must never reach it. A cookie past the snapshot's end reports zero bytes used — end of directory.
* - **truncate by path.** `fd_filestat_set_size` and `fd_allocate` call `truncate(path, size)` with
* the descriptor's remembered path. The store has no hard links, so nothing is lost.
* - **`fd_sync`/`fd_datasync`.** Both map to the broker's `fsync`, whose durability is STORE-WIDE: on
* success every byte written through the broker by ANY client before the call returned is
* recoverable. That is stronger than `fd_sync` promises, never weaker.
* - **symlinks.** The store HAS them, and `SYMLINK_FOLLOW` therefore means something everywhere it
* appears: `path_filestat_get` picks `stat` or `lstat`, and a `path_open` without it sends
* `O_NOFOLLOW` so a final component that IS a link answers `ELOOP` instead of opening its target.
* `path_unlink_file` removes the LINK, never the target — that is the core's `unlink`, not
* something added here. `path_symlink` writes a link and `path_readlink` reads one back, both
* verbatim: the store takes ABSOLUTE targets only (see `validateSymlinkTarget`), so a relative
* target is `EINVAL` rather than being silently reinterpreted, and an absolute one is a STORE
* path — which is the same thing as a guest path whenever the preopen is the store root, the
* default and the only arrangement a datadir uses. `path_link` remains `ENOTSUP`: the store has
* no hard links.
* - **`fd_advise`** is a no-op success. **`fd_filestat_set_times`/`path_filestat_set_times`** answer
* `ENOTSUP`: the broker exposes no `utimes` opcode, and inventing an adapter-local timestamp would
* make two threads on ONE store disagree about a file's mtime, which is exactly what this whole
* arrangement exists to prevent.
*
* ## Failure discipline
*
* Every exported function is wrapped so that a JS exception can never leave it. A throw out of a WASI
* import unwinds through the guest's nounwind frames and surfaces as a bare `RuntimeError:
* unreachable` with no attribution at all; instead the wrapper reports `EIO` and hands the cause to
* `onError` with the call name. A transport failure (the coordinator died, the client was detached) is
* therefore an `EIO` the guest can act on rather than an abort it cannot.
*/
import type { RepackedSyncClient } from "../broker/client";
/**
* WASI preview1 errno numbers. A superset of the store's own `FS_ERRNO` (whose values already ARE
* these numbers); the extra codes are ones a filesystem ADAPTER has to answer and the store core
* never produces, so they live here rather than widening the core's error vocabulary.
*/
export declare const WASI_ERRNO: {
readonly SUCCESS: 0;
readonly ACCES: 2;
readonly BADF: 8;
readonly EXIST: 20;
readonly INVAL: 28;
readonly IO: 29;
readonly ISDIR: 31;
readonly LOOP: 32;
readonly NOENT: 44;
readonly NOSYS: 52;
readonly NOTDIR: 54;
readonly NOTEMPTY: 55;
readonly NOTSUP: 58;
readonly OVERFLOW: 61;
readonly PERM: 63;
readonly SPIPE: 70;
readonly NOTCAPABLE: 76;
};
/** WASI preview1 filetypes. */
export declare const WASI_FILETYPE: {
readonly UNKNOWN: 0;
readonly BLOCK_DEVICE: 1;
readonly CHARACTER_DEVICE: 2;
readonly DIRECTORY: 3;
readonly REGULAR_FILE: 4;
readonly SOCKET_DGRAM: 5;
readonly SOCKET_STREAM: 6;
readonly SYMBOLIC_LINK: 7;
};
/** `path_open` oflags. */
export declare const OFLAGS_CREAT = 1;
export declare const OFLAGS_DIRECTORY = 2;
export declare const OFLAGS_EXCL = 4;
export declare const OFLAGS_TRUNC = 8;
/** `fdflags`, on `path_open` and `fd_fdstat_set_flags`. */
export declare const FDFLAGS_APPEND = 1;
export declare const FDFLAGS_DSYNC = 2;
export declare const FDFLAGS_NONBLOCK = 4;
export declare const FDFLAGS_RSYNC = 8;
export declare const FDFLAGS_SYNC = 16;
/** `lookupflags`, on every path operation that could follow a symlink. */
export declare const LOOKUPFLAGS_SYMLINK_FOLLOW = 1;
/** The two rights the adapter reads; the rest are carried through untouched. */
export declare const RIGHTS_FD_READ: bigint;
export declare const RIGHTS_FD_WRITE: bigint;
/** What a preopen advertises when the host asked for nothing narrower. */
export declare const RIGHTS_ALL = 18446744073709551615n;
/** `fd_seek` whence values. */
export declare const WHENCE_SET = 0;
export declare const WHENCE_CUR = 1;
export declare const WHENCE_END = 2;
export interface WasiPreview1FsOptions {
/** The synchronous broker client this adapter turns WASI calls into. */
readonly client: RepackedSyncClient;
/**
* The guest's linear memory, resolved on EVERY call. A shared wasm memory grows underneath the host
* and any cached `Uint8Array`/`DataView` goes stale (or detaches outright, for a non-shared one), so
* nothing here holds a view across a call.
*/
readonly memory: () => ArrayBuffer | SharedArrayBuffer;
/** The fd the guest sees the preopened directory as. Defaults to 3, the WASI convention. */
readonly preopenFd?: number;
/** The directory that preopen names. Defaults to the store root. */
readonly preopenPath?: string;
/** The first fd the adapter hands out, and the bottom of the range it claims. Defaults to 4. */
readonly fdBase?: number;
/** The adapter's clock, in milliseconds. Defaults to the wall clock; used for diagnostics. */
readonly now?: () => bigint;
/** Where a JS exception that escaped a WASI call is reported. Defaults to `console.error`. */
readonly onError?: (call: string, cause: unknown) => void;
}
/** The `wasi_snapshot_preview1` filesystem surface, with the ABI's exact signatures. */
export interface WasiPreview1FsFunctions {
fd_prestat_get(fd: number, resultPtr: number): number;
fd_prestat_dir_name(fd: number, pathPtr: number, pathLen: number): number;
fd_close(fd: number): number;
fd_read(fd: number, iovsPtr: number, iovsLen: number, nreadPtr: number): number;
fd_pread(fd: number, iovsPtr: number, iovsLen: number, offset: bigint, nreadPtr: number): number;
fd_write(fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number;
fd_pwrite(fd: number, iovsPtr: number, iovsLen: number, offset: bigint, nwrittenPtr: number): number;
fd_seek(fd: number, offset: bigint, whence: number, resultPtr: number): number;
fd_tell(fd: number, resultPtr: number): number;
fd_fdstat_get(fd: number, resultPtr: number): number;
fd_fdstat_set_flags(fd: number, fdflags: number): number;
fd_fdstat_set_rights(fd: number, rightsBase: bigint, rightsInheriting: bigint): number;
fd_filestat_get(fd: number, resultPtr: number): number;
fd_filestat_set_size(fd: number, size: bigint): number;
fd_filestat_set_times(fd: number, atim: bigint, mtim: bigint, fstflags: number): number;
fd_readdir(fd: number, bufPtr: number, bufLen: number, cookie: bigint, bufusedPtr: number): number;
fd_sync(fd: number): number;
fd_datasync(fd: number): number;
fd_allocate(fd: number, offset: bigint, length: bigint): number;
fd_advise(fd: number, offset: bigint, length: bigint, advice: number): number;
path_open(dirfd: number, dirflags: number, pathPtr: number, pathLen: number, oflags: number, rightsBase: bigint, rightsInheriting: bigint, fdflags: number, resultPtr: number): number;
path_filestat_get(dirfd: number, flags: number, pathPtr: number, pathLen: number, resultPtr: number): number;
path_filestat_set_times(dirfd: number, flags: number, pathPtr: number, pathLen: number, atim: bigint, mtim: bigint, fstflags: number): number;
path_create_directory(dirfd: number, pathPtr: number, pathLen: number): number;
path_remove_directory(dirfd: number, pathPtr: number, pathLen: number): number;
path_unlink_file(dirfd: number, pathPtr: number, pathLen: number): number;
path_rename(dirfd: number, oldPtr: number, oldLen: number, newDirfd: number, newPtr: number, newLen: number): number;
path_readlink(dirfd: number, pathPtr: number, pathLen: number, bufPtr: number, bufLen: number, bufusedPtr: number): number;
path_symlink(oldPtr: number, oldLen: number, dirfd: number, newPtr: number, newLen: number): number;
path_link(oldDirfd: number, oldFlags: number, oldPtr: number, oldLen: number, newDirfd: number, newPtr: number, newLen: number): number;
}
/** The adapter: the WASI surface plus the three composition/lifecycle helpers a host needs. */
export interface WasiPreview1Fs extends WasiPreview1FsFunctions {
/** Whether this adapter answers for `fd`: the preopen, or anything at or above `fdBase`. */
owns(fd: number): boolean;
/** How many descriptors the adapter currently holds, preopen excluded. */
openFdCount(): number;
/**
* A merged `wasi_snapshot_preview1` object: every filesystem call goes to the adapter when the fd
* (or the dirfd, for a path operation) is adapter-owned and to `base` otherwise. Everything in
* `base` that is not a filesystem call — `args_get`, `clock_time_get`, `poll_oneoff`, `proc_exit`,
* `random_get`, `sched_yield`, the socket stubs — is carried through untouched, as are `base`'s own
* fd 0/1/2 handlers.
*/
compose(base: Readonly<Record<string, unknown>>): Record<string, unknown>;
/**
* Close every descriptor, returning how many were released. The call a thread makes on its way out:
* without it the coordinator holds the thread's store descriptors until the whole channel detaches.
*/
closeAll(): number;
}
/**
* Canonicalize a guest path the way the store demands: absolute, no `.`/`..`, no empty or repeated
* separator, no trailing slash. Deliberately lenient in the same places a WASI host has to be —
* wasi-libc hands preopen-RELATIVE paths, but an absolute one is accepted too, and a trailing NUL from
* a fixed-size buffer is stripped rather than rejected.
*/
export declare function normalizeWasiPath(path: string): string;
export declare function createWasiPreview1Fs(options: WasiPreview1FsOptions): WasiPreview1Fs;
//# sourceMappingURL=preview1.d.ts.map