diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84999f35f..76f3be3e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1501,6 +1501,7 @@ jobs: node src/bindings/wasix-ts/tools/smoke-node.mjs --runtime node node src/bindings/wasix-ts/tools/smoke-node.mjs --runtime bun node src/bindings/wasix-ts/tools/smoke-node.mjs --runtime deno + node src/bindings/wasix-ts/tools/smoke-browser.mjs --postgis-worker OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh wasix-ts-sdk-package - name: Upload WASIX TypeScript SDK package artifacts diff --git a/docs/internal/DONE.md b/docs/internal/DONE.md index 0925528b1..72079bfec 100644 --- a/docs/internal/DONE.md +++ b/docs/internal/DONE.md @@ -394,9 +394,9 @@ Implemented coverage: after the C bridge reports active streaming COPY; - direct raw protocol streaming is routed through the shared `BackendSession` framed sender instead of a separate client-only transport path; -- Rust-owned guest bridge allocations are scoped through `pg_free`/`free`, and - debug builds now have a direct raw-protocol stress test proving repeated - bridge round trips keep allocation/free counters balanced; +- the C bridge owns reusable input/output capacity, and the direct raw-protocol + stress test proves repeated bridge round trips remain correct without + per-request guest allocation/free calls; - direct LISTEN/UNLISTEN quotes channel identifiers and dispatches notifications by the exact backend channel name, including case-sensitive and quoted names. - a larger PostgreSQL regression subset now ports the relevant Oliphaunt test @@ -422,13 +422,14 @@ Verified ownership boundaries: state, COPY state, portal cleanup, and longjmp recovery boundaries; - the WASIX bridge owns only the host ABI that Wasmer/WASIX cannot provide as a normal OS process boundary: protocol fd transport, locale/identity shims, - single-process shared memory, fail-closed process calls, and explicit - allocation/free ownership. + single-process shared memory, fail-closed process calls, and reusable + guest-owned protocol buffers. Review conclusions: -- guest-memory ownership is scoped through `GuestAllocator`, `pg_free`/`free`, - and debug allocation/free counters; +- guest-memory ownership is scoped through bridge-owned input/output buffers; + hosts copy requests directly into reserved guest memory and copy each + response once into host-owned storage before the bridge buffer is reset; - detached protocol stdio fails closed rather than silently accepting bytes; - COPY state is reported by PostgreSQL through `pgl_protocol_report_copy_response`; the proxy no longer parses SQL text, diff --git a/docs/internal/PG18_WASIX_PERF_STATUS.md b/docs/internal/PG18_WASIX_PERF_STATUS.md index 18364779a..441e6d5fb 100644 --- a/docs/internal/PG18_WASIX_PERF_STATUS.md +++ b/docs/internal/PG18_WASIX_PERF_STATUS.md @@ -707,7 +707,7 @@ and any accidental parallel worker use fails through the existing error path. ## Release Hygiene The rebuilt PG18 runtime binary does not contain the old `pgl_*`/`Oliphaunt` -runtime symbol strings when inspected from the packaged `oliphaunt/bin/oliphaunt` +runtime symbol strings when inspected from the packaged `oliphaunt/bin/postgres` module. The PG18 patch stack and build scripts also keep the new `oliphaunt_wasix_*` naming. diff --git a/docs/internal/WASIX_PATCH_STACK.md b/docs/internal/WASIX_PATCH_STACK.md index a27b32ccc..fb453f27d 100644 --- a/docs/internal/WASIX_PATCH_STACK.md +++ b/docs/internal/WASIX_PATCH_STACK.md @@ -59,17 +59,18 @@ src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write | 36 | `0036-oliphaunt-wasix-skip-activity-id-reporting.patch` | Oliphaunt Maintainers | oliphaunt-wasix: skip activity id reporting | | 37 | `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch` | Oliphaunt Maintainers | oliphaunt-wasix: treat directory fsync EISDIR as unsupported | | 38 | `0038-oliphaunt-wasix-skip-icu-collation-setup-without-icu-data.patch` | Oliphaunt Maintainers | oliphaunt-wasix: skip ICU collation setup without ICU data | -| 39 | `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add stdio pgwire lifecycle | +| 39 | `0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch` | Oliphaunt Maintainers | oliphaunt-wasix: declare hybrid protocol transport | | 40 | `0040-oliphaunt-wasix-use-single-backend-spinlocks.patch` | Oliphaunt Maintainers | oliphaunt-wasix: use single-backend spinlocks | | 41 | `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch` | Oliphaunt Maintainers | oliphaunt-wasix: specialize single-backend atomics | | 42 | `0042-oliphaunt-wasix-buffer-strong-random.patch` | Oliphaunt Maintainers | oliphaunt-wasix: buffer strong random | | 43 | `0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch` | Oliphaunt Maintainers | oliphaunt-wasix: disable unsupported writeback hints | +| 44 | `0044-oliphaunt-wasix-inline-sigsetjmp.patch` | Oliphaunt Maintainers | oliphaunt-wasix: inline sigsetjmp | ## Changed Upstream Files | File | Owning Patch(es) | Rationale | | --- | --- | --- | -| `src/Makefile.shlib` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch` | Defines the WASIX dynamic-link shared-library shape. | +| `src/Makefile.shlib` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0044-oliphaunt-wasix-inline-sigsetjmp.patch` | Defines the WASIX dynamic-link shared-library shape. | | `src/backend/Makefile` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch` | Builds the dynamic-main backend module without changing other ports. | | `src/backend/access/heap/heapam.c` | `0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch` | Adds embedded timing probes and heap fast-path scope. | | `src/backend/access/heap/heapam_handler.c` | `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch` | Keeps embedded heap update timing observable. | @@ -91,7 +92,7 @@ src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write | `src/backend/replication/walsender.c` | `0006-oliphaunt-wasix-report-copy-protocol-state.patch` | Suppresses activity identifier reporting in embedded WASIX. | | `src/backend/storage/file/fd.c` | `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch`, `0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch` | Keeps real fsync while narrowing unsupported WASIX directory and writeback-hint behavior. | | `src/backend/tcop/backend_startup.c` | `0003-oliphaunt-wasix-export-startup-packet-parser.patch` | Exports the startup packet parser for host-driven startup. | -| `src/backend/tcop/postgres.c` | `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch`, `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`, `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch`, `0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch`, `0036-oliphaunt-wasix-skip-activity-id-reporting.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch` | Owns embedded lifecycle, protocol loop, error recovery, and timing hooks. | +| `src/backend/tcop/postgres.c` | `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch`, `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`, `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch`, `0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch`, `0036-oliphaunt-wasix-skip-activity-id-reporting.patch` | Owns embedded lifecycle, protocol loop, error recovery, and timing hooks. | | `src/backend/utils/adt/like.c` | `0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch` | Adds guarded LIKE literal fast path for embedded WASIX. | | `src/backend/utils/adt/like_match.c` | `0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch` | Adds guarded LIKE literal fast path for embedded WASIX. | | `src/backend/utils/init/miscinit.c` | `0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch` | Routes process identity through the WASIX port layer. | @@ -109,12 +110,12 @@ src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write | `src/include/libpq/libpq-be.h` | `0002-oliphaunt-wasix-add-backend-host-io-hooks.patch` | Adds the host I/O callback table to Port only for embedded WASIX. | | `src/include/port/atomics.h` | `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch` | Selects scalar atomics only for the explicitly single-backend WASIX build. | | `src/include/port/atomics/arch-wasix-single.h` | `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch` | Preserves PostgreSQL atomic layouts and contracts without guest atomic instructions. | -| `src/include/port/wasix-dl.h` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0009-oliphaunt-wasix-route-process-identity-through-port.patch`, `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch`, `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`, `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch`, `0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch`, `0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch`, `0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch` | Defines the embedded WASIX port header and ABI redirects. | +| `src/include/port/wasix-dl.h` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0009-oliphaunt-wasix-route-process-identity-through-port.patch`, `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch`, `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`, `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch`, `0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch`, `0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch`, `0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch`, `0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch`, `0044-oliphaunt-wasix-inline-sigsetjmp.patch` | Defines the embedded WASIX port header, ABI redirects, and call-site SJLJ contract. | | `src/include/port/wasix-dl/sys/ipc.h` | `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch` | Provides the WASIX SysV IPC shim surface. | | `src/include/port/wasix-dl/sys/shm.h` | `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch` | Provides the WASIX SysV shared-memory shim surface. | | `src/include/storage/s_lock.h` | `0040-oliphaunt-wasix-use-single-backend-spinlocks.patch` | Specializes spinlocks only for the enforced single-backend WASIX runtime. | | `src/makefiles/Makefile.wasix-dl` | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch` | Builds side modules and PGXS artifacts for WASIX dynamic linking. | -| `src/makefiles/pgxs.mk` | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch` | Installs PGXS extension artifacts for WASIX packaging. | +| `src/makefiles/pgxs.mk` | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch`, `0044-oliphaunt-wasix-inline-sigsetjmp.patch` | Installs PGXS extension artifacts for WASIX packaging. | | `src/port/pg_strong_random.c` | `0042-oliphaunt-wasix-buffer-strong-random.patch` | Batches checked WASI entropy reads for the single-backend runtime. | | `src/template/wasix-dl` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0011-oliphaunt-wasix-prefer-posix-semaphores.patch` | Keeps the WASIX template and atomics invariants source-controlled. | @@ -132,21 +133,23 @@ src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write | Process identity and shared memory stay behind the port header | `0009-oliphaunt-wasix-route-process-identity-through-port.patch`, `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`, `0011-oliphaunt-wasix-prefer-posix-semaphores.patch` | `oliphaunt_wasix_geteuid`, `oliphaunt_wasix_shmget`, `PREFERRED_SEMAPHORES=UNNAMED_POSIX` | WASIX platform gaps are explicit port-layer dependencies, not scattered runtime guesses. | | Tool/runtime platform stubs fail closed | `0021-oliphaunt-wasix-declare-wasix-fork.patch`, `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`, `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch` | `fork_process`, `oliphaunt_wasix_pgdump_fork`, `errno == EISDIR` | Unavailable WASIX behavior is explicit and narrow instead of silently emulated. | | Optional ICU data stays optional during initdb | `0038-oliphaunt-wasix-skip-icu-collation-setup-without-icu-data.patch` | `getenv("ICU_DATA")`, `pg_collation_actual_version`, `pg_import_system_collations` | WASIX initdb skips ICU-backed collation setup until the optional ICU data package is present. | -| Browser-worker hosts can own one blocking stdio pgwire lifecycle | `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch` | `OLIPHAUNT_WASIX_STDIO_PGWIRE`, `oliphaunt_wasix_set_protocol_stdio(1)`, `ProcessStartupPacket(MyProcPort, true, true)` | Only the explicit browser-worker contract enters the blocking stdio path; Rust, browser-direct, and Node hosts keep the export-driven lifecycle. | +| Rust COPY streaming keeps an explicit hybrid transport ABI | `0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch` | `oliphaunt_wasix_set_protocol_transport(int mode)`, `oliphaunt_wasix_protocol_stream_active(void)` | Only COPY switches the Rust proxy from buffered protocol I/O to its attached stream; TypeScript has no process-level stdio lifecycle. | | Single-backend WASIX spinlocks preserve their ABI and scope | `0040-oliphaunt-wasix-use-single-backend-spinlocks.patch` | `defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER)`, `OLIPHAUNT_WASM_SINGLE_BACKEND_ATOMICS`, `typedef int slock_t;`, `oliphaunt_wasix_single_user_tas` | The shared guest lets Rust AOT and every TypeScript placement replace atomic exchange; all concurrent PostgreSQL builds retain upstream spinlocks. | | Single-backend WASIX atomics preserve ABI and operation contracts | `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch` | `override CPPFLAGS += -DOLIPHAUNT_WASM_SINGLE_BACKEND_ATOMICS`, `postmaster mode is unavailable in the single-backend WASIX runtime`, `PG_HAVE_8BYTE_SINGLE_COPY_ATOMICITY`, `volatile uint64 value pg_attribute_aligned(8)`, `*expected = current` | Shared guest backend objects use scalar operations for Rust and TypeScript hosts; frontends, extensions, and every concurrent PostgreSQL build retain normal atomics. | | Single-backend strong randomness remains checked and non-repeating | `0042-oliphaunt-wasix-buffer-strong-random.patch` | `defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER)`, `getrandom(wasix_strong_random_pool + filled`, `if (errno == EINTR)`, `wasix_strong_random_used += copy_len` | The embedded backend amortizes host entropy calls without changing failure handling or any concurrent PostgreSQL build. | | Unsupported writeback hints stay separate from real durability | `0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch` | `#if defined(OLIPHAUNT_WASM_SINGLE_USER)`, `Actual fsync/fdatasync durability remains enabled.`, `#elif defined(HAVE_SYNC_FILE_RANGE)` | The single-backend guest omits only pg_flush_data hints that WASIX rejects on read-only descriptors; PostgreSQL fsync and fdatasync remain active. | +| PostgreSQL side modules own their SJLJ catch frames | `0044-oliphaunt-wasix-inline-sigsetjmp.patch` | `-DOLIPHAUNT_WASM_SIDE_MODULE`, `WebAssembly SJLJ requires setjmp to be visible at the protected call site.`, `defined(__wasm_exception_handling__) && defined(OLIPHAUNT_WASM_SIDE_MODULE)`, `#undef sigsetjmp`, `#define sigsetjmp(env, savesigs) ((void) (savesigs), setjmp(env))` | PG_TRY expands to a compiler-recognized setjmp in every PostgreSQL side module, so nested errors unwind to the live module-local handler. | ## PostgreSQL Patch Symbols - `OLIPHAUNT_WASM_EXIT_ALIVE` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) - `OLIPHAUNT_WASM_HOST_EXPORT` (`0003-oliphaunt-wasix-export-startup-packet-parser.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) +- `OLIPHAUNT_WASM_SIDE_MODULE` (`0044-oliphaunt-wasix-inline-sigsetjmp.patch`) - `OLIPHAUNT_WASM_SINGLE_BACKEND_ATOMICS` (`0040-oliphaunt-wasix-use-single-backend-spinlocks.patch`, `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch`) -- `OLIPHAUNT_WASM_SINGLE_USER` (`0002-oliphaunt-wasix-add-backend-host-io-hooks.patch`, `0003-oliphaunt-wasix-export-startup-packet-parser.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch`, `0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch`, `0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0021-oliphaunt-wasix-declare-wasix-fork.patch`, `0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch`, `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`, `0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch`, `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch`, `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch`, `0036-oliphaunt-wasix-skip-activity-id-reporting.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`, `0040-oliphaunt-wasix-use-single-backend-spinlocks.patch`, `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch`, `0042-oliphaunt-wasix-buffer-strong-random.patch`, `0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch`) +- `OLIPHAUNT_WASM_SINGLE_USER` (`0002-oliphaunt-wasix-add-backend-host-io-hooks.patch`, `0003-oliphaunt-wasix-export-startup-packet-parser.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch`, `0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch`, `0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0021-oliphaunt-wasix-declare-wasix-fork.patch`, `0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch`, `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`, `0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch`, `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch`, `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch`, `0036-oliphaunt-wasix-skip-activity-id-reporting.patch`, `0040-oliphaunt-wasix-use-single-backend-spinlocks.patch`, `0041-oliphaunt-wasix-specialize-single-backend-atomics.patch`, `0042-oliphaunt-wasix-buffer-strong-random.patch`, `0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch`, `0044-oliphaunt-wasix-inline-sigsetjmp.patch`) - `PostgresMainLongJmp` (`0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) - `PostgresMainLoopOnce` (`0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) -- `ProcessStartupPacket` (`0003-oliphaunt-wasix-export-startup-packet-parser.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) +- `ProcessStartupPacket` (`0003-oliphaunt-wasix-export-startup-packet-parser.patch`) - `oliphaunt_wasix_backend_timing_end` (`0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`) - `oliphaunt_wasix_backend_timing_start` (`0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`) - `oliphaunt_wasix_begin_startup_error_capture` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) @@ -165,22 +168,21 @@ src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write - `oliphaunt_wasix_init_protocol_port` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`) - `oliphaunt_wasix_io` (`0002-oliphaunt-wasix-add-backend-host-io-hooks.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) - `oliphaunt_wasix_pgdump_fork` (`0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`) -- `oliphaunt_wasix_pq_flush` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) +- `oliphaunt_wasix_pq_flush` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) - `oliphaunt_wasix_process_startup_options` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) - `oliphaunt_wasix_protocol_io` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) - `oliphaunt_wasix_protocol_report_copy_response` (`0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`) -- `oliphaunt_wasix_send_conn_data` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) -- `oliphaunt_wasix_set_protocol_stdio` (`0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) +- `oliphaunt_wasix_protocol_stream_active` (`0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch`) +- `oliphaunt_wasix_send_conn_data` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_set_protocol_transport` (`0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch`) - `oliphaunt_wasix_shmat` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) - `oliphaunt_wasix_shmctl` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) - `oliphaunt_wasix_shmdt` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) - `oliphaunt_wasix_shmget` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) - `oliphaunt_wasix_single_user_tas` (`0040-oliphaunt-wasix-use-single-backend-spinlocks.patch`) -- `oliphaunt_wasix_start` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) -- `oliphaunt_wasix_start_stdio_pgwire` (`0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) +- `oliphaunt_wasix_start` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) - `oliphaunt_wasix_startup_error_capture_active` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) - `oliphaunt_wasix_startup_error_saved_dest` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) -- `oliphaunt_wasix_stdio_pgwire_requested` (`0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch`) ## Experiment Patch Disposition diff --git a/docs/maintainers/assets.md b/docs/maintainers/assets.md index be7b047b3..a5b427e7e 100644 --- a/docs/maintainers/assets.md +++ b/docs/maintainers/assets.md @@ -58,9 +58,11 @@ per-database skeleton by default. Temporary and template-backed databases use a cached PGDATA template as a lower filesystem and materialize files into database storage only when PostgreSQL opens them for mutation. -The runtime tree keeps both `/bin/oliphaunt` and `/bin/postgres`. They are the same -backend module; the `postgres` path exists so upstream `initdb` can discover and -spawn the backend through PostgreSQL's normal `find_other_exec()` path. +The portable artifact installs the backend once under PostgreSQL's conventional +`/bin/postgres` name. Both direct hosts execute that path, and upstream `initdb` +discovers the same regular file through its normal `find_other_exec()` path. +The internal build output and AOT artifact retain the Oliphaunt product identity, +but that branding does not leak into PostgreSQL's installed executable layout. The cache is content-addressed by the asset manifest and artifact hashes. If an asset hash does not match the manifest, startup fails instead of using a mixed diff --git a/docs/maintainers/repo-structure.md b/docs/maintainers/repo-structure.md index 47375b1dd..50ed98d53 100644 --- a/docs/maintainers/repo-structure.md +++ b/docs/maintainers/repo-structure.md @@ -112,7 +112,7 @@ synthetic root: - `src/bindings/wasix-ts` is the public browser, Node, Bun, and Deno binding over the same portable WASIX runtime. It owns module-Worker and worker-thread orchestration, archive-to-memory mounts, the patched package-relative Wasmer host, and the - stdio pgwire client. It must not depend on `src/sdks/js`, native runtime + direct guest-memory pgwire client. It must not depend on `src/sdks/js`, native runtime carriers, Node direct, or the broker. Ordinary opens consume the generated host-neutral `@oliphaunt/liboliphaunt-wasix` carrier; conditional exports select the host adapter without changing the public package identity. diff --git a/docs/maintainers/wasix-usage.md b/docs/maintainers/wasix-usage.md index 4629a3f49..6e853dfc8 100644 --- a/docs/maintainers/wasix-usage.md +++ b/docs/maintainers/wasix-usage.md @@ -219,14 +219,28 @@ Rust binding's AOT artifacts and the portable module used by browser direct, browser worker, and Node/Bun/Deno worker execution all benefit. They are not host-specific patches. -Transport remains host-specific. PostgreSQL patch 0039 adds an opt-in stdio -pgwire entry point to the shared guest, but only browser-worker execution sets -`OLIPHAUNT_WASIX_STDIO_PGWIRE=1`. Rust, browser-direct, and Node/Bun/Deno hosts pump the -existing lifecycle exports instead. The patches under +Transport remains host-specific. Current TypeScript placements, including the +dedicated browser worker, use the direct guest-memory PGWire driver; worker +execution isolates its synchronous guest calls from the browser main thread. +PostgreSQL patch 0039 declares the hybrid transport ABI used only when the Rust +proxy enters COPY streaming; it does not add a process-level stdio entry point. +Rust pumps the existing lifecycle exports through its native Wasmer host. The patches under `src/bindings/wasix-ts/host` adapt the pinned Wasmer JS 6.1/WASIX 0.601 host; -they do not belong in the Rust host, which uses the coherent Wasmer +that host binds the explicitly single-backend guest clock directly to imported +memory. Its direct PGWire driver also moves +request bytes straight from JavaScript into guest memory and returns one owned +JavaScript response, avoiding intermediate copies without exposing a view that +PostgreSQL could later mutate. Direct-session stderr is retained only as a +bounded 16 KiB diagnostic tail and attached on lifecycle failure. These host +adaptations do not belong in the Rust host, +which uses the coherent Wasmer 7.2.1/WASIX 0.702.1 family. Native runtimes keep PostgreSQL's normal concurrent atomics and their own transport rather than inheriting either WASIX contract. +Wasmer 7.2.1 explicitly disables WebAssembly exception-handling tests on +Windows, so the Rust MSVC host retains PostgreSQL's top-level process-exit +recovery boundary. Nested `PG_TRY`/`PG_CATCH` qualification applies to the +other Rust hosts and the JavaScript host; Windows still proves top-level error +recovery but does not claim nested Wasm-EH support. ## Wasmer compatibility diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs index 3db7f0bbd..7fcf1b186 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs @@ -192,11 +192,6 @@ impl WasixBackendSession { &self.postgres_config } - #[cfg(debug_assertions)] - pub(crate) fn guest_bridge_allocation_counts(&self) -> (u64, u64) { - self.pg.guest_bridge_allocation_counts() - } - pub(crate) fn send_buffered( &mut self, message: &[u8], @@ -366,11 +361,6 @@ impl BackendSession { self.0.postgres_config() } - #[cfg(debug_assertions)] - pub(crate) fn guest_bridge_allocation_counts(&self) -> (u64, u64) { - self.0.guest_bridge_allocation_counts() - } - pub(crate) fn send_buffered( &mut self, message: &[u8], diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs index 3d1f75525..2e224000b 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs @@ -184,7 +184,7 @@ impl RootPrepareOptions { impl RuntimeLayout { pub(crate) fn module_path(&self) -> PathBuf { - self.module_root.join("bin/oliphaunt") + self.module_root.join("bin/postgres") } pub(crate) fn uses_shared_overlay(&self) -> bool { @@ -364,8 +364,8 @@ fn locate_runtime_module(paths: &OliphauntPaths) -> Option<(PathBuf, PathBuf)> { if !oliphaunt_dir.exists() { return None; } - let oliphaunt_bin_dir = oliphaunt_dir.join("bin"); - let module = oliphaunt_bin_dir.join("oliphaunt"); + let bin_dir = oliphaunt_dir.join("bin"); + let module = bin_dir.join("postgres"); if !module.exists() { return None; } @@ -384,7 +384,7 @@ fn locate_runtime_module(paths: &OliphauntPaths) -> Option<(PathBuf, PathBuf)> { { return None; } - Some((module, oliphaunt_bin_dir)) + Some((module, bin_dir)) } fn ensure_full_runtime(paths: &OliphauntPaths) -> Result { @@ -1309,7 +1309,7 @@ fn sha256_hex(bytes: &[u8]) -> String { pub(crate) fn preload_runtime_module() -> Result<()> { let cached_runtime = runtime_cache()?; - let module_path = cached_runtime.runtime_root.join("bin/oliphaunt"); + let module_path = cached_runtime.runtime_root.join("bin/postgres"); PostgresMod::preload_module(&module_path) } @@ -1914,9 +1914,9 @@ mod tests { .context("memory runtime should have an immutable shared root")?; assert!( shared_root - .metadata(Path::new("/bin/oliphaunt")) + .metadata(Path::new("/bin/postgres")) .is_ok_and(|metadata| metadata.is_file()), - "memory runtime is missing /bin/oliphaunt" + "memory runtime is missing /bin/postgres" ); let filesystem = prepared .outcome diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs index c9a664662..d6ddba040 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs @@ -424,13 +424,6 @@ impl Oliphaunt { self.backend.runtime_storage() } - /// Return debug-build bridge allocation/free counters for ownership tests. - #[doc(hidden)] - #[cfg(debug_assertions)] - pub fn guest_bridge_allocation_counts(&self) -> (u64, u64) { - self.backend.guest_bridge_allocation_counts() - } - /// Back up the physical database state to a gzipped tar archive. /// /// The archive is intended to be loaded back into oliphaunt-wasix/Oliphaunt with diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs index 4daa16c8e..d2d9012a7 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs @@ -1,5 +1,3 @@ -#[cfg(debug_assertions)] -use std::cell::Cell; use std::collections::HashSet; use std::fmt; use std::fs; @@ -48,7 +46,7 @@ use wasix_fs::{ }; pub use wasix_fs::{FsTraceSnapshot, fs_trace_snapshot, reset_fs_trace}; -const OLIPHAUNT_EXE_PATH: &str = "/bin/oliphaunt"; +const POSTGRES_EXE_PATH: &str = "/bin/postgres"; const PGDATA_DIR: &str = "/base"; const ICU_DATA_DIR: &str = "/share/icu"; const WASM_PREFIX: &str = "/"; @@ -186,7 +184,6 @@ pub struct PostgresMod { store: Store, _instance: Instance, env: WasiFunctionEnv, - guest_allocator: GuestAllocator, io: WasixOliphauntIo, lifecycle: OliphauntLifecycleExports, protocol: WasixProtocolExports, @@ -297,20 +294,13 @@ struct WasixProtocolStdioExports { struct WasixOliphauntIo { input_reset: TypedFunction<(), i32>, - input_write: TypedFunction<(i32, i32), i32>, + input_reserve: TypedFunction, + input_commit: TypedFunction, input_available: TypedFunction<(), i32>, output_reset: TypedFunction<(), i32>, output_len: TypedFunction<(), i32>, - output_read: TypedFunction<(i32, i32), i32>, -} - -struct GuestAllocator { - malloc: TypedFunction, - free: TypedFunction, - #[cfg(debug_assertions)] - allocations: Cell, - #[cfg(debug_assertions)] - frees: Cell, + output_data: TypedFunction<(), i32>, + output_contains_error: TypedFunction<(), i32>, } impl PostgresMod { @@ -318,7 +308,7 @@ impl PostgresMod { let runtime_root = module_path .parent() .and_then(Path::parent) - .context("runtime module path must be under bin/oliphaunt")?; + .context("runtime module path must be under bin/postgres")?; let (engine, _) = aot::load_runtime_module()?; let process_runtime = process_wasix_runtime(&engine)?; preload_runtime_side_modules( @@ -342,9 +332,9 @@ impl PostgresMod { let runtime_storage = runtime_layout.mutable_root.clone(); let module_runtime_root = runtime_layout.module_root.clone(); ensure!( - module_runtime_root.join("bin/oliphaunt").exists(), - "WASIX Oliphaunt executable not found at {}", - module_runtime_root.join("bin/oliphaunt").display() + module_runtime_root.join("bin/postgres").exists(), + "WASIX PostgreSQL executable not found at {}", + module_runtime_root.join("bin/postgres").display() ); let (engine, module) = aot::load_runtime_module()?; @@ -377,18 +367,17 @@ impl PostgresMod { &instance, &env, "my_exec_path", - OLIPHAUNT_EXE_PATH, + POSTGRES_EXE_PATH, )?; - let (guest_allocator, io, lifecycle, protocol, protocol_stdio) = { + let (io, lifecycle, protocol, protocol_stdio) = { let _phase = timing::phase("wasix.export_load"); - let guest_allocator = GuestAllocator::load(&mut store, &instance)?; let io = WasixOliphauntIo::new(&mut store, &instance)?; ensure_integrated_oliphaunt_contract(&instance)?; let lifecycle = OliphauntLifecycleExports::load(&mut store, &instance)?; let protocol = WasixProtocolExports::load(&mut store, &instance)?; let protocol_stdio = WasixProtocolStdioExports::load(&mut store, &instance)?; - (guest_allocator, io, lifecycle, protocol, protocol_stdio) + (io, lifecycle, protocol, protocol_stdio) }; let pg = Self { @@ -399,7 +388,6 @@ impl PostgresMod { store, _instance: instance, env, - guest_allocator, io, lifecycle, protocol, @@ -424,11 +412,6 @@ impl PostgresMod { self.pgdata_template_root.as_deref() } - #[cfg(debug_assertions)] - pub(crate) fn guest_bridge_allocation_counts(&self) -> (u64, u64) { - self.guest_allocator.allocation_counts() - } - pub(crate) fn ensure_cluster(&mut self) -> Result<()> { self.initialize_cluster()?; self.start_backend() @@ -518,10 +501,7 @@ impl PostgresMod { fn take_startup_output_after_failure(&mut self) -> Option> { let _ = self.protocol.pq_flush.call(&mut self.store); - match self - .io - .take_output(&mut self.store, &self.env, &self.guest_allocator) - { + match self.io.take_output(&mut self.store, &self.env) { Ok(output) if !output.is_empty() => Some(output), Ok(_) => None, Err(err) => { @@ -750,8 +730,7 @@ impl PostgresMod { } { let _phase = timing::phase("postgres.protocol.input_write"); - self.io - .push_input(&mut self.store, &self.env, &self.guest_allocator, payload)?; + self.io.push_input(&mut self.store, &self.env, payload)?; } { @@ -800,13 +779,14 @@ impl PostgresMod { .call(&mut self.store) .context("oliphaunt_wasix_pq_flush after protocol buffer")?; } + let contains_error = self.io.output_contains_error(&mut self.store)?; let output = { let _phase = timing::phase("postgres.protocol.output_read"); self.io - .take_output(&mut self.store, &self.env, &self.guest_allocator) + .take_output(&mut self.store, &self.env) .context("take backend output after protocol buffer")? }; - if !recovered_protocol_error && protocol_response_contains_error(&output) { + if !recovered_protocol_error && contains_error { self.recover_non_trapping_protocol_error()?; } self.record_backend_c_timings()?; @@ -928,8 +908,7 @@ impl PostgresMod { } { let _phase = timing::phase("postgres.startup_packet.input_write"); - self.io - .push_input(&mut self.store, &self.env, &self.guest_allocator, startup)?; + self.io.push_input(&mut self.store, &self.env, startup)?; } // The upstream lifecycle is already running by this point. These calls @@ -953,9 +932,7 @@ impl PostgresMod { }; if status != 0 { let _ = self.protocol.pq_flush.call(&mut self.store); - let output = self - .io - .take_output(&mut self.store, &self.env, &self.guest_allocator)?; + let output = self.io.take_output(&mut self.store, &self.env)?; return Ok(StartupProtocolResponse { output, accepted: false, @@ -979,8 +956,7 @@ impl PostgresMod { } { let _phase = timing::phase("postgres.startup_packet.output_read"); - self.io - .take_output(&mut self.store, &self.env, &self.guest_allocator)? + self.io.take_output(&mut self.store, &self.env)? } }; self.started = true; @@ -1047,9 +1023,7 @@ impl PostgresMod { .pq_flush .call(&mut self.store) .context("oliphaunt_wasix_pq_flush after backend ErrorResponse recovery")?; - let _ = self - .io - .take_output(&mut self.store, &self.env, &self.guest_allocator)?; + let _ = self.io.take_output(&mut self.store, &self.env)?; Ok(()) } @@ -1136,12 +1110,12 @@ fn instantiate_wasix_module( runner.with_stdin(Box::new(protocol_stdio_file.clone())); runner.with_stdout(Box::new(protocol_stdio_file.clone())); runner.with_stderr(Box::new(stderr_file)); - let wasi = Wasi::new(OLIPHAUNT_EXE_PATH); + let wasi = Wasi::new(POSTGRES_EXE_PATH); let mut builder = { let _phase = timing::phase("wasix.instantiate.prepare_env"); runner .prepare_webc_env( - OLIPHAUNT_EXE_PATH, + POSTGRES_EXE_PATH, &wasi, PackageOrHash::Hash(ModuleHash::random()), RuntimeOrEngine::Runtime(input.wasix_runtime.clone()), @@ -1792,11 +1766,17 @@ impl WasixOliphauntIo { fn new(store: &mut Store, instance: &Instance) -> Result { let io = Self { input_reset: typed_export(store, instance, "oliphaunt_wasix_input_reset")?, - input_write: typed_export(store, instance, "oliphaunt_wasix_input_write")?, + input_reserve: typed_export(store, instance, "oliphaunt_wasix_input_reserve")?, + input_commit: typed_export(store, instance, "oliphaunt_wasix_input_commit")?, input_available: typed_export(store, instance, "oliphaunt_wasix_input_available")?, output_reset: typed_export(store, instance, "oliphaunt_wasix_output_reset")?, output_len: typed_export(store, instance, "oliphaunt_wasix_output_len")?, - output_read: typed_export(store, instance, "oliphaunt_wasix_output_read")?, + output_data: typed_export(store, instance, "oliphaunt_wasix_output_data")?, + output_contains_error: typed_export( + store, + instance, + "oliphaunt_wasix_output_contains_error", + )?, }; io.reset(store)?; Ok(io) @@ -1820,24 +1800,29 @@ impl WasixOliphauntIo { Ok(()) } - fn push_input( - &self, - store: &mut Store, - env: &WasiFunctionEnv, - allocator: &GuestAllocator, - bytes: &[u8], - ) -> Result<()> { + fn push_input(&self, store: &mut Store, env: &WasiFunctionEnv, bytes: &[u8]) -> Result<()> { if bytes.is_empty() { return Ok(()); } - let written = allocator.with_bytes(store, env, bytes, |store, ptr| { - self.input_write - .call(&mut *store, ptr, bytes.len() as i32) - .context("oliphaunt_wasix_input_write") - })?; + let len = i32::try_from(bytes.len()).context("protocol input exceeds i32")?; + let ptr = self + .input_reserve + .call(&mut *store, len) + .context("oliphaunt_wasix_input_reserve")?; + ensure!(ptr > 0, "oliphaunt_wasix_input_reserve returned null"); + let view = env + .data(&*store) + .try_memory_view(&*store) + .context("get WASIX memory view")?; + view.write(ptr as u64, bytes) + .with_context(|| format!("write protocol input at 0x{ptr:x}"))?; + let written = self + .input_commit + .call(&mut *store, len) + .context("oliphaunt_wasix_input_commit")?; ensure!( - written == bytes.len() as i32, - "oliphaunt_wasix_input_write wrote {written}, expected {}", + written == len, + "oliphaunt_wasix_input_commit committed {written}, expected {}", bytes.len() ); Ok(()) @@ -1855,12 +1840,7 @@ impl WasixOliphauntIo { Ok(available) } - fn take_output( - &self, - store: &mut Store, - env: &WasiFunctionEnv, - allocator: &GuestAllocator, - ) -> Result> { + fn take_output(&self, store: &mut Store, env: &WasiFunctionEnv) -> Result> { let len = self .output_len .call(&mut *store) @@ -1872,25 +1852,21 @@ impl WasixOliphauntIo { if len == 0 { return Ok(Vec::new()); } - let bytes = allocator.with_allocation(store, len, |store, ptr| { - let read = self - .output_read - .call(&mut *store, ptr, len) - .context("oliphaunt_wasix_output_read")?; - ensure!( - read >= 0 && read <= len, - "invalid oliphaunt_wasix_output_read length {read}" - ); - - let mut bytes = vec![0u8; read as usize]; - let view = env - .data(&*store) - .try_memory_view(&*store) - .context("get WASIX memory view")?; - view.read(ptr as u64, &mut bytes) - .with_context(|| format!("read SQL output at 0x{ptr:x}"))?; - Ok(bytes) - })?; + let ptr = self + .output_data + .call(&mut *store) + .context("oliphaunt_wasix_output_data")?; + ensure!( + ptr > 0, + "oliphaunt_wasix_output_data returned null for non-empty output" + ); + let mut bytes = vec![0u8; len as usize]; + let view = env + .data(&*store) + .try_memory_view(&*store) + .context("get WASIX memory view")?; + view.read(ptr as u64, &mut bytes) + .with_context(|| format!("read protocol output at 0x{ptr:x}"))?; ensure!( self.output_reset .call(&mut *store) @@ -1900,92 +1876,13 @@ impl WasixOliphauntIo { ); Ok(bytes) } -} - -impl GuestAllocator { - fn load(store: &mut Store, instance: &Instance) -> Result { - let malloc = typed_export::(store, instance, "malloc")?; - let free = typed_export::(store, instance, "pg_free") - .or_else(|_| typed_export::(store, instance, "free")) - .context("get pg_free/free export")?; - Ok(Self { - malloc, - free, - #[cfg(debug_assertions)] - allocations: Cell::new(0), - #[cfg(debug_assertions)] - frees: Cell::new(0), - }) - } - - #[cfg(debug_assertions)] - fn allocation_counts(&self) -> (u64, u64) { - (self.allocations.get(), self.frees.get()) - } - - fn with_bytes( - &self, - store: &mut Store, - env: &WasiFunctionEnv, - bytes: &[u8], - f: impl FnOnce(&mut Store, i32) -> Result, - ) -> Result { - let ptr = self.allocate(store, bytes.len() as i32)?; - self.run_and_free(store, ptr, |store, ptr| { - let view = env - .data(&*store) - .try_memory_view(&*store) - .context("get WASIX memory view")?; - view.write(ptr as u64, bytes) - .with_context(|| format!("write guest bytes at 0x{ptr:x}"))?; - f(store, ptr) - }) - } - - fn with_allocation( - &self, - store: &mut Store, - len: i32, - f: impl FnOnce(&mut Store, i32) -> Result, - ) -> Result { - let ptr = self.allocate(store, len)?; - self.run_and_free(store, ptr, f) - } - fn allocate(&self, store: &mut Store, len: i32) -> Result { - let ptr = self - .malloc - .call(&mut *store, len) - .context("malloc guest allocation")?; - ensure!(ptr > 0, "malloc returned null for guest allocation"); - #[cfg(debug_assertions)] - self.allocations.set(self.allocations.get() + 1); - Ok(ptr) - } - - fn run_and_free( - &self, - store: &mut Store, - ptr: i32, - f: impl FnOnce(&mut Store, i32) -> Result, - ) -> Result { - let result = f(store, ptr); - let free_result = self - .free - .call(&mut *store, ptr) - .with_context(|| format!("free guest allocation at 0x{ptr:x}")); - #[cfg(debug_assertions)] - if free_result.is_ok() { - self.frees.set(self.frees.get() + 1); - } - match (result, free_result) { - (Ok(value), Ok(())) => Ok(value), - (Ok(_), Err(err)) => Err(err), - (Err(err), Ok(())) => Err(err), - (Err(err), Err(free_err)) => Err(err.context(format!( - "failed to free guest allocation at 0x{ptr:x} after previous error: {free_err:#}" - ))), - } + fn output_contains_error(&self, store: &mut Store) -> Result { + Ok(self + .output_contains_error + .call(store) + .context("oliphaunt_wasix_output_contains_error")? + != 0) } } @@ -2042,10 +1939,9 @@ fn is_wasm_uncaught_exception(err: &wasmer::RuntimeError) -> bool { } fn host_requires_process_exit_error_recovery() -> bool { - // Wasmer does not implement nested WebAssembly exception throws on MSVC - // hosts. The WASIX bridge therefore routes PostgreSQL ERROR longjmps - // through the existing process-exit recovery boundary on that host - // capability, while preserving normal nested unwinding elsewhere. + // Wasmer 7.2.1 disables its WebAssembly exception-handling tests on + // Windows. Keep PostgreSQL's proven top-level process-exit recovery there; + // other hosts retain nested PG_TRY/PG_CATCH unwinding. cfg!(target_env = "msvc") } diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs index 9914d804e..35d91c835 100644 --- a/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs @@ -495,15 +495,9 @@ fn direct_raw_protocol_api_matches_oliphaunt_exec_protocol_cases() -> anyhow::Re Ok(()) } -#[cfg(debug_assertions)] #[test] -fn direct_protocol_bridge_guest_allocations_are_freed() -> anyhow::Result<()> { +fn direct_protocol_bridge_reuses_guest_owned_buffers() -> anyhow::Result<()> { let mut db = Oliphaunt::builder().open()?; - let (allocations_before, frees_before) = db.guest_bridge_allocation_counts(); - assert_eq!( - allocations_before, frees_before, - "bridge allocations must be balanced before stress loop" - ); for _ in 0..128 { let mut output = Vec::new(); @@ -521,16 +515,6 @@ fn direct_protocol_bridge_guest_allocations_are_freed() -> anyhow::Result<()> { ); } - let (allocations_after, frees_after) = db.guest_bridge_allocation_counts(); - assert_eq!( - allocations_after, frees_after, - "each Rust-owned guest bridge allocation must be freed" - ); - assert!( - allocations_after > allocations_before, - "stress loop should exercise bridge allocations" - ); - db.close()?; Ok(()) } @@ -1074,6 +1058,27 @@ fn runtime_smoke() -> anyhow::Result<()> { let count = pg.query("SELECT count(*)::int AS count FROM tx_items", &[], None)?; assert_eq!(first_row(&count)?.get("count"), Some(&json!(1))); + #[cfg(not(target_env = "msvc"))] + { + trace_step("runtime_smoke nested PostgreSQL error recovery"); + pg.exec("CREATE TEMP TABLE nested_error_catch(value integer)", None)?; + pg.exec( + "DO $$ BEGIN \ + BEGIN PERFORM 1 / 0; \ + EXCEPTION WHEN division_by_zero THEN \ + INSERT INTO nested_error_catch VALUES (1); \ + END; \ + END $$", + None, + )?; + let caught = pg.query( + "SELECT count(*)::int AS count FROM nested_error_catch", + &[], + None, + )?; + assert_eq!(first_row(&caught)?.get("count"), Some(&json!(1))); + } + trace_step("runtime_smoke expected-error syntax"); let syntax_err = pg .exec("SELECT +", None) diff --git a/src/bindings/wasix-ts/ARCHITECTURE.md b/src/bindings/wasix-ts/ARCHITECTURE.md index cfafce0a4..dcf802340 100644 --- a/src/bindings/wasix-ts/ARCHITECTURE.md +++ b/src/bindings/wasix-ts/ARCHITECTURE.md @@ -55,10 +55,9 @@ are not a TypeScript or server-runtime host optimization. Frontends, PGXS side m and concurrent PostgreSQL builds retain the normal atomic implementation. Host lifecycle stays separate. All TypeScript placements assert the shared `OLIPHAUNT_WASIX_SINGLE_BACKEND=1` concurrency invariant and the pinned host -denies guest process and thread creation under it. Only browser worker placement -also uses `OLIPHAUNT_WASIX_STDIO_PGWIRE=1` for the patched stdio-pgwire pump; -browser direct and server-runtime worker placement remove that transport marker and use -the Oliphaunt export driver that mirrors the Rust host. +denies guest process and thread creation under it. Every placement uses the +Oliphaunt export driver and direct guest-memory protocol bridge that mirrors the +Rust host; placement does not select a second transport implementation. ## Browser lifecycle @@ -77,11 +76,11 @@ PostgreSQL session; direct calls also contend for the caller realm's event loop. The pinned host currently instantiates dynamically loaded native side modules synchronously. Chromium refuses main-realm modules above 8 MiB, so direct open -fails early for a selected carrier above that threshold. The current PostGIS -carrier also requires native load-order handling that neither browser -placement implements, so the error does not advertise worker placement as a -working fallback. The core guest uses the asynchronous path; smaller qualified -side modules remain supported. +fails early for a selected carrier above that threshold. Worker execution is +outside that main-realm restriction and applies descriptor-declared native +load order; a real Chrome canary loads PostGIS there and verifies recovery +across its large dependency module. The core guest uses the asynchronous path; +smaller qualified side modules remain supported in direct placement. 1. Worker execution creates one module Web Worker; direct execution imports the package-relative host lazily in the caller realm. @@ -101,23 +100,19 @@ side modules remain supported. `/dev/urandom`. Its narrow `Directory` mutation journal records successful writes and truncates through already-open descriptors as well as file, directory, remove, and rename paths for every placement and provider. - Worker execution passes the verified precompiled main module - and its original bytes to `runWasix`; direct execution gives the same pair to - `instantiateOliphauntDirect` and keeps the resulting Store in the caller - realm. -4. Worker execution enables the explicit `OLIPHAUNT_WASIX_STDIO_PGWIRE=1` - contract, attaches the existing Oliphaunt Port to stdio, and frames backend - output through `ReadyForQuery`. Direct execution filters that transport flag - and instead pushes protocol bytes through the runtime's exported input and - output buffers, mirroring the WASIX Rust host. Both preserve a startup + Every placement passes the verified precompiled main module and its original + bytes to `instantiateOliphauntDirect`. Worker placement keeps the resulting + Store in its package worker; direct placement keeps it in the caller realm. +4. Every placement pushes protocol bytes through guest-owned reusable input + and output buffers. The host writes requests directly into canonical guest + memory and returns one owned JavaScript response copy, so PostgreSQL can + safely reuse or grow its memory after the call. Startup preserves an `ErrorResponse` and its SQLSTATE even when startup terminates the guest. -5. The worker's standalone loop emits a maintained second startup transition, - which `WasixProcess` drains before exposing the session. The direct export - driver completes startup without that stdio-only transition. Carrier-owned - extension lifecycle SQL then runs while the fixed bootstrap superuser is - active. As in the Rust binding, a requested non-default user is selected - from existing roles with `SET ROLE`; standalone bootstrap itself remains the - fixed `postgres` identity. +5. The direct export driver completes the exported startup transition before + exposing the session. Carrier-owned extension lifecycle SQL then runs while + the fixed bootstrap superuser is active. As in the Rust binding, a requested + non-default user is selected from existing roles with `SET ROLE`; standalone + bootstrap itself remains the fixed `postgres` identity. 6. The binding frames later responses through `ReadyForQuery` and exposes serialized `query`, `execute`, `execProtocolRaw`, `checkpoint`, and callback-scoped `transaction` calls through one database contract. The same @@ -129,22 +124,22 @@ side modules remain supported. final boundary. `checkpoint` sends PostgreSQL `CHECKPOINT` without an intermediate publication, validates the normal pgwire result, then runs one checkpoint provider boundary. If a - PostgreSQL `ERROR` crosses either host boundary, the transport-scoped host + PostgreSQL `ERROR` crosses the host boundary, the direct host invokes `PostgresMainLongJmp`, sends and flushes readiness, and continues through `PostgresMainLoopOnce`. Normal ErrorResponse returns receive the same top-level cleanup as trapping errors. -7. Worker `close` writes PostgreSQL Terminate, closes stdin, and waits for a - successful zero process exit. Direct `close` deactivates the embedded - lifecycle and runs its atexit exports synchronously. A successful close +7. `close` sends PostgreSQL Terminate through the same direct bridge, then + deactivates the embedded lifecycle and runs its atexit exports synchronously + in the owning realm. A successful close publishes any remaining persistent delta. Every outcome closes the provider, releases its exclusive database lease, and frees its placement-owned host resources. -The stdio guest entry mode exists because stock Wasmer's public browser API -exposes streams and process completion, but not arbitrary guest exports. The -source-pinned direct host deliberately adds only the narrow Oliphaunt export -driver needed to match the WASIX Rust lifecycle; it is not a general -synchronous WASIX process API. +Stock Wasmer's public browser API exposes streams and process completion, but +not arbitrary guest exports. The source-pinned host deliberately adds only the +narrow Oliphaunt export driver needed to match the WASIX Rust lifecycle; it is +not a general synchronous WASIX process API. Generic Wasmer process streams +remain upstream behavior and are not part of the TypeScript database surface. ## Node, Bun, and Deno lifecycle @@ -319,7 +314,7 @@ into `lib/host`; direct browser execution imports the host in the caller realm, while browser and Node/Bun/Deno direct/worker placements import the same package-relative module. -This is not a general backport of WASIX 0.702 to Wasmer 0.601. The thirteen patches: +This is not a general backport of WASIX 0.702 to Wasmer 0.601. The seventeen patches: 1. compile the large module asynchronously, preserve raw module bytes across the blocking worker, and launch the configured `WasiEnvBuilder` rather than @@ -328,49 +323,73 @@ This is not a general backport of WASIX 0.702 to Wasmer 0.601. The thirteen patc `proc_fork_env` and context create/switch/destroy for both memory widths; 3. add ephemeral `/dev/shm` and Wasmer's unbounded random device without replacing the SDK's stream-backed stdio; -4. recover PostgreSQL wasm-EH exceptions only for the explicit - `OLIPHAUNT_WASIX_STDIO_PGWIRE=1` contract, then remain in the existing - PostgreSQL export pump for the process lifetime; -5. call wasm-bindgen through the object-form initializer required by the pinned +4. call wasm-bindgen through the object-form initializer required by the pinned host toolchain; -6. accept a verified precompiled guest `WebAssembly.Module` together with its +5. accept a verified precompiled guest `WebAssembly.Module` together with its original bytes, so the blocking inner worker can reuse compilation without depending on unsupported Wasmer module serialization; and -7. add a narrow caller-realm Oliphaunt driver that owns one Store, invokes the +6. add a narrow caller-realm Oliphaunt driver that owns one Store, invokes the existing PostgreSQL startup/protocol/cleanup exports synchronously, and rejects generic WASIX task, thread, fork, and network work that would require another execution context; -8. add Wasmer's Promise-backed JavaScript instance construction for modules +7. add Wasmer's Promise-backed JavaScript instance construction for modules that exceed Chromium's synchronous main-realm limit; and -9. carry that async boundary through the pinned WASIX builder and linker only +8. carry that async boundary through the pinned WASIX builder and linker only while constructing the main module. The returned database driver remains synchronous, and oversized dynamic side modules are rejected before open; and -10. repair the pinned source commit's stale npm-lock root metadata, then install +9. repair the pinned source commit's stale npm-lock root metadata, then install that integrity-pinned dependency graph without lockfile mutation; and -11. deny guest process replacement, process creation, and thread creation in +10. deny guest process replacement, process creation, and thread creation in every TypeScript placement while the explicit single-backend contract is - active; the separate stdio-pgwire marker remains transport-only; -12. remove the retired `wasm32-wasi` target from the pinned Wasmer JS build; + active; +11. remove the retired `wasm32-wasi` target from the pinned Wasmer JS build; and -13. cache the single-backend profile and use distinct realtime and monotonic +12. cache the single-backend profile and use distinct realtime and monotonic JavaScript clocks, while amortizing pending-signal checks across high-volume PostgreSQL timing samples; and -14. expose a current-state mutation journal whose write-file wrapper records +13. expose a current-state mutation journal whose write-file wrapper records later writes and truncates through PostgreSQL descriptors retained across - protocol operations. + protocol operations; +14. bind the single-backend guest's WASI clock directly to its imported + `WebAssembly.Memory`, cache its view until memory growth, and retain the Rust + syscall as a bounded pending-work, invalid-input, and compatibility fallback; + and +15. add a narrow direct-PGWire bridge with guest-owned reusable buffers, so + requests enter canonical guest memory directly and responses make one final + copy into owned JavaScript storage that remains valid after PostgreSQL reuses + or grows guest memory; and +16. retain a bounded 16 KiB tail of direct-session stderr and attach it only to + failed startup, protocol, and close operations, avoiding both silent worker + failures and an unbounded capture buffer on long-lived databases; and +17. teach the pinned Wasmer JavaScript module parser to map standard nullable + and non-nullable WebAssembly exception references to its existing + `ExceptionRef` type, allowing worker-loaded side modules that use native + exception handling without adding extension-specific behavior. The clock specialization is intentionally narrower than a general syscall -shortcut. Realtime uses the JavaScript epoch clock, monotonic and CPU-time -compatibility IDs use the host's monotonic clock, synthetic clock offsets -remain honored after `clock_time_set`, and pending WASIX operations are checked -at a bounded interval. Other WASIX programs retain the complete upstream +shortcut. Realtime uses the JavaScript epoch clock, while monotonic reads +calibrate the host's monotonic clock against the canonical Rust fallback epoch, +so fast and fallback reads cannot jump between domains. Process and thread CPU +clocks remain on the canonical fallback because wall time is not an equivalent +clock. Synthetic clock offsets remain honored by declining the direct import +for guests that import `clock_time_set`, and pending WASIX operations are +checked on a real-time bound. Invalid clock IDs, pointers, or host values use +the complete Rust syscall. Other WASIX programs retain the complete upstream per-call path. -The exact pairing is qualified for the single-process stdio-pgwire and direct -Oliphaunt export paths, including repeated PostgreSQL `ERROR` recovery. The +The exact pairing is qualified for the single-process direct Oliphaunt export +path in every placement, including repeated PostgreSQL `ERROR` recovery. The direct driver treats every `PostgresMainLoopOnce` trap as the guest's exported top-level recovery boundary and also cleans up non-trapping ErrorResponses. +Its JavaScript memory bridge is limited to the direct Oliphaunt driver: generic +WASIX streams keep their normal ownership and scheduling semantics. Copy failures +are caught before guest buffers are released, and protocol responses are copied +once into owned JavaScript storage rather than exposed as mutable guest views. +Browser qualification loads and calls PostGIS in a real worker and asserts that +its dependency side module exceeds Chromium's 8 MiB main-thread compilation +limit; the exemption is therefore attached to the worker realm, not to an +extension name or a benchmark payload size. This remains an integration contract with the pinned Oliphaunt runtime rather than a generic Wasmer guarantee. Missing WASIX context switching is a broader compatibility gap, but is not part @@ -437,10 +456,12 @@ This binding keeps the following deliberate divergences: without importing Emscripten FS. Oliphaunt keeps explicit provider-specific atomicity and exclusive ownership; multi-tab leadership remains unsupported. -The manifest's native `load-order` metadata is retained but is not driven by -this host. Selection therefore rejects nonempty `load-order` and -`shared-memory-required` contracts. `pgtap` and the `pg_uuidv7` canary declare -neither; affected extensions remain outside the qualified TypeScript host slice. +The host validates every native `load-order` entry against the carrier's exact +installed-file inventory, then emits PostgreSQL `LOAD` statements in dependency +and declared module order before `CREATE EXTENSION`. PostgreSQL and Wasmer's +dynamic linker remain responsible for each module's declared `dylink-needed` +closure. `shared-memory-required` contracts remain rejected because the +single-backend runtime has not qualified that capability. ## Asset ownership diff --git a/src/bindings/wasix-ts/README.md b/src/bindings/wasix-ts/README.md index c2949e009..dac2b9e6f 100644 --- a/src/bindings/wasix-ts/README.md +++ b/src/bindings/wasix-ts/README.md @@ -54,6 +54,18 @@ pnpm --dir src/bindings/wasix-ts smoke:browser:pg-uuidv7 That canary is narrow integration evidence. It does not add a browser target to the extension catalog or make arbitrary WASIX dynamic modules supported. +The worker-realm canary loads and calls PostGIS through the same direct-memory +session used by normal browser worker execution. It also asserts from the +carrier contract that `postgis_deps` is larger than Chromium's 8 MiB +main-thread synchronous-compilation limit: + +```sh +pnpm --dir src/bindings/wasix-ts smoke:browser:postgis-worker +``` + +This qualifies the realm distinction itself: large side modules remain rejected +on the browser main thread and are accepted only inside a real worker. + ## Compare PGlite in the browser The checked-in benchmark compares equivalent placements against pinned @@ -100,9 +112,8 @@ PGXS side modules, and PostgreSQL builds that permit concurrent backends retain the normal atomic implementation. Every TypeScript placement passes `OLIPHAUNT_WASIX_SINGLE_BACKEND=1`, and the source-pinned host denies guest process replacement, process creation, and thread creation under that contract. -This concurrency marker is independent from `OLIPHAUNT_WASIX_STDIO_PGWIRE=1`, -which browser-worker execution alone uses for its stream transport and recovery -pump. +Protocol transport is independent of that concurrency marker: every supported +placement uses the same direct guest-memory bridge. The same decomposition separates transport from PostgreSQL execution. Worker placement can lose insert wall time to its outer request boundary, while the @@ -174,11 +185,11 @@ Chromium also rejects synchronous compilation or instantiation of Wasm modules larger than 8 MiB on its main realm. Oliphaunt asynchronously constructs the 14 MiB core guest, while current native side-module loading remains synchronous. Direct open therefore rejects an imported extension whose native -module crosses that limit. The current PostGIS carrier additionally requires -native load-order handling that the browser worker has not implemented, so it -is explicitly unsupported in both browser placements today. Oversized carriers -without that additional requirement can use worker execution; smaller -qualified extension carriers remain available in direct mode. +module crosses that limit. Worker execution has no main-realm synchronous +compilation restriction and performs descriptor-declared native load ordering; +the checked-in Chrome canary loads PostGIS there, exercises a nested +side-module error, and proves that the same session recovers. Smaller qualified +extension carriers remain available in direct mode. The same code runs on Node.js, Bun, and Deno. Conditional exports select an explicit facade for each runtime. Worker placement uses that runtime's @@ -481,8 +492,8 @@ and portable bytes. It is versioned in the existing carrier, rather than owning a second release line. Each extension product generates its own `-wasix` package; the development Vite harness derives virtual package descriptors from current canonical target outputs during local builds -and smokes. The optional `pg_uuidv7` canary is dynamically imported only when -`?pg_uuidv7=1` is present. +and smokes. Optional `pg_uuidv7` and PostGIS worker canaries are dynamically +imported only by their explicit smoke profiles. The checked-in Vite app is a development and smoke harness, not a production asset server. Public packaging copies the exact source-built host, its worker, @@ -513,29 +524,25 @@ runtime artifact URL bookkeeping. The first smoke profile selects SQL-only `pgtap`, including its canonical `plpgsql` dependency and lifecycle SQL. A separate opt-in profile selects the native `pg_uuidv7` carrier and has loaded and called its `.so` in the exact - pinned Chrome/host/runtime pairing. This remains a canary rather than a - support claim. The current development bytes are produced by the canonical + pinned Chrome/host/runtime pairing. Another profile loads PostGIS in a real + Chrome worker, including its explicit native load order and a dependency side + module larger than 8 MiB. These remain exact integration canaries rather than + claims about arbitrary third-party modules. The current development bytes are produced by the canonical `liboliphaunt-wasix` asset pipeline, outside this binding; the generated WASIX package places that exact carrier in the owning extension product's existing - version stream. Selected rows that require an explicit native `load-order` or - shared-memory behavior are outside this binding's qualified contract and fail - closed. + version stream. Selected rows that require shared-memory behavior remain + outside this binding's qualified contract and fail closed. - Optional ICU data, tools, backup/restore, server mode, query cancellation, and COPY streaming are outside this binding's public surface. -- Browser worker execution alone uses the explicit stdio-pgwire recovery pump. - Wasmer JS's WASIX 0.601 runner lets the guest's wasm-EH `longjmp` escape the - asynchronous `_start` call as a `WebAssembly.Exception`. Before Wasmer closes - stdio or marks the process finished, the source-patched host recognizes that - exception only when `OLIPHAUNT_WASIX_STDIO_PGWIRE=1`, calls the existing - `PostgresMainLongJmp` cleanup export, and continues through the existing loop - exports. Later errors use the same pump. Browser direct and server-runtime worker - execution instead use the direct Oliphaunt export driver, matching the Rust - host lifecycle: it treats `PostgresMainLoopOnce` traps as the exported - recovery boundary and also cleans up non-trapping `ErrorResponse`s. This - Wasmer version erases the wasm exception tag at the Rust boundary, so the - stdio pairing assumes the only escaping `WebAssembly.Exception` on that - explicit transport is PostgreSQL's top-level jump. Non-exception runtime - errors and traps still fail closed. +- Browser direct, browser worker, and server-runtime worker execution all use + the direct Oliphaunt export driver. The browser worker runs synchronous guest + calls in its isolated realm, but otherwise shares the same request state + machine, native-module load ordering, error recovery, and owned-response + contract as the server workers. The driver treats `PostgresMainLoopOnce` + traps as the exported recovery boundary and also cleans up non-trapping + `ErrorResponse`s. Requests are copied into guest-owned reusable input memory; + each response is copied once into owned JavaScript storage before PostgreSQL + can reuse or grow guest memory. There is no second TypeScript stdio transport. - PostgreSQL errors retain `PostgresError`, SQLSTATE, and backend fields across the worker boundary, including startup database rejection and failures in selected-extension lifecycle SQL during `open()`. Storage ownership and @@ -558,14 +565,16 @@ runtime artifact URL bookkeeping. source-built compatibility host; no opaque host binary is checked in. The patches compile and instantiate the large main module asynchronously, preserve module bytes across the blocking worker, and - run the configured builder so args, environment, mounts, and stdio survive process launch. The - host also owns the two narrow PostgreSQL recovery paths described above. + run the configured builder so args, environment, and mounts survive process + launch. The direct database driver replaces stdin/stdout with its guest-memory + protocol bridge and retains only a bounded 16 KiB stderr tail for failed + lifecycle operations. The host also owns the narrow PostgreSQL recovery path + described above. The resulting JS, worker, Wasm, license, and provenance files are published package-relative so ordinary browser, Node, Bun, and Deno resolution selects the same qualified host without an application alias. -- That host is qualified only for Oliphaunt's single-process stdio-pgwire path - in a browser worker and its direct export path in browser direct or a Node, - Bun, or Deno worker. +- That host is qualified only for Oliphaunt's single-process direct export path + in browser direct, browser worker, or a Node, Bun, or Deno placement. `proc_exit2` maps to the older normal-exit implementation, while `proc_fork_env` and context create/switch/destroy fail with `ENOTSUP`. Those shims are installed for both WASIX memory widths. The host also creates @@ -575,13 +584,11 @@ runtime artifact URL bookkeeping. used for the qualified PostgreSQL error-recovery path. Generic WASIX 0.702 compatibility, broader filesystem behavior, and general native dynamic-extension support are not claimed. -- The browser-worker stdio lifecycle attaches the existing protocol Port before - standalone initialization can report a PostgreSQL startup failure. An `ErrorResponse` - can therefore end startup without `ReadyForQuery` and still retain its - SQLSTATE. Successful startup has a nonstandard two-part boundary: the first - response ends after authentication/connection data; the standalone main loop - then emits `ParameterStatus*` and a second `ReadyForQuery`. The worker drains - and validates that second batch before exposing the session. +- The direct lifecycle captures startup output before exposing the session. An + `ErrorResponse` can therefore end startup without `ReadyForQuery` and still + retain its SQLSTATE. A successful startup consumes and validates the complete + exported startup transition in the same realm that will execute later + protocol calls. - The Wasmer npm release records source commit `93b8b738...`, whose checked-in package metadata says `0.8.0` and whose npm lock has stale root metadata. The host build pins that exact Git commit plus Cargo crate checksums, applies a @@ -605,8 +612,8 @@ runtime artifact URL bookkeeping. coherent 0.601 host remains pinned; mixing generations is not papered over as an update. A full 0.702 host port also changes the exact engine identity and requires rebuilding and qualifying the runtime and extension carrier set. -- The `pg_uuidv7` canary proves this exact small native module, not generic - dynamic loading. Wasmer's broader pending +- The `pg_uuidv7` and PostGIS canaries prove the exact small-module and + large-worker-module contracts, not arbitrary dynamic loading. Wasmer's broader pending [`fd_read`/`dlopen` correction](https://github.com/wasmerio/wasmer/pull/6485) still has an unresolved memory-safety review concern, so native browser extension promotion remains blocked on a safer host boundary and broader diff --git a/src/bindings/wasix-ts/examples/browser/carriers.d.ts b/src/bindings/wasix-ts/examples/browser/carriers.d.ts index 1f2496635..81ad79e5d 100644 --- a/src/bindings/wasix-ts/examples/browser/carriers.d.ts +++ b/src/bindings/wasix-ts/examples/browser/carriers.d.ts @@ -7,3 +7,8 @@ declare module '@oliphaunt/extension-pg-uuidv7-wasix' { const extension: import('../../src/types.js').WasixExtensionDescriptor; export default extension; } + +declare module '@oliphaunt/extension-postgis-wasix' { + const extension: import('../../src/types.js').WasixExtensionDescriptor; + export default extension; +} diff --git a/src/bindings/wasix-ts/examples/browser/main.ts b/src/bindings/wasix-ts/examples/browser/main.ts index 70bd369fd..1ced5b0a9 100644 --- a/src/bindings/wasix-ts/examples/browser/main.ts +++ b/src/bindings/wasix-ts/examples/browser/main.ts @@ -1,6 +1,7 @@ import pgtap from '@oliphaunt/extension-pgtap-wasix'; import Oliphaunt, { PostgresError, + simpleQuery, type QueryParam, type OliphauntDatabase, type WasixExtensionDescriptor, @@ -17,6 +18,7 @@ const output = requireElement('output'); const searchParams = new URL(globalThis.location.href).searchParams; const smoke = searchParams.has('smoke'); const pgUuidv7Canary = searchParams.has('pg_uuidv7'); +const postgisWorkerCanary = searchParams.has('postgis_worker'); const directWorkerAudit = smoke ? auditDirectWorkerConstruction() : undefined; try { @@ -26,6 +28,7 @@ try { extensions.push(pgUuidv7); } if (smoke) { + expectOwnedMemoryCopyAcrossGrowth(); await expectFailedDirectOpenRecovery(); } const storage = indexedDB('browser-smoke'); @@ -52,6 +55,7 @@ try { await expectSqlstate(database, 'SELECT 1 / $1::int', '22012', [0]); await expectAnswer(database); await expectTransaction(database); + await expectClockConsistency(database); const recoveredPgtapVersion = await readPgtapVersion(database); if (recoveredPgtapVersion !== pgtapVersion) { throw new Error('browser smoke observed a different pgtap version after recovery'); @@ -63,8 +67,15 @@ try { directWorkerAudit?.assertNoneAndRestore(); await expectDirectWithoutWorker(); + await expectFailedWorkerOpenRecovery(); database = await Oliphaunt.open({ execution: 'worker', storage, extensions }); + await expectSqlstate(database, 'SELEC 1', '42601'); + await expectAnswer(database); + await expectSqlstate(database, 'SELECT 1 / $1::int', '22012', [0]); + await expectAnswer(database); + await expectDirectMemoryProtocol(database); + await expectClockConsistency(database); const reopened = await database.query( 'SELECT string_agg(answer::text, $1 ORDER BY answer) AS answers FROM browser_reopen_probe', [','], @@ -80,6 +91,7 @@ try { } await database.close(); const opfsAnswers = await expectOpfsPersistence(extensions); + const postgisVersion = postgisWorkerCanary ? await expectLargePostgisWorkerModule() : undefined; status.textContent = 'Browser smoke passed.'; output.textContent = JSON.stringify({ answers: [42, 43], @@ -88,6 +100,7 @@ try { startupSqlstate: '3D000', directWorkers: 0, ...(firstUuid === undefined ? {} : { pg_uuidv7: firstUuid }), + ...(postgisVersion === undefined ? {} : { postgis: postgisVersion }), }); document.documentElement.dataset.oliphauntSmoke = 'passed'; } else { @@ -119,6 +132,78 @@ try { directWorkerAudit?.restore(); } +async function expectLargePostgisWorkerModule(): Promise { + const { default: postgis } = await import('@oliphaunt/extension-postgis-wasix'); + const dependencyModule = postgis.carriers + .flatMap((carrier) => carrier.install.nativeModules) + .find((module) => module.name === 'postgis_deps'); + if (dependencyModule === undefined || dependencyModule.size <= 8 * 1024 * 1024) { + throw new Error('browser worker canary requires a PostGIS side module larger than 8 MiB'); + } + + const database = await Oliphaunt.open({ execution: 'worker', extensions: [postgis] }); + try { + const version = await readPostgisVersion(database); + await database.query('CREATE TEMP TABLE postgis_nested_error_catch(value integer)'); + await database.query( + `DO $$ BEGIN + BEGIN + PERFORM ST_GeomFromText('POINT('); + EXCEPTION WHEN OTHERS THEN + INSERT INTO postgis_nested_error_catch VALUES (1); + END; + END $$`, + ); + const caught = await database.query( + 'SELECT count(*)::int AS count FROM postgis_nested_error_catch', + ); + if (caught.getText(0, 'count') !== '1') { + throw new Error('browser worker did not catch an error crossing PostGIS side modules'); + } + try { + await database.query("SELECT ST_GeomFromText('POINT(')"); + throw new Error('browser worker expected malformed PostGIS geometry to fail'); + } catch (error) { + if (!(error instanceof PostgresError)) { + throw error; + } + } + if ((await readPostgisVersion(database)) !== version) { + throw new Error('browser worker did not recover its PostGIS session after an error'); + } + return version; + } finally { + await database.close(); + } +} + +async function readPostgisVersion(database: OliphauntDatabase): Promise { + const result = await database.query('SELECT postgis_full_version()::text AS version'); + const version = result.getText(0, 'version'); + if (version === null || !version.includes('POSTGIS=')) { + throw new Error( + `browser worker returned an invalid PostGIS version: ${JSON.stringify(version)}`, + ); + } + return version; +} + +function expectOwnedMemoryCopyAcrossGrowth(): void { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 2 }); + const guest = new Uint8Array(memory.buffer, 0, 4); + guest.set([1, 2, 3, 4]); + const owned = guest.slice(); + const previousBuffer = memory.buffer; + memory.grow(1); + if (memory.buffer === previousBuffer) { + throw new Error('WebAssembly memory growth did not replace its backing buffer'); + } + new Uint8Array(memory.buffer, 0, 4).fill(9); + if (!owned.every((byte, index) => byte === index + 1)) { + throw new Error('owned protocol bytes changed after explicit WebAssembly memory growth'); + } +} + async function expectConcurrentDirectExecution(first: OliphauntDatabase): Promise { const attempts = await Promise.allSettled([ Oliphaunt.open({ execution: 'direct' }), @@ -158,6 +243,7 @@ async function expectDirectWithoutWorker(): Promise { const database = await Oliphaunt.open({ execution: 'direct' }); try { await expectAnswer(database); + await expectDirectMemoryProtocol(database); } finally { await database.close(); } @@ -180,9 +266,50 @@ async function expectTransaction(database: OliphauntDatabase): Promise { } } -async function expectStartupSqlstate(database: string, sqlstate: string): Promise { +async function expectDirectMemoryProtocol(database: OliphauntDatabase): Promise { + const retained = await database.execProtocolRaw( + simpleQuery("SELECT repeat('a', 10240) AS retained_payload"), + ); + const snapshot = retained.slice(); + const large = await database.execProtocolRaw( + simpleQuery("SELECT repeat('z', 1048576) AS large_payload"), + ); + if (large.byteLength < 1048576) { + throw new Error( + `browser worker returned a truncated large PGWire response: ${large.byteLength}`, + ); + } + if ( + retained.byteLength !== snapshot.byteLength || + !retained.every((byte, index) => byte === snapshot[index]) + ) { + throw new Error('browser worker response changed after the guest reused its output memory'); + } +} + +async function expectClockConsistency(database: OliphauntDatabase): Promise { + const wallClock = await database.query( + 'SELECT (extract(epoch FROM clock_timestamp()) * 1000)::bigint AS millis', + ); + const wallClockMillis = Number(wallClock.getText(0, 'millis')); + if (!Number.isFinite(wallClockMillis) || Math.abs(Date.now() - wallClockMillis) > 5_000) { + throw new Error(`browser WASI realtime clock drifted: ${wallClockMillis}`); + } + const plan = await database.query('EXPLAIN (ANALYZE, FORMAT JSON) SELECT pg_sleep(0.05)'); + const explain = JSON.parse(plan.getText(0, 'QUERY PLAN') ?? 'null'); + const elapsed = explain?.[0]?.['Execution Time']; + if (!Number.isFinite(elapsed) || elapsed < 25 || elapsed > 5_000) { + throw new Error(`browser WASI monotonic clock returned invalid elapsed time: ${elapsed}`); + } +} + +async function expectStartupSqlstate( + database: string, + sqlstate: string, + execution: 'direct' | 'worker', +): Promise { try { - const unexpected = await Oliphaunt.open({ database, execution: 'direct' }); + const unexpected = await Oliphaunt.open({ database, execution }); await unexpected.close(); throw new Error( `browser smoke unexpectedly opened missing database ${JSON.stringify(database)}`, @@ -199,7 +326,7 @@ async function expectStartupSqlstate(database: string, sqlstate: string): Promis } async function expectFailedDirectOpenRecovery(): Promise { - await expectStartupSqlstate('oliphaunt_browser_smoke_missing_database', '3D000'); + await expectStartupSqlstate('oliphaunt_browser_smoke_missing_database', '3D000', 'direct'); const reopened = await Oliphaunt.open({ execution: 'direct' }); try { await expectAnswer(reopened); @@ -208,6 +335,16 @@ async function expectFailedDirectOpenRecovery(): Promise { } } +async function expectFailedWorkerOpenRecovery(): Promise { + await expectStartupSqlstate('oliphaunt_browser_worker_missing_database', '3D000', 'worker'); + const reopened = await Oliphaunt.open({ execution: 'worker' }); + try { + await expectAnswer(reopened); + } finally { + await reopened.close(); + } +} + async function expectExclusiveOwnership( storage: WasixStorage, extensions: readonly WasixExtensionDescriptor[], diff --git a/src/bindings/wasix-ts/examples/browser/vite.config.ts b/src/bindings/wasix-ts/examples/browser/vite.config.ts index 04d5c28ad..8a486a46b 100644 --- a/src/bindings/wasix-ts/examples/browser/vite.config.ts +++ b/src/bindings/wasix-ts/examples/browser/vite.config.ts @@ -36,6 +36,7 @@ function wasixAssets(): Plugin { ['@oliphaunt/liboliphaunt-wasix', '\0oliphaunt:liboliphaunt-wasix'], ['@oliphaunt/extension-pgtap-wasix', '\0oliphaunt:extension-pgtap-wasix'], ['@oliphaunt/extension-pg-uuidv7-wasix', '\0oliphaunt:extension-pg-uuidv7-wasix'], + ['@oliphaunt/extension-postgis-wasix', '\0oliphaunt:extension-postgis-wasix'], ]); const packageByVirtualModule = new Map( [...virtualModules].map(([packageName, virtualModule]) => [virtualModule, packageName]), @@ -47,6 +48,7 @@ function wasixAssets(): Plugin { ['/manifest', resolve(assetRoot, 'manifest.json')], ['/extensions/pgtap', resolve(assetRoot, 'extensions/pgtap.tar.zst')], ['/extensions/pg_uuidv7', resolve(assetRoot, 'extensions/pg_uuidv7.tar.zst')], + ['/extensions/postgis', resolve(assetRoot, 'extensions/postgis.tar.zst')], ['/pglite.data', resolve(pgliteAssetRoot, 'pglite.data')], ['/pglite.wasm', resolve(pgliteAssetRoot, 'pglite.wasm')], ['/initdb.wasm', resolve(pgliteAssetRoot, 'initdb.wasm')], @@ -232,6 +234,12 @@ function extensionPackage(packageName: string): { releasePath: 'src/extensions/external/pg_uuidv7', sqlName: 'pg_uuidv7', }; + case '@oliphaunt/extension-postgis-wasix': + return { + product: 'oliphaunt-extension-postgis', + releasePath: 'src/extensions/external/postgis', + sqlName: 'postgis', + }; default: throw new Error(`unsupported development WASIX package ${packageName}`); } diff --git a/src/bindings/wasix-ts/host/build-sdk.sh b/src/bindings/wasix-ts/host/build-sdk.sh index 285e7457d..87d1b8084 100755 --- a/src/bindings/wasix-ts/host/build-sdk.sh +++ b/src/bindings/wasix-ts/host/build-sdk.sh @@ -156,6 +156,19 @@ if grep -Fq 'Local::now()' "$wasmer_wasix_dir/src/syscalls/wasm.rs"; then echo "wasix-ts host build: WASM clock regressed to the timezone-aware wall clock" >&2 exit 1 fi +grep -Fq 'oliphaunt_fast_clock_import' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq 'pub fn oliphaunt_direct_memory' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq 'fallbackAndCalibrate' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq 'view.getBigUint64(pointer, true)' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq 'anchor.nanoseconds' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq '} else if (clockId === 1) {' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq 'const wallMillis = Date.now();' "$wasmer_wasix_dir/src/lib.rs" +grep -Fq 'wallMillis - lastFallbackWallMillis >= 16' "$wasmer_wasix_dir/src/lib.rs" +if grep -Fq 'clockId === 2' "$wasmer_wasix_dir/src/lib.rs"; then + echo "wasix-ts host build: wall-time fast path incorrectly handles CPU clocks" >&2 + exit 1 +fi +grep -Fq 'oliphaunt_direct_clock_active' "$wasmer_wasix_dir/src/state/env.rs" grep -Fq 'js_name = "changedPaths"' "$wasmer_js_dir/src/fs/directory.rs" grep -Fq 'js_name = "entryType"' "$wasmer_js_dir/src/fs/directory.rs" grep -Fq 'record_change(&changes, &from)' "$wasmer_js_dir/src/fs/directory.rs" @@ -164,11 +177,27 @@ grep -Fq 'Pin::new(&mut *self.file).poll_write(cx, buffer)' \ "$wasmer_js_dir/src/fs/directory.rs" grep -Fq 'conf.truncate || conf.create_new || (conf.create && !existed)' \ "$wasmer_js_dir/src/fs/directory.rs" -grep -Fq 'key != "OLIPHAUNT_WASIX_STDIO_PGWIRE"' "$wasmer_js_dir/src/options.rs" if grep -Fq 'key != "OLIPHAUNT_WASIX_SINGLE_BACKEND"' "$wasmer_js_dir/src/options.rs"; then echo "wasix-ts host build: direct execution discarded the single-backend invariant" >&2 exit 1 fi +if grep -R -Fq -- 'OLIPHAUNT_WASIX_STDIO_PGWIRE' \ + "$wasmer_js_dir/src" "$wasmer_wasix_dir/src"; then + echo "wasix-ts host build: retired stdio-pgwire product transport returned" >&2 + exit 1 +fi +grep -Fq 'new Uint8Array(memory.buffer, pointer, input.byteLength).set(input)' \ + "$wasmer_js_dir/src/postgres_direct.rs" +grep -Fq 'new Uint8Array(memory.buffer, pointer, length).slice()' \ + "$wasmer_js_dir/src/postgres_direct.rs" +grep -Fq 'struct BoundedStderr' "$wasmer_js_dir/src/postgres_direct.rs" +grep -Fq 'const STDERR_LIMIT_BYTES: usize = 16 * 1024' \ + "$wasmer_js_dir/src/postgres_direct.rs" +grep -Fq 'WASIX stderr (last 16 KiB)' "$wasmer_js_dir/src/postgres_direct.rs" +grep -Fq 'builder.set_stderr(stderr)' "$wasmer_js_dir/src/options.rs" +grep -Fq 'wasmparser::RefType::EXNREF' "$wasmer_dir/src/utils/polyfill.rs" +grep -Fq 'wasmparser::RefType::NULLEXNREF' "$wasmer_dir/src/utils/polyfill.rs" +grep -Fq 'Ok(Type::ExceptionRef)' "$wasmer_dir/src/utils/polyfill.rs" # The pinned source commit's npm lock predates its package metadata. Patch only # the missing root metadata and dependencies, then install the integrity-pinned diff --git a/src/bindings/wasix-ts/host/patches/0004-wasmer-wasix-recover-stdio-pgwire-errors.patch b/src/bindings/wasix-ts/host/patches/0004-wasmer-wasix-recover-stdio-pgwire-errors.patch deleted file mode 100644 index c4b8475b6..000000000 --- a/src/bindings/wasix-ts/host/patches/0004-wasmer-wasix-recover-stdio-pgwire-errors.patch +++ /dev/null @@ -1,90 +0,0 @@ -diff --git a/src/bin_factory/exec.rs b/src/bin_factory/exec.rs -index 5ae3f53..1c67bd0 100644 ---- a/src/bin_factory/exec.rs -+++ b/src/bin_factory/exec.rs -@@ -239,6 +239,74 @@ fn get_start(ctx: &WasiFunctionEnv, store: &Store) -> Option { - .ok() - } - -+const OLIPHAUNT_STDIO_PGWIRE_ENV: &[u8] = b"OLIPHAUNT_WASIX_STDIO_PGWIRE=1"; -+const WEBASSEMBLY_EXCEPTION_MESSAGE: &str = "[object WebAssembly.Exception]"; -+ -+fn oliphaunt_stdio_pgwire_requested(ctx: &WasiFunctionEnv, store: &Store) -> bool { -+ ctx.data(store) -+ .state -+ .envs -+ .lock() -+ .map(|envs| { -+ envs.iter() -+ .any(|entry| entry.as_slice() == OLIPHAUNT_STDIO_PGWIRE_ENV) -+ }) -+ .unwrap_or(false) -+} -+ -+fn is_webassembly_exception(error: &RuntimeError) -> bool { -+ error.message().contains(WEBASSEMBLY_EXCEPTION_MESSAGE) -+} -+ -+fn continue_oliphaunt_stdio_pgwire_after_error( -+ ctx: &WasiFunctionEnv, -+ store: &mut Store, -+ error: RuntimeError, -+) -> Result, RuntimeError> { -+ if !oliphaunt_stdio_pgwire_requested(ctx, store) || !is_webassembly_exception(&error) { -+ return Err(error); -+ } -+ -+ let inner = ctx.data(store).inner(); -+ let exports = &inner -+ .main_module_instance_handles() -+ .instance -+ .exports; -+ let (Ok(main_loop), Ok(recover), Ok(send_ready), Ok(flush)) = ( -+ exports.get_function("PostgresMainLoopOnce").cloned(), -+ exports.get_function("PostgresMainLongJmp").cloned(), -+ exports -+ .get_function("PostgresSendReadyForQueryIfNecessary") -+ .cloned(), -+ exports.get_function("oliphaunt_wasix_pq_flush").cloned(), -+ ) else { -+ return Err(error); -+ }; -+ -+ debug!( -+ "Oliphaunt stdio pgwire escaped PostgreSQL ERROR recovery; switching to the export pump" -+ ); -+ let mut pending_error = Some(error); -+ loop { -+ if let Some(error) = pending_error.take() { -+ if !is_webassembly_exception(&error) { -+ return Err(error); -+ } -+ recover.call(store, &[])?; -+ send_ready.call(store, &[])?; -+ flush.call(store, &[])?; -+ } -+ -+ match main_loop.call(store, &[]) { -+ Ok(_) => { -+ send_ready.call(store, &[])?; -+ flush.call(store, &[])?; -+ } -+ Err(error) => pending_error = Some(error), -+ } -+ } -+} -+ - /// Calls the module - fn call_module( - ctx: WasiFunctionEnv, -@@ -308,6 +376,10 @@ fn call_module( - } - } - -+ if let Err(error) = call_ret { -+ call_ret = continue_oliphaunt_stdio_pgwire_after_error(&ctx, &mut store, error); -+ } -+ - if let Err(err) = call_ret { - match err.downcast::() { - Ok(WasiError::Exit(code)) if code.is_success() => Ok(Errno::Success), diff --git a/src/bindings/wasix-ts/host/patches/0007-wasmer-js-run-oliphaunt-direct.patch b/src/bindings/wasix-ts/host/patches/0007-wasmer-js-run-oliphaunt-direct.patch index e517d44db..73f4ed108 100644 --- a/src/bindings/wasix-ts/host/patches/0007-wasmer-js-run-oliphaunt-direct.patch +++ b/src/bindings/wasix-ts/host/patches/0007-wasmer-js-run-oliphaunt-direct.patch @@ -22,7 +22,7 @@ diff --git a/src/options.rs b/src/options.rs index 967ac16..7b93ca4 100644 --- a/src/options.rs +++ b/src/options.rs -@@ -186,6 +186,23 @@ extern "C" { +@@ -186,6 +186,19 @@ extern "C" { } impl RunOptions { @@ -30,11 +30,7 @@ index 967ac16..7b93ca4 100644 + &self, + builder: &mut WasiEnvBuilder, + ) -> Result<(), Error> { -+ self.configure_common_builder(builder, |key| { -+ // Streaming stdio is mutually exclusive with the caller-realm -+ // export pump. Keep the rest of RunOptions byte-for-byte shared. -+ key != "OLIPHAUNT_WASIX_STDIO_PGWIRE" -+ })?; ++ self.configure_common_builder(builder)?; + + builder.set_stdin(Box::::default()); + builder.set_stdout(Box::::default()); @@ -46,7 +42,7 @@ index 967ac16..7b93ca4 100644 /// Propagate any provided options to the [`WasiEnvBuilder`], returning /// streams that can be used for stdin/stdout/stderr. pub(crate) fn configure_builder( -@@ -199,13 +216,7 @@ impl RunOptions { +@@ -199,13 +212,7 @@ impl RunOptions { ), Error, > { @@ -57,11 +53,11 @@ index 967ac16..7b93ca4 100644 - for (key, value) in self.parse_env()? { - builder.add_env(key, value); - } -+ self.configure_common_builder(builder, |_| true)?; ++ self.configure_common_builder(builder)?; let stdin = match self.read_stdin() { Some(stdin) => { -@@ -226,11 +237,32 @@ impl RunOptions { +@@ -226,11 +233,29 @@ impl RunOptions { let (stderr_file, stderr) = crate::streams::output_pipe(); builder.set_stderr(Box::new(stderr_file)); @@ -71,16 +67,13 @@ index 967ac16..7b93ca4 100644 + fn configure_common_builder( + &self, + builder: &mut WasiEnvBuilder, -+ mut include_env: impl FnMut(&str) -> bool, + ) -> Result<(), Error> { + for arg in self.parse_args()? { + builder.add_arg(arg); + } + + for (key, value) in self.parse_env()? { -+ if include_env(&key) { -+ builder.add_env(key, value); -+ } ++ builder.add_env(key, value); + } + + if let Some(cwd) = self.parse_cwd()? { @@ -111,7 +104,7 @@ index 0000000..e0f5463 + +use crate::{runtime::Runtime, tasks::CallerRealmTaskManager, utils::Error, RunOptions}; + -+const DEFAULT_PROGRAM_NAME: &str = "/bin/oliphaunt"; ++const DEFAULT_PROGRAM_NAME: &str = "/bin/postgres"; +const OLIPHAUNT_EXIT_ALIVE: i32 = 99; + +/// Instantiate the integrated Oliphaunt/PostgreSQL guest in this JS realm. diff --git a/src/bindings/wasix-ts/host/patches/0014-wasmer-js-track-directory-mutations.patch b/src/bindings/wasix-ts/host/patches/0014-wasmer-js-track-directory-mutations.patch index e7b761f87..9b8b9fcd5 100644 --- a/src/bindings/wasix-ts/host/patches/0014-wasmer-js-track-directory-mutations.patch +++ b/src/bindings/wasix-ts/host/patches/0014-wasmer-js-track-directory-mutations.patch @@ -11,6 +11,7 @@ index 34af7da..51fafa5 100644 + sync::{Arc, Mutex}, + task::{Context as TaskContext, Poll}, }; + use anyhow::Context; -use js_sys::Reflect; +use js_sys::{Array, Reflect}; @@ -22,6 +23,7 @@ index 34af7da..51fafa5 100644 +}; use wasm_bindgen::{prelude::wasm_bindgen, JsCast, JsValue}; use wasmer_wasix::runtime::task_manager::InlineWaker; + @@ -15,7 +21,10 @@ use crate::{utils::Error, StringOrBytes}; /// A directory that can be mounted inside a WASIX instance. #[derive(Debug, Clone, wasm_bindgen_derive::TryFromJsValue)] @@ -92,6 +94,7 @@ index 34af7da..51fafa5 100644 + } + } } + impl Directory { + fn from_filesystem(fs: Arc) -> Self { + Self { @@ -200,7 +203,7 @@ index 34af7da..51fafa5 100644 } } -@@ -226,7 +309,168 @@ impl virtual_fs::FileOpener for Directory { +@@ -226,8 +309,169 @@ impl virtual_fs::FileOpener for Directory { path: &std::path::Path, conf: &virtual_fs::OpenOptionsConfig, ) -> virtual_fs::Result> { @@ -369,3 +372,5 @@ index 34af7da..51fafa5 100644 + journal.insert(relative.to_string_lossy().replace('\\', "/")); } } + + #[wasm_bindgen(typescript_custom_section)] diff --git a/src/bindings/wasix-ts/host/patches/0016-wasmer-wasix-direct-single-backend-clock.patch b/src/bindings/wasix-ts/host/patches/0016-wasmer-wasix-direct-single-backend-clock.patch new file mode 100644 index 000000000..a80f8d8e1 --- /dev/null +++ b/src/bindings/wasix-ts/host/patches/0016-wasmer-wasix-direct-single-backend-clock.patch @@ -0,0 +1,304 @@ +diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -92,6 +92,178 @@ pub use crate::{ + }; + use wasmer_wasix_types::wasi::{Errno, ExitCode}; + ++#[cfg(all(feature = "js", target_arch = "wasm32"))] ++use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; ++ ++#[cfg(all(feature = "js", target_arch = "wasm32"))] ++#[wasm_bindgen(inline_js = r#" ++export function oliphauntFastClockImport(memory, fallback) { ++ let lastFallbackWallMillis; ++ const monotonicAnchors = new Map(); ++ let buffer = memory.buffer; ++ let view = new DataView(buffer); ++ function refreshView(timePointer) { ++ const pointer = timePointer >>> 0; ++ if (buffer !== memory.buffer) { ++ buffer = memory.buffer; ++ view = new DataView(buffer); ++ } ++ return pointer + 8 <= buffer.byteLength ? pointer : undefined; ++ } ++ function callFallback(clockId, precision, timePointer) { ++ const errno = fallback(clockId, precision, timePointer); ++ if (errno === 0) { ++ const wallMillis = Date.now(); ++ if (Number.isFinite(wallMillis) && wallMillis >= 0) { ++ lastFallbackWallMillis = wallMillis; ++ } ++ } ++ return errno; ++ } ++ function fallbackAndCalibrate(clockId, precision, timePointer, monotonicMillis) { ++ const errno = callFallback(clockId, precision, timePointer); ++ if (errno !== 0 || monotonicMillis === undefined) { ++ return errno; ++ } ++ const pointer = refreshView(timePointer); ++ if (pointer === undefined) { ++ return errno; ++ } ++ const sampledMillis = globalThis.performance.now(); ++ if (!Number.isFinite(sampledMillis) || sampledMillis < monotonicMillis) { ++ monotonicAnchors.delete(clockId); ++ return errno; ++ } ++ monotonicAnchors.set(clockId, { ++ millis: sampledMillis, ++ nanoseconds: view.getBigUint64(pointer, true), ++ }); ++ return errno; ++ } ++ return function clock_time_get(clockId, precision, timePointer) { ++ let millis; ++ let nanoseconds; ++ if (clockId === 0) { ++ millis = Date.now(); ++ if (!Number.isFinite(millis) || millis < 0) { ++ return callFallback(clockId, precision, timePointer); ++ } ++ nanoseconds = BigInt(Math.trunc(millis)) * 1000000n; ++ } else if (clockId === 1) { ++ if (globalThis.performance === undefined) { ++ return callFallback(clockId, precision, timePointer); ++ } ++ millis = globalThis.performance.now(); ++ if (!Number.isFinite(millis) || millis < 0) { ++ return callFallback(clockId, precision, timePointer); ++ } ++ const anchor = monotonicAnchors.get(clockId); ++ if (anchor === undefined || millis < anchor.millis || millis - anchor.millis >= 16) { ++ return fallbackAndCalibrate(clockId, precision, timePointer, millis); ++ } ++ nanoseconds = anchor.nanoseconds + BigInt(Math.round((millis - anchor.millis) * 1000000)); ++ } else { ++ return callFallback(clockId, precision, timePointer); ++ } ++ ++ // Service Wasmer's pending signal/timer work on a real-time bound, ++ // independent of how quickly or slowly the guest reads its clock. ++ // Keep scheduling in one clock domain. Date.now() rollback forces an ++ // immediate canonical fallback, which also re-establishes the bound. ++ const wallMillis = Date.now(); ++ if (!Number.isFinite(wallMillis) || wallMillis < 0 || ++ lastFallbackWallMillis === undefined || ++ wallMillis < lastFallbackWallMillis || ++ wallMillis - lastFallbackWallMillis >= 16) { ++ return callFallback(clockId, precision, timePointer); ++ } ++ ++ const pointer = refreshView(timePointer); ++ if (pointer === undefined) { ++ return callFallback(clockId, precision, timePointer); ++ } ++ view.setBigUint64(pointer, nanoseconds, true); ++ return 0; ++ }; ++} ++"#)] ++extern "C" { ++ #[wasm_bindgen(js_name = oliphauntFastClockImport)] ++ fn oliphaunt_fast_clock_import( ++ memory: &JsValue, ++ fallback: &JsValue, ++ ) -> js_sys::Function; ++} ++ ++#[cfg(all(feature = "js", target_arch = "wasm32"))] ++pub(crate) fn install_oliphaunt_fast_clock( ++ store: &mut impl AsStoreMut, ++ module: &wasmer::Module, ++ env: &FunctionEnv, ++ memory: &wasmer::Memory, ++ imports: &mut Imports, ++) -> Result<(), String> { ++ use wasmer::js::AsJs; ++ ++ if !env.as_ref(&store.as_store_ref()).oliphaunt_single_backend { ++ return Ok(()); ++ } ++ ++ const CLOCK_NAMESPACES: [&str; 2] = ["wasi_snapshot_preview1", "wasi_unstable"]; ++ if module.imports().any(|import| { ++ CLOCK_NAMESPACES.contains(&import.module()) && import.name() == "clock_time_set" ++ }) { ++ return Ok(()); ++ } ++ ++ let namespaces = module ++ .imports() ++ .filter(|import| { ++ CLOCK_NAMESPACES.contains(&import.module()) && import.name() == "clock_time_get" ++ }) ++ .map(|import| import.module().to_string()) ++ .collect::>(); ++ if namespaces.is_empty() { ++ return Ok(()); ++ } ++ ++ let raw_memory = memory.as_jsvalue(&store.as_store_ref()); ++ for namespace in namespaces { ++ let fallback = imports ++ .get_export(&namespace, "clock_time_get") ++ .and_then(|export| match export { ++ wasmer::Extern::Function(function) => Some(function), ++ _ => None, ++ }) ++ .ok_or_else(|| format!("missing {namespace}.clock_time_get fallback"))?; ++ let function_type = fallback.ty(&store.as_store_ref()); ++ let raw_fallback = fallback.as_jsvalue(&store.as_store_ref()); ++ let raw_fast = oliphaunt_fast_clock_import(&raw_memory, &raw_fallback); ++ let fast = wasmer::Function::from_jsvalue( ++ store, ++ &function_type, ++ raw_fast.as_ref(), ++ ) ++ .map_err(|error| format!("{error:?}"))?; ++ imports.define(&namespace, "clock_time_get", fast); ++ } ++ env.as_mut(&mut store.as_store_mut()) ++ .oliphaunt_direct_clock_active = true; ++ Ok(()) ++} ++ ++#[cfg(all(feature = "js", target_arch = "wasm32"))] ++pub fn oliphaunt_direct_memory( ++ env: &WasiFunctionEnv, ++ store: &impl wasmer::AsStoreRef, ++) -> Option { ++ use wasmer::js::AsJs; ++ ++ env.data(store) ++ .try_memory() ++ .map(|memory| memory.as_jsvalue(store)) ++} ++ + pub use crate::{ + fs::{default_fs_backing, Fd, WasiFs, WasiInodes, VIRTUAL_ROOT_FD}, + os::{ +diff --git a/src/state/env.rs b/src/state/env.rs +--- a/src/state/env.rs ++++ b/src/state/env.rs +@@ -145,6 +145,8 @@ pub struct WasiEnv { + /// Counts fast clock reads so pending signals are still serviced at a + /// bounded interval without paying that cost on every timing sample. + pub(crate) oliphaunt_fast_clock_calls: u16, ++ /// Whether the guest clock import uses the direct JavaScript wrapper. ++ pub(crate) oliphaunt_direct_clock_active: bool, + /// Whether this environment has installed a synthetic WASI clock offset. + pub(crate) oliphaunt_clock_offset_active: bool, + /// Shared state of the WASI system. Manages all the data that the +@@ -203,6 +205,7 @@ impl Clone for WasiEnv { + poll_seed: self.poll_seed, + oliphaunt_single_backend: self.oliphaunt_single_backend, + oliphaunt_fast_clock_calls: 0, ++ oliphaunt_direct_clock_active: false, + oliphaunt_clock_offset_active: self.oliphaunt_clock_offset_active, + thread: self.thread.clone(), + layout: self.layout.clone(), +@@ -250,6 +253,7 @@ impl WasiEnv { + poll_seed: 0, + oliphaunt_single_backend: self.oliphaunt_single_backend, + oliphaunt_fast_clock_calls: 0, ++ oliphaunt_direct_clock_active: false, + oliphaunt_clock_offset_active: self.oliphaunt_clock_offset_active, + bin_factory, + state, +@@ -403,6 +407,7 @@ impl WasiEnv { + poll_seed: 0, + oliphaunt_single_backend, + oliphaunt_fast_clock_calls: 0, ++ oliphaunt_direct_clock_active: false, + oliphaunt_clock_offset_active: false, + state: Arc::new(init.state), + inner: Default::default(), +@@ -568,6 +573,22 @@ impl WasiEnv { + None + }; + ++ #[cfg(all(feature = "js", target_arch = "wasm32"))] ++ if let Some(memory) = imported_memory.as_ref() { ++ crate::install_oliphaunt_fast_clock( ++ &mut store, ++ &module, ++ &func_env.env, ++ memory, ++ &mut import_object, ++ ) ++ .map_err(|error| { ++ WasiThreadError::InstanceCreateFailed(Box::new( ++ wasmer::InstantiationError::Link(wasmer::LinkError::Resource(error)), ++ )) ++ })?; ++ } ++ + // Construct the instance. + let instance_result = if async_instantiation { + #[cfg(all(feature = "js", target_arch = "wasm32"))] +diff --git a/src/state/linker.rs b/src/state/linker.rs +--- a/src/state/linker.rs ++++ b/src/state/linker.rs +@@ -1180,6 +1180,20 @@ impl Linker { + &well_known_imports, + )?; + ++ #[cfg(all(feature = "js", target_arch = "wasm32"))] ++ crate::install_oliphaunt_fast_clock( ++ store, ++ main_module, ++ &func_env.env, ++ &memory, ++ &mut imports, ++ ) ++ .map_err(|error| { ++ LinkError::InstantiationError(InstantiationError::Link( ++ wasmer::LinkError::Resource(error), ++ )) ++ })?; ++ + // TODO: figure out which way is faster (stubs in main or stubs in sides), + // use that ordering. My *guess* is that, since main exports all the libc + // functions and those are called frequently by basically any code, then giving +@@ -1428,6 +1442,20 @@ impl Linker { + &mut pending_resolutions, + )?; + ++ #[cfg(all(feature = "js", target_arch = "wasm32"))] ++ crate::install_oliphaunt_fast_clock( ++ store, ++ &main_module, ++ &func_env.env, ++ &memory, ++ &mut imports, ++ ) ++ .map_err(|error| { ++ LinkError::InstantiationError(InstantiationError::Link( ++ wasmer::LinkError::Resource(error), ++ )) ++ })?; ++ + let main_instance = Instance::new(store, &main_module, &imports)?; + + instance_group.main_instance = Some(main_instance.clone()); +diff --git a/src/syscalls/wasi/clock_time_get.rs b/src/syscalls/wasi/clock_time_get.rs +--- a/src/syscalls/wasi/clock_time_get.rs ++++ b/src/syscalls/wasi/clock_time_get.rs +@@ -32,8 +32,12 @@ pub fn clock_time_get( + if oliphaunt_fast_path { + let check_pending = { + let env = ctx.data_mut(); +- env.oliphaunt_fast_clock_calls = env.oliphaunt_fast_clock_calls.wrapping_add(1); +- env.oliphaunt_fast_clock_calls & 0x03ff == 0 ++ if env.oliphaunt_direct_clock_active { ++ true ++ } else { ++ env.oliphaunt_fast_clock_calls = env.oliphaunt_fast_clock_calls.wrapping_add(1); ++ env.oliphaunt_fast_clock_calls & 0x03ff == 0 ++ } + }; + if check_pending { + WasiEnv::do_pending_operations(&mut ctx)?; diff --git a/src/bindings/wasix-ts/host/patches/0017-wasmer-js-direct-pgwire-memory-bridge.patch b/src/bindings/wasix-ts/host/patches/0017-wasmer-js-direct-pgwire-memory-bridge.patch new file mode 100644 index 000000000..a560fd1bd --- /dev/null +++ b/src/bindings/wasix-ts/host/patches/0017-wasmer-js-direct-pgwire-memory-bridge.patch @@ -0,0 +1,387 @@ +diff --git a/src/postgres_direct.rs b/src/postgres_direct.rs +index b381faf5..87e202c5 100644 +--- a/src/postgres_direct.rs ++++ b/src/postgres_direct.rs +@@ -2,15 +2,44 @@ use std::sync::Arc; + + use anyhow::{ensure, Context}; + use js_sys::{Uint8Array, WebAssembly}; +-use wasm_bindgen::prelude::wasm_bindgen; ++use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + use wasmer::{Instance as WasmerInstance, Store, TypedFunction, Value, WasmTypeList}; +-use wasmer_wasix::{Runtime as _, WasiEnvBuilder, WasiError, WasiFunctionEnv}; ++use wasmer_wasix::{ ++ oliphaunt_direct_memory, Runtime as _, WasiEnvBuilder, WasiError, WasiFunctionEnv, ++}; + + use crate::{runtime::Runtime, tasks::CallerRealmTaskManager, utils::Error, RunOptions}; + + const DEFAULT_PROGRAM_NAME: &str = "/bin/postgres"; + const OLIPHAUNT_EXIT_ALIVE: i32 = 99; + ++#[wasm_bindgen(inline_js = r#" ++export function oliphauntCopyToGuest(memory, pointer, input) { ++ new Uint8Array(memory.buffer, pointer, input.byteLength).set(input); ++} ++ ++export function oliphauntCopyFromGuest(memory, pointer, length) { ++ // slice() deliberately returns owned protocol bytes. A view would become ++ // invalid or mutable as soon as PostgreSQL reuses or grows guest memory. ++ return new Uint8Array(memory.buffer, pointer, length).slice(); ++} ++ ++"#)] ++extern "C" { ++ #[wasm_bindgen(catch, js_name = oliphauntCopyToGuest)] ++ fn oliphaunt_copy_to_guest( ++ memory: &JsValue, ++ pointer: u32, ++ input: &Uint8Array, ++ ) -> Result<(), JsValue>; ++ #[wasm_bindgen(catch, js_name = oliphauntCopyFromGuest)] ++ fn oliphaunt_copy_from_guest( ++ memory: &JsValue, ++ pointer: u32, ++ length: u32, ++ ) -> Result; ++} ++ + /// Instantiate the integrated Oliphaunt/PostgreSQL guest in this JS realm. + /// + /// The returned driver is synchronous by design: every guest export runs on +@@ -31,14 +60,15 @@ pub struct OliphauntDirectInstance { + store: Store, + _instance: WasmerInstance, +- env: WasiFunctionEnv, ++ _env: WasiFunctionEnv, +- malloc: TypedFunction, +- free: TypedFunction, ++ guest_memory: JsValue, + input_reset: TypedFunction<(), i32>, +- input_write: TypedFunction<(i32, i32), i32>, ++ input_reserve: TypedFunction, ++ input_commit: TypedFunction, + input_available: TypedFunction<(), i32>, + output_reset: TypedFunction<(), i32>, + output_len: TypedFunction<(), i32>, +- output_read: TypedFunction<(i32, i32), i32>, ++ output_data: TypedFunction<(), i32>, ++ output_contains_error: TypedFunction<(), i32>, + set_force_host_error_recovery: Option>, + set_active: TypedFunction, + wasi_start: TypedFunction<(), ()>, +@@ -62,8 +92,8 @@ impl OliphauntDirectInstance { + /// Start PostgreSQL and process one frontend startup packet. + #[wasm_bindgen(js_name = startup)] + pub fn startup(&mut self, packet: Uint8Array) -> Result { +- match self.startup_inner(&packet.to_vec()) { +- Ok(output) => Ok(Uint8Array::from(output.as_slice())), ++ match self.startup_inner(&packet) { ++ Ok(output) => Ok(output), + Err(error) => Err(self.startup_error(error)), + } + } +@@ -71,9 +101,7 @@ impl OliphauntDirectInstance { + /// Execute raw PostgreSQL frontend-protocol bytes synchronously. + #[wasm_bindgen(js_name = execProtocolRaw)] + pub fn exec_protocol_raw(&mut self, input: Uint8Array) -> Result { +- self.exec_protocol_raw_inner(&input.to_vec()) +- .map(|output| Uint8Array::from(output.as_slice())) +- .map_err(Error::from) ++ self.exec_protocol_raw_inner(&input).map_err(Error::from) + } + + /// Shut down the embedded lifecycle. This does not consume the JS object. +@@ -83,7 +111,7 @@ impl OliphauntDirectInstance { + } + + impl OliphauntDirectInstance { +- fn startup_inner(&mut self, packet: &[u8]) -> anyhow::Result> { ++ fn startup_inner(&mut self, packet: &Uint8Array) -> anyhow::Result { + self.ensure_open()?; + ensure!( + !self.protocol_started, +@@ -91,7 +119,7 @@ impl OliphauntDirectInstance { + ); + self.start_backend()?; + self.reset_io()?; +- self.push_input(packet)?; ++ self.push_input_js(packet)?; + + let port = self + .get_port +@@ -112,22 +140,23 @@ impl OliphauntDirectInstance { + .context("oliphaunt_wasix_pq_flush after startup")?; + self.protocol_started = true; + } +- self.take_output() ++ self.take_output_js() + } + +- fn exec_protocol_raw_inner(&mut self, payload: &[u8]) -> anyhow::Result> { ++ fn exec_protocol_raw_inner(&mut self, payload: &Uint8Array) -> anyhow::Result { + self.ensure_open()?; + ensure!( + self.protocol_started, + "Oliphaunt direct startup has not completed" + ); +- if payload.is_empty() { +- return Ok(Vec::new()); ++ if payload.length() == 0 { ++ return Ok(Uint8Array::new_with_length(0)); + } + + self.reset_io()?; +- self.push_input(&payload)?; +- let max_attempts = (payload.len() / 5).saturating_add(2).max(1); ++ self.push_input_js(payload)?; ++ let payload_len = payload.length() as usize; ++ let max_attempts = (payload_len / 5).saturating_add(2).max(1); + let mut attempts = 0usize; + let mut recovered_protocol_error = false; + while self.protocol_input_remaining()? > 0 { +@@ -140,7 +169,7 @@ impl OliphauntDirectInstance { + // Match the native host: the exported recovery boundary is + // authoritative even when a JS engine reports an unfamiliar + // trap spelling. +- self.recover_protocol_error(payload.len())?; ++ self.recover_protocol_error(payload_len)?; + recovered_protocol_error = true; + } + } +@@ -150,8 +179,13 @@ impl OliphauntDirectInstance { + self.pq_flush + .call(&mut self.store) + .context("oliphaunt_wasix_pq_flush after protocol input")?; +- let output = self.take_output()?; +- if !recovered_protocol_error && protocol_response_contains_error(&output) { ++ let output_contains_error = self ++ .output_contains_error ++ .call(&mut self.store) ++ .context("oliphaunt_wasix_output_contains_error")? ++ != 0; ++ let output = self.take_output_js()?; ++ if !recovered_protocol_error && output_contains_error { + self.recover_non_trapping_protocol_error()?; + } + Ok(output) +@@ -196,6 +230,8 @@ impl OliphauntDirectInstance { + .instantiate_async(module, &mut store) + .await + .context("instantiate Oliphaunt direct WASIX module")?; ++ let guest_memory = oliphaunt_direct_memory(&env, &store) ++ .context("get WASIX guest memory for direct PGWire")?; + seed_exported_c_string( + &mut store, + &instance, +@@ -204,16 +240,19 @@ impl OliphauntDirectInstance { + DEFAULT_PROGRAM_NAME, + )?; + +- let malloc = typed_export(&mut store, &instance, "malloc")?; +- let free = typed_export(&mut store, &instance, "pg_free") +- .or_else(|_| typed_export(&mut store, &instance, "free"))?; + let input_reset = typed_export(&mut store, &instance, "oliphaunt_wasix_input_reset")?; +- let input_write = typed_export(&mut store, &instance, "oliphaunt_wasix_input_write")?; ++ let input_reserve = typed_export(&mut store, &instance, "oliphaunt_wasix_input_reserve")?; ++ let input_commit = typed_export(&mut store, &instance, "oliphaunt_wasix_input_commit")?; + let input_available = + typed_export(&mut store, &instance, "oliphaunt_wasix_input_available")?; + let output_reset = typed_export(&mut store, &instance, "oliphaunt_wasix_output_reset")?; + let output_len = typed_export(&mut store, &instance, "oliphaunt_wasix_output_len")?; +- let output_read = typed_export(&mut store, &instance, "oliphaunt_wasix_output_read")?; ++ let output_data = typed_export(&mut store, &instance, "oliphaunt_wasix_output_data")?; ++ let output_contains_error = typed_export( ++ &mut store, ++ &instance, ++ "oliphaunt_wasix_output_contains_error", ++ )?; + let set_force_host_error_recovery = optional_typed_export( + &mut store, + &instance, +@@ -243,14 +282,15 @@ impl OliphauntDirectInstance { + store, + _instance: instance, +- env, ++ _env: env, +- malloc, +- free, ++ guest_memory, + input_reset, +- input_write, ++ input_reserve, ++ input_commit, + input_available, + output_reset, + output_len, +- output_read, ++ output_data, ++ output_contains_error, + set_force_host_error_recovery, + set_active, + wasi_start, +@@ -322,68 +362,42 @@ impl OliphauntDirectInstance { + Ok(()) + } + +- fn push_input(&mut self, bytes: &[u8]) -> anyhow::Result<()> { +- if bytes.is_empty() { ++ fn push_input_js(&mut self, bytes: &Uint8Array) -> anyhow::Result<()> { ++ let len = i32::try_from(bytes.length()).context("direct protocol input exceeds i32")?; ++ if len == 0 { + return Ok(()); + } +- let ptr = self.allocate(bytes.len() as i32)?; +- let result = (|| { +- let view = self +- .env +- .data(&self.store) +- .try_memory_view(&self.store) +- .context("get WASIX memory view")?; +- view.write(ptr as u64, bytes) +- .context("write direct protocol input into guest")?; +- let written = self +- .input_write +- .call(&mut self.store, ptr, bytes.len() as i32) +- .context("oliphaunt_wasix_input_write")?; +- ensure!( +- written == bytes.len() as i32, +- "short direct protocol input write" +- ); +- Ok(()) +- })(); +- self.free +- .call(&mut self.store, ptr) +- .context("free direct protocol input")?; +- result ++ let ptr = self ++ .input_reserve ++ .call(&mut self.store, len) ++ .context("oliphaunt_wasix_input_reserve")?; ++ ensure!(ptr > 0, "oliphaunt_wasix_input_reserve returned null"); ++ oliphaunt_copy_to_guest(&self.guest_memory, ptr as u32, bytes) ++ .map_err(|error| anyhow::anyhow!("copy direct protocol input into guest: {error:?}"))?; ++ let committed = self ++ .input_commit ++ .call(&mut self.store, len) ++ .context("oliphaunt_wasix_input_commit")?; ++ ensure!(committed == len, "short direct protocol input commit"); ++ Ok(()) + } + +- fn take_output(&mut self) -> anyhow::Result> { ++ fn take_output_js(&mut self) -> anyhow::Result { + let len = self + .output_len + .call(&mut self.store) + .context("oliphaunt_wasix_output_len")?; + ensure!(len >= 0, "negative direct protocol output length"); + if len == 0 { +- return Ok(Vec::new()); ++ return Ok(Uint8Array::new_with_length(0)); + } +- let ptr = self.allocate(len)?; +- let result = (|| { +- let read = self +- .output_read +- .call(&mut self.store, ptr, len) +- .context("oliphaunt_wasix_output_read")?; +- ensure!( +- read >= 0 && read <= len, +- "invalid direct protocol output read" +- ); +- let mut bytes = vec![0; read as usize]; +- let view = self +- .env +- .data(&self.store) +- .try_memory_view(&self.store) +- .context("get WASIX memory view")?; +- view.read(ptr as u64, &mut bytes) +- .context("read direct protocol output from guest")?; +- Ok(bytes) +- })(); +- self.free +- .call(&mut self.store, ptr) +- .context("free direct protocol output")?; +- let bytes = result?; ++ let ptr = self ++ .output_data ++ .call(&mut self.store) ++ .context("oliphaunt_wasix_output_data")?; ++ ensure!(ptr > 0, "oliphaunt_wasix_output_data returned null"); ++ let output = oliphaunt_copy_from_guest(&self.guest_memory, ptr as u32, len as u32) ++ .map_err(|error| anyhow::anyhow!("copy direct protocol output from guest: {error:?}")); + ensure!( + self.output_reset + .call(&mut self.store) +@@ -391,16 +405,7 @@ impl OliphauntDirectInstance { + == 0, + "oliphaunt_wasix_output_reset after read failed" + ); +- Ok(bytes) +- } +- +- fn allocate(&mut self, len: i32) -> anyhow::Result { +- let ptr = self +- .malloc +- .call(&mut self.store, len) +- .context("malloc direct guest buffer")?; +- ensure!(ptr > 0, "malloc returned null for direct guest buffer"); +- Ok(ptr) ++ output + } + + fn protocol_input_remaining(&mut self) -> anyhow::Result { +@@ -448,14 +453,16 @@ impl OliphauntDirectInstance { + self.pq_flush + .call(&mut self.store) + .context("oliphaunt_wasix_pq_flush after backend ErrorResponse")?; +- let _ = self.take_output()?; ++ let _ = self.take_output_js()?; + Ok(()) + } + + fn startup_error(&mut self, error: anyhow::Error) -> Error { + let _ = self.pq_flush.call(&mut self.store); +- let protocol = self.take_output().unwrap_or_default(); +- if protocol.is_empty() { ++ let protocol = self ++ .take_output_js() ++ .unwrap_or_else(|_| Uint8Array::new_with_length(0)); ++ if protocol.length() == 0 { + return Error::from(error); + } + +@@ -463,7 +470,7 @@ impl OliphauntDirectInstance { + let _ = js_sys::Reflect::set( + &js_error, + &wasm_bindgen::JsValue::from_str("protocolResponse"), +- &Uint8Array::from(protocol.as_slice()), ++ &protocol, + ); + let _ = js_sys::Reflect::set( + &js_error, +@@ -545,23 +552,3 @@ fn runtime_exit_code(error: &wasmer::RuntimeError) -> Option { + _ => None, + }) + } +- +-fn protocol_response_contains_error(response: &[u8]) -> bool { +- let mut cursor = 0usize; +- while cursor + 5 <= response.len() { +- let tag = response[cursor]; +- let len = i32::from_be_bytes(response[cursor + 1..cursor + 5].try_into().unwrap()); +- if len < 4 { +- return false; +- } +- let total = 1usize.saturating_add(len as usize); +- if cursor + total > response.len() { +- return false; +- } +- if tag == b'E' { +- return true; +- } +- cursor += total; +- } +- false +-} diff --git a/src/bindings/wasix-ts/host/patches/0018-wasmer-js-bound-direct-stderr.patch b/src/bindings/wasix-ts/host/patches/0018-wasmer-js-bound-direct-stderr.patch new file mode 100644 index 000000000..d34f7af51 --- /dev/null +++ b/src/bindings/wasix-ts/host/patches/0018-wasmer-js-bound-direct-stderr.patch @@ -0,0 +1,244 @@ +--- a/src/options.rs ++++ b/src/options.rs +@@ -189,12 +189,13 @@ + pub(crate) fn configure_direct_builder( + &self, + builder: &mut WasiEnvBuilder, ++ stderr: Box, + ) -> Result<(), Error> { + self.configure_common_builder(builder)?; + + builder.set_stdin(Box::::default()); + builder.set_stdout(Box::::default()); +- builder.set_stderr(Box::::default()); ++ builder.set_stderr(stderr); + + Ok(()) + } +--- a/src/postgres_direct.rs ++++ b/src/postgres_direct.rs +@@ -1,7 +1,15 @@ +-use std::sync::Arc; ++use std::{ ++ collections::VecDeque, ++ io::{self, SeekFrom}, ++ pin::Pin, ++ sync::{Arc, Mutex}, ++ task::{Context as TaskContext, Poll}, ++}; + + use anyhow::{ensure, Context}; + use js_sys::{Uint8Array, WebAssembly}; ++use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; ++use virtual_fs::VirtualFile; + use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + use wasmer::{Instance as WasmerInstance, Store, TypedFunction, Value, WasmTypeList}; + use wasmer_wasix::{ +@@ -12,6 +20,133 @@ + + const DEFAULT_PROGRAM_NAME: &str = "/bin/postgres"; + const OLIPHAUNT_EXIT_ALIVE: i32 = 99; ++const STDERR_LIMIT_BYTES: usize = 16 * 1024; ++ ++#[derive(Clone, Debug, Default)] ++struct BoundedStderr { ++ bytes: Arc>>, ++} ++ ++impl BoundedStderr { ++ fn append(&self, input: &[u8]) { ++ let mut bytes = self.bytes.lock().expect("bounded stderr lock poisoned"); ++ if input.len() >= STDERR_LIMIT_BYTES { ++ bytes.clear(); ++ bytes.extend(&input[input.len() - STDERR_LIMIT_BYTES..]); ++ return; ++ } ++ let overflow = bytes ++ .len() ++ .saturating_add(input.len()) ++ .saturating_sub(STDERR_LIMIT_BYTES); ++ bytes.drain(..overflow); ++ bytes.extend(input); ++ } ++ ++ fn attach(&self, error: anyhow::Error) -> anyhow::Error { ++ let mut bytes = self.bytes.lock().expect("bounded stderr lock poisoned"); ++ if bytes.is_empty() { ++ return error; ++ } ++ let stderr = String::from_utf8_lossy(bytes.make_contiguous()); ++ error.context(format!("WASIX stderr (last 16 KiB):\n{stderr}")) ++ } ++} ++ ++impl AsyncWrite for BoundedStderr { ++ fn poll_write( ++ self: Pin<&mut Self>, ++ _cx: &mut TaskContext<'_>, ++ input: &[u8], ++ ) -> Poll> { ++ self.append(input); ++ Poll::Ready(Ok(input.len())) ++ } ++ ++ fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { ++ Poll::Ready(Ok(())) ++ } ++ ++ fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { ++ Poll::Ready(Ok(())) ++ } ++} ++ ++impl AsyncRead for BoundedStderr { ++ fn poll_read( ++ self: Pin<&mut Self>, ++ _cx: &mut TaskContext<'_>, ++ _buffer: &mut ReadBuf<'_>, ++ ) -> Poll> { ++ Poll::Ready(Ok(())) ++ } ++} ++ ++impl AsyncSeek for BoundedStderr { ++ fn start_seek(self: Pin<&mut Self>, _position: SeekFrom) -> io::Result<()> { ++ Ok(()) ++ } ++ ++ fn poll_complete(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { ++ Poll::Ready(Ok(0)) ++ } ++} ++ ++impl VirtualFile for BoundedStderr { ++ fn last_accessed(&self) -> u64 { ++ 0 ++ } ++ ++ fn last_modified(&self) -> u64 { ++ 0 ++ } ++ ++ fn created_time(&self) -> u64 { ++ 0 ++ } ++ ++ fn size(&self) -> u64 { ++ self.bytes ++ .lock() ++ .expect("bounded stderr lock poisoned") ++ .len() as u64 ++ } ++ ++ fn set_len(&mut self, new_size: u64) -> virtual_fs::Result<()> { ++ let target = usize::try_from(new_size) ++ .unwrap_or(usize::MAX) ++ .min(STDERR_LIMIT_BYTES); ++ let mut bytes = self.bytes.lock().expect("bounded stderr lock poisoned"); ++ while bytes.len() > target { ++ bytes.pop_front(); ++ } ++ bytes.resize(target, 0); ++ Ok(()) ++ } ++ ++ fn unlink(&mut self) -> virtual_fs::Result<()> { ++ self.bytes ++ .lock() ++ .expect("bounded stderr lock poisoned") ++ .clear(); ++ Ok(()) ++ } ++ ++ fn get_special_fd(&self) -> Option { ++ Some(2) ++ } ++ ++ fn poll_read_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { ++ Poll::Ready(Ok(0)) ++ } ++ ++ fn poll_write_ready( ++ self: Pin<&mut Self>, ++ _cx: &mut TaskContext<'_>, ++ ) -> Poll> { ++ Poll::Ready(Ok(8192)) ++ } ++} + + #[wasm_bindgen(inline_js = r#" + export function oliphauntCopyToGuest(memory, pointer, input) { +@@ -50,7 +185,10 @@ + module_bytes: Uint8Array, + options: RunOptions, + ) -> Result { +- OliphauntDirectInstance::instantiate(module, module_bytes, options).await ++ let stderr = BoundedStderr::default(); ++ OliphauntDirectInstance::instantiate(module, module_bytes, options, stderr.clone()) ++ .await ++ .map_err(|error| Error::from(stderr.attach(error.into_anyhow()))) + } + + #[wasm_bindgen] +@@ -60,6 +198,7 @@ + store: Store, + _instance: WasmerInstance, + _env: WasiFunctionEnv, ++ stderr: BoundedStderr, + guest_memory: JsValue, + input_reset: TypedFunction<(), i32>, + input_reserve: TypedFunction, +@@ -101,12 +240,18 @@ + /// Execute raw PostgreSQL frontend-protocol bytes synchronously. + #[wasm_bindgen(js_name = execProtocolRaw)] + pub fn exec_protocol_raw(&mut self, input: Uint8Array) -> Result { +- self.exec_protocol_raw_inner(&input).map_err(Error::from) ++ match self.exec_protocol_raw_inner(&input) { ++ Ok(output) => Ok(output), ++ Err(error) => Err(Error::from(self.stderr.attach(error))), ++ } + } + + /// Shut down the embedded lifecycle. This does not consume the JS object. + pub fn close(&mut self) -> Result<(), Error> { +- self.close_inner().map_err(Error::from) ++ match self.close_inner() { ++ Ok(()) => Ok(()), ++ Err(error) => Err(Error::from(self.stderr.attach(error))), ++ } + } + } + +@@ -212,6 +357,7 @@ + module: WebAssembly::Module, + module_bytes: Uint8Array, + options: RunOptions, ++ stderr: BoundedStderr, + ) -> Result { + // Direct execution intentionally has no configurable networking or + // worker-backed runtime. RunOptions is reused for args/env/fs mounts. +@@ -222,7 +368,7 @@ + .as_string() + .unwrap_or_else(|| DEFAULT_PROGRAM_NAME.to_owned()); + let mut builder = WasiEnvBuilder::new(program_name).runtime(runtime.clone()); +- options.configure_direct_builder(&mut builder)?; ++ options.configure_direct_builder(&mut builder, Box::new(stderr.clone()))?; + + let module = wasmer::Module::from((module, module_bytes.to_vec())); + let mut store = Store::new(runtime.engine()); +@@ -282,6 +428,7 @@ + store, + _instance: instance, + _env: env, ++ stderr, + guest_memory, + input_reset, + input_reserve, +@@ -458,6 +605,7 @@ + } + + fn startup_error(&mut self, error: anyhow::Error) -> Error { ++ let error = self.stderr.attach(error); + let _ = self.pq_flush.call(&mut self.store); + let protocol = self + .take_output_js() diff --git a/src/bindings/wasix-ts/host/patches/0019-wasmer-parse-exception-reference-types.patch b/src/bindings/wasix-ts/host/patches/0019-wasmer-parse-exception-reference-types.patch new file mode 100644 index 000000000..6a6137364 --- /dev/null +++ b/src/bindings/wasix-ts/host/patches/0019-wasmer-parse-exception-reference-types.patch @@ -0,0 +1,12 @@ +diff --git a/src/utils/polyfill.rs b/src/utils/polyfill.rs +--- a/src/utils/polyfill.rs ++++ b/src/utils/polyfill.rs +@@ -371,6 +371,8 @@ pub fn wpreftype_to_type(ty: wasmparser::RefType) -> WasmResult { + Ok(Type::ExternRef) + } else if ty.is_func_ref() { + Ok(Type::FuncRef) ++ } else if ty == wasmparser::RefType::EXNREF || ty == wasmparser::RefType::NULLEXNREF { ++ Ok(Type::ExceptionRef) + } else { + Err(format!("Unsupported ref type: {:?}", ty)) + } diff --git a/src/bindings/wasix-ts/host/source.toml b/src/bindings/wasix-ts/host/source.toml index a0dcc5c98..c98549780 100644 --- a/src/bindings/wasix-ts/host/source.toml +++ b/src/bindings/wasix-ts/host/source.toml @@ -26,7 +26,6 @@ series = [ "0001-wasmer-js-run-configured-wasix-process.patch", "0002-wasmer-wasix-add-0702-compatibility-imports.patch", "0003-wasmer-js-install-browser-runtime-devices.patch", - "0004-wasmer-wasix-recover-stdio-pgwire-errors.patch", "0005-wasmer-js-use-object-wasm-init.patch", "0006-wasmer-js-reuse-precompiled-wasix-module.patch", "0007-wasmer-js-run-oliphaunt-direct.patch", @@ -37,4 +36,8 @@ series = [ "0012-wasmer-js-remove-retired-wasm32-wasi-target.patch", "0013-wasmer-wasix-fast-single-backend-clock.patch", "0014-wasmer-js-track-directory-mutations.patch", + "0016-wasmer-wasix-direct-single-backend-clock.patch", + "0017-wasmer-js-direct-pgwire-memory-bridge.patch", + "0018-wasmer-js-bound-direct-stderr.patch", + "0019-wasmer-parse-exception-reference-types.patch", ] diff --git a/src/bindings/wasix-ts/moon.yml b/src/bindings/wasix-ts/moon.yml index a334d5e66..bdbfe728b 100644 --- a/src/bindings/wasix-ts/moon.yml +++ b/src/bindings/wasix-ts/moon.yml @@ -328,3 +328,21 @@ tasks: cache: false runFromWorkspaceRoot: true runInCI: false + smoke-postgis-worker: + tags: ["quality", "smoke", "extension", "browser", "worker"] + command: "pnpm --dir src/bindings/wasix-ts smoke:browser:postgis-worker" + deps: + - "oliphaunt-wasix-ts:check" + - "oliphaunt-wasix-ts:test" + - "liboliphaunt-wasix:runtime-portable" + inputs: + - "/.release-please-manifest.json" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/bindings/wasix-ts/**/*" + - "/src/extensions/external/postgis/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/bindings/wasix-ts/package.json b/src/bindings/wasix-ts/package.json index 8adfe2877..b14f4006f 100644 --- a/src/bindings/wasix-ts/package.json +++ b/src/bindings/wasix-ts/package.json @@ -76,7 +76,7 @@ "THIRD_PARTY_NOTICES.md" ], "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "node tools/clean-lib.mjs && tsc -p tsconfig.build.json", "dev": "pnpm run package:build && vite --config examples/browser/vite.config.ts", "host:build": "bash host/build-sdk.sh", "package:build": "pnpm run host:build && pnpm run build && node tools/stage-host.mjs", @@ -84,6 +84,7 @@ "bench:browser": "pnpm run package:build && node tools/smoke-browser.mjs --benchmark", "smoke:browser": "pnpm run package:build && node tools/smoke-browser.mjs", "smoke:browser:pg-uuidv7": "pnpm run package:build && node tools/smoke-browser.mjs --pg-uuidv7", + "smoke:browser:postgis-worker": "pnpm run package:build && node tools/smoke-browser.mjs --postgis-worker", "smoke:bun": "pnpm run package:build && node tools/smoke-node.mjs --runtime bun", "smoke:deno": "pnpm run package:build && node tools/smoke-node.mjs --runtime deno", "smoke:node": "pnpm run package:build && node tools/smoke-node.mjs --runtime node", diff --git a/src/bindings/wasix-ts/src/__tests__/archive.test.ts b/src/bindings/wasix-ts/src/__tests__/archive.test.ts index ee9ccd3d9..6caf64a0d 100644 --- a/src/bindings/wasix-ts/src/__tests__/archive.test.ts +++ b/src/bindings/wasix-ts/src/__tests__/archive.test.ts @@ -11,13 +11,13 @@ describe('WASIX TypeScript archives', () => { it('extracts regular files from a tar archive', () => { const archive = tar([ - ['oliphaunt/bin/oliphaunt', Uint8Array.of(0, 97, 115, 109)], + ['oliphaunt/bin/postgres', Uint8Array.of(0, 97, 115, 109)], ['oliphaunt/share/postgresql/postgres.bki', new TextEncoder().encode('bki')], ]); expect(extractTar(archive)).toEqual({ files: new Map([ - ['oliphaunt/bin/oliphaunt', Uint8Array.of(0, 97, 115, 109)], + ['oliphaunt/bin/postgres', Uint8Array.of(0, 97, 115, 109)], ['oliphaunt/share/postgresql/postgres.bki', new TextEncoder().encode('bki')], ]), directories: new Set(), @@ -72,7 +72,7 @@ describe('WASIX TypeScript archives', () => { it('separates canonical runtime and PGDATA files into Wasmer mounts', () => { const runtime = { files: new Map([ - ['oliphaunt/bin/oliphaunt', Uint8Array.of(0, 97, 115, 109)], + ['oliphaunt/bin/postgres', Uint8Array.of(0, 97, 115, 109)], ['oliphaunt/lib/postgresql/plpgsql.so', Uint8Array.of(1)], ['oliphaunt/share/postgresql/postgres.bki', Uint8Array.of(2)], ]), @@ -89,7 +89,8 @@ describe('WASIX TypeScript archives', () => { const layout = layoutRuntime(runtime, pgdata); expect(layout.module).toEqual(Uint8Array.of(0, 97, 115, 109)); - expect(layout.mounts['/bin']?.files.oliphaunt).toEqual(layout.module); + expect(layout.mounts['/bin']?.files.postgres).toEqual(layout.module); + expect(layout.mounts['/bin']?.files.oliphaunt).toBeUndefined(); expect(layout.mounts['/lib']?.files['postgresql/plpgsql.so']).toEqual(Uint8Array.of(1)); expect(layout.mounts['/lib']?.directories).toContain('postgresql'); expect(layout.mounts['/base']?.files['global/pg_control']).toEqual(Uint8Array.of(3)); diff --git a/src/bindings/wasix-ts/src/__tests__/direct-client-common.test.ts b/src/bindings/wasix-ts/src/__tests__/direct-client-common.test.ts index 1d5541228..a9cbf2bfe 100644 --- a/src/bindings/wasix-ts/src/__tests__/direct-client-common.test.ts +++ b/src/bindings/wasix-ts/src/__tests__/direct-client-common.test.ts @@ -250,7 +250,7 @@ describe('direct WASIX session lifecycle', () => { expect(attempts).toBe(2); }); - it('rejects an oversized load-ordered side module without promising an invalid worker fallback', async () => { + it('keeps Chromium oversized-module policy on the main realm while allowing workers', async () => { let prepared = false; const options = openOptions(); options.extensionCarriers.postgis = { @@ -306,19 +306,21 @@ describe('direct WASIX session lifecycle', () => { }, }; - await expect( - DirectWasixSession.open(options, fakeHost({}), guardedDependencies), - ).rejects.toThrow(/worker execution does not yet implement.*native load order/); - expect(prepared).toBe(false); - - const postgisCarrier = options.extensionCarriers.postgis; - if (postgisCarrier === undefined) throw new Error('postgis test carrier is missing'); - postgisCarrier.install.loadOrder = []; await expect( DirectWasixSession.open(options, fakeHost({}), guardedDependencies), ).rejects.toThrow(/use execution: "worker" for postgis/); expect(prepared).toBe(false); + const workerSession = await DirectWasixSession.open( + options, + fakeHost({}), + guardedDependencies, + 'browser-worker', + ); + expect(prepared).toBe(true); + await workerSession.close(); + + prepared = false; const nodeSession = await DirectWasixSession.open( options, fakeHost({}), diff --git a/src/bindings/wasix-ts/src/__tests__/extension-descriptor.test.ts b/src/bindings/wasix-ts/src/__tests__/extension-descriptor.test.ts index aa309a2a8..d46672b42 100644 --- a/src/bindings/wasix-ts/src/__tests__/extension-descriptor.test.ts +++ b/src/bindings/wasix-ts/src/__tests__/extension-descriptor.test.ts @@ -146,6 +146,17 @@ describe('WASIX extension descriptors', () => { carriers: [carrier('pgtap', { install: duplicateStartupConfig })], }), ).toThrow('startupConfig must not repeat values'); + + const missingLoadOrderFile = { + ...install('pgtap'), + loadOrder: ['lib/postgresql/pgtap.so'], + }; + expect(() => + defineWasixExtension({ + ...descriptorInput('pgtap'), + carriers: [carrier('pgtap', { install: missingLoadOrderFile })], + }), + ).toThrow('load-order path is absent from installedFiles'); }); it('freezes package-authored descriptors and their carrier rows', () => { diff --git a/src/bindings/wasix-ts/src/__tests__/extensions.test.ts b/src/bindings/wasix-ts/src/__tests__/extensions.test.ts index 811bb8363..282c71744 100644 --- a/src/bindings/wasix-ts/src/__tests__/extensions.test.ts +++ b/src/bindings/wasix-ts/src/__tests__/extensions.test.ts @@ -157,11 +157,14 @@ describe('WASIX TypeScript extensions', () => { ).toEqual([]); }); - it('fails closed with host-neutral diagnostics for Node and browser callers', () => { - const ordered = extension('ordered', { loadOrder: ['lib/postgresql/ordered.so'] }); - expect(() => resolveWasixExtensions(manifest(), carrierMap(ordered), ['ordered'])).toThrow( - 'requires native load-order handling that the @oliphaunt/wasix-ts host does not implement', - ); + it('drives declared native load order and fails closed on shared-memory requirements', () => { + const ordered = extension('ordered', { + installedFiles: ['share/postgresql/extension/ordered.control', 'lib/postgresql/ordered.so'], + loadOrder: ['lib/postgresql/ordered.so'], + }); + expect( + extensionSetupSql(resolveWasixExtensions(manifest(), carrierMap(ordered), ['ordered'])), + ).toEqual(["LOAD '/lib/postgresql/ordered.so';", 'CREATE EXTENSION IF NOT EXISTS "ordered";']); const shared = extension('shared', { sharedMemoryRequired: true }); expect(() => resolveWasixExtensions(manifest(), carrierMap(shared), ['shared'])).toThrow( diff --git a/src/bindings/wasix-ts/src/__tests__/pgwire.test.ts b/src/bindings/wasix-ts/src/__tests__/pgwire.test.ts index 0f16f156b..92d19a98f 100644 --- a/src/bindings/wasix-ts/src/__tests__/pgwire.test.ts +++ b/src/bindings/wasix-ts/src/__tests__/pgwire.test.ts @@ -1,14 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { - assertSuccessfulStartupResponse, - PgwireStream, - startupPacket, - terminatePacket, -} from '../pgwire.js'; +import { assertSuccessfulStartupResponse, startupPacket } from '../pgwire.js'; import { PostgresError } from '../query.js'; -describe('browser pgwire stream', () => { +describe('direct PostgreSQL startup protocol', () => { it('encodes a normal PostgreSQL startup packet', () => { const packet = startupPacket('postgres', 'postgres'); const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength); @@ -19,138 +14,17 @@ describe('browser pgwire stream', () => { expect(new TextDecoder().decode(packet)).toContain('DateStyle\0ISO, MDY\0TimeZone\0UTC\0'); }); - it('collects arbitrarily chunked backend messages through ReadyForQuery', async () => { - const response = Uint8Array.of( - 0x43, - 0, - 0, - 0, - 9, - ...new TextEncoder().encode('OK 1'), - 0, - 0x5a, - 0, - 0, - 0, - 5, - 0x49, - ); - const writes: Uint8Array[] = []; - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(response.slice(0, 3)); - controller.enqueue(response.slice(3, 11)); - controller.enqueue(response.slice(11)); - }, - }); - const writable = new WritableStream({ - write(chunk) { - writes.push(chunk.slice()); - }, - }); - const wire = new PgwireStream(readable, writable); - const input = Uint8Array.of(1, 2, 3); - - await expect(wire.exchange(input)).resolves.toEqual(response); - expect(writes).toEqual([input]); - }); - - it('reuses a contiguous stdout chunk for the completed response', async () => { - const response = backendResponse('CZ'); - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(response); - }, - }); - const wire = new PgwireStream(readable, new WritableStream()); - - const result = await wire.exchange(Uint8Array.of(1)); - - expect(result).toEqual(response); - expect(result.buffer).toBe(response.buffer); - }); - - it('retains a coalesced following response without slicing the unread tail', async () => { - const first = backendResponse('CZ'); - const second = backendResponse('CZ'); - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(concatenate(first, second)); - }, - }); - const wire = new PgwireStream(readable, new WritableStream()); - - await expect(wire.exchange(Uint8Array.of(1))).resolves.toEqual(first); - await expect(wire.exchange(Uint8Array.of(2))).resolves.toEqual(second); - }); - - it('collects a multi-message response across geometric buffer growth', async () => { - const messages = [ - backendMessage('N', new Uint8Array(3 * 1024).fill(1)), - backendMessage('N', new Uint8Array(5 * 1024).fill(2)), - backendMessage('C', new Uint8Array(9 * 1024).fill(3)), - backendResponse('Z'), - ]; - const response = messages.reduce(concatenate, new Uint8Array()); - const readable = new ReadableStream({ - start(controller) { - for (const message of messages) { - controller.enqueue(message); - } - }, - }); - const wire = new PgwireStream(readable, new WritableStream()); - - await expect(wire.exchange(Uint8Array.of(1))).resolves.toEqual(response); - }); - - it('uses the PostgreSQL Terminate message', () => { - expect(terminatePacket()).toEqual(Uint8Array.of(0x58, 0, 0, 0, 4)); - }); - - it('drains the standalone main-loop startup transition before the first query', async () => { - const startup = backendResponse('RZ'); - const settled = backendResponse('SSZ'); - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(concatenate(startup, settled)); - }, - }); - const writable = new WritableStream(); - const wire = new PgwireStream(readable, writable); - - const first = await wire.exchange(Uint8Array.of(1)); - expect(first).toEqual(startup); - expect(() => assertSuccessfulStartupResponse(first)).not.toThrow(); - await expect(wire.settleStartup()).resolves.toBeUndefined(); + it('accepts AuthenticationOk followed by ReadyForQuery', () => { + expect(() => assertSuccessfulStartupResponse(backendResponse('RZ'))).not.toThrow(); }); - it('stops at a startup ErrorResponse without requiring ReadyForQuery', async () => { - const response = backendError('3D000', 'database does not exist'); - const readable = new ReadableStream({ - start(controller) { - controller.enqueue(response.slice(0, 7)); - controller.enqueue(response.slice(7)); - controller.close(); - }, - }); - const writes: Uint8Array[] = []; - const writable = new WritableStream({ - write(chunk) { - writes.push(chunk.slice()); - }, - }); - const wire = new PgwireStream(readable, writable); - - const startup = await wire.startup(Uint8Array.of(1, 2, 3)); - expect(startup).toEqual(response); - expect(writes).toEqual([Uint8Array.of(1, 2, 3)]); - + it('preserves a startup ErrorResponse as PostgresError', () => { + expect(() => + assertSuccessfulStartupResponse(backendError('3D000', 'database does not exist')), + ).toThrowError(PostgresError); try { - assertSuccessfulStartupResponse(startup); - throw new Error('expected startup response to fail'); + assertSuccessfulStartupResponse(backendError('3D000', 'database does not exist')); } catch (error) { - expect(error).toBeInstanceOf(PostgresError); expect(error).toMatchObject({ severity: 'FATAL', sqlstate: '3D000', @@ -161,14 +35,15 @@ describe('browser pgwire stream', () => { }); function backendResponse(tags: string): Uint8Array { - const messages = [...tags].map((tag) => - tag === 'Z' - ? Uint8Array.of(tag.charCodeAt(0), 0, 0, 0, 5, 'I'.charCodeAt(0)) - : tag === 'R' - ? Uint8Array.of(tag.charCodeAt(0), 0, 0, 0, 8, 0, 0, 0, 0) - : Uint8Array.of(tag.charCodeAt(0), 0, 0, 0, 4), - ); - return messages.reduce(concatenate, new Uint8Array()); + return [...tags] + .map((tag) => + tag === 'Z' + ? Uint8Array.of(tag.charCodeAt(0), 0, 0, 0, 5, 'I'.charCodeAt(0)) + : tag === 'R' + ? Uint8Array.of(tag.charCodeAt(0), 0, 0, 0, 8, 0, 0, 0, 0) + : Uint8Array.of(tag.charCodeAt(0), 0, 0, 0, 4), + ) + .reduce(concatenate, new Uint8Array()); } function backendError(sqlstate: string, message: string): Uint8Array { @@ -184,12 +59,8 @@ function backendError(sqlstate: string, message: string): Uint8Array { 0, 0, ]); - return backendMessage('E', body); -} - -function backendMessage(tag: string, body: Uint8Array): Uint8Array { const result = new Uint8Array(body.length + 5); - result[0] = tag.charCodeAt(0); + result[0] = 'E'.charCodeAt(0); new DataView(result.buffer).setUint32(1, body.length + 4); result.set(body, 5); return result; diff --git a/src/bindings/wasix-ts/src/__tests__/wasix-process.test.ts b/src/bindings/wasix-ts/src/__tests__/wasix-process.test.ts deleted file mode 100644 index acc7b26e5..000000000 --- a/src/bindings/wasix-ts/src/__tests__/wasix-process.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { WasixStorageError } from '../errors.js'; -import type { PreparedWasixRuntime } from '../extensions.js'; -import { PostgresError } from '../query.js'; -import type { SerializedOpenOptions } from '../rpc.js'; -import type { WasixStorageLease } from '../storage-provider.js'; -import { - compileWasixModule, - composeLifecycleFailure, - type WasixHost, - WasixProcess, - type WasixProcessDependencies, -} from '../wasix-process.js'; - -describe('WASIX process lifecycle failures', () => { - it('coalesces verified guest compilation by module identity', async () => { - const bytes = Uint8Array.of(0, 97, 115, 109, 1, 0, 0, 0); - const identity = `test-${crypto.randomUUID()}`; - - const first = compileWasixModule(bytes, identity); - const second = compileWasixModule(bytes.slice(), identity); - - expect(second).toBe(first); - await expect(first).resolves.toBeInstanceOf(WebAssembly.Module); - }); - - it('overlaps host bootstrap with runtime preparation', async () => { - const sequence: string[] = []; - const dependencies: WasixProcessDependencies = { - async prepareRuntime() { - sequence.push('prepare-start'); - await Promise.resolve(); - sequence.push('prepare-finish'); - return preparedRuntime(); - }, - async acquireStorage() { - return fakeLease(async () => undefined); - }, - }; - const baseHost = fakeHost({ stdout: startupSuccess() }); - const host: WasixHost = { - ...baseHost, - async init() { - sequence.push('host-init'); - }, - }; - - const process = await WasixProcess.open(openOptions(), host, dependencies); - expect(sequence).toEqual(['prepare-start', 'host-init', 'prepare-finish']); - await process.close(); - }); - - it('disables maintenance workers in the single-process backend', async () => { - let args: string[] | undefined; - let env: Record | undefined; - const baseHost = fakeHost({ stdout: startupSuccess() }); - const host: WasixHost = { - ...baseHost, - async runWasix(module, options) { - args = options.args; - env = options.env; - return baseHost.runWasix(module, options); - }, - }; - - const process = await WasixProcess.open( - openOptions(), - host, - fakeDependencies(async () => fakeLease(async () => undefined)), - ); - - expect(args).toContain('max_parallel_maintenance_workers=0'); - expect(env).toMatchObject({ - OLIPHAUNT_WASIX_SINGLE_BACKEND: '1', - OLIPHAUNT_WASIX_STDIO_PGWIRE: '1', - }); - await process.close(); - }); - - it.each([ - 'max_worker_processes', - 'MAX_PARALLEL_MAINTENANCE_WORKERS', - 'io_method', - ])('rejects an override of the managed single-backend setting %s', async (name) => { - const options = openOptions(); - options.startupGUCs = { [name]: '1' }; - - await expect( - WasixProcess.open( - options, - fakeHost({ stdout: startupSuccess() }), - fakeDependencies(async () => fakeLease(async () => undefined)), - ), - ).rejects.toThrow(name); - }); - - it('canonicalizes an explicitly matching single-backend setting', async () => { - let args: string[] | undefined; - const baseHost = fakeHost({ stdout: startupSuccess() }); - const host: WasixHost = { - ...baseHost, - async runWasix(module, options) { - args = options.args; - return baseHost.runWasix(module, options); - }, - }; - const options = openOptions(); - options.startupGUCs = { MAX_WORKER_PROCESSES: '0' }; - - const process = await WasixProcess.open( - options, - host, - fakeDependencies(async () => fakeLease(async () => undefined)), - ); - - expect(args?.filter((arg) => arg === 'max_worker_processes=0')).toHaveLength(1); - expect(args).not.toContain('MAX_WORKER_PROCESSES=0'); - await process.close(); - }); - - it('keeps startup SQLSTATE, reports release failure, and permits reacquisition', async () => { - let held = false; - let failFirstRelease = true; - let acquisitions = 0; - const lifecycle = { frees: 0, terminates: 0, waits: 0 }; - const dependencies = fakeDependencies(async () => { - if (held) { - throw new WasixStorageError('storage is still held', { - code: 'busy', - durability: 'unchanged', - }); - } - held = true; - acquisitions += 1; - return fakeLease(async () => { - held = false; - if (failFirstRelease) { - failFirstRelease = false; - throw new Error('simulated release diagnostic'); - } - }); - }); - - const first = await rejection( - WasixProcess.open( - openOptions(), - fakeHost({ stdout: startupError('3D000'), lifecycle }), - dependencies, - ), - ); - expect(first).toBeInstanceOf(PostgresError); - expect(first).toMatchObject({ sqlstate: '3D000', postgresMessage: 'database does not exist' }); - expect(first.message).toContain('storage release also failed: simulated release diagnostic'); - expect(first.cause).toBeInstanceOf(AggregateError); - expect(held).toBe(false); - - const second = await rejection( - WasixProcess.open( - openOptions(), - fakeHost({ stdout: startupError('3D000'), lifecycle }), - dependencies, - ), - ); - expect(second).toBeInstanceOf(PostgresError); - expect(second).toMatchObject({ sqlstate: '3D000' }); - expect(second.message).not.toContain('storage is still held'); - expect(acquisitions).toBe(2); - expect(held).toBe(false); - expect(lifecycle).toEqual({ frees: 0, terminates: 2, waits: 2 }); - }); - - it('terminates and awaits a healthy guest when extension setup fails', async () => { - const lifecycle = { frees: 0, terminates: 0, waits: 0 }; - const events: string[] = []; - const outcomes: string[] = []; - const dependencies = fakeDependencies( - async () => - fakeLease(async (_directory, outcome) => { - events.push('storage'); - outcomes.push(outcome); - }, 'new'), - preparedRuntime(['SELECT 1 / 0']), - ); - - const failure = await rejection( - WasixProcess.open( - openOptions(), - fakeHost({ - stdout: concatenate([startupSuccess(), queryError('22012', 'division by zero')]), - lifecycle, - events, - }), - dependencies, - ), - ); - - expect(failure).toBeInstanceOf(PostgresError); - expect(failure).toMatchObject({ sqlstate: '22012', postgresMessage: 'division by zero' }); - expect(lifecycle).toEqual({ frees: 0, terminates: 1, waits: 1 }); - expect(outcomes).toEqual(['failed']); - expect(events).toEqual(['wait', 'storage']); - }); - - it('preserves storage code and durability while composing cleanup diagnostics', () => { - const primary = new WasixStorageError('snapshot metadata is corrupt', { - code: 'corrupt', - durability: 'unchanged', - }); - - const failure = composeLifecycleFailure( - primary, - 'storage release also failed', - new Error('lock'), - ); - - expect(failure).toBeInstanceOf(WasixStorageError); - expect(failure).toMatchObject({ code: 'corrupt', durability: 'unchanged' }); - expect(failure.message).toContain('snapshot metadata is corrupt'); - expect(failure.message).toContain('storage release also failed: lock'); - expect(failure.cause).toBeInstanceOf(AggregateError); - }); - - it.each([ - { - label: 'terminate', - host: { - stdout: concatenate([startupSuccess(), querySuccess()]), - terminateFailure: new Error('terminate transport failed'), - }, - primary: 'WASIX PostgreSQL terminate failed: terminate transport failed', - }, - { - label: 'wait', - host: { - stdout: concatenate([startupSuccess(), querySuccess()]), - waitFailure: new Error('wait exploded'), - }, - primary: 'WASIX PostgreSQL wait failed: wait exploded', - }, - { - label: 'exit', - host: { - stdout: concatenate([startupSuccess(), querySuccess()]), - output: { ok: false, code: 7 }, - }, - primary: 'WASIX PostgreSQL exited with code 7', - }, - ])('keeps the $label failure primary when storage cleanup also fails', async ({ - host, - primary, - }) => { - const outcomes: string[] = []; - const dependencies = fakeDependencies(async () => - fakeLease(async (_directory, outcome) => { - outcomes.push(outcome); - throw new Error('cleanup release failed'); - }), - ); - const process = await WasixProcess.open(openOptions(), fakeHost(host), dependencies); - - const failure = await rejection(process.close()); - - expect(failure.message).toContain(primary); - expect(failure.message).toContain('storage release also failed: cleanup release failed'); - expect(failure.cause).toBeInstanceOf(AggregateError); - expect(outcomes).toEqual(['failed']); - }); -}); - -async function rejection(promise: Promise): Promise { - try { - await promise; - } catch (error) { - if (error instanceof Error) return error; - throw new Error(`expected Error rejection, received ${String(error)}`); - } - throw new Error('expected promise to reject'); -} - -function fakeDependencies( - acquireStorage: WasixProcessDependencies['acquireStorage'], - prepared: PreparedWasixRuntime = preparedRuntime(), -): WasixProcessDependencies { - return { - async prepareRuntime(options) { - return { ...prepared, startupGUCs: { ...options.startupGUCs } }; - }, - acquireStorage, - }; -} - -function fakeLease( - close: WasixStorageLease['close'], - state: WasixStorageLease['state'] = 'existing', -): WasixStorageLease { - return { - state, - mount: pgdataMount(), - async sync() {}, - close, - }; -} - -function preparedRuntime(setupSql: string[] = []): PreparedWasixRuntime { - return { - layout: { - module: Uint8Array.of(0), - mounts: { '/base': pgdataMount() }, - }, - startupGUCs: {}, - setupSql, - storageCompatibility: { - schema: 'oliphaunt-wasix-pgdata-compatibility-v1', - runtime: { - product: 'liboliphaunt-wasix', - version: '0.1.1', - manifestSha256: '1'.repeat(64), - runtimeArchiveSha256: '2'.repeat(64), - pgdataTemplateSha256: '3'.repeat(64), - moduleSha256: '4'.repeat(64), - sourceFingerprint: 'source', - postgresVersion: '18.4', - }, - extensions: [], - }, - }; -} - -function pgdataMount() { - return { - files: { - PG_VERSION: new TextEncoder().encode('18\n'), - 'global/pg_control': Uint8Array.of(1), - }, - directories: ['global'], - }; -} - -function openOptions(): SerializedOpenOptions { - return { - runtime: { - schema: 'oliphaunt-wasix-runtime-v1', - runtime: 'wasix', - product: 'liboliphaunt-wasix', - version: '0.1.1', - runtimeArchive: { - archive: 'runtime.tar.zst', - sha256: '1'.repeat(64), - size: 1, - source: Uint8Array.of(1), - }, - pgdataArchive: { - archive: 'pgdata.tar.zst', - sha256: '2'.repeat(64), - size: 1, - source: Uint8Array.of(2), - }, - manifest: { sha256: '3'.repeat(64), size: 1, source: Uint8Array.of(3) }, - }, - extensionCarriers: {}, - extensions: [], - username: 'postgres', - database: 'postgres', - startupGUCs: {}, - storage: { schema: 'oliphaunt-wasix-storage-v2', kind: 'memory' }, - }; -} - -type FakeHostOptions = { - stdout: Uint8Array; - terminateFailure?: Error; - waitFailure?: Error; - output?: { ok: boolean; code: number }; - lifecycle?: { frees: number; terminates: number; waits: number }; - events?: string[]; -}; - -function fakeHost(options: FakeHostOptions): WasixHost { - return { - Directory: FakeDirectory as unknown as WasixHost['Directory'], - async init() {}, - async runWasix() { - return { - stdin: new WritableStream({ - write(value) { - const terminate = value[0] === 'X'.charCodeAt(0); - if (terminate && options.lifecycle !== undefined) { - options.lifecycle.terminates += 1; - } - if (terminate && options.terminateFailure !== undefined) { - throw options.terminateFailure; - } - }, - }), - stdout: byteStream(options.stdout), - stderr: byteStream(new Uint8Array()), - free() { - if (options.lifecycle !== undefined) options.lifecycle.frees += 1; - }, - async wait() { - if (options.lifecycle !== undefined) options.lifecycle.waits += 1; - if (options.events !== undefined) { - options.events.push('wait'); - } - if (options.waitFailure !== undefined) throw options.waitFailure; - return { - stdoutBytes: new Uint8Array(), - stdout: '', - stderrBytes: new Uint8Array(), - stderr: '', - ...(options.output ?? { ok: true, code: 0 }), - }; - }, - }; - }, - } as WasixHost; -} - -class FakeDirectory { - readonly #files: Record; - - constructor(files: Record = {}) { - this.#files = files; - } - - async createDir(): Promise {} - - async readDir(): Promise<[]> { - return []; - } - - async readFile(path: string): Promise { - const value = this.#files[path]; - if (value === undefined) throw new Error(`missing fake file ${path}`); - return value; - } -} - -function byteStream(bytes: Uint8Array): ReadableStream { - return new ReadableStream({ - start(controller) { - if (bytes.length > 0) controller.enqueue(bytes); - controller.close(); - }, - }); -} - -function startupSuccess(): Uint8Array { - return concatenate([ - backendMessage('R', Uint8Array.of(0, 0, 0, 0)), - backendMessage('Z', Uint8Array.of('I'.charCodeAt(0))), - backendMessage('Z', Uint8Array.of('I'.charCodeAt(0))), - ]); -} - -function startupError(sqlstate: string): Uint8Array { - return errorResponse('FATAL', sqlstate, 'database does not exist'); -} - -function queryError(sqlstate: string, message: string): Uint8Array { - return concatenate([ - errorResponse('ERROR', sqlstate, message), - backendMessage('Z', Uint8Array.of('I'.charCodeAt(0))), - ]); -} - -function querySuccess(): Uint8Array { - return backendMessage('Z', Uint8Array.of('I'.charCodeAt(0))); -} - -function errorResponse(severity: string, sqlstate: string, message: string): Uint8Array { - const encoder = new TextEncoder(); - const fields: number[] = []; - for (const [code, value] of [ - ['S', severity], - ['C', sqlstate], - ['M', message], - ] as const) { - fields.push(code.charCodeAt(0), ...encoder.encode(value), 0); - } - fields.push(0); - return backendMessage('E', Uint8Array.from(fields)); -} - -function backendMessage(tag: string, body: Uint8Array): Uint8Array { - const length = body.length + 4; - return Uint8Array.of( - tag.charCodeAt(0), - (length >>> 24) & 0xff, - (length >>> 16) & 0xff, - (length >>> 8) & 0xff, - length & 0xff, - ...body, - ); -} - -function concatenate(chunks: readonly Uint8Array[]): Uint8Array { - const result = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} diff --git a/src/bindings/wasix-ts/src/archive.ts b/src/bindings/wasix-ts/src/archive.ts index 1f0686e2b..b48e41a35 100644 --- a/src/bindings/wasix-ts/src/archive.ts +++ b/src/bindings/wasix-ts/src/archive.ts @@ -92,9 +92,9 @@ export function layoutRuntime( ): WasixRuntimeLayout { const runtimeFiles = runtime.files; const pgdataFiles = pgdata.files; - const module = runtimeFiles.get('oliphaunt/bin/oliphaunt'); + const module = runtimeFiles.get('oliphaunt/bin/postgres'); if (module === undefined || module.length === 0) { - throw new Error('runtime archive is missing oliphaunt/bin/oliphaunt'); + throw new Error('runtime archive is missing oliphaunt/bin/postgres'); } if (!pgdataFiles.has('PG_VERSION') || !pgdataFiles.has('global/pg_control')) { throw new Error('PGDATA template is missing PG_VERSION or global/pg_control'); diff --git a/src/bindings/wasix-ts/src/direct-client-common.ts b/src/bindings/wasix-ts/src/direct-client-common.ts index 9d2238433..a8a14d891 100644 --- a/src/bindings/wasix-ts/src/direct-client-common.ts +++ b/src/bindings/wasix-ts/src/direct-client-common.ts @@ -30,7 +30,7 @@ import { materializeWasixMounts, wasixPostgresArgs, wasixPostgresEnvironment, -} from './wasix-process.js'; +} from './wasix-runtime.js'; /** @internal Narrow caller-realm host contract. */ export type DirectWasixHost = Readonly<{ @@ -54,7 +54,7 @@ export type DirectWasixDependencies = Readonly<{ compileModule(module: Uint8Array, sha256: string): Promise; }>; -export type DirectWasixEnvironment = 'browser' | 'node'; +export type DirectWasixEnvironment = 'browser-main' | 'browser-worker' | 'node'; const preparedRuntimes = new Map>(); const MAX_PREPARED_RUNTIMES = 1; @@ -73,10 +73,18 @@ export async function openWasixDirect( options: SerializedOpenOptions, host: DirectWasixHost, ): Promise { - const session = await DirectWasixSession.open(options, host, defaultDependencies); + const session = await DirectWasixSession.open(options, host, defaultDependencies, 'browser-main'); return new WasixDatabaseImpl(session); } +/** @internal Open a direct-memory session inside the dedicated browser worker realm. */ +export function openBrowserWorkerSession( + options: SerializedOpenOptions, + host: DirectWasixHost, +): Promise { + return DirectWasixSession.open(options, host, defaultDependencies, 'browser-worker'); +} + /** Owns the caller-realm guest, its mounted PGDATA, and their joint lifecycle. */ /** @internal */ export class DirectWasixSession implements WasixDatabaseSession { @@ -101,9 +109,9 @@ export class DirectWasixSession implements WasixDatabaseSession { options: SerializedOpenOptions, host: DirectWasixHost, dependencies: DirectWasixDependencies = defaultDependencies, - environment: DirectWasixEnvironment = 'browser', + environment: DirectWasixEnvironment = 'browser-main', ): Promise { - if (environment === 'browser') assertDirectExtensionCompatibility(options); + if (environment === 'browser-main') assertDirectExtensionCompatibility(options); const prepared = await dependencies.prepareRuntime(options); const pgdataTemplate = prepared.layout.mounts['/base']; if (pgdataTemplate === undefined) { @@ -136,8 +144,7 @@ export class DirectWasixSession implements WasixDatabaseSession { const runtimeOptions = { ...options, startupGUCs: prepared.startupGUCs }; instance = await instantiateDirectWithDeadline( host.instantiateOliphauntDirect(module, prepared.layout.module, { - program: '/bin/oliphaunt', - moduleBytes: prepared.layout.module, + program: '/bin/postgres', args: wasixPostgresArgs(runtimeOptions), cwd: '/', env: wasixPostgresEnvironment(runtimeOptions), @@ -320,7 +327,6 @@ function assertDirectExtensionCompatibility(options: SerializedOpenOptions): voi .map((module) => ({ extension: carrier.sqlName, module, - requiresLoadOrder: carrier.install.loadOrder.length > 0, })), ) .sort((left, right) => left.extension.localeCompare(right.extension)); @@ -333,11 +339,6 @@ function assertDirectExtensionCompatibility(options: SerializedOpenOptions): voi `${extension}:${module.path} (${module.size.toLocaleString('en-US')} bytes)`, ) .join(', '); - if (unsupported.some(({ requiresLoadOrder }) => requiresLoadOrder)) { - throw new TypeError( - `@oliphaunt/wasix-ts browser execution cannot currently load ${detail}: direct execution exceeds Chromium's 8 MiB synchronous side-module limit, and worker execution does not yet implement the carrier's native load order`, - ); - } throw new TypeError( `@oliphaunt/wasix-ts direct execution cannot load native extension modules larger than 8 MiB in Chromium; use execution: "worker" for ${detail}`, ); diff --git a/src/bindings/wasix-ts/src/extension-descriptor.ts b/src/bindings/wasix-ts/src/extension-descriptor.ts index ce60cd366..251eda2ec 100644 --- a/src/bindings/wasix-ts/src/extension-descriptor.ts +++ b/src/bindings/wasix-ts/src/extension-descriptor.ts @@ -277,7 +277,7 @@ function validateInstall( throw new Error(`${label} dependencies must not include its own SQL name '${sqlName}'`); } requireUniqueStringArray(install.coreExportsRequired, `${label} core exports required`); - requireUniquePathArray(install.loadOrder, `${label} load order`); + const loadOrder = requireUniquePathArray(install.loadOrder, `${label} load order`); validateLifecycle(install.lifecycle, `${label} lifecycle`); const installedFiles = requireUniquePathArray(install.installedFiles, `${label} installed files`); for (const module of install.nativeModules) { @@ -285,6 +285,11 @@ function validateInstall( throw new Error(`${label} native module path is absent from installedFiles: ${module.path}`); } } + for (const path of loadOrder) { + if (!installedFiles.includes(path)) { + throw new Error(`${label} load-order path is absent from installedFiles: ${path}`); + } + } if (!Array.isArray(install.unresolvedImports)) { throw new Error(`${label} unresolved imports must be an array`); } diff --git a/src/bindings/wasix-ts/src/extensions.ts b/src/bindings/wasix-ts/src/extensions.ts index 41b1ea9ca..b344838e5 100644 --- a/src/bindings/wasix-ts/src/extensions.ts +++ b/src/bindings/wasix-ts/src/extensions.ts @@ -279,11 +279,6 @@ export function resolveWasixExtensions( if (visited.has(sqlName)) { return; } - if (extension['load-order'].length > 0) { - throw new Error( - `selected WASIX extension '${sqlName}' requires native load-order handling that the @oliphaunt/wasix-ts host does not implement`, - ); - } if (extension.lifecycle['shared-memory-required']) { throw new Error( `selected WASIX extension '${sqlName}' requires shared-memory behavior that the @oliphaunt/wasix-ts host has not qualified`, @@ -483,8 +478,15 @@ export function extensionSetupSql(resolved: ResolvedWasixExtensions): string[] { const statements = resolved.runtimeDependencies.map( (dependency) => `CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(dependency)};`, ); + const loadedModules = new Set(); for (const extension of resolved.extensions) { const lifecycle = extension.lifecycle; + for (const path of extension['load-order']) { + if (!loadedModules.has(path)) { + statements.push(`LOAD ${quoteLiteral(`/${path}`)};`); + loadedModules.add(path); + } + } if (lifecycle['create-extension']) { const schema = lifecycle['create-schema'] ?? undefined; if (schema !== undefined && schema !== 'pg_catalog') { @@ -639,6 +641,13 @@ function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } +function quoteLiteral(value: string): string { + if (value.includes('\0')) { + throw new Error('PostgreSQL string literal contains a NUL byte'); + } + return `'${value.replaceAll("'", "''")}'`; +} + function appendCsv(value: string | undefined, ordered: string[], seen: Set): void { for (const item of value?.split(',') ?? []) { const trimmed = item.trim(); diff --git a/src/bindings/wasix-ts/src/node-direct.ts b/src/bindings/wasix-ts/src/node-direct.ts index 089c2c143..8e7a154d6 100644 --- a/src/bindings/wasix-ts/src/node-direct.ts +++ b/src/bindings/wasix-ts/src/node-direct.ts @@ -16,7 +16,7 @@ let environmentInstalled = false; const directHost: DirectWasixHost = { Directory: host.Directory, - init: host.initDirect, + init: host.init, instantiateOliphauntDirect: host.instantiateOliphauntDirect, }; diff --git a/src/bindings/wasix-ts/src/node-host.ts b/src/bindings/wasix-ts/src/node-host.ts index f40ebc3b2..ecd099f71 100644 --- a/src/bindings/wasix-ts/src/node-host.ts +++ b/src/bindings/wasix-ts/src/node-host.ts @@ -1,19 +1,11 @@ import { readFile } from 'node:fs/promises'; import * as host from './host/index.mjs'; -import { installNodeWebWorker } from './node-web-worker.js'; export const Directory = host.Directory; -export const runWasix = host.runWasix; export const instantiateOliphauntDirect = host.instantiateOliphauntDirect; export async function init(options: Record = {}): Promise { - installNodeWebWorker(); - return initDirect(options); -} - -/** Initialize the host without installing Wasmer's inner-worker adapter. */ -export async function initDirect(options: Record = {}): Promise { const module = await readFile(new URL('./host/wasmer_js_bg.wasm', import.meta.url)); return host.init({ ...options, diff --git a/src/bindings/wasix-ts/src/node-web-worker-thread.ts b/src/bindings/wasix-ts/src/node-web-worker-thread.ts deleted file mode 100644 index db7912b73..000000000 --- a/src/bindings/wasix-ts/src/node-web-worker-thread.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { parentPort, workerData } from 'node:worker_threads'; - -import { installNodeWebWorker } from './node-web-worker.js'; - -const port = parentPort; -const source = (workerData as { source?: unknown } | undefined)?.source; -if (port === null || typeof source !== 'string' || source.length === 0) { - throw new Error('Oliphaunt WASIX inner worker has invalid worker_threads bootstrap data'); -} - -type MessageListener = ((event: { data: unknown }) => void) | null; -let onmessage: MessageListener = null; -const queued: unknown[] = []; - -Object.defineProperty(globalThis, 'onmessage', { - configurable: true, - get: () => onmessage, - set: (listener: MessageListener) => { - onmessage = typeof listener === 'function' ? listener : null; - if (onmessage !== null) { - for (const data of queued.splice(0)) onmessage({ data }); - } - }, -}); -Object.defineProperty(globalThis, 'postMessage', { - configurable: true, - value: (value: unknown) => port.postMessage(value), -}); -port.on('message', (data) => { - if (onmessage === null) queued.push(data); - else onmessage({ data }); -}); - -installNodeWebWorker(); -await import(source); diff --git a/src/bindings/wasix-ts/src/node-web-worker.ts b/src/bindings/wasix-ts/src/node-web-worker.ts deleted file mode 100644 index 5b34e9ea8..000000000 --- a/src/bindings/wasix-ts/src/node-web-worker.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Worker as ThreadWorker, type TransferListItem } from 'node:worker_threads'; - -import { nodeWorkerExecArgv } from './node-worker-options.js'; - -type WebWorkerListener = (event: { data: unknown }) => void; -type WebWorkerErrorListener = (error: Error) => void; - -/** Minimal Web Worker contract backed by worker_threads for Wasmer's inner workers. */ -export class NodeWebWorker { - onmessage: WebWorkerListener | null = null; - onmessageerror: WebWorkerErrorListener | null = null; - onerror: WebWorkerErrorListener | null = null; - - readonly #worker: ThreadWorker; - readonly #messageListeners = new Set(); - readonly #errorListeners = new Set(); - readonly #messageErrorListeners = new Set(); - - constructor(source: string | URL, options: { name?: string } = {}) { - this.#worker = new ThreadWorker(new URL('./node-web-worker-thread.js', import.meta.url), { - execArgv: nodeWorkerExecArgv(), - name: options.name, - workerData: { source: String(source) }, - }); - this.#worker.on('message', (data) => { - const event = { data }; - this.onmessage?.(event); - for (const listener of this.#messageListeners) listener(event); - }); - this.#worker.on('messageerror', (error) => { - this.onmessageerror?.(error); - for (const listener of this.#messageErrorListeners) listener(error); - }); - this.#worker.on('error', (error) => { - this.onerror?.(error); - for (const listener of this.#errorListeners) listener(error); - }); - } - - postMessage(value: unknown, transfer: readonly TransferListItem[] = []): void { - this.#worker.postMessage(value, transfer); - } - - terminate(): void { - void this.#worker.terminate(); - } - - addEventListener(type: string, listener: WebWorkerListener | WebWorkerErrorListener): void { - if (type === 'message') this.#messageListeners.add(listener as WebWorkerListener); - else if (type === 'error') this.#errorListeners.add(listener as WebWorkerErrorListener); - else if (type === 'messageerror') { - this.#messageErrorListeners.add(listener as WebWorkerErrorListener); - } - } - - removeEventListener(type: string, listener: WebWorkerListener | WebWorkerErrorListener): void { - if (type === 'message') this.#messageListeners.delete(listener as WebWorkerListener); - else if (type === 'error') this.#errorListeners.delete(listener as WebWorkerErrorListener); - else if (type === 'messageerror') { - this.#messageErrorListeners.delete(listener as WebWorkerErrorListener); - } - } -} - -export function installNodeWebWorker(): void { - Object.defineProperty(globalThis, 'Worker', { - configurable: true, - value: NodeWebWorker, - writable: true, - }); - // Wasmer's blocking helper falls back to a portable data URL when object - // URLs are unavailable. Node cannot import its own blob:nodedata URLs. - Object.defineProperty(URL, 'createObjectURL', { - configurable: true, - value: undefined, - writable: true, - }); -} diff --git a/src/bindings/wasix-ts/src/pgwire.ts b/src/bindings/wasix-ts/src/pgwire.ts index 1fcde9ff7..0ae3a807c 100644 --- a/src/bindings/wasix-ts/src/pgwire.ts +++ b/src/bindings/wasix-ts/src/pgwire.ts @@ -1,8 +1,6 @@ import { assertSuccessfulQueryResponse } from './query.js'; const encoder = new TextEncoder(); -const INITIAL_RECEIVE_BUFFER_BYTES = 4 * 1024; -const MAX_BACKEND_MESSAGE_BYTES = 64 * 1024 * 1024; export function startupPacket(username: string, database: string): Uint8Array { assertStartupValue('username', username); @@ -28,173 +26,6 @@ export function startupPacket(username: string, database: string): Uint8Array { return Uint8Array.from(packet); } -export function terminatePacket(): Uint8Array { - return Uint8Array.of('X'.charCodeAt(0), 0, 0, 0, 4); -} - -export class PgwireStream { - readonly #reader: ReadableStreamDefaultReader; - readonly #writer: WritableStreamDefaultWriter; - #buffer: Uint8Array = new Uint8Array(); - #readOffset = 0; - #writeOffset = 0; - #closed = false; - - constructor(readable: ReadableStream, writable: WritableStream) { - this.#reader = readable.getReader(); - this.#writer = writable.getWriter(); - } - - async exchange(input: Uint8Array): Promise { - if (this.#closed) { - throw new Error('Oliphaunt WASIX pgwire stream is closed'); - } - await this.#writer.write(input); - return this.#readUntilReady(false); - } - - /** - * PostgreSQL startup failures terminate this single-user guest after writing - * ErrorResponse; they are not required to emit ReadyForQuery first. Stop at - * the complete ErrorResponse so the caller can preserve its SQLSTATE rather - * than converting a normal startup rejection into an stdout-EOF failure. - */ - async startup(input: Uint8Array): Promise { - if (this.#closed) { - throw new Error('Oliphaunt WASIX pgwire stream is closed'); - } - await this.#writer.write(input); - return this.#readUntilReady(true); - } - - async settleStartup(): Promise { - const response = await this.#readUntilReady(false); - let offset = 0; - while (offset < response.length) { - const tag = response[offset]; - const messageLength = readI32(response, offset + 1) + 1; - const isFinalReady = tag === 'Z'.charCodeAt(0) && offset + messageLength === response.length; - if (tag !== 'S'.charCodeAt(0) && !isFinalReady) { - throw new Error( - `unexpected PostgreSQL message while settling WASIX startup: ${String.fromCharCode(tag ?? 0)}`, - ); - } - offset += messageLength; - } - } - - async close(): Promise { - if (this.#closed) { - return; - } - this.#closed = true; - - let failure: unknown; - try { - await this.#writer.write(terminatePacket()); - } catch (error) { - failure = error; - } - try { - await this.#writer.close(); - } catch (error) { - failure = - failure === undefined - ? error - : new AggregateError( - [failure, error], - `${describeError(failure)}; pgwire writer close also failed: ${describeError(error)}`, - ); - } finally { - this.#writer.releaseLock(); - } - if (failure !== undefined) { - throw failure; - } - } - - async #readUntilReady(stopAtError: boolean): Promise { - // A prior exchange may have read ahead into the next response. Compact it - // once here, before this response begins, so growth can retain all bytes - // from offset zero and the completed response needs only one final copy. - this.#compactUnread(); - const responseStart = this.#readOffset; - - while (true) { - await this.#fill(5); - const tag = this.#buffer[this.#readOffset]; - const bodyLength = readI32(this.#buffer, this.#readOffset + 1); - if (bodyLength < 4) { - throw new Error(`invalid PostgreSQL backend message length ${bodyLength}`); - } - const messageLength = bodyLength + 1; - if (messageLength > MAX_BACKEND_MESSAGE_BYTES) { - throw new Error(`PostgreSQL backend message exceeds 64 MiB: ${messageLength} bytes`); - } - await this.#fill(messageLength); - this.#readOffset += messageLength; - - if (tag === 'Z'.charCodeAt(0) || (stopAtError && tag === 'E'.charCodeAt(0))) { - const responseEnd = this.#readOffset; - if ( - responseStart === 0 && - responseEnd === this.#writeOffset && - responseEnd === this.#buffer.length - ) { - const response = this.#buffer; - this.#buffer = new Uint8Array(); - this.#readOffset = 0; - this.#writeOffset = 0; - return response; - } - return this.#buffer.slice(responseStart, responseEnd); - } - } - } - - async #fill(length: number): Promise { - while (this.#writeOffset - this.#readOffset < length) { - const next = await this.#reader.read(); - if (next.done) { - throw new Error('Oliphaunt WASIX process closed stdout before ReadyForQuery'); - } - if (this.#buffer.length === 0 && this.#readOffset === 0 && this.#writeOffset === 0) { - this.#buffer = next.value; - this.#writeOffset = next.value.length; - continue; - } - this.#ensureCapacity(next.value.length); - this.#buffer.set(next.value, this.#writeOffset); - this.#writeOffset += next.value.length; - } - } - - #compactUnread(): void { - if (this.#readOffset === 0) { - return; - } - if (this.#readOffset < this.#writeOffset) { - this.#buffer.copyWithin(0, this.#readOffset, this.#writeOffset); - } - this.#writeOffset -= this.#readOffset; - this.#readOffset = 0; - } - - #ensureCapacity(additionalLength: number): void { - const requiredLength = this.#writeOffset + additionalLength; - if (requiredLength <= this.#buffer.length) { - return; - } - const capacity = Math.max( - requiredLength, - Math.max(INITIAL_RECEIVE_BUFFER_BYTES, this.#buffer.length * 2), - ); - const expanded = new Uint8Array(capacity); - expanded.set(this.#buffer.subarray(0, this.#writeOffset)); - this.#buffer = expanded; - } -} - /** Validate the first PostgreSQL startup exchange before settling the host loop. */ export function assertSuccessfulStartupResponse(response: Uint8Array): void { let offset = 0; @@ -279,10 +110,6 @@ function readI32(bytes: Uint8Array, offset: number): number { ); } -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function concatenate(chunks: ReadonlyArray, totalLength: number): Uint8Array { const result = new Uint8Array(totalLength); let offset = 0; diff --git a/src/bindings/wasix-ts/src/wasix-process.ts b/src/bindings/wasix-ts/src/wasix-process.ts deleted file mode 100644 index f892a425f..000000000 --- a/src/bindings/wasix-ts/src/wasix-process.ts +++ /dev/null @@ -1,543 +0,0 @@ -import type { WasixDirectoryMount, WasixRuntimeLayout } from './archive.js'; -import type { WasixPersistenceMode } from './database.js'; -import { WasixStorageError } from './errors.js'; -import { type PreparedWasixRuntime, prepareWasixRuntime } from './extensions.js'; -import type { Directory, Instance, WasmerInitOptions } from './host/index.mjs'; -import { assertSuccessfulStartupResponse, PgwireStream, startupPacket } from './pgwire.js'; -import { simpleQuery } from './protocol.js'; -import { assertSuccessfulQueryResponse, PostgresError } from './query.js'; -import type { SerializedOpenOptions } from './rpc.js'; -import { - acquireWasixStorage, - type WasixStorageLease, - type WasixStorageSyncBoundary, -} from './storage-provider.js'; - -export type WasixHost = Readonly<{ - Directory: typeof Directory; - init(options?: WasmerInitOptions): Promise; - runWasix( - module: Uint8Array | WebAssembly.Module, - options: { - program: string; - moduleBytes?: Uint8Array; - args: string[]; - cwd: string; - env: Record; - mount: Record; - }, - ): Promise; -}>; - -/** @internal Dependency seam for deterministic lifecycle-failure qualification. */ -export type WasixProcessDependencies = Readonly<{ - prepareRuntime(options: SerializedOpenOptions): Promise; - acquireStorage( - storage: SerializedOpenOptions['storage'], - template: WasixDirectoryMount, - compatibility: PreparedWasixRuntime['storageCompatibility'], - ): Promise; - /** Test seam; production caches the verified guest compilation per JS realm. */ - compileModule?(module: Uint8Array, sha256: string): Promise; -}>; - -const defaultDependencies: WasixProcessDependencies = { - prepareRuntime: prepareWasixRuntime, - acquireStorage: acquireWasixStorage, - compileModule: compileWasixModule, -}; - -let compiledModuleCache: { sha256: string; module: Promise } | undefined; - -/** @internal Verified-module compilation cache shared by direct opens in one JS realm. */ -export function compileWasixModule( - module: Uint8Array, - sha256: string, -): Promise { - if (compiledModuleCache?.sha256 !== sha256) { - const source = - module.buffer instanceof ArrayBuffer - ? (module as Uint8Array) - : Uint8Array.from(module); - const compiled = WebAssembly.compile(source); - compiledModuleCache = { sha256, module: compiled }; - void compiled.catch(() => { - if (compiledModuleCache?.module === compiled) { - compiledModuleCache = undefined; - } - }); - } - return compiledModuleCache.module; -} - -/** Host-neutral PostgreSQL/WASIX lifecycle shared by browser, Node, Bun, and Deno workers. */ -export class WasixProcess { - readonly #instance: Instance; - readonly #wire: PgwireStream; - readonly #stderr: Promise; - readonly #storage: WasixStorageLease; - readonly #baseDirectory: Directory; - #closed = false; - #failed = false; - - private constructor( - instance: Instance, - wire: PgwireStream, - stderr: Promise, - storage: WasixStorageLease, - baseDirectory: Directory, - ) { - this.#instance = instance; - this.#wire = wire; - this.#stderr = stderr; - this.#storage = storage; - this.#baseDirectory = baseDirectory; - } - - static async open( - options: SerializedOpenOptions, - host: WasixHost, - dependencies: WasixProcessDependencies = defaultDependencies, - ): Promise { - // Host bootstrap and carrier preparation are independent cold-open work. - const [prepared] = await Promise.all([dependencies.prepareRuntime(options), host.init({})]); - const module = - dependencies.compileModule === undefined - ? prepared.layout.module - : await dependencies.compileModule( - prepared.layout.module, - prepared.storageCompatibility.runtime.moduleSha256, - ); - const runtimeOptions = { ...options, startupGUCs: prepared.startupGUCs }; - const pgdataTemplate = prepared.layout.mounts['/base']; - if (pgdataTemplate === undefined) { - throw new Error('prepared WASIX runtime has no PGDATA mount'); - } - const storage = await dependencies.acquireStorage( - options.storage, - pgdataTemplate, - prepared.storageCompatibility, - ); - - let instance: Instance | undefined; - let opened: WasixProcess | undefined; - let baseDirectory: Directory | undefined; - try { - const materialized = await materializeWasixMounts( - host.Directory, - prepared.layout, - storage.mount, - ); - baseDirectory = materialized.baseDirectory; - instance = await host.runWasix(module, { - program: '/bin/oliphaunt', - moduleBytes: prepared.layout.module, - args: wasixPostgresArgs(runtimeOptions), - cwd: '/', - env: wasixPostgresEnvironment(runtimeOptions), - mount: materialized.mounts, - }); - if (instance.stdin === undefined) { - throw new Error('Wasmer did not expose a writable stdin stream for the WASIX process'); - } - - const stderr = captureText(instance.stderr).catch( - (error) => `stderr capture failed: ${describeError(error)}`, - ); - const wire = new PgwireStream(instance.stdout, instance.stdin); - const process = new WasixProcess(instance, wire, stderr, storage, baseDirectory); - opened = process; - assertSuccessfulStartupResponse( - await wire.startup(startupPacket(options.username, options.database)), - ); - // The standalone main loop reports its final GUC values and another - // ReadyForQuery after the explicit startup response. Drain and validate - // that transition before exposing the session to callers. - await wire.settleStartup(); - await configureWasixDatabase(options, prepared, storage.state, (input) => - process.exec(input), - ); - return process; - } catch (error) { - const detail = opened === undefined ? '' : await opened.#stderrSnapshot(); - let failure: Error = - error instanceof WasixStorageError || error instanceof PostgresError - ? error - : new Error(`WASIX PostgreSQL startup failed: ${describeError(error)}${detail}`, { - cause: error, - }); - if (opened !== undefined) { - throw await opened.#closeAfterOpenFailure(failure); - } - if (instance !== undefined) { - try { - instance.free(); - } catch (freeError) { - failure = composeLifecycleFailure( - failure, - 'WASIX instance cleanup also failed', - freeError, - ); - } - } - try { - await storage.close(baseDirectory, 'failed'); - } catch (releaseError) { - failure = composeLifecycleFailure(failure, 'storage release also failed', releaseError); - } - throw failure; - } - } - - async exec(input: Uint8Array, persistence: WasixPersistenceMode = 'sync'): Promise { - if (this.#closed) { - throw new Error('Oliphaunt WASIX process is closed'); - } - if (this.#failed) { - throw new Error('Oliphaunt WASIX process failed; close this database and open a new one'); - } - try { - const response = await this.#wire.exchange(input); - if (persistence === 'sync') { - await this.#storage.sync(this.#baseDirectory, 'operation'); - } - return response; - } catch (error) { - this.#failed = true; - if (error instanceof WasixStorageError) throw error; - const detail = await this.#stderrSnapshot(); - throw new Error( - `${describeError(error)}; this database can no longer be used and must be reopened${detail}`, - ); - } - } - - async sync(boundary: WasixStorageSyncBoundary): Promise { - if (this.#closed) { - throw new Error('Oliphaunt WASIX process is closed'); - } - if (this.#failed) { - throw new Error('Oliphaunt WASIX process failed; close this database and open a new one'); - } - try { - await this.#storage.sync(this.#baseDirectory, boundary); - } catch (error) { - // The preceding PostgreSQL CHECKPOINT may already include committed - // application work. Do not allow another query or encourage an unsafe - // retry after the host delta failed to publish. - this.#failed = true; - if (error instanceof WasixStorageError) { - throw error; - } - throw new WasixStorageError(`WASIX PGDATA ${boundary} failed: ${describeError(error)}`, { - code: 'checkpoint-failed', - durability: 'unknown', - cause: error, - }); - } - } - - async close(): Promise { - if (this.#closed) { - return; - } - this.#closed = true; - // Disposal remains safe after a genuine transport/runtime failure, where - // stdin/stdout may already be closed. PostgreSQL statement errors are - // recovered by the transport-scoped host pump and do not enter this path. - // wait() consumes the Wasmer Instance handle; do not call free() afterwards. - if (this.#failed) { - await this.#wire.close().catch(() => undefined); - await this.#instance.wait().catch(() => undefined); - await this.#storage.close(this.#baseDirectory, 'failed'); - return; - } - - let closeError: unknown; - let closeFailed = false; - try { - await this.#wire.close(); - } catch (error) { - closeError = error; - closeFailed = true; - } - - let output: Awaited> | undefined; - let waitError: unknown; - try { - output = await this.#instance.wait(); - } catch (error) { - waitError = error; - } - const detail = await this.#stderrSnapshot(); - let failure: Error | undefined; - if (closeFailed) { - failure = new Error( - `WASIX PostgreSQL terminate failed: ${describeError(closeError)}${detail}`, - { cause: closeError }, - ); - } else if (waitError !== undefined) { - failure = new Error(`WASIX PostgreSQL wait failed: ${describeError(waitError)}${detail}`, { - cause: waitError, - }); - } else if (output === undefined) { - failure = new Error(`WASIX PostgreSQL produced no exit result${detail}`); - } else if (!output.ok || output.code !== 0) { - failure = new Error(`WASIX PostgreSQL exited with code ${output.code}${detail}`); - } - if (failure !== undefined) { - try { - await this.#storage.close(this.#baseDirectory, 'failed'); - } catch (releaseError) { - failure = composeLifecycleFailure(failure, 'storage release also failed', releaseError); - } - throw failure; - } - await this.#storage.close(this.#baseDirectory, 'clean'); - } - - async #stderrSnapshot(): Promise { - const stderr = await Promise.race([ - this.#stderr.catch(() => ''), - new Promise((resolve) => setTimeout(() => resolve(''), 100)), - ]); - return stderr.length > 0 ? `\nWASIX stderr:\n${stderr}` : ''; - } - - async #closeAfterOpenFailure(failure: Error): Promise { - this.#closed = true; - this.#failed = true; - try { - await this.#wire.close(); - } catch (terminateError) { - failure = composeLifecycleFailure( - failure, - 'WASIX terminate after failed open also failed', - terminateError, - ); - } - try { - // wait() is the ownership boundary for a started Wasmer instance. - await this.#instance.wait(); - } catch (waitError) { - failure = composeLifecycleFailure( - failure, - 'WASIX wait after failed open also failed', - waitError, - ); - } - try { - await this.#storage.close(this.#baseDirectory, 'failed'); - } catch (releaseError) { - failure = composeLifecycleFailure(failure, 'storage release also failed', releaseError); - } - return failure; - } -} - -/** @internal Materialize the exact runtime mounts shared by both execution placements. */ -export async function materializeWasixMounts( - DirectoryConstructor: typeof Directory, - layout: WasixRuntimeLayout, - pgdata: WasixDirectoryMount, -): Promise<{ mounts: Record; baseDirectory: Directory }> { - const mounts: Record = {}; - for (const [mountPath, contents] of Object.entries(layout.mounts)) { - mounts[mountPath] = await materializeDirectory( - DirectoryConstructor, - mountPath === '/base' ? pgdata : contents, - ); - } - const baseDirectory = mounts['/base']; - if (baseDirectory === undefined) { - throw new Error('materialized WASIX runtime has no /base mount'); - } - return { mounts, baseDirectory }; -} - -async function materializeDirectory( - DirectoryConstructor: typeof Directory, - contents: WasixDirectoryMount, -): Promise { - const directory = new DirectoryConstructor(contents.files); - const existing = directoriesImpliedByFiles(Object.keys(contents.files)); - const explicit = [...new Set(contents.directories)].sort(compareDirectoryDepth); - for (const path of explicit) { - if (existing.has(path)) { - continue; - } - await directory.createDir(path); - existing.add(path); - } - return directory; -} - -function directoriesImpliedByFiles(paths: readonly string[]): Set { - const directories = new Set(); - for (const path of paths) { - const segments = path.split('/'); - for (let index = 1; index < segments.length; index += 1) { - directories.add(segments.slice(0, index).join('/')); - } - } - return directories; -} - -function compareDirectoryDepth(left: string, right: string): number { - return left.split('/').length - right.split('/').length || left.localeCompare(right); -} - -/** @internal PostgreSQL argv shared by both execution placements. */ -export function wasixPostgresArgs(options: SerializedOpenOptions): string[] { - const args = ['--single']; - if (options.storage.kind === 'memory') args.push('-F'); - args.push('-O', '-j'); - const startupGUCs = { ...options.startupGUCs }; - for (const [configuredName, configuredValue] of Object.entries(startupGUCs)) { - const managed = Object.entries(SINGLE_BACKEND_GUCS).find( - ([name]) => name === configuredName.toLowerCase(), - ); - if (managed === undefined) continue; - const [, requiredValue] = managed; - if (configuredValue !== requiredValue) { - throw new Error( - `PostgreSQL setting ${JSON.stringify(configuredName)} is managed by @oliphaunt/wasix-ts and must remain ${JSON.stringify(requiredValue)}`, - ); - } - delete startupGUCs[configuredName]; - } - for (const [name, value] of Object.entries({ - search_path: 'public', - log_checkpoints: 'false', - wal_buffers: '4MB', - min_wal_size: '80MB', - shared_buffers: '128MB', - ...SINGLE_BACKEND_GUCS, - ...startupGUCs, - })) { - validateGuc(name, value); - args.push('-c', `${name}=${value}`); - } - // Keep a database name that begins with `-` out of PostgreSQL's option - // parser. PostgreSQL's bundled getopt honors this standard delimiter. - args.push('-D', '/base', '--', options.database); - return args; -} - -const SINGLE_BACKEND_GUCS = { - exit_on_error: 'false', - max_wal_senders: '0', - max_worker_processes: '0', - max_parallel_workers: '0', - max_parallel_workers_per_gather: '0', - max_parallel_maintenance_workers: '0', - io_method: 'sync', -} as const; - -/** @internal PostgreSQL environment shared by both execution placements. */ -export function wasixPostgresEnvironment(options: SerializedOpenOptions): Record { - return { - PREFIX: '/', - PGDATA: '/base', - PGUSER: options.username, - PGDATABASE: options.database, - MODE: 'REACT', - REPL: 'N', - PGSYSCONFDIR: '/base', - PGCLIENTENCODING: 'UTF8', - HOME: '/home/postgres', - USER: options.username, - LOGNAME: options.username, - PATH: '/bin', - LC_CTYPE: 'C.UTF-8', - TZ: 'UTC', - PGTZ: 'UTC', - PG_COLOR: 'never', - PROJ_DATA: '/share/proj', - // The canonical guest specializes backend atomics for a one-backend - // WebAssembly instance. Every host placement must enforce that invariant; - // the stdio marker below is independently scoped to browser-worker I/O. - OLIPHAUNT_WASIX_SINGLE_BACKEND: '1', - OLIPHAUNT_WASIX_STDIO_PGWIRE: '1', - }; -} - -function validateGuc(name: string, value: string): void { - if (!/^[A-Za-z][A-Za-z0-9_.]*$/.test(name)) { - throw new Error(`invalid PostgreSQL setting name ${JSON.stringify(name)}`); - } - if (value.includes('\0')) { - throw new Error(`PostgreSQL setting ${name} contains a NUL byte`); - } -} - -async function captureText(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - let tail = ''; - while (true) { - const next = await reader.read(); - if (next.done) { - tail += decoder.decode(); - return tail.length > 16_384 ? tail.slice(-16_384) : tail; - } - tail += decoder.decode(next.value, { stream: true }); - if (tail.length > 16_384) { - tail = tail.slice(-16_384); - } - } -} - -/** @internal Normalize lifecycle diagnostics without discarding structured primary errors. */ -export function describeError(error: unknown): string { - if (!(error instanceof Error)) { - return String(error); - } - const detailed = (error as Error & { detailedMessage?: unknown }).detailedMessage; - return typeof detailed === 'string' && detailed.length > 0 && detailed !== error.message - ? `${error.message}: ${detailed}` - : error.message; -} - -/** @internal Preserve a structured primary while attaching cleanup diagnostics. */ -export function composeLifecycleFailure(primary: Error, label: string, secondary: unknown): Error { - const message = `${primary.message}; ${label}: ${describeError(secondary)}`; - const cause = new AggregateError( - [primary, secondary], - `${label} while handling ${primary.name || 'Error'}`, - ); - if (primary instanceof PostgresError) { - const composed = new PostgresError(primary.fields.map((field) => ({ ...field }))); - composed.message = message; - Object.defineProperty(composed, 'cause', { configurable: true, value: cause }); - return composed; - } - if (primary instanceof WasixStorageError) { - return new WasixStorageError(message, { - code: primary.code, - durability: primary.durability, - cause, - }); - } - return new Error(message, { cause }); -} - -/** @internal Complete extension and role setup after either transport reaches ReadyForQuery. */ -export async function configureWasixDatabase( - options: SerializedOpenOptions, - prepared: PreparedWasixRuntime, - storageState: WasixStorageLease['state'], - exec: (input: Uint8Array) => Promise, -): Promise { - // Imported carrier install contracts own extension lifecycle. Activate them - // while the fixed bootstrap superuser is selected, then apply the caller's role. - if (storageState === 'new') { - for (const sql of prepared.setupSql) { - assertSuccessfulQueryResponse(await exec(simpleQuery(sql))); - } - } - if (options.username !== 'postgres') { - const username = options.username.replaceAll('"', '""'); - assertSuccessfulQueryResponse(await exec(simpleQuery(`SET ROLE "${username}"`))); - } -} diff --git a/src/bindings/wasix-ts/src/wasix-runtime.ts b/src/bindings/wasix-ts/src/wasix-runtime.ts new file mode 100644 index 000000000..858658e7f --- /dev/null +++ b/src/bindings/wasix-ts/src/wasix-runtime.ts @@ -0,0 +1,219 @@ +import type { WasixDirectoryMount, WasixRuntimeLayout } from './archive.js'; +import { WasixStorageError } from './errors.js'; +import type { PreparedWasixRuntime } from './extensions.js'; +import type { Directory } from './host/index.mjs'; +import { simpleQuery } from './protocol.js'; +import { assertSuccessfulQueryResponse, PostgresError } from './query.js'; +import type { SerializedOpenOptions } from './rpc.js'; +import type { WasixStorageLease } from './storage-provider.js'; + +let compiledModuleCache: { sha256: string; module: Promise } | undefined; + +/** @internal Verified-module compilation cache shared by direct opens in one JS realm. */ +export function compileWasixModule( + module: Uint8Array, + sha256: string, +): Promise { + if (compiledModuleCache?.sha256 !== sha256) { + const source = + module.buffer instanceof ArrayBuffer + ? (module as Uint8Array) + : Uint8Array.from(module); + const compiled = WebAssembly.compile(source); + compiledModuleCache = { sha256, module: compiled }; + void compiled.catch(() => { + if (compiledModuleCache?.module === compiled) { + compiledModuleCache = undefined; + } + }); + } + return compiledModuleCache.module; +} + +/** @internal Materialize the exact runtime mounts shared by both execution placements. */ +export async function materializeWasixMounts( + DirectoryConstructor: typeof Directory, + layout: WasixRuntimeLayout, + pgdata: WasixDirectoryMount, +): Promise<{ mounts: Record; baseDirectory: Directory }> { + const mounts: Record = {}; + for (const [mountPath, contents] of Object.entries(layout.mounts)) { + mounts[mountPath] = await materializeDirectory( + DirectoryConstructor, + mountPath === '/base' ? pgdata : contents, + ); + } + const baseDirectory = mounts['/base']; + if (baseDirectory === undefined) { + throw new Error('materialized WASIX runtime has no /base mount'); + } + return { mounts, baseDirectory }; +} + +async function materializeDirectory( + DirectoryConstructor: typeof Directory, + contents: WasixDirectoryMount, +): Promise { + const directory = new DirectoryConstructor(contents.files); + const existing = directoriesImpliedByFiles(Object.keys(contents.files)); + const explicit = [...new Set(contents.directories)].sort(compareDirectoryDepth); + for (const path of explicit) { + if (existing.has(path)) { + continue; + } + await directory.createDir(path); + existing.add(path); + } + return directory; +} + +function directoriesImpliedByFiles(paths: readonly string[]): Set { + const directories = new Set(); + for (const path of paths) { + const segments = path.split('/'); + for (let index = 1; index < segments.length; index += 1) { + directories.add(segments.slice(0, index).join('/')); + } + } + return directories; +} + +function compareDirectoryDepth(left: string, right: string): number { + return left.split('/').length - right.split('/').length || left.localeCompare(right); +} + +/** @internal PostgreSQL argv shared by both execution placements. */ +export function wasixPostgresArgs(options: SerializedOpenOptions): string[] { + const args = ['--single']; + if (options.storage.kind === 'memory') args.push('-F'); + args.push('-O', '-j'); + const startupGUCs = { ...options.startupGUCs }; + for (const [configuredName, configuredValue] of Object.entries(startupGUCs)) { + const managed = Object.entries(SINGLE_BACKEND_GUCS).find( + ([name]) => name === configuredName.toLowerCase(), + ); + if (managed === undefined) continue; + const [, requiredValue] = managed; + if (configuredValue !== requiredValue) { + throw new Error( + `PostgreSQL setting ${JSON.stringify(configuredName)} is managed by @oliphaunt/wasix-ts and must remain ${JSON.stringify(requiredValue)}`, + ); + } + delete startupGUCs[configuredName]; + } + for (const [name, value] of Object.entries({ + search_path: 'public', + log_checkpoints: 'false', + wal_buffers: '4MB', + min_wal_size: '80MB', + shared_buffers: '128MB', + ...SINGLE_BACKEND_GUCS, + ...startupGUCs, + })) { + validateGuc(name, value); + args.push('-c', `${name}=${value}`); + } + // Keep a database name that begins with `-` out of PostgreSQL's option + // parser. PostgreSQL's bundled getopt honors this standard delimiter. + args.push('-D', '/base', '--', options.database); + return args; +} + +const SINGLE_BACKEND_GUCS = { + exit_on_error: 'false', + max_wal_senders: '0', + max_worker_processes: '0', + max_parallel_workers: '0', + max_parallel_workers_per_gather: '0', + max_parallel_maintenance_workers: '0', + io_method: 'sync', +} as const; + +/** @internal PostgreSQL environment shared by both execution placements. */ +export function wasixPostgresEnvironment(options: SerializedOpenOptions): Record { + return { + PREFIX: '/', + PGDATA: '/base', + PGUSER: options.username, + PGDATABASE: options.database, + MODE: 'REACT', + REPL: 'N', + PGSYSCONFDIR: '/base', + PGCLIENTENCODING: 'UTF8', + HOME: '/home/postgres', + USER: options.username, + LOGNAME: options.username, + PATH: '/bin', + LC_CTYPE: 'C.UTF-8', + TZ: 'UTC', + PGTZ: 'UTC', + PG_COLOR: 'never', + PROJ_DATA: '/share/proj', + // The canonical guest specializes backend atomics for a one-backend + // WebAssembly instance. Every host placement must enforce that invariant. + OLIPHAUNT_WASIX_SINGLE_BACKEND: '1', + }; +} + +function validateGuc(name: string, value: string): void { + if (!/^[A-Za-z][A-Za-z0-9_.]*$/.test(name)) { + throw new Error(`invalid PostgreSQL setting name ${JSON.stringify(name)}`); + } + if (value.includes('\0')) { + throw new Error(`PostgreSQL setting ${name} contains a NUL byte`); + } +} + +/** @internal Normalize lifecycle diagnostics without discarding structured primary errors. */ +export function describeError(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + const detailed = (error as Error & { detailedMessage?: unknown }).detailedMessage; + return typeof detailed === 'string' && detailed.length > 0 && detailed !== error.message + ? `${error.message}: ${detailed}` + : error.message; +} + +/** @internal Preserve a structured primary while attaching cleanup diagnostics. */ +export function composeLifecycleFailure(primary: Error, label: string, secondary: unknown): Error { + const message = `${primary.message}; ${label}: ${describeError(secondary)}`; + const cause = new AggregateError( + [primary, secondary], + `${label} while handling ${primary.name || 'Error'}`, + ); + if (primary instanceof PostgresError) { + const composed = new PostgresError(primary.fields.map((field) => ({ ...field }))); + composed.message = message; + Object.defineProperty(composed, 'cause', { configurable: true, value: cause }); + return composed; + } + if (primary instanceof WasixStorageError) { + return new WasixStorageError(message, { + code: primary.code, + durability: primary.durability, + cause, + }); + } + return new Error(message, { cause }); +} + +/** @internal Complete extension and role setup after the direct bridge reaches ReadyForQuery. */ +export async function configureWasixDatabase( + options: SerializedOpenOptions, + prepared: PreparedWasixRuntime, + storageState: WasixStorageLease['state'], + exec: (input: Uint8Array) => Promise, +): Promise { + // Imported carrier install contracts own extension lifecycle. Activate them + // while the fixed bootstrap superuser is selected, then apply the caller's role. + if (storageState === 'new') { + for (const sql of prepared.setupSql) { + assertSuccessfulQueryResponse(await exec(simpleQuery(sql))); + } + } + if (options.username !== 'postgres') { + const username = options.username.replaceAll('"', '""'); + assertSuccessfulQueryResponse(await exec(simpleQuery(`SET ROLE "${username}"`))); + } +} diff --git a/src/bindings/wasix-ts/src/worker-dispatch.ts b/src/bindings/wasix-ts/src/worker-dispatch.ts index d4f0e08f7..8ba63af16 100644 --- a/src/bindings/wasix-ts/src/worker-dispatch.ts +++ b/src/bindings/wasix-ts/src/worker-dispatch.ts @@ -6,7 +6,6 @@ import { type WorkerResponse, } from './rpc.js'; import type { WasixStorageSyncBoundary } from './storage-provider.js'; -import { type WasixHost, WasixProcess } from './wasix-process.js'; import { prepareTransferableBytes } from './worker-transfer.js'; export type WorkerResponder = (response: WorkerResponse, transfer?: readonly ArrayBuffer[]) => void; @@ -19,12 +18,7 @@ type WorkerSession = Readonly<{ export type WorkerSessionOpener = (options: SerializedOpenOptions) => Promise; -/** One RPC dispatcher shared by browser Workers and Node worker_threads. */ -export function createWorkerDispatcher(host: WasixHost, respond: WorkerResponder) { - return createWorkerSessionDispatcher((options) => WasixProcess.open(options, host), respond); -} - -/** @internal One request state machine shared by stream and in-realm worker hosts. */ +/** @internal One request state machine shared by browser and server worker realms. */ export function createWorkerSessionDispatcher( openSession: WorkerSessionOpener, respond: WorkerResponder, diff --git a/src/bindings/wasix-ts/src/worker.ts b/src/bindings/wasix-ts/src/worker.ts index 5b3382c0c..a1211d11d 100644 --- a/src/bindings/wasix-ts/src/worker.ts +++ b/src/bindings/wasix-ts/src/worker.ts @@ -2,11 +2,20 @@ import * as host from './host/index.mjs'; +import { openBrowserWorkerSession, type DirectWasixHost } from './direct-client-common.js'; import type { WorkerRequest, WorkerResponse } from './rpc.js'; -import { createWorkerDispatcher } from './worker-dispatch.js'; +import { createWorkerSessionDispatcher } from './worker-dispatch.js'; const scope = globalThis as unknown as DedicatedWorkerGlobalScope; -const dispatch = createWorkerDispatcher(host, respond); +const directHost: DirectWasixHost = { + Directory: host.Directory, + init: host.init, + instantiateOliphauntDirect: host.instantiateOliphauntDirect, +}; +const dispatch = createWorkerSessionDispatcher( + (options) => openBrowserWorkerSession(options, directHost), + respond, +); scope.addEventListener('message', (event: MessageEvent) => { void dispatch(event.data); diff --git a/src/bindings/wasix-ts/tools/check-package.mjs b/src/bindings/wasix-ts/tools/check-package.mjs index ca64b42fd..29cce2248 100644 --- a/src/bindings/wasix-ts/tools/check-package.mjs +++ b/src/bindings/wasix-ts/tools/check-package.mjs @@ -60,8 +60,6 @@ try { 'lib/node-worker-options.js', 'lib/node-zstd.js', 'lib/server-runtime.js', - 'lib/node-web-worker.js', - 'lib/node-web-worker-thread.js', 'lib/worker.js', 'lib/host/index.mjs', 'lib/host/index.d.mts', @@ -91,6 +89,16 @@ try { } } + for (const path of [ + 'lib/node-web-worker.js', + 'lib/node-web-worker-thread.js', + 'lib/wasix-process.js', + ]) { + if (paths.has(path)) { + throw new Error(`WASIX TypeScript package retained retired transport artifact ${path}`); + } + } + // New products intentionally remain at 0.0.0 until Release Please creates // the first release candidate; publication still requires that transition. if ( diff --git a/src/bindings/wasix-ts/tools/clean-lib.mjs b/src/bindings/wasix-ts/tools/clean-lib.mjs new file mode 100644 index 000000000..24176a07d --- /dev/null +++ b/src/bindings/wasix-ts/tools/clean-lib.mjs @@ -0,0 +1,10 @@ +import { rm } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const output = resolve(packageRoot, 'lib'); +if (dirname(output) !== packageRoot) { + throw new Error(`refusing to clean unexpected TypeScript output path ${output}`); +} +await rm(output, { force: true, recursive: true }); diff --git a/src/bindings/wasix-ts/tools/smoke-browser.mjs b/src/bindings/wasix-ts/tools/smoke-browser.mjs index bf88800cf..ac2f988a8 100644 --- a/src/bindings/wasix-ts/tools/smoke-browser.mjs +++ b/src/bindings/wasix-ts/tools/smoke-browser.mjs @@ -49,6 +49,7 @@ const timeoutMs = Number( process.env.OLIPHAUNT_BROWSER_SMOKE_TIMEOUT_MS ?? (benchmark ? 900_000 : 300_000), ); const pgUuidv7Canary = process.argv.includes('--pg-uuidv7'); +const postgisWorkerCanary = process.argv.includes('--postgis-worker'); const requiredInputs = [ resolve(repositoryRoot, 'target/oliphaunt-wasix/assets/oliphaunt.wasix.tar.zst'), resolve(repositoryRoot, 'target/oliphaunt-wasix/assets/prepopulated/pgdata-template.tar.zst'), @@ -65,6 +66,11 @@ if (pgUuidv7Canary) { resolve(repositoryRoot, 'target/oliphaunt-wasix/assets/extensions/pg_uuidv7.tar.zst'), ); } +if (postgisWorkerCanary) { + requiredInputs.push( + resolve(repositoryRoot, 'target/oliphaunt-wasix/assets/extensions/postgis.tar.zst'), + ); +} if (benchmark) { requiredInputs.push( resolve(bindingRoot, 'node_modules/@electric-sql/pglite/dist/pglite.data'), @@ -148,7 +154,7 @@ try { const smokeUrl = benchmark ? `http://127.0.0.1:${vitePort}/benchmark.html${quickBenchmark ? '?quick=1' : ''}` - : `http://127.0.0.1:${vitePort}/?smoke=1${pgUuidv7Canary ? '&pg_uuidv7=1' : ''}`; + : `http://127.0.0.1:${vitePort}/?smoke=1${pgUuidv7Canary ? '&pg_uuidv7=1' : ''}${postgisWorkerCanary ? '&postgis_worker=1' : ''}`; await cdp.send('Page.navigate', { url: smokeUrl }); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/src/bindings/wasix-ts/tools/smoke-node.mjs b/src/bindings/wasix-ts/tools/smoke-node.mjs index fb357e6e3..27e10e826 100644 --- a/src/bindings/wasix-ts/tools/smoke-node.mjs +++ b/src/bindings/wasix-ts/tools/smoke-node.mjs @@ -31,7 +31,7 @@ const runtime = ${JSON.stringify(runtime)}; const runtimeName = ${JSON.stringify(runtimeName)}; const packageOnly = ${JSON.stringify(packageOnly)}; const pgtap = packageOnly ? undefined : (await import(extension)).default; -const { default: Oliphaunt, PostgresError, WasixStorageError } = await import(candidate); +const { default: Oliphaunt, PostgresError, WasixStorageError, simpleQuery } = await import(candidate); const { directory } = await import(candidate + '/storage/' + runtime); const resolved = import.meta.resolve(candidate); @@ -98,6 +98,22 @@ if (packageOnly) { async function verifyMemory(execution) { const db = await Oliphaunt.open({ execution, extensions: [pgtap] }); const version = (await db.query('SELECT pgtap_version()::text AS version')).getText(0, 'version'); + const retainedProtocol = await db.execProtocolRaw( + simpleQuery("SELECT repeat('a', 8192) AS retained_payload"), + ); + const retainedSnapshot = retainedProtocol.slice(); + await db.execProtocolRaw(simpleQuery("SELECT repeat('z', 8192) AS replacement_payload")); + const protocolResponseOwned = + retainedProtocol.length === retainedSnapshot.length && + retainedProtocol.every((byte, index) => byte === retainedSnapshot[index]); + const wallClockMillis = Number((await db.query( + 'SELECT (extract(epoch FROM clock_timestamp()) * 1000)::bigint AS millis', + )).getText(0, 'millis')); + const wallClockDeltaMillis = Math.abs(Date.now() - wallClockMillis); + const explain = JSON.parse((await db.query( + 'EXPLAIN (ANALYZE, FORMAT JSON) SELECT pg_sleep(0.05)', + )).getText(0, 'QUERY PLAN')); + const monotonicElapsedMillis = explain[0]?.['Execution Time']; await db.query('CREATE TABLE smoke_transaction (value integer NOT NULL)'); const transactionValue = await db.transaction(async (tx) => { await tx.query('INSERT INTO smoke_transaction VALUES ($1)', [7]); @@ -125,9 +141,23 @@ async function verifyMemory(execution) { } const answer = (await db.query('SELECT 42::int AS answer')).getText(0, 'answer'); await db[Symbol.asyncDispose](); - const result = { version, transactionValue, transactionRows, sqlstate, answer }; + const result = { + version, + protocolResponseOwned, + wallClockDeltaMillis, + monotonicElapsedMillis, + transactionValue, + transactionRows, + sqlstate, + answer, + }; if ( !version || + !protocolResponseOwned || + wallClockDeltaMillis > 5_000 || + !Number.isFinite(monotonicElapsedMillis) || + monotonicElapsedMillis < 25 || + monotonicElapsedMillis > 5_000 || transactionValue !== '7' || transactionRows !== '1' || sqlstate !== '42601' || diff --git a/src/docs/content/sdk/wasm/browser-typescript.mdx b/src/docs/content/sdk/wasm/browser-typescript.mdx index d3d5b1638..f7084f254 100644 --- a/src/docs/content/sdk/wasm/browser-typescript.mdx +++ b/src/docs/content/sdk/wasm/browser-typescript.mdx @@ -69,9 +69,9 @@ blocks that JavaScript agent until PostgreSQL returns, and cross-origin isolation remains required. Separate memory or persistent-store identities can remain open in either browser placement, although direct calls share one event loop. Native extension side modules over Chromium's 8 MiB synchronous-module -limit require worker placement only when the carrier has no other unsupported -host requirement. The current PostGIS carrier also requires native load-order -handling and is therefore unsupported in both browser placements. +limit require worker placement. Worker execution validates and performs the +carrier's declared native load order before `CREATE EXTENSION`; the checked-in +Chrome canary qualifies the current PostGIS carrier in that placement. ## Node.js, Bun, and Deno diff --git a/src/extensions/external/postgis/tools/build_wasix.sh b/src/extensions/external/postgis/tools/build_wasix.sh index 7c7519523..44ddf8e57 100755 --- a/src/extensions/external/postgis/tools/build_wasix.sh +++ b/src/extensions/external/postgis/tools/build_wasix.sh @@ -178,7 +178,7 @@ EOF export CXX=wasixcc++ export AR=wasixar export RANLIB=wasixranlib - export CPPFLAGS="-I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl -I$LIBICONV_PREFIX/include" +export CPPFLAGS="$("$ROOT/pg_config_wasix.sh" --cppflags) -I$LIBICONV_PREFIX/include" export CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -fvisibility=hidden -Wno-unused-command-line-argument" export CXXFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -fvisibility=hidden -fvisibility-inlines-hidden -Wno-unused-command-line-argument" export LDFLAGS="-L$LIBICONV_PREFIX/lib -L$SQLITE_PREFIX/lib -liconv -lcharset -lsqlite3 -lc++ -lc++abi -lunwind" @@ -298,6 +298,8 @@ EOF -loliphaunt_postgis_deps \ -rpath '$ORIGIN' \ -o "$POSTGIS_BUILD_DIR/postgis/postgis-3.so" + oliphaunt_wasix_verify_side_module_sjlj "$postgis_deps_module" + oliphaunt_wasix_verify_side_module_sjlj "$POSTGIS_BUILD_DIR/postgis/postgis-3.so" # PostGIS core upgrade SQL still includes raster-unpackage stubs even when # raster support is disabled. Generate those SQL inputs as a best-effort # prerequisite before packaging PostGIS. Keep this serial: the diff --git a/src/extensions/generated/docs/extension-evidence.json b/src/extensions/generated/docs/extension-evidence.json index d6f078c64..3683542f9 100644 --- a/src/extensions/generated/docs/extension-evidence.json +++ b/src/extensions/generated/docs/extension-evidence.json @@ -1268,7 +1268,7 @@ "collector": "src/extensions/tools/collect-wasix-evidence.sh", "kind": "exact-sha-ci" }, - "source-digest": "sha256:f4130ad63a10ef74089c556dbc57fe5ab66126ce8cc3ac44515e3f0517ec75ed", + "source-digest": "sha256:3f56fc26ef6dd143cf8e933c7ea4c569af5a6e4ef216d7d0f711eabea64f605c", "source-digest-inputs": [ "src/postgres/versions/18/source.toml", "src/extensions/catalog/extensions.source.json", diff --git a/src/runtimes/liboliphaunt/wasix-postmaster/postgres/main-optimizations.series b/src/runtimes/liboliphaunt/wasix-postmaster/postgres/main-optimizations.series index 19d1619ad..1d14368b0 100644 --- a/src/runtimes/liboliphaunt/wasix-postmaster/postgres/main-optimizations.series +++ b/src/runtimes/liboliphaunt/wasix-postmaster/postgres/main-optimizations.series @@ -1,7 +1,6 @@ # Compatible PostgreSQL optimizations owned by the main WASIX runtime. # # Deliberately excluded from this multi-backend lane: -# - 0039 is the TypeScript browser-worker stdio pgwire transport. # - 0040 and 0041 specialize spinlocks and atomics for the canonical guest # shared by Rust and TypeScript bindings, whose hosts enforce one PostgreSQL # backend execution context per isolated instance. diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh index 4e13f09cc..d51880b8e 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh @@ -163,5 +163,6 @@ fi find "$BUILD_DIR/contrib/$contrib_dir" -maxdepth 1 -type f -name "*.so" -print >&2 exit 1 fi + oliphaunt_wasix_verify_side_module_sjlj "$BUILD_DIR/contrib/$contrib_dir/$module_file" done < "$PLAN" ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh index 220aa4418..10ac1ed39 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh @@ -113,7 +113,7 @@ fi clean >/dev/null 2>&1 || true make -s -j"$JOBS" -C "$extension_dir" \ PG_CONFIG="$CONTAINER_ROOT/pg_config_wasix.sh" \ - CPPFLAGS="-I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl" \ + CPPFLAGS="$("$CONTAINER_ROOT/pg_config_wasix.sh" --cppflags)" \ OPTFLAGS="" \ "${extra_make_args[@]}" \ all @@ -129,5 +129,6 @@ fi find "$extension_dir" -maxdepth 1 -type f -name "*.so" -print >&2 exit 1 fi + oliphaunt_wasix_verify_side_module_sjlj "$extension_dir/$module_file" done < "$PLAN" ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh index 31874697e..ed2e788e9 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh @@ -86,5 +86,7 @@ fi make -s -j"$JOBS" -C "$BUILD_DIR/src/backend/snowball" all test -f "$BUILD_DIR/src/pl/plpgsql/src/plpgsql.so" test -f "$BUILD_DIR/src/backend/snowball/dict_snowball.so" + oliphaunt_wasix_verify_side_module_sjlj "$BUILD_DIR/src/pl/plpgsql/src/plpgsql.so" + oliphaunt_wasix_verify_side_module_sjlj "$BUILD_DIR/src/backend/snowball/dict_snowball.so" test -f "$BUILD_DIR/src/backend/snowball/snowball_create.sql" ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh index 1fcca1eb3..5689f85b7 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh @@ -10,3 +10,31 @@ if [ "${HOME:-}" != "${WASIX_HOME%/.wasixcc}" ] && fi export PATH="$WASIX_HOME/bin:$PATH" + +oliphaunt_wasix_wasm_dis() { + if [ -x "$WASIX_HOME/binaryen/bin/wasm-dis" ]; then + printf '%s\n' "$WASIX_HOME/binaryen/bin/wasm-dis" + return 0 + fi + command -v wasm-dis +} + +# WebAssembly SJLJ handlers must be emitted in the module that owns the +# protected frame. An out-of-line sigsetjmp import silently builds but turns a +# later PG_RE_THROW into an uncaught exception, so reject it at production. +oliphaunt_wasix_verify_side_module_sjlj() { + local module="${1:?WASIX side-module path is required}" + local wasm_dis + test -s "$module" + wasm_dis="$(oliphaunt_wasix_wasm_dis)" || { + echo "wasm-dis is required to verify WASIX side-module SJLJ: $module" >&2 + return 1 + } + if "$wasm_dis" "$module" -o - | awk ' + /\(import / && /"sigsetjmp"/ { found = 1 } + END { exit found ? 0 : 1 } + '; then + echo "WASIX side module imports out-of-line sigsetjmp: $module" >&2 + return 1 + fi +} diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh b/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh index ae9151b06..fc96d2453 100755 --- a/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh @@ -106,7 +106,7 @@ case "${1:-}" in echo "wasixcc" ;; --cppflags) - echo "-I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl" + echo "-D_GNU_SOURCE -DOLIPHAUNT_WASM_SIDE_MODULE -I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl" ;; --cflags) echo "" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch deleted file mode 100644 index f819657e6..000000000 --- a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch +++ /dev/null @@ -1,107 +0,0 @@ -From 0000000000000000000000000000000000000039 Mon Sep 17 00:00:00 2001 -From: Oliphaunt Maintainers -Date: Tue, 11 Aug 2026 00:00:00 +0000 -Subject: [PATCH] oliphaunt-wasix: add stdio pgwire lifecycle - -Wasmer's browser SDK intentionally exposes process stdio without exposing the -guest export table. The Rust binding can pump the existing lifecycle exports, -but a browser host therefore needs PostgreSQL to enter that same direct pgwire -lifecycle from `_start`. - -Keep standalone initialization as the single owner of backend state. When the -explicit `OLIPHAUNT_WASIX_STDIO_PGWIRE=1` host contract is present, attach the -existing Oliphaunt Port to stdio while startup errors are captured, parse one -normal startup packet from stdin after initialization, write normal backend -messages to stdout, and continue through PostgreSQL's own blocking main loop. -The default export-pumped Rust lifecycle is unchanged. ---- - src/backend/tcop/postgres.c | 44 ++++++++++++++++++++++++++++++++++++++++ - src/include/port/wasix-dl.h | 1 + - 2 files changed, 45 insertions(+) - -diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c ---- a/src/backend/tcop/postgres.c -+++ b/src/backend/tcop/postgres.c -@@ -180,6 +180,8 @@ static StringInfoData row_description_buf; - #define OLIPHAUNT_WASM_HOST_EXPORT(name) __attribute__((export_name(name))) - - extern volatile int is_oliphaunt_active; -+ -+extern int ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done); - - static OliphauntWasmHostIO oliphaunt_wasix_protocol_io = - { -@@ -287,11 +289,48 @@ oliphaunt_wasix_send_conn_data(void) - ReadyForQuery(DestRemote); - } -+ -+static bool -+oliphaunt_wasix_stdio_pgwire_requested(void) -+{ -+ const char *value = getenv("OLIPHAUNT_WASIX_STDIO_PGWIRE"); -+ -+ return value != NULL && strcmp(value, "1") == 0; -+} -+ -+static void -+oliphaunt_wasix_start_stdio_pgwire(void) -+{ -+ int status; -+ -+ /* -+ * This is the process-level counterpart to the export-pumped Rust host. -+ * Stream transport maps the Port's reads and writes directly to stdin and -+ * stdout, which is the lifecycle surface available to browser WASIX hosts. -+ */ -+ oliphaunt_wasix_start(); -+ oliphaunt_wasix_set_protocol_stdio(1); -+ status = ProcessStartupPacket(MyProcPort, true, true); -+ if (status != STATUS_OK) -+ { -+ oliphaunt_wasix_pq_flush(); -+ proc_exit(0); -+ } -+ -+ oliphaunt_wasix_send_conn_data(); -+ oliphaunt_wasix_pq_flush(); -+ -+ /* send_conn_data() supplied the initial ReadyForQuery already. */ -+ send_ready_for_query = false; -+} -+ - static CommandDest oliphaunt_wasix_startup_error_saved_dest = DestDebug; - - static void - oliphaunt_wasix_begin_startup_error_capture(void) - { - if (MyProcPort == NULL) - oliphaunt_wasix_init_protocol_port(); -+ if (oliphaunt_wasix_stdio_pgwire_requested()) -+ oliphaunt_wasix_set_protocol_stdio(1); - oliphaunt_wasix_startup_error_saved_dest = whereToSendOutput; - oliphaunt_wasix_startup_error_capture_active = 1; -@@ -5230,6 +5266,11 @@ PostgresMain(const char *dbname, const char *username) - /* Need not flush since ReadyForQuery will do it. */ - } - -+#ifdef OLIPHAUNT_WASM_SINGLE_USER -+ if (oliphaunt_wasix_stdio_pgwire_requested()) -+ oliphaunt_wasix_start_stdio_pgwire(); -+#endif -+ - /* Welcome banner for standalone case */ - if (whereToSendOutput == DestDebug) - printf("\nPostgreSQL stand-alone backend %s\n", PG_VERSION); -diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h ---- a/src/include/port/wasix-dl.h -+++ b/src/include/port/wasix-dl.h -@@ -38,6 +38,7 @@ extern void *oliphaunt_wasix_shmat(int shmid, const void *shmaddr, int shmflg); - extern int oliphaunt_wasix_shmdt(const void *shmaddr); - extern int oliphaunt_wasix_shmctl(int shmid, int cmd, struct shmid_ds *buf); - extern void oliphaunt_wasix_protocol_report_copy_response(int state); -+extern int oliphaunt_wasix_set_protocol_stdio(int enabled); - - #ifdef OLIPHAUNT_WASIX_BACKEND_TIMING - #define OLIPHAUNT_BACKEND_TIMING_EXEC_SIMPLE_QUERY 36 --- -2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch new file mode 100644 index 000000000..36f9ce0a5 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch @@ -0,0 +1,26 @@ +From 0000000000000000000000000000000000000039 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Tue, 18 Aug 2026 00:00:00 +0000 +Subject: [PATCH] oliphaunt-wasix: declare hybrid protocol transport + +The Rust proxy switches from buffered host I/O to a hybrid stream only while +PostgreSQL is inside COPY. Keep that bridge contract visible in the WASIX port +header without retaining the retired process-level stdio lifecycle. +--- + src/include/port/wasix-dl.h | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -38,6 +38,8 @@ extern void *oliphaunt_wasix_shmat(int shmid, const void *shmaddr, int shmflg); + extern int oliphaunt_wasix_shmdt(const void *shmaddr); + extern int oliphaunt_wasix_shmctl(int shmid, int cmd, struct shmid_ds *buf); + extern void oliphaunt_wasix_protocol_report_copy_response(int state); ++extern int oliphaunt_wasix_set_protocol_transport(int mode); ++extern int oliphaunt_wasix_protocol_stream_active(void); + + #ifdef OLIPHAUNT_WASIX_BACKEND_TIMING + #define OLIPHAUNT_BACKEND_TIMING_EXEC_SIMPLE_QUERY 36 +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0044-oliphaunt-wasix-inline-sigsetjmp.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0044-oliphaunt-wasix-inline-sigsetjmp.patch new file mode 100644 index 000000000..06388ad16 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0044-oliphaunt-wasix-inline-sigsetjmp.patch @@ -0,0 +1,82 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Tue, 18 Aug 2026 00:00:00 +0000 +Subject: [PATCH] oliphaunt-wasix: inline sigsetjmp + +The released WASIX exception-handling sysroot exposes sigsetjmp as a normal +function. That is not sufficient for WebAssembly SJLJ: the compiler must see +setjmp at each call site so that it emits the exception handler in the module +whose frame is being protected. A PostgreSQL side module otherwise calls the +main executable's sigsetjmp, returns from its handler frame, and later turns a +perfectly catchable PostgreSQL ERROR into an uncaught WebAssembly exception. + +Mark PostgreSQL's shared-library and PGXS module compilations explicitly, then +define the POSIX spelling in the WASIX port header in terms of the +compiler-recognized setjmp call for those modules only. The pinned WASIX +implementation currently does not preserve a signal mask in sigsetjmp either, +so discarding savesigs retains its existing semantics while making +PG_TRY/PG_CATCH correct in every PostgreSQL side module. +--- + src/Makefile.shlib | 1 + + src/include/port/wasix-dl.h | 16 +++++++++++++++- + src/makefiles/pgxs.mk | 5 +++++ + 3 files changed, 22 insertions(+), 1 deletion(-) + +diff --git a/src/Makefile.shlib b/src/Makefile.shlib +index 55129150fa..8340cb90bd 100644 +--- a/src/Makefile.shlib ++++ b/src/Makefile.shlib +@@ -197,6 +197,7 @@ ifeq ($(PORTNAME), linux) + endif + + ifeq ($(PORTNAME), wasix-dl) ++ override CPPFLAGS += -DOLIPHAUNT_WASM_SIDE_MODULE + LINK.shared = $(COMPILER) -shared + ifdef SO_MAJOR_VERSION + shlib = $(shlib_bare) +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index ba0d68bb70..aac596375e 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -8,6 +8,21 @@ +-#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#if defined(OLIPHAUNT_WASM_SINGLE_USER) || defined(OLIPHAUNT_WASM_SIDE_MODULE) + #include ++#endif ++ ++#if defined(__wasm_exception_handling__) && defined(OLIPHAUNT_WASM_SIDE_MODULE) ++/* ++ * WebAssembly SJLJ requires setjmp to be visible at the protected call site. ++ * The released WASIX EH sysroot implements sigsetjmp as an out-of-line ++ * wrapper, whose exception handler has already returned by the time a side ++ * module calls siglongjmp. Its implementation ignores savesigs, so this ++ * call-site expansion preserves the same signal-mask semantics. ++ */ ++#undef sigsetjmp ++#define sigsetjmp(env, savesigs) ((void) (savesigs), setjmp(env)) ++#endif ++ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER + #include + #include + #include + #include +diff --git a/src/makefiles/pgxs.mk b/src/makefiles/pgxs.mk +index 4a1474db44..ab8037f33a 100644 +--- a/src/makefiles/pgxs.mk ++++ b/src/makefiles/pgxs.mk +@@ -101,6 +101,12 @@ + + override CPPFLAGS := -I. -I$(srcdir) $(CPPFLAGS) + ++ifeq ($(PORTNAME), wasix-dl) ++ifdef MODULES ++override CPPFLAGS += -DOLIPHAUNT_WASM_SIDE_MODULE ++endif ++endif ++ + # See equivalent block in Makefile.shlib + ifdef MODULES + override LDFLAGS_SL += $(CFLAGS_SL_MODULE) +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series index 9da3e3162..a7d885a77 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series @@ -36,8 +36,9 @@ 0036-oliphaunt-wasix-skip-activity-id-reporting.patch 0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch 0038-oliphaunt-wasix-skip-icu-collation-setup-without-icu-data.patch -0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch +0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch 0040-oliphaunt-wasix-use-single-backend-spinlocks.patch 0041-oliphaunt-wasix-specialize-single-backend-atomics.patch 0042-oliphaunt-wasix-buffer-strong-random.patch 0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch +0044-oliphaunt-wasix-inline-sigsetjmp.patch diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml index e0db35a18..5c7c4ecd2 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml @@ -38,9 +38,10 @@ series = [ "0036-oliphaunt-wasix-skip-activity-id-reporting.patch", "0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch", "0038-oliphaunt-wasix-skip-icu-collation-setup-without-icu-data.patch", - "0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch", + "0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch", "0040-oliphaunt-wasix-use-single-backend-spinlocks.patch", "0041-oliphaunt-wasix-specialize-single-backend-atomics.patch", "0042-oliphaunt-wasix-buffer-strong-random.patch", "0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch", + "0044-oliphaunt-wasix-inline-sigsetjmp.patch", ] diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c index caeae37a0..6ae89cb4b 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -78,10 +79,14 @@ pg_encoding_to_char(int encoding) static unsigned char *oliphaunt_wasix_input_buf; static size_t oliphaunt_wasix_input_len; static size_t oliphaunt_wasix_input_off; +static size_t oliphaunt_wasix_input_cap; +static size_t oliphaunt_wasix_input_reserved; static unsigned char *oliphaunt_wasix_output_buf; static size_t oliphaunt_wasix_output_len_value; static size_t oliphaunt_wasix_output_cap; +static size_t oliphaunt_wasix_output_scan_off; +static bool oliphaunt_wasix_output_contains_error_value; enum { OLIPHAUNT_WASIX_PROTOCOL_BUFFERED = 0, @@ -107,13 +112,6 @@ int oliphaunt_wasix_set_protocol_transport(int mode); ssize_t oliphaunt_wasix_recv(int fd, void *buf, size_t n, int flags); ssize_t oliphaunt_wasix_send(int fd, const void *buf, size_t n, int flags); -int EMSCRIPTEN_KEEPALIVE -oliphaunt_wasix_set_protocol_stdio(int enabled) -{ - return oliphaunt_wasix_set_protocol_transport(enabled ? OLIPHAUNT_WASIX_PROTOCOL_STREAM - : OLIPHAUNT_WASIX_PROTOCOL_BUFFERED); -} - int EMSCRIPTEN_KEEPALIVE oliphaunt_wasix_set_protocol_transport(int mode) { @@ -194,7 +192,7 @@ oliphaunt_wasix_longjmp(jmp_buf env, int val) */ if (is_oliphaunt_active && (force_host_error_recovery || - memcmp(env, (void *) postgresmain_sigjmp_buf, sizeof(jmp_buf)) == 0)) + env == (void *) postgresmain_sigjmp_buf)) { exit(POSTGRES_MAIN_LONGJMP); } @@ -277,18 +275,22 @@ oliphaunt_wasix_input_reset(void) { oliphaunt_wasix_input_len = 0; oliphaunt_wasix_input_off = 0; + oliphaunt_wasix_input_reserved = 0; return 0; } -int EMSCRIPTEN_KEEPALIVE -oliphaunt_wasix_input_write(const void *buffer, size_t length) +void *EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_input_reserve(size_t length) { - if (length == 0) - return 0; - if (buffer == NULL) + if (length == 0 || oliphaunt_wasix_input_reserved != 0) { errno = EINVAL; - return -1; + return NULL; + } + if (length > INT_MAX || oliphaunt_wasix_input_len > (size_t) INT_MAX - length) + { + errno = EOVERFLOW; + return NULL; } if (oliphaunt_wasix_input_off == oliphaunt_wasix_input_len) @@ -297,17 +299,48 @@ oliphaunt_wasix_input_write(const void *buffer, size_t length) oliphaunt_wasix_input_off = 0; } + if (length > SIZE_MAX - oliphaunt_wasix_input_len) + { + errno = EOVERFLOW; + return NULL; + } size_t new_len = oliphaunt_wasix_input_len + length; - unsigned char *new_buf = realloc(oliphaunt_wasix_input_buf, new_len); - if (new_buf == NULL) + if (new_len > oliphaunt_wasix_input_cap) { - errno = ENOMEM; - return -1; + size_t next_cap = oliphaunt_wasix_input_cap ? oliphaunt_wasix_input_cap : 8192; + while (next_cap < new_len) + { + if (next_cap > SIZE_MAX / 2) + { + next_cap = new_len; + break; + } + next_cap *= 2; + } + unsigned char *new_buf = realloc(oliphaunt_wasix_input_buf, next_cap); + if (new_buf == NULL) + { + errno = ENOMEM; + return NULL; + } + oliphaunt_wasix_input_buf = new_buf; + oliphaunt_wasix_input_cap = next_cap; } - oliphaunt_wasix_input_buf = new_buf; - memcpy(oliphaunt_wasix_input_buf + oliphaunt_wasix_input_len, buffer, length); - oliphaunt_wasix_input_len = new_len; + oliphaunt_wasix_input_reserved = length; + return oliphaunt_wasix_input_buf + oliphaunt_wasix_input_len; +} + +int EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_input_commit(size_t length) +{ + if (length == 0 || length != oliphaunt_wasix_input_reserved) + { + errno = EINVAL; + return -1; + } + oliphaunt_wasix_input_len += length; + oliphaunt_wasix_input_reserved = 0; return (int) length; } @@ -368,6 +401,8 @@ int EMSCRIPTEN_KEEPALIVE oliphaunt_wasix_output_reset(void) { oliphaunt_wasix_output_len_value = 0; + oliphaunt_wasix_output_scan_off = 0; + oliphaunt_wasix_output_contains_error_value = false; oliphaunt_wasix_protocol_copy_state_value = OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE; oliphaunt_wasix_protocol_stream_requested = false; return 0; @@ -379,17 +414,38 @@ oliphaunt_wasix_output_len(void) return oliphaunt_wasix_output_len_value; } -size_t EMSCRIPTEN_KEEPALIVE -oliphaunt_wasix_output_read(void *buffer, size_t max_length) +const void *EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_output_data(void) { - if (buffer == NULL || max_length == 0 || oliphaunt_wasix_output_len_value == 0) - return 0; + return oliphaunt_wasix_output_buf; +} + +int EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_output_contains_error(void) +{ + return oliphaunt_wasix_output_contains_error_value ? 1 : 0; +} - size_t to_copy = oliphaunt_wasix_output_len_value < max_length - ? oliphaunt_wasix_output_len_value - : max_length; - memcpy(buffer, oliphaunt_wasix_output_buf, to_copy); - return to_copy; +static void +oliphaunt_wasix_scan_buffered_output(void) +{ + while (oliphaunt_wasix_output_scan_off + 5 <= oliphaunt_wasix_output_len_value) + { + const unsigned char *message = + oliphaunt_wasix_output_buf + oliphaunt_wasix_output_scan_off; + size_t body_len = ((size_t) message[1] << 24) | + ((size_t) message[2] << 16) | + ((size_t) message[3] << 8) | + (size_t) message[4]; + if (body_len < 4 || body_len > SIZE_MAX - 1) + return; + size_t message_len = body_len + 1; + if (message_len > oliphaunt_wasix_output_len_value - oliphaunt_wasix_output_scan_off) + return; + if (message[0] == 'E') + oliphaunt_wasix_output_contains_error_value = true; + oliphaunt_wasix_output_scan_off += message_len; + } } static ssize_t @@ -403,12 +459,25 @@ oliphaunt_wasix_buffer_write(const void *buffer, size_t length) return -1; } + if (length > INT_MAX || oliphaunt_wasix_output_len_value > (size_t) INT_MAX - length) + { + errno = EOVERFLOW; + return -1; + } + size_t required = oliphaunt_wasix_output_len_value + length; if (required > oliphaunt_wasix_output_cap) { size_t next_cap = oliphaunt_wasix_output_cap ? oliphaunt_wasix_output_cap : 8192; while (next_cap < required) + { + if (next_cap > SIZE_MAX / 2) + { + next_cap = required; + break; + } next_cap *= 2; + } unsigned char *new_buf = realloc(oliphaunt_wasix_output_buf, next_cap); if (new_buf == NULL) { @@ -421,6 +490,7 @@ oliphaunt_wasix_buffer_write(const void *buffer, size_t length) memcpy(oliphaunt_wasix_output_buf + oliphaunt_wasix_output_len_value, buffer, length); oliphaunt_wasix_output_len_value += length; + oliphaunt_wasix_scan_buffered_output(); if (oliphaunt_wasix_protocol_transport == OLIPHAUNT_WASIX_PROTOCOL_HYBRID && oliphaunt_wasix_protocol_stream_requested) { diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c index 2554c540f..8f75815fc 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -54,16 +55,17 @@ struct passwd *oliphaunt_wasix_getpwuid(uid_t uid); int oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, struct passwd **result); int oliphaunt_wasix_input_reset(void); -int oliphaunt_wasix_input_write(const void *buffer, size_t length); +void *oliphaunt_wasix_input_reserve(size_t length); +int oliphaunt_wasix_input_commit(size_t length); size_t oliphaunt_wasix_input_available(void); int oliphaunt_wasix_output_reset(void); size_t oliphaunt_wasix_output_len(void); -size_t oliphaunt_wasix_output_read(void *buffer, size_t max_length); +const void *oliphaunt_wasix_output_data(void); +int oliphaunt_wasix_output_contains_error(void); int oliphaunt_wasix_fcntl(int fd, int cmd, ...); int oliphaunt_wasix_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen); int oliphaunt_wasix_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen); int oliphaunt_wasix_getsockname(int fd, struct sockaddr *addr, socklen_t *len); -int oliphaunt_wasix_set_protocol_stdio(int enabled); int oliphaunt_wasix_set_protocol_transport(int mode); int oliphaunt_wasix_protocol_stream_active(void); void oliphaunt_wasix_protocol_report_copy_response(int state); @@ -196,23 +198,52 @@ check_protocol_socket(void) CHECK(oliphaunt_wasix_input_reset() == 0); CHECK(oliphaunt_wasix_output_reset() == 0); CHECK(oliphaunt_wasix_recv(1, buf, sizeof(buf), 0) == 0); - CHECK(oliphaunt_wasix_input_write(input, sizeof(input) - 1) == (int) (sizeof(input) - 1)); + void *input_buffer = oliphaunt_wasix_input_reserve(sizeof(input) - 1); + CHECK(input_buffer != NULL); + memcpy(input_buffer, input, sizeof(input) - 1); + CHECK(oliphaunt_wasix_input_commit(sizeof(input) - 1) == (int) (sizeof(input) - 1)); CHECK(oliphaunt_wasix_input_available() == sizeof(input) - 1); CHECK(oliphaunt_wasix_recv(1, buf, 2, 0) == 2); CHECK(memcmp(buf, "ab", 2) == 0); CHECK(oliphaunt_wasix_input_available() == 1); + CHECK(oliphaunt_wasix_recv(1, buf, 1, 0) == 1); + CHECK(oliphaunt_wasix_input_reset() == 0); + void *reused_input_buffer = oliphaunt_wasix_input_reserve(sizeof(input) - 1); + CHECK(reused_input_buffer == input_buffer); + memcpy(reused_input_buffer, input, sizeof(input) - 1); + CHECK(oliphaunt_wasix_input_commit(sizeof(input) - 1) == (int) (sizeof(input) - 1)); + CHECK(oliphaunt_wasix_input_reset() == 0); CHECK(oliphaunt_wasix_send(1, output, sizeof(output) - 1, 0) == (ssize_t) (sizeof(output) - 1)); CHECK(oliphaunt_wasix_output_len() == sizeof(output) - 1); + const void *output_buffer = oliphaunt_wasix_output_data(); memset(buf, 0, sizeof(buf)); - CHECK(oliphaunt_wasix_output_read(buf, sizeof(buf)) == sizeof(output) - 1); + memcpy(buf, output_buffer, oliphaunt_wasix_output_len()); CHECK(memcmp(buf, output, sizeof(output) - 1) == 0); + CHECK(oliphaunt_wasix_output_contains_error() == 0); + + CHECK(oliphaunt_wasix_output_reset() == 0); + const unsigned char error_header[] = {'E', 0, 0, 0, 4}; + CHECK(oliphaunt_wasix_send(1, error_header, 2, 0) == 2); + CHECK(oliphaunt_wasix_output_contains_error() == 0); + CHECK(oliphaunt_wasix_send(1, error_header + 2, sizeof(error_header) - 2, 0) == + (ssize_t) (sizeof(error_header) - 2)); + CHECK(oliphaunt_wasix_output_data() == output_buffer); + CHECK(oliphaunt_wasix_output_contains_error() == 1); + CHECK(oliphaunt_wasix_output_reset() == 0); + CHECK(oliphaunt_wasix_output_contains_error() == 0); + errno = 0; + CHECK(oliphaunt_wasix_input_reserve((size_t) INT_MAX + 1) == NULL); + CHECK(errno == EOVERFLOW); + errno = 0; + CHECK(oliphaunt_wasix_send(1, output, (size_t) INT_MAX + 1, 0) == -1); + CHECK(errno == EOVERFLOW); - CHECK(oliphaunt_wasix_set_protocol_stdio(0) == 0); + CHECK(oliphaunt_wasix_set_protocol_transport(0) == 0); CHECK(oliphaunt_wasix_protocol_stream_active() == 0); - CHECK(oliphaunt_wasix_set_protocol_stdio(1) == 0); + CHECK(oliphaunt_wasix_set_protocol_transport(1) == 0); CHECK(oliphaunt_wasix_protocol_stream_active() == 1); - CHECK(oliphaunt_wasix_set_protocol_stdio(0) == 1); + CHECK(oliphaunt_wasix_set_protocol_transport(0) == 1); CHECK(oliphaunt_wasix_protocol_stream_active() == 0); CHECK(oliphaunt_wasix_set_protocol_transport(2) == 0); CHECK(oliphaunt_wasix_protocol_stream_active() == 0); @@ -277,7 +308,10 @@ check_protocol_socket(void) struct pollfd fds[1] = {{.fd = 1, .events = POLLIN, .revents = 0}}; CHECK(oliphaunt_wasix_poll(fds, 1, 0) == 0); CHECK(fds[0].revents == 0); - CHECK(oliphaunt_wasix_input_write("q", 1) == 1); + input_buffer = oliphaunt_wasix_input_reserve(1); + CHECK(input_buffer != NULL); + memcpy(input_buffer, "q", 1); + CHECK(oliphaunt_wasix_input_commit(1) == 1); CHECK(oliphaunt_wasix_poll(fds, 1, 0) == 1); CHECK((fds[0].revents & POLLIN) != 0); diff --git a/src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports b/src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports index 1c6215b38..456df7e2b 100644 --- a/src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports +++ b/src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports @@ -398,6 +398,9 @@ XLogRecStoreStats XLogRegisterData XactIsoLevel XidInMVCCSnapshot +__wasm_longjmp +__wasm_setjmp +__wasm_setjmp_test _bt_allequalimage _bt_binsrch_insert _bt_check_natts @@ -897,17 +900,18 @@ oidin oidout oliphaunt_wasix_get_proc_port oliphaunt_wasix_input_available +oliphaunt_wasix_input_commit +oliphaunt_wasix_input_reserve oliphaunt_wasix_input_reset -oliphaunt_wasix_input_write +oliphaunt_wasix_output_contains_error +oliphaunt_wasix_output_data oliphaunt_wasix_output_len -oliphaunt_wasix_output_read oliphaunt_wasix_output_reset oliphaunt_wasix_pq_flush oliphaunt_wasix_protocol_stream_active oliphaunt_wasix_send_conn_data oliphaunt_wasix_set_active oliphaunt_wasix_set_force_host_error_recovery -oliphaunt_wasix_set_protocol_stdio oliphaunt_wasix_set_protocol_transport oliphaunt_wasix_start op_hashjoinable @@ -1075,7 +1079,6 @@ shmem_request_hook shmem_startup_hook sigaction signal -sigsetjmp sin sinl slot_getsomeattrs_int diff --git a/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs b/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs index 466c6dc2f..722af1612 100755 --- a/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs +++ b/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs @@ -67,7 +67,7 @@ const EXPECTED_TOUCHPOINTS = new Map([ ['src/include/access/xlog.h', 'Exposes the embedded idle-boundary checkpoint handoff within PostgreSQL.'], ['src/include/port/atomics.h', 'Selects scalar atomics only for the explicitly single-backend WASIX build.'], ['src/include/port/atomics/arch-wasix-single.h', 'Preserves PostgreSQL atomic layouts and contracts without guest atomic instructions.'], - ['src/include/port/wasix-dl.h', 'Defines the embedded WASIX port header and ABI redirects.'], + ['src/include/port/wasix-dl.h', 'Defines the embedded WASIX port header, ABI redirects, and call-site SJLJ contract.'], ['src/include/port/wasix-dl/sys/ipc.h', 'Provides the WASIX SysV IPC shim surface.'], ['src/include/port/wasix-dl/sys/shm.h', 'Provides the WASIX SysV shared-memory shim surface.'], ['src/include/storage/s_lock.h', 'Specializes spinlocks only for the enforced single-backend WASIX runtime.'], @@ -156,14 +156,13 @@ const REQUIRED_AUDIT_CHECKS = [ posture: 'WASIX initdb skips ICU-backed collation setup until the optional ICU data package is present.', }, { - requirement: 'Browser-worker hosts can own one blocking stdio pgwire lifecycle', - patches: ['0039-oliphaunt-wasix-add-stdio-pgwire-lifecycle.patch'], + requirement: 'Rust COPY streaming keeps an explicit hybrid transport ABI', + patches: ['0039-oliphaunt-wasix-declare-hybrid-protocol-transport.patch'], evidence: [ - 'OLIPHAUNT_WASIX_STDIO_PGWIRE', - 'oliphaunt_wasix_set_protocol_stdio(1)', - 'ProcessStartupPacket(MyProcPort, true, true)', + 'oliphaunt_wasix_set_protocol_transport(int mode)', + 'oliphaunt_wasix_protocol_stream_active(void)', ], - posture: 'Only the explicit browser-worker contract enters the blocking stdio path; Rust, browser-direct, and Node hosts keep the export-driven lifecycle.', + posture: 'Only COPY switches the Rust proxy from buffered protocol I/O to its attached stream; TypeScript has no process-level stdio lifecycle.', }, { requirement: 'Single-backend WASIX spinlocks preserve their ABI and scope', @@ -209,6 +208,18 @@ const REQUIRED_AUDIT_CHECKS = [ ], posture: 'The single-backend guest omits only pg_flush_data hints that WASIX rejects on read-only descriptors; PostgreSQL fsync and fdatasync remain active.', }, + { + requirement: 'PostgreSQL side modules own their SJLJ catch frames', + patches: ['0044-oliphaunt-wasix-inline-sigsetjmp.patch'], + evidence: [ + '-DOLIPHAUNT_WASM_SIDE_MODULE', + 'WebAssembly SJLJ requires setjmp to be visible at the protected call site.', + 'defined(__wasm_exception_handling__) && defined(OLIPHAUNT_WASM_SIDE_MODULE)', + '#undef sigsetjmp', + '#define sigsetjmp(env, savesigs) ((void) (savesigs), setjmp(env))', + ], + posture: 'PG_TRY expands to a compiler-recognized setjmp in every PostgreSQL side module, so nested errors unwind to the live module-local handler.', + }, ]; if (!['--check', '--write'].includes(mode)) { diff --git a/tools/release/wasix-cargo-artifact-contract.mjs b/tools/release/wasix-cargo-artifact-contract.mjs index c21886307..ce51b45fb 100644 --- a/tools/release/wasix-cargo-artifact-contract.mjs +++ b/tools/release/wasix-cargo-artifact-contract.mjs @@ -31,7 +31,6 @@ export const SNOWBALL_STOPWORD_LANGUAGES = [ ]; export const CORE_RUNTIME_ARCHIVE_FILES = [ - "oliphaunt/bin/oliphaunt", "oliphaunt/bin/initdb", "oliphaunt/bin/postgres", "oliphaunt/lib/postgresql/dict_snowball.so", diff --git a/tools/release/wasix-runtime-npm-carrier.mjs b/tools/release/wasix-runtime-npm-carrier.mjs index 15debd48f..5ef3a1876 100644 --- a/tools/release/wasix-runtime-npm-carrier.mjs +++ b/tools/release/wasix-runtime-npm-carrier.mjs @@ -44,7 +44,7 @@ const TOOL = "wasix-runtime-npm-carrier.mjs"; const ROOT = path.resolve(import.meta.dirname, "../.."); const LOWER_SHA256 = /^[0-9a-f]{64}$/u; const SQL_NAME = /^[a-z0-9][a-z0-9_-]*$/u; -const RUNTIME_MODULE_MEMBER = "oliphaunt/bin/oliphaunt"; +const RUNTIME_MODULE_MEMBER = "oliphaunt/bin/postgres"; const NPM_PACKAGE_SAFETY_LIMIT_BYTES = 100 * 1024 * 1024; const MAX_COMMAND_CAPTURE_BYTES = 32 * 1024 * 1024; const NOTICE_OPTIONS = Object.freeze({ profile: "wasix-runtime" }); diff --git a/tools/release/wasix-runtime-npm-carrier.test.mjs b/tools/release/wasix-runtime-npm-carrier.test.mjs index 8ef449290..4e022ff2a 100644 --- a/tools/release/wasix-runtime-npm-carrier.test.mjs +++ b/tools/release/wasix-runtime-npm-carrier.test.mjs @@ -69,7 +69,7 @@ function portableReleaseFixture(root, { transformManifest = (manifest) => manife const pgdataBytes = zstdCompressSync(deterministicTar(pgdataStage, ".")); const sourceFingerprint = "fixture-postgres-source-fingerprint"; - const runtimeModuleSha256 = sha256(Buffer.from("fixture:oliphaunt/bin/oliphaunt\n")); + const runtimeModuleSha256 = sha256(Buffer.from("fixture:oliphaunt/bin/postgres\n")); const manifest = transformManifest({ "format-version": 1, "source-fingerprint": sourceFingerprint, diff --git a/tools/release/wasix-typescript-package.mjs b/tools/release/wasix-typescript-package.mjs index 9d09be16d..d73ea7f98 100644 --- a/tools/release/wasix-typescript-package.mjs +++ b/tools/release/wasix-typescript-package.mjs @@ -176,6 +176,15 @@ export function assertWasixTypescriptNpmArchive(archive) { } return Buffer.from(entry.data()); }; + for (const name of [ + 'lib/node-web-worker.js', + 'lib/node-web-worker-thread.js', + 'lib/wasix-process.js', + ]) { + if (entries.has(`package/${name}`)) { + fail(`${path.basename(file)} retained retired transport artifact package/${name}`); + } + } const manifest = assertWasixTypescriptManifest( JSON.parse(requireFile('package.json').toString('utf8')), `${path.basename(file)} package.json`, @@ -191,8 +200,6 @@ export function assertWasixTypescriptNpmArchive(archive) { 'lib/node-worker.js', 'lib/node-worker-options.js', 'lib/node-zstd.js', - 'lib/node-web-worker.js', - 'lib/node-web-worker-thread.js', 'lib/server-runtime.js', 'lib/storage/bun.js', 'lib/storage/deno.js', diff --git a/tools/xtask/src/asset_checks.rs b/tools/xtask/src/asset_checks.rs index 8994b9d5e..372b1654a 100644 --- a/tools/xtask/src/asset_checks.rs +++ b/tools/xtask/src/asset_checks.rs @@ -267,7 +267,7 @@ pub(crate) fn verify_asset_manifest_hashes() -> Result<()> { &manifest.runtime.sha256, "runtime archive", )?; - let runtime_module = archive_entry_bytes(&runtime_archive, "oliphaunt/bin/oliphaunt")?; + let runtime_module = archive_entry_bytes(&runtime_archive, RUNTIME_MODULE_ARCHIVE_MEMBER)?; ensure_eq( &sha256_bytes(&runtime_module), &manifest.runtime.module_sha256, @@ -987,6 +987,9 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "WASIX_HOME:=/opt/wasixcc-home/.wasixcc", "ln -s \"$WASIX_HOME\" \"$HOME/.wasixcc\"", "export PATH=\"$WASIX_HOME/bin:$PATH\"", + "oliphaunt_wasix_verify_side_module_sjlj", + "WASIX side module imports out-of-line sigsetjmp", + "/\"sigsetjmp\"/", ], )?; for path in wasix_build_scripts_requiring_docker_env()? { @@ -1100,6 +1103,7 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "PostgreSQL $(postgres_version)", "--includedir-server", "$BUILD_DIR/src/include", + "-DOLIPHAUNT_WASM_SIDE_MODULE", ], )?; ensure_file_contains_all( @@ -1117,7 +1121,6 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "oliphaunt_wasix_set_active", "oliphaunt_wasix_longjmp", "oliphaunt_wasix_siglongjmp", - "memcmp(env, (void *) postgresmain_sigjmp_buf, sizeof(jmp_buf)) == 0", "oliphaunt_wasix_getegid", "oliphaunt_wasix_getpwuid_r", "oliphaunt_wasix_run_atexit_funcs", @@ -1200,8 +1203,17 @@ pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { "oliphaunt_wasix_run_extension_build_in_docker_if_needed", "oliphaunt_wasix_extension_build_outputs_exist", "ac_cv_lib_xml2_xmlInitParser=yes", + "oliphaunt_wasix_verify_side_module_sjlj \"$postgis_deps_module\"", + "oliphaunt_wasix_verify_side_module_sjlj \"$POSTGIS_BUILD_DIR/postgis/postgis-3.so\"", ], )?; + for path in [ + "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", + ] { + ensure_file_contains_all(path, &["oliphaunt_wasix_verify_side_module_sjlj"])?; + } ensure_file_contains_all( "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh", &[ @@ -1295,8 +1307,7 @@ pub(crate) fn check_canonical_asset_layout_in(asset_dir: &Path, strict: bool) -> let runtime_entries = archive_entries(&runtime_archive)?; let required_paths = [ - "oliphaunt/bin/oliphaunt", - "oliphaunt/bin/postgres", + RUNTIME_MODULE_ARCHIVE_MEMBER, "oliphaunt/bin/initdb", "oliphaunt/lib/postgresql/dict_snowball.so", "oliphaunt/lib/postgresql/plpgsql.so", @@ -1356,6 +1367,7 @@ pub(crate) fn check_canonical_asset_layout_in(asset_dir: &Path, strict: bool) -> "oliphaunt/lib/dict_snowball.so", "oliphaunt/bin/pg_dump", "oliphaunt/bin/psql", + "oliphaunt/bin/oliphaunt", ] { if runtime_entries.contains(forbidden) || runtime_entries diff --git a/tools/xtask/src/asset_pipeline.rs b/tools/xtask/src/asset_pipeline.rs index 300431e8e..0d2a54d65 100644 --- a/tools/xtask/src/asset_pipeline.rs +++ b/tools/xtask/src/asset_pipeline.rs @@ -362,7 +362,7 @@ impl BuildOutputs { let runtime_path = base.join("runtime/oliphaunt"); write_bytes_file( &runtime_path, - &archive_entry_bytes(&runtime_archive, "oliphaunt/bin/oliphaunt")?, + &archive_entry_bytes(&runtime_archive, RUNTIME_MODULE_ARCHIVE_MEMBER)?, )?; let mut modules = vec![BuildModuleOutput { @@ -981,8 +981,13 @@ pub(crate) fn check_source_controlled_wasix_export_list() -> Result<()> { "oliphaunt_wasix_protocol_stream_active", "oliphaunt_wasix_start", "oliphaunt_wasix_set_protocol_transport", - "oliphaunt_wasix_input_write", - "oliphaunt_wasix_output_read", + "oliphaunt_wasix_input_reserve", + "oliphaunt_wasix_input_commit", + "oliphaunt_wasix_output_data", + "oliphaunt_wasix_output_contains_error", + "__wasm_longjmp", + "__wasm_setjmp", + "__wasm_setjmp_test", "malloc", "free", ] { @@ -1201,29 +1206,7 @@ fn wasix_export_list_from_modules(modules: &[BuildModuleManifestOut]) -> Result< } pub(crate) fn required_runtime_abi_exports() -> &'static [&'static str] { - &[ - "_start", - "oliphaunt_wasix_set_active", - "oliphaunt_wasix_start", - "oliphaunt_wasix_get_proc_port", - "ProcessStartupPacket", - "oliphaunt_wasix_send_conn_data", - "oliphaunt_wasix_pq_flush", - "pq_buffer_remaining_data", - "PostgresMainLoopOnce", - "PostgresSendReadyForQueryIfNecessary", - "PostgresMainLongJmp", - "oliphaunt_wasix_set_protocol_stdio", - "oliphaunt_wasix_set_force_host_error_recovery", - "oliphaunt_wasix_protocol_stream_active", - "oliphaunt_wasix_input_reset", - "oliphaunt_wasix_input_write", - "oliphaunt_wasix_input_available", - "oliphaunt_wasix_output_reset", - "oliphaunt_wasix_output_len", - "oliphaunt_wasix_output_read", - "oliphaunt_wasix_set_protocol_transport", - ] + REQUIRED_RUNTIME_ABI_EXPORTS } const WASIX_LINKER_RUNTIME_EXPORTS: &[&str] = &[ @@ -2185,7 +2168,6 @@ fn stage_runtime_tree(build: &Path, source: &Path, runtime: &Path) -> Result<()> fs::create_dir_all(&lib).with_context(|| format!("create {}", lib.display()))?; fs::create_dir_all(&share).with_context(|| format!("create {}", share.display()))?; - copy_file(&build.join("src/backend/oliphaunt"), &bin.join("oliphaunt"))?; copy_file(&build.join("src/backend/oliphaunt"), &bin.join("postgres"))?; copy_file(&build.join("src/bin/initdb/initdb"), &bin.join("initdb"))?; fs::write(runtime.join("password"), b"password\n") @@ -3491,7 +3473,7 @@ pub(crate) fn update_staged_root_asset_metadata(workspace: &Path) -> Result<()> let asset_dir = workspace.join(GENERATED_ASSETS_DIR); let manifest = read_asset_manifest_from(&asset_dir)?; let runtime_archive = asset_dir.join(&manifest.runtime.archive); - let runtime_module = archive_entry_bytes(&runtime_archive, "oliphaunt/bin/oliphaunt")?; + let runtime_module = archive_entry_bytes(&runtime_archive, RUNTIME_MODULE_ARCHIVE_MEMBER)?; update_root_asset_metadata_in( workspace, &asset_dir, diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs index c0e7bc223..f81cb0c1f 100644 --- a/tools/xtask/src/main.rs +++ b/tools/xtask/src/main.rs @@ -71,6 +71,7 @@ const DEFAULT_ASSET_BUILD_PROFILE: &str = "release"; const SOURCE_CHECKOUT_ROOT: &str = "target/oliphaunt-sources/checkouts"; const GENERATED_ASSETS_DIR: &str = "target/oliphaunt-wasix/assets"; const GENERATED_AOT_DIR: &str = "target/oliphaunt-wasix/aot"; +const RUNTIME_MODULE_ARCHIVE_MEMBER: &str = "oliphaunt/bin/postgres"; const ASSET_CRATE_PAYLOAD_DIR: &str = "src/runtimes/liboliphaunt/wasix/crates/assets/payload"; const RELEASE_STAGE_DIR: &str = "target/oliphaunt-wasix/release"; const RELEASE_ASSET_BUNDLE_DIR: &str = "target/oliphaunt-wasix/release-assets"; @@ -89,11 +90,13 @@ const RUST_HOST_REQUIRED_RUNTIME_EXPORTS: &[&str] = &[ "PostgresMainLongJmp", "oliphaunt_wasix_protocol_stream_active", "oliphaunt_wasix_input_reset", - "oliphaunt_wasix_input_write", + "oliphaunt_wasix_input_reserve", + "oliphaunt_wasix_input_commit", "oliphaunt_wasix_input_available", "oliphaunt_wasix_output_reset", "oliphaunt_wasix_output_len", - "oliphaunt_wasix_output_read", + "oliphaunt_wasix_output_data", + "oliphaunt_wasix_output_contains_error", ]; const RUST_HOST_OPTIONAL_RUNTIME_EXPORTS: &[&str] = &[ "oliphaunt_wasix_set_force_host_error_recovery", @@ -103,10 +106,33 @@ const RUST_HOST_OPTIONAL_RUNTIME_EXPORTS: &[&str] = &[ "oliphaunt_wasix_set_protocol_transport", ]; const RUNTIME_EXPORT_LIST_COMPAT_EXPORTS: &[&str] = &[ - "oliphaunt_wasix_set_protocol_stdio", "oliphaunt_wasix_set_force_host_error_recovery", "oliphaunt_wasix_set_protocol_transport", ]; +const REQUIRED_RUNTIME_ABI_EXPORTS: &[&str] = &[ + "_start", + "oliphaunt_wasix_set_active", + "oliphaunt_wasix_start", + "oliphaunt_wasix_get_proc_port", + "ProcessStartupPacket", + "oliphaunt_wasix_send_conn_data", + "oliphaunt_wasix_pq_flush", + "pq_buffer_remaining_data", + "PostgresMainLoopOnce", + "PostgresSendReadyForQueryIfNecessary", + "PostgresMainLongJmp", + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_protocol_stream_active", + "oliphaunt_wasix_input_reset", + "oliphaunt_wasix_input_reserve", + "oliphaunt_wasix_input_commit", + "oliphaunt_wasix_input_available", + "oliphaunt_wasix_output_reset", + "oliphaunt_wasix_output_len", + "oliphaunt_wasix_output_data", + "oliphaunt_wasix_output_contains_error", + "oliphaunt_wasix_set_protocol_transport", +]; const PG18_POSTGRES_HOST_EXPORTS: &[&str] = &[ "ProcessStartupPacket", "oliphaunt_wasix_start", diff --git a/tools/xtask/src/postgres_guard.rs b/tools/xtask/src/postgres_guard.rs index 4835f6e32..ccf575ac5 100644 --- a/tools/xtask/src/postgres_guard.rs +++ b/tools/xtask/src/postgres_guard.rs @@ -96,6 +96,7 @@ pub(crate) fn check_postgres_source_spine() -> Result<()> { "0041-oliphaunt-wasix-specialize-single-backend-atomics.patch", "0042-oliphaunt-wasix-buffer-strong-random.patch", "0043-oliphaunt-wasix-disable-unsupported-writeback-hints.patch", + "0044-oliphaunt-wasix-inline-sigsetjmp.patch", ] { ensure!( series.contains(&required), @@ -178,6 +179,19 @@ pub(crate) fn check_postgres_source_spine() -> Result<()> { "#elif defined(HAVE_SYNC_FILE_RANGE)", ], )?; + ensure_file_contains_all( + &Path::new(POSTGRES_PATCH_DIR).join("0044-oliphaunt-wasix-inline-sigsetjmp.patch"), + &[ + "src/Makefile.shlib", + "src/include/port/wasix-dl.h", + "src/makefiles/pgxs.mk", + "-DOLIPHAUNT_WASM_SIDE_MODULE", + "WebAssembly SJLJ requires setjmp to be visible at the protected call site.", + "defined(__wasm_exception_handling__) && defined(OLIPHAUNT_WASM_SIDE_MODULE)", + "#undef sigsetjmp", + "#define sigsetjmp(env, savesigs) ((void) (savesigs), setjmp(env))", + ], + )?; for entry in fs::read_dir(POSTGRES_PATCH_DIR).with_context(|| format!("read {POSTGRES_PATCH_DIR}"))? @@ -708,8 +722,8 @@ fn collect_pg18_legacy_symbol_leaks( fn check_postgres_patch_series_hygiene(patches: &[(String, String)]) -> Result<()> { ensure!( - patches.len() == 43, - "PG18 WASIX patch series should stay reviewable at exactly 43 audited patches; got {}", + patches.len() == 44, + "PG18 WASIX patch series should stay reviewable at exactly 44 audited patches; got {}", patches.len() ); for (index, (patch_name, patch_text)) in patches.iter().enumerate() { @@ -924,6 +938,9 @@ fn check_postgres_applied_runtime_abi(source: &Path) -> Result<()> { "OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT", "OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH", "extern void oliphaunt_wasix_protocol_report_copy_response(int state)", + "defined(__wasm_exception_handling__) && defined(OLIPHAUNT_WASM_SIDE_MODULE)", + "#undef sigsetjmp", + "#define sigsetjmp(env, savesigs) ((void) (savesigs), setjmp(env))", ], )?; ensure_file_contains_all( @@ -998,15 +1015,16 @@ fn check_postgres_applied_runtime_abi(source: &Path) -> Result<()> { &[ "oliphaunt_wasix_set_active", "oliphaunt_wasix_set_force_host_error_recovery", - "oliphaunt_wasix_set_protocol_stdio", "oliphaunt_wasix_set_protocol_transport", "oliphaunt_wasix_protocol_stream_active", "oliphaunt_wasix_input_reset", - "oliphaunt_wasix_input_write", + "oliphaunt_wasix_input_reserve", + "oliphaunt_wasix_input_commit", "oliphaunt_wasix_input_available", "oliphaunt_wasix_output_reset", "oliphaunt_wasix_output_len", - "oliphaunt_wasix_output_read", + "oliphaunt_wasix_output_data", + "oliphaunt_wasix_output_contains_error", ], )?; Ok(()) @@ -1688,7 +1706,8 @@ pub(crate) fn check_rust_startup_abi_boundary() -> Result<()> { "fn record_backend_c_timings", "oliphaunt_wasix_backend_timing_reset", "oliphaunt_wasix_backend_timing_elapsed_us", - "host_requires_process_exit_error_recovery", + "fn host_requires_process_exit_error_recovery() -> bool", + "cfg!(target_env = \"msvc\")", "oliphaunt_wasix_set_force_host_error_recovery", "oliphaunt_wasix_set_protocol_transport", "oliphaunt_wasix_protocol_stream_active", diff --git a/tools/xtask/src/template_runner.rs b/tools/xtask/src/template_runner.rs index d8844ec36..766376c67 100644 --- a/tools/xtask/src/template_runner.rs +++ b/tools/xtask/src/template_runner.rs @@ -45,7 +45,7 @@ pub(crate) fn run_wasix_initdb_template(runtime_stage: &Path, work_root: &Path) &package_dir.join("modules/initdb.wasm"), )?; copy_file( - &runtime_stage.join("bin/oliphaunt"), + &runtime_stage.join("bin/postgres"), &package_dir.join("modules/postgres.wasm"), )?; let wasmer_toml = r#"