From 82c81ad15348246999a133a3ecf67a956273f8e0 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 10 Sep 2026 10:56:00 -0700 Subject: [PATCH 01/12] Use released socket2 and wasm-streams rlib-only branch Drop the wasm-streams checkout and cdylib patch in favor of guybedford/wasm-streams (MattiasBuelens/wasm-streams PR pending). The socket2 git rev was exactly the v0.6.5 release. --- AGENTS.md | 2 +- Cargo.lock | 4 +++- Cargo.toml | 3 +-- docs/dependencies.md | 4 +--- patches/README.md | 1 - patches/wasm-streams-rlib.patch | 25 ------------------------- scripts/setup.sh | 2 -- 7 files changed, 6 insertions(+), 35 deletions(-) delete mode 100644 patches/wasm-streams-rlib.patch diff --git a/AGENTS.md b/AGENTS.md index a5418ab..c56eaa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ process to free a port. ## Reproducibility and data -Changes to patched checkouts (Pumpkin, wasm-bindgen, workers-rs, wasm-streams) must +Changes to patched checkouts (Pumpkin, wasm-bindgen, workers-rs) must be reflected in `patches/`. Keep unpatched checkouts unmodified; update pins for upstream changes. Preserve the unified ticker and cooperative scheduler unless the task requires a runtime change. Do not reintroduce old Tokio/libc networking diff --git a/Cargo.lock b/Cargo.lock index 30dfaff..b0c9c8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2421,7 +2421,8 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" version = "0.6.5" -source = "git+https://github.com/rust-lang/socket2?rev=239dd83a4ced08e514d2c38942aab99791119f0d#239dd83a4ced08e514d2c38942aab99791119f0d" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys", @@ -2938,6 +2939,7 @@ dependencies = [ [[package]] name = "wasm-streams" version = "0.6.0" +source = "git+https://github.com/guybedford/wasm-streams?rev=115f0f27380a4f5fa33cbd9427a7107ec94cb557#115f0f27380a4f5fa33cbd9427a7107ec94cb557" dependencies = [ "futures-util", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index ce459aa..513f051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,11 +40,10 @@ wasm-bindgen-futures = { path = ".work/wasm-bindgen/crates/futures" } wasm-bindgen-macro-support = { path = ".work/wasm-bindgen/crates/macro-support" } wasm-bindgen-shared = { path = ".work/wasm-bindgen/crates/shared" } web-sys = { path = ".work/wasm-bindgen/crates/web-sys" } -wasm-streams = { path = ".work/wasm-streams" } +wasm-streams = { git = "https://github.com/guybedford/wasm-streams", rev = "115f0f27380a4f5fa33cbd9427a7107ec94cb557" } libc = { path = ".work/libc" } mio = { git = "https://github.com/guybedford/mio", rev = "a62c9e46833fc255c9217ab9aa362c6221ed4401" } ring = { path = ".work/ring" } -socket2 = { git = "https://github.com/rust-lang/socket2", rev = "239dd83a4ced08e514d2c38942aab99791119f0d" } tokio = { path = ".work/tokio/tokio" } tokio-macros = { path = ".work/tokio/tokio-macros" } diff --git a/docs/dependencies.md b/docs/dependencies.md index 6dbb09c..ee9c865 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -14,7 +14,6 @@ are ignored under `.work/`. Both Cargo and npm dependency graphs are locked. | ring | [emscripten](https://github.com/guybedford/ring) | `6671f7cfbb13f249b571ffa6326275a8596e0ca2` | Portable Emscripten crypto backend; unmodified | | pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Headless embedding, shared async scheduler, compact templates/generation chunks, and build-script fixes | | workers-rs | [connect-bindings](https://github.com/ThomasRubini/workers-rs) | `7db011ec97658a5d907f3e3102028ce86c044f19` | TCP ingress PR #1041; pinned ABI, Emscripten Tokio promise adapter, host-owned initialization, and socket shutdown flushing | -| wasm-streams | [v0.6.0](https://github.com/MattiasBuelens/wasm-streams) | `35665f7b1da830b5ac51b4c6c3ff13f5c1a09ccb` | Build only the Rust library; its standalone `cdylib` is incompatible with static Emscripten linking | Patches are relative to the pinned commits above. Setup checks reverse application before applying a patch and refuses to repin a modified checkout. @@ -29,7 +28,7 @@ commit. | Crate | Source | Commit | | --- | --- | --- | | mio | https://github.com/guybedford/mio | `a62c9e46833fc255c9217ab9aa362c6221ed4401` | -| socket2 | https://github.com/rust-lang/socket2 | `239dd83a4ced08e514d2c38942aab99791119f0d` | +| wasm-streams | https://github.com/guybedford/wasm-streams | `115f0f27380a4f5fa33cbd9427a7107ec94cb557` | The root Cargo.toml applies these overrides and the local checkouts. Cargo.lock pins the full application graph. `toolchain/wasm-bindgen.Cargo.lock` separately pins @@ -61,7 +60,6 @@ git -C .work/pumpkin diff b5b9b9d7010e793806a83c495af223c67e1d35ee -- \ crates/pumpkin-world/src/generation > .work/pumpkin-memory.diff git -C .work/wasm-bindgen diff 4b69f3b3ba4212c857be6854f77fa5aec8b62871 -- crates/cli-support/src/js/mod.rs > .work/wasm-bindgen-emscripten-closures.diff git -C .work/workers-rs diff 7db011ec97658a5d907f3e3102028ce86c044f19 > .work/workers-rs-emscripten-toolchain.diff -git -C .work/wasm-streams diff 35665f7b1da830b5ac51b4c6c3ff13f5c1a09ccb > .work/wasm-streams-rlib.diff ``` Replace the corresponding patch's contents from its first `diff --git` line onward diff --git a/patches/README.md b/patches/README.md index 945c45b..86af881 100644 --- a/patches/README.md +++ b/patches/README.md @@ -13,7 +13,6 @@ diff below it. Preserve the description when regenerating the diff. | `pumpkin-memory.patch` | `.work/pumpkin` | Shared indexed structure templates, immutable block-entity NBT, paletted generation chunks, and a temporary dense noise buffer | | `wasm-bindgen-emscripten-closures.patch` | `.work/wasm-bindgen` | Escape generated closure-finalizer postsets as JavaScript strings | | `workers-rs-emscripten-toolchain.patch` | `.work/workers-rs` | Match the pinned wasm-bindgen ABI, provide the Emscripten Tokio promise adapter, leave initialization/recovery to the host, and flush pending writes before socket shutdown | -| `wasm-streams-rlib.patch` | `.work/wasm-streams` | Omit the standalone cdylib when embedding the stream adapter in Emscripten | `pumpkin-emscripten.patch` includes the injected-stream connection entry point used by the DO. Setup applies it before `pumpkin-memory.patch`. The two patches use the diff --git a/patches/wasm-streams-rlib.patch b/patches/wasm-streams-rlib.patch deleted file mode 100644 index 8afa042..0000000 --- a/patches/wasm-streams-rlib.patch +++ /dev/null @@ -1,25 +0,0 @@ -Subject: [PATCH] Build wasm-streams as an rlib for Emscripten - -The embedded application links wasm-streams into its final WebAssembly -module. Declaring both rlib and cdylib also makes Cargo attempt a standalone -library link, which is incompatible with this static Emscripten build. - -Build only the rlib so the application supplies the final link and host -bindings. The Rust stream adapter API is unchanged. - -Base-commit: 35665f7b1da830b5ac51b4c6c3ff13f5c1a09ccb - ---- -diff --git a/Cargo.toml b/Cargo.toml -index c57b1c6..52f6805 100644 ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -16,7 +16,7 @@ exclude = [ - ] - - [lib] --crate-type = ["cdylib", "rlib"] -+crate-type = ["rlib"] - - [dependencies] - js-sys = "^0.3.85" diff --git a/scripts/setup.sh b/scripts/setup.sh index a640927..cdb584e 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -50,12 +50,10 @@ checkout libc https://github.com/guybedford/libc libc-0.2-emscripten 4091fe0b0dc checkout ring https://github.com/guybedford/ring emscripten 6671f7cfbb13f249b571ffa6326275a8596e0ca2 checkout pumpkin https://github.com/Pumpkin-MC/Pumpkin master b5b9b9d7010e793806a83c495af223c67e1d35ee checkout workers-rs https://github.com/ThomasRubini/workers-rs connect-bindings 7db011ec97658a5d907f3e3102028ce86c044f19 -checkout wasm-streams https://github.com/MattiasBuelens/wasm-streams main 35665f7b1da830b5ac51b4c6c3ff13f5c1a09ccb apply_patch wasm-bindgen wasm-bindgen-emscripten-closures.patch apply_patch pumpkin pumpkin-emscripten.patch apply_patch pumpkin pumpkin-memory.patch apply_patch workers-rs workers-rs-emscripten-toolchain.patch -apply_patch wasm-streams wasm-streams-rlib.patch if [ "${1:-}" = --sources-only ]; then exit 0; fi From 4cb27347a0478cdcb33f7788db975cde37127dd9 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 10 Sep 2026 19:32:49 -0700 Subject: [PATCH 02/12] Run Pumpkin bottom-up on stock Tokio under JSPI One perpetual `#[wasm_bindgen(jspi)]` export builds a current-thread runtime and `block_on`s the whole server lifetime; every park suspends the Wasm stack on `epoll_wait`, so the hosted runtime adapter is gone. Pumpkin binds its stock `TcpListener` on 25565 inside the Durable Object's port table, and the object routes each inbound socket to it with `handleAsNodeConnection`, replacing the injected-stream entry point, wasm-streams, and the workers-rs dependency. `stop` cancels the server and the run promise settling is the checkpoint signal. `-sREENTRANT_JSPI` gives each activation its own shadow stack, so other entries into the module while the server is suspended cannot clobber its frames. Toolchain: Rust beta; emscripten main plus the JSPI hooks, reentrant JSPI and epoll listener PRs, with the paired emscripten-releases LLVM and the jspi-hooks Binaryen branch built by setup; wasm-bindgen 0.2.128 CLI via `-sWASM_BINDGEN`; exnref exception handling throughout; tokio `emscripten-epoll`, mio tokio-rs/mio#1969, libc `libc-0.2`. rustc needs a larger compile-thread stack for pumpkin-data. The wasm-bindgen and workers-rs patches and the CLI lockfile are gone; the Pumpkin patch drops the injected-stream entry point. Requires a workerd with per-Durable-Object port tables and `net.Server` inbound routing (`MINIFLARE_WORKERD_PATH`). CI moves to Linux. --- .cargo/config.toml | 15 +- .github/workflows/ci.yml | 13 +- AGENTS.md | 7 +- Cargo.lock | 446 +- Cargo.toml | 25 +- README.md | 24 +- docs/architecture.md | 45 +- docs/dependencies.md | 62 +- docs/development.md | 3 +- patches/README.md | 5 +- patches/pumpkin-emscripten.patch | 481 +- .../wasm-bindgen-emscripten-closures.patch | 30 - patches/workers-rs-emscripten-toolchain.patch | 184 - rust-toolchain.toml | 2 +- scripts/common.sh | 22 +- scripts/setup.sh | 75 +- src/config.rs | 5 +- src/main.rs | 108 +- tests/fixtures/filesystem.rs | 53 +- tests/fixtures/worker.mjs | 20 +- tests/integration.mjs | 2 +- toolchain/wasm-bindgen.Cargo.lock | 4510 ----------------- worker/index.ts | 3 +- worker/modules.d.ts | 12 +- worker/pumpkin.ts | 21 +- worker/world.ts | 1 + 26 files changed, 559 insertions(+), 5615 deletions(-) delete mode 100644 patches/wasm-bindgen-emscripten-closures.patch delete mode 100644 patches/workers-rs-emscripten-toolchain.patch delete mode 100644 toolchain/wasm-bindgen.Cargo.lock diff --git a/.cargo/config.toml b/.cargo/config.toml index aceda7e..ddb88b3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,16 +4,23 @@ target-dir = "target/workers" [target.wasm32-unknown-emscripten] rustflags = [ - # Static linking and unwind semantics are required by the hosted runtime. "-Crelocation-model=static", - "--cfg=tokio_unstable", - "-Clink-arg=-sWASM_BINDGEN=auto", + # exnref exception handling throughout: the C side gets `-fwasm-exceptions + # -sWASM_LEGACY_EXCEPTIONS=0` from EMCC_CFLAGS (scripts/common.sh), emcc + # translates the prebuilt std at link, and wasm-bindgen's JSPI wrappers use + # try_table. V8 rejects a module mixing the two. + "-Cllvm-args=-wasm-use-legacy-eh=false", + # Link-only settings stay out of EMCC_CFLAGS so they do not reach C compiles. + "-Clink-arg=-sWASM_BINDGEN", + "-Clink-arg=-sJSPI", + # Each promising activation runs on its own shadow stack, so a request or + # timer entering the module while the server is suspended cannot clobber it. + "-Clink-arg=-sREENTRANT_JSPI", "-Clink-arg=-sEXPORT_ES6", # Generated host glue uses Workers' Node compatibility APIs. "-Clink-arg=-sENVIRONMENT=node", "-Clink-arg=-sNODERAWSOCKETS", "-Clink-arg=-sNODERAWFS", - "-Clink-arg=-sJSPI", "-Clink-arg=-sASSERTIONS=0", "-Clink-arg=-sSTACK_SIZE=8MB", "-Clink-arg=-sALLOW_MEMORY_GROWTH=1", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb185c..4778200 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ concurrency: jobs: smoke: - runs-on: macos-14 - timeout-minutes: 75 + runs-on: ubuntu-latest + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -25,18 +25,21 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.13' - - name: Install Emscripten backend - run: brew install emscripten + - run: sudo apt-get install -y ninja-build - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git - key: minecraft-cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + .work/emsdk + .work/binaryen/build + key: minecraft-toolchain-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', 'scripts/setup.sh') }} - name: Setup pinned sources and toolchain run: bash scripts/setup.sh - name: Install Worker dependencies run: npm ci + # Inbound net.Server routing inside Durable Objects is not yet in Wrangler's + # bundled workerd; set MINIFLARE_WORKERD_PATH to a build that has it. - name: Worker TCP, shared world, and SQLite restart run: | set -o pipefail diff --git a/AGENTS.md b/AGENTS.md index c56eaa3..1096947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,13 @@ Read [README.md](README.md), [architecture](docs/architecture.md), and ## Project layout -- `src/`: Pumpkin entry points and configuration. The hosted Tokio bridge lives in workers-rs. +- `src/`: Pumpkin entry points and configuration. - `worker/`: TCP ingress, Durable Object lifecycle, and SQLite filesystem. - `tests/`: protocol clients, integration tests, and isolated filesystem fixtures. - `scripts/setup.sh`: provision pinned sources and build the toolchain. - `scripts/{build,serve,test}.sh`: build, run, and validate the Workers server. -Use Node 24+ (26 recommended) and the pinned Rust nightly. Run `npm test` after +Use Node 24+ (26 recommended) and the pinned Rust toolchain. Run `npm test` after build/runtime changes; it must print `PUMPKIN-DO-SQLITE-RESTART-OK` after verifying player and chunk restoration. Keep test fixtures out of the production bundle. Scripts bind to loopback and fail if their ports are occupied. Do not kill another @@ -19,8 +19,7 @@ process to free a port. ## Reproducibility and data -Changes to patched checkouts (Pumpkin, wasm-bindgen, workers-rs) must -be reflected in `patches/`. Keep unpatched checkouts unmodified; update pins for +Changes to the patched Pumpkin checkout must be reflected in `patches/`. Keep unpatched checkouts unmodified; update pins for upstream changes. Preserve the unified ticker and cooperative scheduler unless the task requires a runtime change. Do not reintroduce old Tokio/libc networking patches. diff --git a/Cargo.lock b/Cargo.lock index b0c9c8a..da0cad1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.44" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" dependencies = [ "compression-codecs", "compression-core", @@ -51,9 +51,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -107,9 +107,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -164,9 +164,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "shlex", @@ -195,26 +195,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", "rand_core", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "js-sys", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "cipher" version = "0.5.2" @@ -261,9 +250,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" dependencies = [ "compression-core", "flate2", @@ -305,9 +294,9 @@ checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -324,9 +313,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -339,9 +328,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -352,18 +341,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -371,27 +360,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-bigint" @@ -493,9 +482,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ "const-oid", "pem-rfc7468", @@ -532,13 +521,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -579,9 +568,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -654,9 +643,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" @@ -686,9 +675,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -701,9 +690,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -711,15 +700,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -728,38 +717,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -807,9 +796,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -882,9 +871,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -902,9 +891,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -921,9 +910,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "subtle", "typenum", @@ -932,9 +921,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -991,9 +980,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1005,9 +994,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1018,9 +1007,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1032,16 +1021,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1052,15 +1042,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1107,9 +1097,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1126,9 +1116,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itertools" @@ -1196,7 +1186,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -1212,12 +1204,13 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.189" +source = "git+https://github.com/rust-lang/libc?branch=libc-0.2#31503352774deadf65cc53257b59ee4f3299f644" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1230,9 +1223,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lz4-java-wrc" @@ -1259,12 +1252,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "md-5" version = "0.10.6" @@ -1496,26 +1483,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1569,9 +1536,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1845,7 +1812,6 @@ dependencies = [ "rayon", "tokio", "wasm-bindgen", - "worker", ] [[package]] @@ -1950,9 +1916,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1967,11 +1933,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "h2", @@ -2014,7 +1980,8 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.14" +version = "0.17.16000" +source = "git+https://github.com/guybedford/ring?branch=emscripten#a1930ce8d9b8006e5060eda14cacf7cf6f7401c1" dependencies = [ "cc", "cfg-if", @@ -2069,9 +2036,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "log", "once_cell", @@ -2096,9 +2063,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -2132,9 +2099,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -2153,12 +2120,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -2236,17 +2197,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde_core" version = "1.0.229" @@ -2300,18 +2250,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serdect" version = "0.4.3" @@ -2414,9 +2352,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "socket2" @@ -2465,27 +2403,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "subtle" version = "2.6.1" @@ -2536,18 +2453,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -2565,9 +2482,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -2597,9 +2514,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2607,7 +2524,7 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" dependencies = [ "bytes", "libc", @@ -2620,11 +2537,11 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -2654,9 +2571,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.5+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "serde_core", "serde_spanned", @@ -2849,9 +2766,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom", "js-sys", @@ -2893,7 +2810,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2904,7 +2823,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -2912,7 +2833,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2920,37 +2843,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.6.0" -source = "git+https://github.com/guybedford/wasm-streams?rev=115f0f27380a4f5fa33cbd9427a7107ec94cb557#115f0f27380a4f5fa33cbd9427a7107ec94cb557" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3007,64 +2924,11 @@ dependencies = [ "primefield", ] -[[package]] -name = "worker" -version = "0.8.5" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures-channel", - "futures-util", - "http", - "http-body", - "js-sys", - "matchit", - "pin-project", - "serde", - "serde-wasm-bindgen", - "serde_json", - "serde_urlencoded", - "strum", - "tokio", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "worker-macros", - "worker-sys", -] - -[[package]] -name = "worker-macros" -version = "0.8.5" -dependencies = [ - "async-trait", - "proc-macro2", - "quote", - "strum", - "syn 3.0.5", - "wasm-bindgen", - "wasm-bindgen-macro-support", - "worker-sys", -] - -[[package]] -name = "worker-sys" -version = "0.8.5" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xxhash-rust" @@ -3124,9 +2988,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3135,9 +2999,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3146,13 +3010,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 513f051..1e7f0ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,31 +25,26 @@ name = "pumpkin-do" path = "src/main.rs" [dependencies] -wasm-bindgen = { path = ".work/wasm-bindgen" } -js-sys = { path = ".work/wasm-bindgen/crates/js-sys" } +wasm-bindgen = "0.2.128" +js-sys = "0.3.105" tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync", "time", "net", "io-util"] } pumpkin = { path = ".work/pumpkin/crates/pumpkin", default-features = false, features = ["single-threaded"] } pumpkin-config = { path = ".work/pumpkin/crates/pumpkin-config", default-features = false } rayon = "1.12" -worker = { path = ".work/workers-rs/worker", default-features = false } +# wasm32-unknown-emscripten support pending upstream releases. [patch.crates-io] -js-sys = { path = ".work/wasm-bindgen/crates/js-sys" } -wasm-bindgen = { path = ".work/wasm-bindgen" } -wasm-bindgen-futures = { path = ".work/wasm-bindgen/crates/futures" } -wasm-bindgen-macro-support = { path = ".work/wasm-bindgen/crates/macro-support" } -wasm-bindgen-shared = { path = ".work/wasm-bindgen/crates/shared" } -web-sys = { path = ".work/wasm-bindgen/crates/web-sys" } -wasm-streams = { git = "https://github.com/guybedford/wasm-streams", rev = "115f0f27380a4f5fa33cbd9427a7107ec94cb557" } -libc = { path = ".work/libc" } -mio = { git = "https://github.com/guybedford/mio", rev = "a62c9e46833fc255c9217ab9aa362c6221ed4401" } -ring = { path = ".work/ring" } tokio = { path = ".work/tokio/tokio" } tokio-macros = { path = ".work/tokio/tokio-macros" } +# tokio-rs/mio#1969; its emscripten epoll bindings come from libc's unreleased libc-0.2 branch. +mio = { git = "https://github.com/guybedford/mio", rev = "a62c9e46833fc255c9217ab9aa362c6221ed4401" } +libc = { git = "https://github.com/rust-lang/libc", branch = "libc-0.2" } +# getrandom-backed SystemRandom on emscripten. +ring = { git = "https://github.com/guybedford/ring", branch = "emscripten" } +# mio's own libc source, unified with the crates.io users above. [patch."https://github.com/guybedford/libc"] -libc = { path = ".work/libc" } - +libc = { git = "https://github.com/rust-lang/libc", branch = "libc-0.2" } [profile.release] opt-level = "s" diff --git a/README.md b/README.md index 3fc6595..3a03ec5 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,25 @@ Minecraft → Workers TCP ingress → MinecraftWorld → Pumpkin + SQLite ## Try it -The supported build host is macOS with Homebrew. Install Git, -[rustup](https://rustup.rs/), Python 3.11+, and Node 24+ (26 recommended). -From a checkout of this repository: +Linux and macOS build hosts are supported. Install Git, +[rustup](https://rustup.rs/), Python 3.11+, CMake and Ninja, and Node 24+ (26 +recommended). From a checkout of this repository: ```sh -brew install emscripten npm ci bash scripts/setup.sh npm run dev ``` -Setup installs the pinned Rust toolchain, fetches dependency sources, and builds -the matching wasm-bindgen CLI. `npm run dev` compiles Pumpkin and starts Wrangler. -The first build takes several minutes; later builds use Cargo's cache. +Setup installs the pinned Rust toolchain, fetches dependency sources and the +Emscripten toolchain under `.work/` (building Binaryen), and installs the +wasm-bindgen CLI. `npm run +dev` compiles Pumpkin and starts Wrangler. The first build takes several +minutes; later builds use Cargo's cache. + +Until Wrangler's bundled workerd routes inbound sockets to `net.Server` listeners +inside Durable Objects, point Miniflare at a workerd build that does: +`MINIFLARE_WORKERD_PATH=/path/to/workerd npm run dev`. Connect **Minecraft Java 26.2** to **`localhost:25565`**. Status is available at **http://localhost:8787/**. @@ -72,9 +77,8 @@ Provision the public TCP endpoint separately and route it to this Worker's ## Credits and license Built on [Pumpkin](https://github.com/Pumpkin-MC/Pumpkin), -[Guy Bedford's Rust/Emscripten work](https://github.com/guybedford), -[workers-rs](https://github.com/cloudflare/workers-rs), Thomas Rubini's TCP ingress -work, and [worker-fs-mount](https://github.com/danlapid/worker-fs-mount). +[Guy Bedford's Rust/Emscripten work](https://github.com/guybedford), and +[worker-fs-mount](https://github.com/danlapid/worker-fs-mount). Licensed under [GPL-3.0-only](LICENSE), consistent with Pumpkin. The [original MIT notice](LICENSES/rust-workers-minecraft-MIT.txt) is retained for diff --git a/docs/architecture.md b/docs/architecture.md index e41d442..1427a9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,17 +3,31 @@ ## Runtime and connections The Worker forwards TCP streams to a named Durable Object using the platform's -stream-piping API. The object lazily creates an Emscripten module and passes a -workers-rs `Socket` to Pumpkin's injected connection entry point. +stream-piping API. The object lazily creates an Emscripten module and calls its +single `pumpkin_run` export. That export is a `#[wasm_bindgen(jspi)]` function: it +builds a current-thread Tokio runtime and `block_on`s the whole server lifetime, +and every park (`epoll_wait`, timers) suspends the Wasm stack on the host event +loop. There is no host-driven scheduling and no Rust promise adapter; the run +promise settles only when the server has stopped and saved. `-sREENTRANT_JSPI` +gives each promising activation its own shadow stack, so status calls and +connection routing enter the module while the server is suspended without +touching its frames. + +Pumpkin binds its stock `TcpListener` on port 25565. Emscripten's Node socket +backend implements that with `net.BoundSocket`/`net.Server`, which workerd scopes +to the Durable Object's own port table, so every object binds the same port. The +object's `connect` handler routes each inbound socket to that listener with +`handleAsNodeConnection`; the accepted connection then surfaces through epoll +readiness and Pumpkin's normal accept loop. Accepted sockets report the bound +address and an unspecified peer. Each object owns separate wasm memory, Rust statics, filesystem descriptors, and -one hosted Tokio runtime, supplied by workers-rs. Pumpkin's `single-threaded` feature selects cooperative -inline chunk generation and the async ticker. The same scheduler loop serves -native builds, which dispatch generation onto Rayon. Save batches await queue -capacity; shutdown drains results with an elapsed-time timeout before the final -flush. Bounded CPU tasks use -regular Tokio tasks because the pinned fork's public `spawn_blocking` still -requires OS threads. JSPI supplies stack suspension, not threads. +its Tokio runtime. Pumpkin's `single-threaded` feature selects cooperative inline +chunk generation and the async ticker. The same scheduler loop serves native +builds, which dispatch generation onto Rayon. Save batches await queue capacity; +shutdown drains results with an elapsed-time timeout before the final flush. +Bounded CPU tasks use regular Tokio tasks because `spawn_blocking` requires OS +threads. JSPI supplies stack suspension, not threads. Structure templates store palette indices and share their block arrays between the placement and query APIs. Block-entity NBT is shared until a placement needs @@ -53,8 +67,8 @@ The upstream `entries` table stores metadata, and `file_pages` stores contents i sparse files, and file writes and native rename use SQLite transactions. Whole-file reads still require a buffer large enough for the result. -After the final connection leaves, Pumpkin saves and shuts down, and the object -awaits `storage.sync()`. New connections wait for this checkpoint, then create a +After the final connection leaves, the object requests a stop; Pumpkin saves, +`pumpkin_run` resolves, and the object awaits `storage.sync()`. New connections wait for this checkpoint, then create a fresh runtime from the stored files. Checkpoint failures remain visible in status. This preserves issued writes; terminating a server with active clients can still lose changes in Pumpkin's in-memory caches. @@ -81,14 +95,13 @@ storage under `.data/probes/` and restart Wrangler to verify restoration. The ec and large-file test fixture has its own Worker configuration and wasm binary; it is excluded from the application bundle. -Current workerd versions can report `Network connection lost` when TCP clients -disconnect. Rust panics, Rust error logs, and runtime crashes fail the integration -test. +Rust panics, Rust error logs, and runtime crashes fail the integration test. ## Build constraints -- Use the pinned Rust nightly and matching wasm-bindgen CLI from setup. -- Keep static relocation, unwind semantics, the 8 MiB stack, and memory growth. +- Use the pinned Rust toolchain, Emscripten, and wasm-bindgen CLI from setup. +- Keep static relocation, exnref exception handling on both the C and Rust + sides, the 8 MiB stack, and memory growth. - Pass Emscripten link settings through rustc so they do not affect C compilation. - Keep the compatibility initializer linked even when Rust does not call `fd_sync`; `build.rs` configures this and tracks the library as a build input. diff --git a/docs/dependencies.md b/docs/dependencies.md index ee9c865..a2d9ed5 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -1,19 +1,18 @@ # Dependency sources and pins A clean checkout is self-contained after `bash scripts/setup.sh`. Dependency sources -are ignored under `.work/`. Both Cargo and npm dependency graphs are locked. +and toolchains are ignored under `.work/`. Both Cargo and npm dependency graphs are +locked. ## Direct checkouts | Component | Source / branch | Commit | Local changes or purpose | | --- | --- | --- | --- | -| emscripten | [cf](https://github.com/guybedford/emscripten) | `21166256c4c4d73d39b3685c8973d7cbe427ce8c` | Fork frontend: epoll, JSPI, wasm-bindgen post-link | -| wasm-bindgen | [emscripten-non-identifier-names](https://github.com/guybedford/wasm-bindgen) | `4b69f3b3ba4212c857be6854f77fa5aec8b62871` | Closure-finalizer string escaping | -| tokio | [emscripten-layering](https://github.com/guybedford/tokio) | `7c1d4977c510866775ed6164b58b2218a6a2955b` | HostedRuntime and normal Tokio TCP/UDP APIs; unmodified | -| libc | [libc-0.2-emscripten](https://github.com/guybedford/libc) | `4091fe0b0dc5f9c1a27bed75be1ff02bb27e756d` | Emscripten epoll and socket support; unmodified | -| ring | [emscripten](https://github.com/guybedford/ring) | `6671f7cfbb13f249b571ffa6326275a8596e0ca2` | Portable Emscripten crypto backend; unmodified | | pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Headless embedding, shared async scheduler, compact templates/generation chunks, and build-script fixes | -| workers-rs | [connect-bindings](https://github.com/ThomasRubini/workers-rs) | `7db011ec97658a5d907f3e3102028ce86c044f19` | TCP ingress PR #1041; pinned ABI, Emscripten Tokio promise adapter, host-owned initialization, and socket shutdown flushing | +| tokio | [emscripten-epoll](https://github.com/guybedford/tokio) | `8d0a2a845c546e93a4cf0e8ff2c21ac50a3d1931` | JSPI parking and `net` over epoll on Emscripten (tokio-rs/tokio#8281 follow-ons); unmodified | +| emscripten | [cf-final](https://github.com/guybedford/emscripten) | `a5013dd597ec9d48856370f5707d438937a7b4e8` | Upstream main plus emscripten-core/emscripten#27698, #27699 (`REENTRANT_JSPI`), #27547, and two fixes pending for #27699; unmodified | +| binaryen | [jspi-hooks](https://github.com/guybedford/binaryen) | `d6483a04d7dab0ef83e5c43f87343061d97a3b8d` | WebAssembly/binaryen#9102, the `--jspi-hooks` pass; built by setup | +| emsdk | [main](https://github.com/emscripten-core/emsdk) | `5eb0bde7585670252e8ba05e9d361627bffd08b5` | LLVM from emscripten-releases build `e8579ea489b44a6792f5abf95377a6ee38a16cce`, paired with the frontend's main | Patches are relative to the pinned commits above. Setup checks reverse application before applying a patch and refuses to repin a modified checkout. @@ -25,26 +24,34 @@ commit. ## Cargo-managed forks -| Crate | Source | Commit | +| Crate | Source | Purpose | | --- | --- | --- | -| mio | https://github.com/guybedford/mio | `a62c9e46833fc255c9217ab9aa362c6221ed4401` | -| wasm-streams | https://github.com/guybedford/wasm-streams | `115f0f27380a4f5fa33cbd9427a7107ec94cb557` | +| mio | https://github.com/guybedford/mio `a62c9e46833fc255c9217ab9aa362c6221ed4401` | Emscripten epoll selector (tokio-rs/mio#1969) | +| libc | https://github.com/rust-lang/libc branch `libc-0.2` | Emscripten epoll bindings, unreleased | +| ring | https://github.com/guybedford/ring branch `emscripten` | getrandom-backed `SystemRandom` on Emscripten | The root Cargo.toml applies these overrides and the local checkouts. Cargo.lock -pins the full application graph. `toolchain/wasm-bindgen.Cargo.lock` separately pins -the host CLI graph, since that upstream workspace does not commit a lockfile. +pins the full application graph, including the branch commits. ## Host tools -- Rust: `nightly-2026-07-20`, target `wasm32-unknown-emscripten`; setup installs both through rustup. +- Rust: `beta` channel (1.99; `OwnedFd::try_clone` on Emscripten), target + `wasm32-unknown-emscripten`; setup installs both through rustup. rustc needs a + larger compile-thread stack for pumpkin-data's generated tables; the scripts + set `RUST_MIN_STACK`. +- wasm-bindgen CLI: the release matching the `wasm-bindgen` crate version in + Cargo.toml, installed by setup into `.work/bin/` (or `WASM_BINDGEN_BIN`). emcc + runs it as a post-link step under `-sWASM_BINDGEN`. - Node: 24+; 26 recommended and selected in CI. Used for build tools and tests. - Python: 3.11+ (Emscripten scripts and TOML parsing). -- Backend: Homebrew `emscripten`, detected with `brew --prefix emscripten`. - Homebrew backend versions are external prerequisites, not pinned source files. - -Setup deliberately uses the fork frontend with Homebrew's LLVM/binaryen backend; -emsdk is not needed. It writes a machine-local `.emscripten_cf`, builds wasm-bindgen -explicitly for the host target, and places the CLI in `.work/bin/`. +- Emscripten backend: LLVM through emsdk under `.work/emsdk` (or an activated + emsdk selected with `EMSDK`), and Binaryen built from the checkout above with + CMake and Ninja. Setup writes the frontend's machine-local `.emscripten_cf` + pointing at both. Homebrew's release cannot be used while the frontend is + ahead of the tagged releases. +- workerd: Wrangler must run a workerd with per-Durable-Object port tables and + `net.Server` inbound routing (`handleAsNodeConnection`). Until that ships in + Wrangler's bundled version, set `MINIFLARE_WORKERD_PATH`. ## Updating a patch @@ -53,13 +60,12 @@ upstream base from the repository root. Write it under `.work/` to preserve the commit description at the top of the maintained patch: ```sh +git -C .work/pumpkin add -N crates/pumpkin/src/net/bedrock/nethernet_stub.rs git -C .work/pumpkin diff b5b9b9d7010e793806a83c495af223c67e1d35ee -- \ . ':(exclude)Cargo.lock' ':(exclude)crates/pumpkin-world/src/generation' \ > .work/pumpkin-emscripten.diff git -C .work/pumpkin diff b5b9b9d7010e793806a83c495af223c67e1d35ee -- \ crates/pumpkin-world/src/generation > .work/pumpkin-memory.diff -git -C .work/wasm-bindgen diff 4b69f3b3ba4212c857be6854f77fa5aec8b62871 -- crates/cli-support/src/js/mod.rs > .work/wasm-bindgen-emscripten-closures.diff -git -C .work/workers-rs diff 7db011ec97658a5d907f3e3102028ce86c044f19 > .work/workers-rs-emscripten-toolchain.diff ``` Replace the corresponding patch's contents from its first `diff --git` line onward @@ -70,14 +76,14 @@ The Pumpkin path filters reflect the current separation: all memory-patch files are under `crates/pumpkin-world/src/generation/`. Adjust the filters if that scope changes, keeping platform support and memory optimizations in their own patches. -`git diff` omits untracked files, including files created by existing patches. -Preserve those added-file diffs when regenerating (for example, -`nethernet_stub.rs` in the compatibility patch). If you add a file in a dependency -checkout, include it explicitly and verify application on a fresh copy of the base. -Run `bash scripts/test.sh` after runtime changes. Keep unpatched checkouts unmodified. -Setup refuses to repin a modified checkout rather than discarding local work. +`git diff` omits untracked files, including files created by existing patches; +`add -N` above includes `nethernet_stub.rs`. If you add a file in a dependency +checkout, include it the same way and verify application on a fresh copy of the +base. Run `bash scripts/test.sh` after runtime changes. Keep unpatched checkouts +unmodified. Setup refuses to repin a modified checkout rather than discarding +local work. -For source/patch validation without rebuilding the toolchain: +For source/patch validation without provisioning the toolchain: ```sh bash scripts/setup.sh --sources-only diff --git a/docs/development.md b/docs/development.md index f27b4bb..f05481d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,7 +1,8 @@ # Development Follow the [README setup instructions](../README.md#try-it) to install the pinned -Rust toolchain, provision dependency sources, and build the matching wasm-bindgen CLI. +Rust toolchain, provision dependency sources and Emscripten, and install the +wasm-bindgen CLI. The initial Pumpkin compilation takes several minutes. Later builds use Cargo's cache. `build.rs` owns the Emscripten library arguments and tracks changes to the compatibility library. diff --git a/patches/README.md b/patches/README.md index 86af881..882110c 100644 --- a/patches/README.md +++ b/patches/README.md @@ -11,11 +11,8 @@ diff below it. Preserve the description when regenerating the diff. | --- | --- | --- | | `pumpkin-emscripten.patch` | `.work/pumpkin` | Headless optional subsystems, single-threaded runtime and blocking-task helper, shared async chunk scheduler with backpressure and timed draining, unified ticker, and platform guards | | `pumpkin-memory.patch` | `.work/pumpkin` | Shared indexed structure templates, immutable block-entity NBT, paletted generation chunks, and a temporary dense noise buffer | -| `wasm-bindgen-emscripten-closures.patch` | `.work/wasm-bindgen` | Escape generated closure-finalizer postsets as JavaScript strings | -| `workers-rs-emscripten-toolchain.patch` | `.work/workers-rs` | Match the pinned wasm-bindgen ABI, provide the Emscripten Tokio promise adapter, leave initialization/recovery to the host, and flush pending writes before socket shutdown | -`pumpkin-emscripten.patch` includes the injected-stream connection entry point used -by the DO. Setup applies it before `pumpkin-memory.patch`. The two patches use the +Setup applies `pumpkin-emscripten.patch` before `pumpkin-memory.patch`. The two patches use the same pinned base and currently modify separate files, so each can also be applied and checked independently. diff --git a/patches/pumpkin-emscripten.patch b/patches/pumpkin-emscripten.patch index ce284ef..fc4bfd6 100644 --- a/patches/pumpkin-emscripten.patch +++ b/patches/pumpkin-emscripten.patch @@ -1,17 +1,16 @@ Subject: [PATCH] Support headless Pumpkin on a cooperative runtime -Embedding Pumpkin in a Durable Object requires accepting host-provided -streams and running without OS worker threads or native plugin runtimes. -Add an injected-stream connection entry point and optional subsystems for -NetherNet, plugin loading, the console, and system crash reporting. Native -builds retain these subsystems and thread-backed execution by default. +Embedding Pumpkin in a Durable Object requires running without OS worker +threads or native plugin runtimes. Add optional subsystems for NetherNet, +plugin loading, the console, and system crash reporting. Native builds +retain these subsystems and thread-backed execution by default. Share asynchronous ticker and chunk-scheduler loops between native and single-threaded builds. Route Rayon and blocking jobs through helpers that can run on the current thread, await scheduler notifications and results, -and track cooperative tasks through shutdown. Match the Tokio requirement -to the hosted-runtime fork and guard unsupported Emscripten system calls -and synchronous HTTP lookups. +and track cooperative tasks through shutdown. Drop the Tokio features that +need OS threads and signals from single-threaded builds and guard +unsupported Emscripten system calls and synchronous HTTP lookups. Reserve save-queue capacity before transferring chunks or marking writes pending, and widen pending-write counters to usize. Drain late generation @@ -27,7 +26,7 @@ Base-commit: b5b9b9d7010e793806a83c495af223c67e1d35ee --- diff --git a/Cargo.toml b/Cargo.toml -index 143ba8a36..57314ca35 100644 +index 143ba8a3..57314ca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,7 +126,7 @@ strip = false @@ -40,7 +39,7 @@ index 143ba8a36..57314ca35 100644 thiserror = { version = "2.0", default-features = false } diff --git a/crates/pumpkin-util/Cargo.toml b/crates/pumpkin-util/Cargo.toml -index f4fb121fb..0a4a30761 100644 +index f4fb121f..0a4a3076 100644 --- a/crates/pumpkin-util/Cargo.toml +++ b/crates/pumpkin-util/Cargo.toml @@ -6,11 +6,13 @@ rust-version.workspace = true @@ -67,7 +66,7 @@ index f4fb121fb..0a4a30761 100644 [[bench]] diff --git a/crates/pumpkin-util/src/lib.rs b/crates/pumpkin-util/src/lib.rs -index 5119797bc..a1ae947a2 100644 +index 5119797b..a1ae947a 100644 --- a/crates/pumpkin-util/src/lib.rs +++ b/crates/pumpkin-util/src/lib.rs @@ -310,3 +310,42 @@ impl TryFrom for Hand { @@ -114,7 +113,7 @@ index 5119797bc..a1ae947a2 100644 + } +} diff --git a/crates/pumpkin-world/Cargo.toml b/crates/pumpkin-world/Cargo.toml -index f1675a8f2..82c219179 100644 +index f1675a8f..82c21917 100644 --- a/crates/pumpkin-world/Cargo.toml +++ b/crates/pumpkin-world/Cargo.toml @@ -17,11 +17,9 @@ bytes.workspace = true @@ -145,7 +144,7 @@ index f1675a8f2..82c219179 100644 tempfile.workspace = true criterion = { workspace = true, features = ["html_reports", "async_tokio"] } diff --git a/crates/pumpkin-world/src/chunk/format/anvil.rs b/crates/pumpkin-world/src/chunk/format/anvil.rs -index 38de745f9..60cedf53d 100644 +index 38de745f..60cedf53 100644 --- a/crates/pumpkin-world/src/chunk/format/anvil.rs +++ b/crates/pumpkin-world/src/chunk/format/anvil.rs @@ -798,7 +798,7 @@ impl ChunkSerializer for AnvilChunkFile< @@ -168,7 +167,7 @@ index 38de745f9..60cedf53d 100644 }); }); diff --git a/crates/pumpkin-world/src/chunk/format/linear.rs b/crates/pumpkin-world/src/chunk/format/linear.rs -index 3927c66fc..fd76019bf 100644 +index 3927c66f..fd76019b 100644 --- a/crates/pumpkin-world/src/chunk/format/linear.rs +++ b/crates/pumpkin-world/src/chunk/format/linear.rs @@ -599,7 +599,7 @@ impl ChunkSerializer for LinearV2File @@ -191,7 +190,7 @@ index 3927c66fc..fd76019bf 100644 }); }); diff --git a/crates/pumpkin-world/src/chunk/format/pump.rs b/crates/pumpkin-world/src/chunk/format/pump.rs -index f1c44adc7..b5a8dd1d7 100644 +index f1c44adc..b5a8dd1d 100644 --- a/crates/pumpkin-world/src/chunk/format/pump.rs +++ b/crates/pumpkin-world/src/chunk/format/pump.rs @@ -147,7 +147,7 @@ where @@ -214,7 +213,7 @@ index f1c44adc7..b5a8dd1d7 100644 }); }); diff --git a/crates/pumpkin-world/src/chunk/io/mod.rs b/crates/pumpkin-world/src/chunk/io/mod.rs -index c4daf5bb5..78271c344 100644 +index c4daf5bb..78271c34 100644 --- a/crates/pumpkin-world/src/chunk/io/mod.rs +++ b/crates/pumpkin-world/src/chunk/io/mod.rs @@ -13,7 +13,7 @@ where @@ -227,7 +226,7 @@ index c4daf5bb5..78271c344 100644 /// The result of loading a chunk data. diff --git a/crates/pumpkin-world/src/chunk_system/channel.rs b/crates/pumpkin-world/src/chunk_system/channel.rs -index 190d578fa..c138b1f50 100644 +index 190d578f..c138b1f5 100644 --- a/crates/pumpkin-world/src/chunk_system/channel.rs +++ b/crates/pumpkin-world/src/chunk_system/channel.rs @@ -14,6 +14,7 @@ pub type LevelChange = ( @@ -281,7 +280,7 @@ index 190d578fa..c138b1f50 100644 } } diff --git a/crates/pumpkin-world/src/chunk_system/mod.rs b/crates/pumpkin-world/src/chunk_system/mod.rs -index f670ad37b..b9d9a8671 100644 +index f670ad37..b9d9a867 100644 --- a/crates/pumpkin-world/src/chunk_system/mod.rs +++ b/crates/pumpkin-world/src/chunk_system/mod.rs @@ -13,7 +13,7 @@ pub type HashSetType = rustc_hash::FxHashSet; @@ -294,7 +293,7 @@ index f670ad37b..b9d9a8671 100644 )>; diff --git a/crates/pumpkin-world/src/chunk_system/schedule.rs b/crates/pumpkin-world/src/chunk_system/schedule.rs -index 145e15de8..2942a1d31 100644 +index 145e15de..2942a1d3 100644 --- a/crates/pumpkin-world/src/chunk_system/schedule.rs +++ b/crates/pumpkin-world/src/chunk_system/schedule.rs @@ -69,13 +69,14 @@ pub struct GenerationSchedule { @@ -863,7 +862,7 @@ index 145e15de8..2942a1d31 100644 + } +} diff --git a/crates/pumpkin-world/src/chunk_system/worker_logic.rs b/crates/pumpkin-world/src/chunk_system/worker_logic.rs -index b50ac05c9..281fa41f0 100644 +index b50ac05c..281fa41f 100644 --- a/crates/pumpkin-world/src/chunk_system/worker_logic.rs +++ b/crates/pumpkin-world/src/chunk_system/worker_logic.rs @@ -90,7 +90,7 @@ fn process_loaded_chunk(chunk: Arc, level: &Level) -> C @@ -876,7 +875,7 @@ index b50ac05c9..281fa41f0 100644 lock: IOLock, ) { diff --git a/crates/pumpkin-world/src/level.rs b/crates/pumpkin-world/src/level.rs -index fbc69749a..533b09d21 100644 +index fbc69749..533b09d2 100644 --- a/crates/pumpkin-world/src/level.rs +++ b/crates/pumpkin-world/src/level.rs @@ -316,7 +316,7 @@ impl Level { @@ -969,7 +968,7 @@ index fbc69749a..533b09d21 100644 } diff --git a/crates/pumpkin-world/src/world_info/anvil.rs b/crates/pumpkin-world/src/world_info/anvil.rs -index ebbcb49b6..46975c6c5 100644 +index ebbcb49b..46975c6c 100644 --- a/crates/pumpkin-world/src/world_info/anvil.rs +++ b/crates/pumpkin-world/src/world_info/anvil.rs @@ -425,6 +425,11 @@ impl WorldInfoWriter for AnvilLevelInfo { @@ -985,7 +984,7 @@ index ebbcb49b6..46975c6c5 100644 let path = level_folder.join(LEVEL_DAT_FILE_NAME); let path_new = level_folder.join("level.dat_new"); diff --git a/crates/pumpkin/Cargo.toml b/crates/pumpkin/Cargo.toml -index 6ade6bfcb..c9230c03a 100644 +index 6ade6bfc..c9230c03 100644 --- a/crates/pumpkin/Cargo.toml +++ b/crates/pumpkin/Cargo.toml @@ -17,6 +17,14 @@ LegalCopyright = "Copyright © 2026 Aleksander Medvedev" @@ -1109,7 +1108,7 @@ index 6ade6bfcb..c9230c03a 100644 [lints] diff --git a/crates/pumpkin/build.rs b/crates/pumpkin/build.rs -index 5314180a1..a6accb815 100644 +index 5314180a..a6accb81 100644 --- a/crates/pumpkin/build.rs +++ b/crates/pumpkin/build.rs @@ -23,8 +23,21 @@ fn main() { @@ -1137,7 +1136,7 @@ index 5314180a1..a6accb815 100644 println!("cargo::rustc-env=GIT_HASH_FULL={git_hash_full}"); } diff --git a/crates/pumpkin/src/command/commands/pumpkin.rs b/crates/pumpkin/src/command/commands/pumpkin.rs -index 0045511a9..9586e5dc5 100644 +index 0045511a..9586e5dc 100644 --- a/crates/pumpkin/src/command/commands/pumpkin.rs +++ b/crates/pumpkin/src/command/commands/pumpkin.rs @@ -56,6 +56,7 @@ fn fetch_all_contributors_cached() -> Vec { @@ -1176,7 +1175,7 @@ index 0045511a9..9586e5dc5 100644 if let Ok(mut guard) = DONATORS_CACHE.lock() { *guard = Some(DonatorCache { diff --git a/crates/pumpkin/src/crash.rs b/crates/pumpkin/src/crash.rs -index 6f88670e3..9969598c2 100644 +index 6f88670e..9969598c 100644 --- a/crates/pumpkin/src/crash.rs +++ b/crates/pumpkin/src/crash.rs @@ -13,6 +13,7 @@ use pumpkin_util::text::{ @@ -1204,7 +1203,7 @@ index 6f88670e3..9969598c2 100644 writeln_output!(output); let cpus = sys.cpus(); diff --git a/crates/pumpkin/src/data/datapack/mod.rs b/crates/pumpkin/src/data/datapack/mod.rs -index 2f8f72349..eefa123d0 100644 +index 2f8f7234..eefa123d 100644 --- a/crates/pumpkin/src/data/datapack/mod.rs +++ b/crates/pumpkin/src/data/datapack/mod.rs @@ -253,7 +253,7 @@ impl DatapackManager { @@ -1217,7 +1216,7 @@ index 2f8f72349..eefa123d0 100644 format!("Failed to open structure '{display_path}': {error}") })?; diff --git a/crates/pumpkin/src/data/player_server.rs b/crates/pumpkin/src/data/player_server.rs -index 0899fa9c1..733e66780 100644 +index 0899fa9c..733e6678 100644 --- a/crates/pumpkin/src/data/player_server.rs +++ b/crates/pumpkin/src/data/player_server.rs @@ -87,7 +87,7 @@ impl ServerPlayerData { @@ -1230,7 +1229,7 @@ index 0899fa9c1..733e66780 100644 if let Err(e) = storage.save_player_data(&uuid, nbt) { error!("Failed to save player data for {uuid}: {e}"); diff --git a/crates/pumpkin/src/entity/mod.rs b/crates/pumpkin/src/entity/mod.rs -index 7d64f9b1c..ca28e83b4 100644 +index 7d64f9b1..ca28e83b 100644 --- a/crates/pumpkin/src/entity/mod.rs +++ b/crates/pumpkin/src/entity/mod.rs @@ -2361,7 +2361,7 @@ impl Entity { @@ -1243,7 +1242,7 @@ index 7d64f9b1c..ca28e83b4 100644 let Some(entity_arc) = world_clone.get_entity_by_id(entity_id) else { return; diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs -index a19825bc2..45d6783e8 100644 +index a19825bc..45d6783e 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -572,6 +572,7 @@ impl Player { @@ -1277,7 +1276,7 @@ index a19825bc2..45d6783e8 100644 || { self.chunk_sender diff --git a/crates/pumpkin/src/lib.rs b/crates/pumpkin/src/lib.rs -index 7110bc9e4..3438afb65 100644 +index 7110bc9e..060225ea 100644 --- a/crates/pumpkin/src/lib.rs +++ b/crates/pumpkin/src/lib.rs @@ -8,12 +8,14 @@ extern crate pumpkin_macros; @@ -1415,141 +1414,7 @@ index 7110bc9e4..3438afb65 100644 if let Some((wrapper, _, _)) = LOGGER_IMPL.wait() && let Some(rl) = wrapper.take_readline() { -@@ -534,6 +568,72 @@ impl PumpkinServer { - } - } - -+ /// Run the normal Java client lifecycle for a host-provided byte stream. -+ pub async fn serve_connection(&self, connection: S, client_addr: SocketAddr, client_id: u64) -+ where -+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Sync + Unpin + 'static, -+ { -+ let server_clone = self.server.clone(); -+ let packet_limiter = PacketRateLimiter::from_config( -+ &server_clone.advanced_config.networking.java.packet_limiter, -+ ); -+ let mut pending = -+ PendingConnection::from_stream(connection, client_addr, client_id, packet_limiter); -+ let login_result = pending.handle_login_sequence(&server_clone).await; -+ -+ match login_result { -+ PacketHandlerResult::Stop => { -+ pending.close(); -+ } -+ PacketHandlerResult::ReadyToPlay(profile, config) => { -+ let mut java_client = -+ JavaClient::from_pending(pending, profile.clone(), config.clone()); -+ java_client.start_outgoing_packet_task(); -+ -+ if let Some((player, world)) = server_clone.add_player( -+ Arc::new(ClientPlatform::Java(java_client)), -+ profile, -+ Some(config), -+ ) { -+ if let ClientPlatform::Java(client) = player.client.as_ref() { -+ client.set_player(player.clone()); -+ } -+ world -+ .spawn_java_player(&server_clone.basic_config, &player, &server_clone) -+ .await; -+ -+ if let ClientPlatform::Java(client) = player.client.as_ref() { -+ client.progress_player_packets(&player, &server_clone).await; -+ -+ // Close when done -+ client.close(); -+ client.await_tasks().await; -+ } -+ player.remove().await; -+ server_clone.remove_player(&player); -+ if let Err(e) = server_clone -+ .player_data_storage -+ .handle_player_leave(&player) -+ { -+ error!("Failed to save player data on disconnect: {e}"); -+ } -+ if let Err(e) = server_clone.advancement_manager.save_player(&player).await { -+ error!("Failed to save player advancement on disconnect: {e}"); -+ } -+ } -+ } -+ } -+ } -+ -+ fn clone_for_connection(&self) -> Self { -+ Self { -+ server: self.server.clone(), -+ tcp_listener: None, -+ bedrock_status: None, -+ nethernet_listener: None, -+ } -+ } -+ - #[allow(clippy::too_many_lines)] - pub async fn unified_listener_task( - &self, -@@ -559,66 +659,13 @@ impl PumpkinServer { - format!("{client_addr}") - }; - debug!("Accepted connection from Java Edition: {formatted_address} (id {client_id})"); -- let server_clone = self.server.clone(); -- -+ let server = self.clone_for_connection(); - tasks.spawn(async move { -- let packet_limiter = PacketRateLimiter::from_config( -- &server_clone.advanced_config.networking.java.packet_limiter, -- ); -- let mut pending = PendingConnection::new( -- connection, -- client_addr, -- client_id, -- packet_limiter, -- ); -- let login_result = pending.handle_login_sequence(&server_clone).await; -- -- match login_result { -- PacketHandlerResult::Stop => { -- pending.close(); -- }, -- PacketHandlerResult::ReadyToPlay(profile, config) => { -- let mut java_client = JavaClient::from_pending(pending, profile.clone(), config.clone()); -- java_client.start_outgoing_packet_task(); -- -- if let Some((player, world)) = server_clone -- .add_player(Arc::new(ClientPlatform::Java(java_client)), profile, Some(config)) -- { -- -- if let ClientPlatform::Java(client) = player.client.as_ref() { -- client.set_player(player.clone()); -- } -- world -- .spawn_java_player(&server_clone.basic_config, &player, &server_clone) -- .await; -- -- if let ClientPlatform::Java(client) = player.client.as_ref() { -- client.progress_player_packets(&player, &server_clone).await; -- -- // Close when done -- client.close(); -- client.await_tasks().await; -- } -- player.remove().await; -- server_clone.remove_player(&player); -- if let Err(e) = server_clone -- .player_data_storage -- .handle_player_leave(&player) -- { -- error!("Failed to save player data on disconnect: {e}"); -- } -- if let Err(e) = server_clone.advancement_manager -- .save_player(&player) -- .await { -- error!("Failed to save player advancement on disconnect: {e}"); -- } -- } -- }, -- } -+ server.serve_connection(connection, client_addr, client_id).await; +@@ -618,7 +652,7 @@ impl PumpkinServer { }); } Err(e) => { @@ -1558,7 +1423,7 @@ index 7110bc9e4..3438afb65 100644 if e.raw_os_error() == Some(libc::EMFILE) { error!( "Too many open files! Server reached file descriptor limit. \ -@@ -719,6 +766,7 @@ impl PumpkinServer { +@@ -719,6 +753,7 @@ impl PumpkinServer { } } @@ -1566,7 +1431,7 @@ index 7110bc9e4..3438afb65 100644 fn setup_stdin_console(server: &Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel(1); let rt = tokio::runtime::Handle::current(); -@@ -759,6 +807,7 @@ fn setup_stdin_console(server: &Arc) { +@@ -759,6 +794,7 @@ fn setup_stdin_console(server: &Arc) { }); } @@ -1574,7 +1439,7 @@ index 7110bc9e4..3438afb65 100644 fn setup_console(mut rl: Editor, server: Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel(1); let (tx_reply, mut rx_reply) = tokio::sync::mpsc::channel(1); -@@ -839,7 +888,7 @@ fn scrub_address(ip: &str) -> String { +@@ -839,7 +875,7 @@ fn scrub_address(ip: &str) -> String { .collect() } @@ -1584,7 +1449,7 @@ index 7110bc9e4..3438afb65 100644 let mut rlim = libc::rlimit { rlim_cur: 0, diff --git a/crates/pumpkin/src/logging.rs b/crates/pumpkin/src/logging.rs -index 85b51a45a..4006f6eca 100644 +index 85b51a45..4006f6ec 100644 --- a/crates/pumpkin/src/logging.rs +++ b/crates/pumpkin/src/logging.rs @@ -2,11 +2,17 @@ @@ -1741,7 +1606,7 @@ index 85b51a45a..4006f6eca 100644 type Candidate = String; diff --git a/crates/pumpkin/src/net/authentication.rs b/crates/pumpkin/src/net/authentication.rs -index f344d1978..b32c3bc2a 100644 +index f344d197..b32c3bc2 100644 --- a/crates/pumpkin/src/net/authentication.rs +++ b/crates/pumpkin/src/net/authentication.rs @@ -311,6 +311,7 @@ pub async fn lookup_profile_by_name( @@ -1771,7 +1636,7 @@ index f344d1978..b32c3bc2a 100644 uuid: Uuid, auth_config: &AuthenticationConfig, diff --git a/crates/pumpkin/src/net/bedrock/mod.rs b/crates/pumpkin/src/net/bedrock/mod.rs -index 5ea3e41ac..fd913a72b 100644 +index 5ea3e41a..fd913a72 100644 --- a/crates/pumpkin/src/net/bedrock/mod.rs +++ b/crates/pumpkin/src/net/bedrock/mod.rs @@ -1,3 +1,7 @@ @@ -1782,8 +1647,70 @@ index 5ea3e41ac..fd913a72b 100644 pub mod nethernet; pub mod play; pub mod status; +diff --git a/crates/pumpkin/src/net/bedrock/nethernet_stub.rs b/crates/pumpkin/src/net/bedrock/nethernet_stub.rs +new file mode 100644 +index 00000000..90f0989c +--- /dev/null ++++ b/crates/pumpkin/src/net/bedrock/nethernet_stub.rs +@@ -0,0 +1,56 @@ ++//! WebRTC-free stub of the NetherNet transport, compiled when the `nethernet` ++//! feature is off (e.g. the emscripten/embedded Java-only build). The types ++//! exist so the always-compiled Bedrock client code type-checks, but no Bedrock ++//! connection is ever created (the listener is never bound), so the method ++//! bodies are unreachable. ++use std::net::SocketAddr; ++use std::sync::Arc; ++ ++use bytes::Bytes; ++use pumpkin_util::p384::PublicKey; ++ ++pub type IncomingSession = (Arc, SocketAddr); ++ ++/// Stub listener: never bound when `nethernet` is off, so `accept` never yields. ++pub struct NetherNetListener { ++ _private: (), ++} ++ ++impl NetherNetListener { ++ pub async fn accept(&self) -> Option { ++ None ++ } ++ ++ pub const fn local_addr(&self) -> SocketAddr { ++ SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0) ++ } ++} ++ ++/// Stub session. Never constructed when `nethernet` is off. ++pub struct NetherNetSession { ++ _private: (), ++} ++ ++impl NetherNetSession { ++ pub async fn recv(&self) -> Option { ++ None ++ } ++ ++ pub async fn send(&self, _data: Bytes) -> Result<(), String> { ++ Err("NetherNet transport is disabled in this build".to_string()) ++ } ++ ++ pub async fn send_unreliable(&self, _data: Bytes) -> Result<(), String> { ++ Err("NetherNet transport is disabled in this build".to_string()) ++ } ++ ++ pub const fn is_closed(&self) -> bool { ++ true ++ } ++ ++ pub async fn close(&self) {} ++ ++ pub const fn client_public_key(&self) -> Option<&PublicKey> { ++ None ++ } ++} diff --git a/crates/pumpkin/src/net/java/config/known_packs.rs b/crates/pumpkin/src/net/java/config/known_packs.rs -index 7e45c3db9..8a4d5af4d 100644 +index 7e45c3db..8a4d5af4 100644 --- a/crates/pumpkin/src/net/java/config/known_packs.rs +++ b/crates/pumpkin/src/net/java/config/known_packs.rs @@ -14,7 +14,7 @@ impl JavaClient { @@ -1796,67 +1723,10 @@ index 7e45c3db9..8a4d5af4d 100644 let mut packets = Vec::new(); let mut sent_dimension_type = false; diff --git a/crates/pumpkin/src/net/java/mod.rs b/crates/pumpkin/src/net/java/mod.rs -index fb0e9c901..96e3b1101 100644 +index fb0e9c90..6583eb12 100644 --- a/crates/pumpkin/src/net/java/mod.rs +++ b/crates/pumpkin/src/net/java/mod.rs -@@ -42,7 +42,6 @@ use pumpkin_util::text::TextComponent; - use pumpkin_util::version::JavaMinecraftVersion; - use tokio::{ - io::{BufReader, BufWriter}, -- net::tcp::{OwnedReadHalf, OwnedWriteHalf}, - sync::oneshot, - }; - use tokio::{ -@@ -53,6 +52,9 @@ use tokio_util::sync::CancellationToken; - use tokio_util::task::TaskTracker; - use tracing::{debug, error, warn}; - -+pub type ConnectionReader = Box; -+pub type ConnectionWriter = Box; -+ - pub mod config; - pub mod handshake; - pub mod login; -@@ -103,9 +105,9 @@ pub struct JavaClient { - /// A high-priority queue of serialized packets to send to the network. - outgoing_packet_priority_recv: Option>, - /// The packet encoder for outgoing packets. -- network_writer: std::sync::Mutex>>>, -+ network_writer: std::sync::Mutex>>>, - /// The packet decoder for incoming packets. -- network_reader: std::sync::Mutex>>>, -+ network_reader: std::sync::Mutex>>>, - /// Keep Alive: - /// - /// Whether we are waiting for a response after sending a keep alive packet. -@@ -155,10 +157,10 @@ fn take_frame_batch(packets: &mut VecDeque) -> Vec>, -+ mut writer: TCPNetworkEncoder>, - batch: &[OutgoingPacket], - ) -> ( -- TCPNetworkEncoder>, -+ TCPNetworkEncoder>, - Vec, - Option, - ) { -@@ -174,11 +176,11 @@ fn frame_packet_batch( - } - - async fn frame_batch_maybe_offload( -- writer: TCPNetworkEncoder>, -+ writer: TCPNetworkEncoder>, - packet_batch: Vec, - ) -> Result< - ( -- TCPNetworkEncoder>, -+ TCPNetworkEncoder>, - Vec, - Vec, - Option, -@@ -190,7 +192,7 @@ async fn frame_batch_maybe_offload( +@@ -190,7 +190,7 @@ async fn frame_batch_maybe_offload( .any(|packet| writer.is_compressing_packet(&packet.data)); if needs_offload { @@ -1865,7 +1735,7 @@ index fb0e9c901..96e3b1101 100644 let (writer, frame, frame_err) = frame_packet_batch(writer, &packet_batch); (writer, packet_batch, frame, frame_err) }) -@@ -406,7 +408,7 @@ impl JavaClient { +@@ -406,7 +406,7 @@ impl JavaClient { let version = self.version.load(); let (tx, rx) = oneshot::channel(); @@ -1874,77 +1744,8 @@ index fb0e9c901..96e3b1101 100644 let mut serialized = Vec::with_capacity(valid_chunks.len()); for chunk in valid_chunks { let mut buf = Vec::with_capacity(32 * 1024); -@@ -532,7 +534,7 @@ impl JavaClient { - - pub async fn get_packet_with_reader( - &self, -- network_reader: &mut TCPNetworkDecoder>, -+ network_reader: &mut TCPNetworkDecoder>, - ) -> Option { - tokio::select! { - () = self.await_close_interrupt() => { -diff --git a/crates/pumpkin/src/net/java/pending.rs b/crates/pumpkin/src/net/java/pending.rs -index 2522573e0..a627b07b8 100644 ---- a/crates/pumpkin/src/net/java/pending.rs -+++ b/crates/pumpkin/src/net/java/pending.rs -@@ -23,10 +23,7 @@ use pumpkin_protocol::{ - use pumpkin_util::{Hand, text::TextComponent, version::JavaMinecraftVersion}; - use tokio::{ - io::{BufReader, BufWriter}, -- net::{ -- TcpStream, -- tcp::{OwnedReadHalf, OwnedWriteHalf}, -- }, -+ net::TcpStream, - }; - use tokio_util::sync::CancellationToken; - use tracing::{debug, error, warn}; -@@ -40,7 +37,7 @@ use crate::{ - server::Server, - }; - --use super::JavaClient; -+use super::{ConnectionReader, ConnectionWriter, JavaClient}; - - const BRAND_CHANNEL_PREFIX: &str = "minecraft:brand"; - -@@ -61,8 +58,8 @@ pub struct PendingConnection { - pub version: AtomicCell, - pub connection_state: AtomicCell, - pub close_token: CancellationToken, -- pub network_writer: TCPNetworkEncoder>, -- pub network_reader: TCPNetworkDecoder>, -+ pub network_writer: TCPNetworkEncoder>, -+ pub network_reader: TCPNetworkDecoder>, - pub gameprofile: Option, - pub config: Option, - pub brand: Option, -@@ -78,7 +75,22 @@ impl PendingConnection { - id: u64, - packet_limiter: PacketRateLimiter, - ) -> Self { -- let (read, write) = tcp_stream.into_split(); -+ Self::from_stream(tcp_stream, address, id, packet_limiter) -+ } -+ -+ /// Accept an injected byte stream while retaining the native TCP constructor. -+ pub fn from_stream( -+ stream: S, -+ address: SocketAddr, -+ id: u64, -+ packet_limiter: PacketRateLimiter, -+ ) -> Self -+ where -+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Sync + Unpin + 'static, -+ { -+ let (read, write) = tokio::io::split(stream); -+ let read: ConnectionReader = Box::new(read); -+ let write: ConnectionWriter = Box::new(write); - Self { - id, - address, diff --git a/crates/pumpkin/src/net/java/play/chat_message.rs b/crates/pumpkin/src/net/java/play/chat_message.rs -index 589277311..ba28c7450 100644 +index 58927731..ba28c745 100644 --- a/crates/pumpkin/src/net/java/play/chat_message.rs +++ b/crates/pumpkin/src/net/java/play/chat_message.rs @@ -266,7 +266,7 @@ impl JavaClient { @@ -1957,7 +1758,7 @@ index 589277311..ba28c7450 100644 let verifying_key = VerifyingKey::::new(key.clone()); verifying_key.verify(&signable, &key_signature).is_ok() diff --git a/crates/pumpkin/src/plugin/loader/mod.rs b/crates/pumpkin/src/plugin/loader/mod.rs -index fa7f8c5ab..61bbe63f1 100644 +index fa7f8c5a..61bbe63f 100644 --- a/crates/pumpkin/src/plugin/loader/mod.rs +++ b/crates/pumpkin/src/plugin/loader/mod.rs @@ -1,8 +1,12 @@ @@ -1983,7 +1784,7 @@ index fa7f8c5ab..61bbe63f1 100644 WasmInitializationError(#[from] PluginInitError), } diff --git a/crates/pumpkin/src/plugin/mod.rs b/crates/pumpkin/src/plugin/mod.rs -index b686898d0..33ed73b36 100644 +index b686898d..33ed73b3 100644 --- a/crates/pumpkin/src/plugin/mod.rs +++ b/crates/pumpkin/src/plugin/mod.rs @@ -1,6 +1,9 @@ @@ -2181,7 +1982,7 @@ index b686898d0..33ed73b36 100644 #[expect(clippy::result_unit_err)] diff --git a/crates/pumpkin/src/server/mod.rs b/crates/pumpkin/src/server/mod.rs -index 28690a867..5f58b8b58 100644 +index 28690a86..5f58b8b5 100644 --- a/crates/pumpkin/src/server/mod.rs +++ b/crates/pumpkin/src/server/mod.rs @@ -64,6 +64,7 @@ pub mod ticker; @@ -2235,7 +2036,7 @@ index 28690a867..5f58b8b58 100644 self.scheduled_functions.tick( self, diff --git a/crates/pumpkin/src/server/scheduler.rs b/crates/pumpkin/src/server/scheduler.rs -index e2056b48a..f6221d64a 100644 +index e2056b48..f6221d64 100644 --- a/crates/pumpkin/src/server/scheduler.rs +++ b/crates/pumpkin/src/server/scheduler.rs @@ -1,3 +1,4 @@ @@ -2300,7 +2101,7 @@ index e2056b48a..f6221d64a 100644 #[must_use] pub fn new() -> Self { diff --git a/crates/pumpkin/src/server/ticker.rs b/crates/pumpkin/src/server/ticker.rs -index ae45c2b05..1f35b272b 100644 +index ae45c2b0..1f35b272 100644 --- a/crates/pumpkin/src/server/ticker.rs +++ b/crates/pumpkin/src/server/ticker.rs @@ -16,9 +16,14 @@ use tracing::debug; @@ -2386,7 +2187,7 @@ index ae45c2b05..1f35b272b 100644 if STOP_INTERRUPT.is_cancelled() { break 'ticker; diff --git a/crates/pumpkin/src/world/portal/mod.rs b/crates/pumpkin/src/world/portal/mod.rs -index aeadba752..85bf10aff 100644 +index aeadba75..85bf10af 100644 --- a/crates/pumpkin/src/world/portal/mod.rs +++ b/crates/pumpkin/src/world/portal/mod.rs @@ -92,6 +92,7 @@ impl PortalType { @@ -2405,65 +2206,3 @@ index aeadba752..85bf10aff 100644 if let Ok(handle) = tokio::runtime::Handle::try_current() { tokio::task::block_in_place(|| { handle.block_on(async { -diff --git a/crates/pumpkin/src/net/bedrock/nethernet_stub.rs b/crates/pumpkin/src/net/bedrock/nethernet_stub.rs -new file mode 100644 -index 000000000..90f0989c4 ---- /dev/null -+++ b/crates/pumpkin/src/net/bedrock/nethernet_stub.rs -@@ -0,0 +1,56 @@ -+//! WebRTC-free stub of the NetherNet transport, compiled when the `nethernet` -+//! feature is off (e.g. the emscripten/embedded Java-only build). The types -+//! exist so the always-compiled Bedrock client code type-checks, but no Bedrock -+//! connection is ever created (the listener is never bound), so the method -+//! bodies are unreachable. -+use std::net::SocketAddr; -+use std::sync::Arc; -+ -+use bytes::Bytes; -+use pumpkin_util::p384::PublicKey; -+ -+pub type IncomingSession = (Arc, SocketAddr); -+ -+/// Stub listener: never bound when `nethernet` is off, so `accept` never yields. -+pub struct NetherNetListener { -+ _private: (), -+} -+ -+impl NetherNetListener { -+ pub async fn accept(&self) -> Option { -+ None -+ } -+ -+ pub const fn local_addr(&self) -> SocketAddr { -+ SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0) -+ } -+} -+ -+/// Stub session. Never constructed when `nethernet` is off. -+pub struct NetherNetSession { -+ _private: (), -+} -+ -+impl NetherNetSession { -+ pub async fn recv(&self) -> Option { -+ None -+ } -+ -+ pub async fn send(&self, _data: Bytes) -> Result<(), String> { -+ Err("NetherNet transport is disabled in this build".to_string()) -+ } -+ -+ pub async fn send_unreliable(&self, _data: Bytes) -> Result<(), String> { -+ Err("NetherNet transport is disabled in this build".to_string()) -+ } -+ -+ pub const fn is_closed(&self) -> bool { -+ true -+ } -+ -+ pub async fn close(&self) {} -+ -+ pub const fn client_public_key(&self) -> Option<&PublicKey> { -+ None -+ } -+} diff --git a/patches/wasm-bindgen-emscripten-closures.patch b/patches/wasm-bindgen-emscripten-closures.patch deleted file mode 100644 index 88700c3..0000000 --- a/patches/wasm-bindgen-emscripten-closures.patch +++ /dev/null @@ -1,30 +0,0 @@ -Subject: [PATCH] Escape Emscripten closure finalizer postsets - -The closure finalizer is emitted inside an Emscripten JavaScript string. -Interpolating its destructor expression directly can leave quotes and -backslashes unescaped, producing invalid generated JavaScript. - -Build the complete postset first, then format it as an escaped string -literal. This preserves the finalizer expression and the fallback used -when FinalizationRegistry is unavailable. - -Base-commit: 4b69f3b3ba4212c857be6854f77fa5aec8b62871 - ---- -diff --git a/crates/cli-support/src/js/mod.rs b/crates/cli-support/src/js/mod.rs -index 4486b980..06d4a28a 100644 ---- a/crates/cli-support/src/js/mod.rs -+++ b/crates/cli-support/src/js/mod.rs -@@ -4326,10 +4326,11 @@ if (require('worker_threads').isMainThread) {{ - }; - - if matches!(self.config.mode, OutputMode::Emscripten) { -+ let postset = format!("CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') ? {{ register: () => {{}}, unregister: () => {{}} }} : new FinalizationRegistry({prevent_stale});"); - format!( - "addToLibrary({{ - $CLOSURE_DTORS: {{}}, -- $CLOSURE_DTORS__postset: \"CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') ? {{ register: () => {{}}, unregister: () => {{}} }} : new FinalizationRegistry({prevent_stale});\" -+ $CLOSURE_DTORS__postset: {postset:?} - }});\n" - ) - } else { diff --git a/patches/workers-rs-emscripten-toolchain.patch b/patches/workers-rs-emscripten-toolchain.patch deleted file mode 100644 index 425e670..0000000 --- a/patches/workers-rs-emscripten-toolchain.patch +++ /dev/null @@ -1,184 +0,0 @@ -Subject: [PATCH] Embed workers-rs with the hosted Emscripten runtime - -The embedding host creates each Emscripten module and owns its lifetime. -The SDK's standalone wasm-bindgen loader and recovery hook cannot -reinitialize those instances, so leave initialization and panic handling -to the host on this target. - -Expose an Emscripten-only adapter that drives promise exports on one hosted -Tokio runtime per module and converts injected sockets into SDK streams. -It uses the hosted-runtime Tokio fork selected by the application. Align -the wasm-bindgen, js-sys, and web-sys version family with the pinned -Emscripten-enabled wasm-bindgen CLI so their generated bindings agree. - -Flush outstanding socket writes before closing the WritableStream. This -satisfies AsyncWrite's shutdown contract and releases the writer lock -before close, including when a pending write completes asynchronously. - -Base-commit: 7db011ec97658a5d907f3e3102028ce86c044f19 - ---- -diff --git a/Cargo.toml b/Cargo.toml -index 4e08234..9888881 100644 ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -28,7 +28,7 @@ chrono = { version = "0.4.41", default-features = false, features = [ - futures-channel = "0.3.31" - futures-util = { version = "0.3.31", default-features = false } - http = "1.3" --js-sys = { version = "0.3.104" } -+js-sys = { version = "0.3.103" } - serde = { version = "1.0.164", features = ["derive"] } - strum = { version = "0.28", features = ["derive"] } - serde_json = "1.0.140" -@@ -37,14 +37,14 @@ syn = "3.0.3" - trybuild = "1.0" - proc-macro2 = "1.0.60" - quote = "1.0.28" --wasm-bindgen = { version = "0.2.127" } --wasm-bindgen-cli-support = { version = "0.2.127" } --wasm-bindgen-futures = { version = "0.4.77" } --wasm-bindgen-macro-support = { version = "0.2.127" } --wasm-bindgen-shared = { version = "0.2.127" } --wasm-bindgen-test = { version = "0.3.77" } -+wasm-bindgen = { version = "0.2.126" } -+wasm-bindgen-cli-support = { version = "0.2.126" } -+wasm-bindgen-futures = { version = "0.4.76" } -+wasm-bindgen-macro-support = { version = "0.2.126" } -+wasm-bindgen-shared = { version = "0.2.126" } -+wasm-bindgen-test = { version = "0.3.76" } - wasm-streams = { version = "0.6.0" } --web-sys = { version = "0.3.104", features = [ -+web-sys = { version = "0.3.103", features = [ - "AbortController", - "AbortSignal", - "BinaryType", -@@ -106,11 +106,11 @@ opt-level = "z" - # These are local patches we use to test against local wasm bindgen - # We always align on the exact stable wasm bindgen version for releases - [patch.crates-io] --js-sys = { version = "0.3.104", path = './wasm-bindgen/crates/js-sys' } --wasm-bindgen = { version = "0.2.127", path = './wasm-bindgen' } --wasm-bindgen-cli-support = { version = "0.2.127", path = "./wasm-bindgen/crates/cli-support" } --wasm-bindgen-futures = { version = "0.4.77", path = './wasm-bindgen/crates/futures' } --wasm-bindgen-macro-support = { version = "0.2.127", path = "./wasm-bindgen/crates/macro-support" } --wasm-bindgen-shared = { version = "0.2.127", path = "./wasm-bindgen/crates/shared" } --wasm-bindgen-test = { version = "0.3.77", path = "./wasm-bindgen/crates/test" } --web-sys = { version = "0.3.104", path = './wasm-bindgen/crates/web-sys' } -+js-sys = { version = "0.3.103", path = './wasm-bindgen/crates/js-sys' } -+wasm-bindgen = { version = "0.2.126", path = './wasm-bindgen' } -+wasm-bindgen-cli-support = { version = "0.2.126", path = "./wasm-bindgen/crates/cli-support" } -+wasm-bindgen-futures = { version = "0.4.76", path = './wasm-bindgen/crates/futures' } -+wasm-bindgen-macro-support = { version = "0.2.126", path = "./wasm-bindgen/crates/macro-support" } -+wasm-bindgen-shared = { version = "0.2.126", path = "./wasm-bindgen/crates/shared" } -+wasm-bindgen-test = { version = "0.3.76", path = "./wasm-bindgen/crates/test" } -+web-sys = { version = "0.3.103", path = './wasm-bindgen/crates/web-sys' } -diff --git a/worker/Cargo.toml b/worker/Cargo.toml -index 573a8f2..d286f02 100644 ---- a/worker/Cargo.toml -+++ b/worker/Cargo.toml -@@ -45,6 +45,9 @@ web-sys.workspace = true - worker-macros.workspace = true - worker-sys.workspace = true - -+[target.'cfg(target_os = "emscripten")'.dependencies] -+tokio = { version = "1.28", default-features = false, features = ["rt", "time", "net"] } -+ - [features] - queue = ["worker-macros/queue", "worker-sys/queue"] - d1 = ["worker-sys/d1"] -diff --git a/worker/src/lib.rs b/worker/src/lib.rs -index bc2cdc9..72097fe 100644 ---- a/worker/src/lib.rs -+++ b/worker/src/lib.rs -@@ -223,6 +223,11 @@ mod container; - mod context; - mod cors; - pub mod crypto; -+#[cfg(target_os = "emscripten")] -+pub mod emscripten; -+// The embedding host owns Emscripten module instances and panic handling. -+// SDK reinitialization targets wasm-bindgen's standalone loader instead. -+#[cfg(not(target_os = "emscripten"))] - mod init; - // Require pub module for macro export - #[cfg(feature = "d1")] -diff --git a/worker/src/socket.rs b/worker/src/socket.rs -index bc46cd4..a32d2bd 100644 ---- a/worker/src/socket.rs -+++ b/worker/src/socket.rs -@@ -289,6 +289,12 @@ impl AsyncWrite for Socket { - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { -+ // An outstanding write owns the stream's writer lock. Finish it before -+ // closing the WritableStream, as required by AsyncWrite::poll_shutdown. -+ match self.as_mut().poll_flush(cx) { -+ Poll::Ready(Ok(())) => {} -+ pending_or_error => return pending_or_error, -+ } - fn handle_future(cx: &mut Context<'_>, mut fut: JsFuture) -> (Closing, Poll>) { - match fut.poll_unpin(cx) { - Poll::Pending => (Closing::Pending(fut), Poll::Pending), -diff --git a/worker/src/emscripten.rs b/worker/src/emscripten.rs -new file mode 100644 -index 0000000..e3516ed ---- /dev/null -+++ b/worker/src/emscripten.rs -@@ -0,0 +1,56 @@ -+//! Promise exports driven by the Emscripten hosted Tokio runtime. -+//! -+//! Each modularized Wasm instance owns its runtime. This adapter keeps Tokio -+//! timers and I/O on that executor when JavaScript invokes an async Rust export. -+//! It requires the Emscripten-enabled Tokio toolchain. -+ -+use std::future::Future; -+use wasm_bindgen::prelude::*; -+ -+thread_local! { -+ static RUNTIME: tokio::runtime::HostedRuntime = tokio::runtime::Builder::new_current_thread() -+ .enable_all() -+ .build_hosted_event_loop_runtime() -+ .expect("hosted runtime"); -+} -+ -+/// All exports in one wasm instance use the same runtime. Emscripten's module -+/// factory gives each world its own wasm memory, thread locals, and callbacks. -+pub fn future_to_promise(future: F) -> js_sys::Promise -+where -+ F: Future> + 'static, -+{ -+ // Tokio catches future panics and delivers JoinError to the completion callback. -+ let mut future = std::panic::AssertUnwindSafe(Some(future)); -+ js_sys::Promise::new(&mut move |resolve, reject| { -+ let future = future.take().expect("Promise executor runs once"); -+ RUNTIME.with(|runtime| { -+ runtime.schedule(future, move |result| { -+ let result = result -+ .unwrap_or_else(|error| Err(js_sys::Error::new(&error.to_string()).into())); -+ match result { -+ Ok(value) => { -+ let _ = resolve.call1(&JsValue::UNDEFINED, &value); -+ } -+ Err(error) => { -+ let _ = reject.call1(&JsValue::UNDEFINED, &error); -+ } -+ } -+ }); -+ runtime.drive(); -+ }); -+ }) -+} -+ -+pub fn js_error(error: impl std::fmt::Display) -> JsValue { -+ js_sys::Error::new(&error.to_string()).into() -+} -+ -+/// Convert the socket passed to an Emscripten export into the SDK's async stream. -+pub fn socket_from_value(value: JsValue) -> Result { -+ use crate::FromSocket; -+ crate::Socket::from_raw(value.unchecked_into()).map_err(|error| { -+ let error: Box = error.into(); -+ js_error(error) -+ }) -+} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7fced56..6f67d1c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "nightly-2026-07-20" +channel = "beta" profile = "minimal" targets = ["wasm32-unknown-emscripten"] diff --git a/scripts/common.sh b/scripts/common.sh index acc92a8..8a7f652 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -4,6 +4,10 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$REPO/.work" NODE="${NODE:-node}" +# Setup provisions the frontend checkout, compiler backend config, and the +# wasm-bindgen CLI under .work/; WASM_BINDGEN_BIN may point at another CLI directory. +EMSCRIPTEN="$WORK/emscripten" +WASM_BINDGEN_BIN="${WASM_BINDGEN_BIN:-$WORK/bin}" require_node() { local major @@ -15,17 +19,23 @@ require_node() { } require_toolchain() { - if [ ! -f "$WORK/emscripten/.emscripten_cf" ] || - [ ! -x "$WORK/bin/wasm-bindgen" ] || + if [ ! -f "$EMSCRIPTEN/.emscripten_cf" ] || + [ ! -x "$EMSCRIPTEN/emcc" ] || + [ ! -x "$WASM_BINDGEN_BIN/wasm-bindgen" ] || [ ! -f "$WORK/pumpkin/crates/pumpkin/Cargo.toml" ] || - [ ! -f "$WORK/workers-rs/worker/Cargo.toml" ]; then + [ ! -f "$WORK/tokio/tokio/Cargo.toml" ]; then echo "error: run bash scripts/setup.sh first." >&2 exit 1 fi - export EM_CONFIG="$WORK/emscripten/.emscripten_cf" - export PATH="$WORK/emscripten:$WORK/bin:$PATH" - export CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER="$WORK/emscripten/emcc" + export EM_CONFIG="$EMSCRIPTEN/.emscripten_cf" + export PATH="$EMSCRIPTEN:$WASM_BINDGEN_BIN:$PATH" + export CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER="$EMSCRIPTEN/emcc" + # Uniform exnref exception handling for C and Rust objects (see .cargo/config.toml). + export EMCC_CFLAGS="${EMCC_CFLAGS:-} -fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=0" export CARGO_TARGET_DIR="$REPO/target/workers" + # rustc's LLVM const emission recurses deeply on pumpkin-data's generated tables + # and overflows its default 8 MiB compile-thread stack on current toolchains. + export RUST_MIN_STACK="${RUST_MIN_STACK:-268435456}" } require_packages() { diff --git a/scripts/setup.sh b/scripts/setup.sh index cdb584e..6faeb84 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Clone pinned sources, apply dependency patches, and build the host toolchain. +# Clone pinned sources, apply dependency patches, and provision the toolchain. set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/common.sh" @@ -12,7 +12,7 @@ mkdir -p "$WORK" checkout() { # name url branch commit local name="$1" url="$2" branch="$3" commit="$4" dir="$WORK/$1" - if [ ! -d "$dir/.git" ]; then + if [ ! -d "$dir/.git" ] && [ ! -f "$dir/.git" ]; then if [ -e "$dir" ]; then echo "error: $dir exists but is not a Git checkout." >&2 exit 1 @@ -43,51 +43,60 @@ apply_patch() { } echo "==> Pinned dependencies" -checkout emscripten https://github.com/guybedford/emscripten cf 21166256c4c4d73d39b3685c8973d7cbe427ce8c -checkout wasm-bindgen https://github.com/guybedford/wasm-bindgen emscripten-non-identifier-names 4b69f3b3ba4212c857be6854f77fa5aec8b62871 -checkout tokio https://github.com/guybedford/tokio emscripten-layering 7c1d4977c510866775ed6164b58b2218a6a2955b -checkout libc https://github.com/guybedford/libc libc-0.2-emscripten 4091fe0b0dc5f9c1a27bed75be1ff02bb27e756d -checkout ring https://github.com/guybedford/ring emscripten 6671f7cfbb13f249b571ffa6326275a8596e0ca2 checkout pumpkin https://github.com/Pumpkin-MC/Pumpkin master b5b9b9d7010e793806a83c495af223c67e1d35ee -checkout workers-rs https://github.com/ThomasRubini/workers-rs connect-bindings 7db011ec97658a5d907f3e3102028ce86c044f19 -apply_patch wasm-bindgen wasm-bindgen-emscripten-closures.patch +checkout tokio https://github.com/guybedford/tokio emscripten-epoll 8d0a2a845c546e93a4cf0e8ff2c21ac50a3d1931 apply_patch pumpkin pumpkin-emscripten.patch apply_patch pumpkin pumpkin-memory.patch -apply_patch workers-rs workers-rs-emscripten-toolchain.patch if [ "${1:-}" = --sources-only ]; then exit 0; fi -echo "==> Pinned Rust toolchain" +echo "==> Rust toolchain" RUST_CHANNEL="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["toolchain"]["channel"])' "$REPO/rust-toolchain.toml")" rustup toolchain install "$RUST_CHANNEL" --profile minimal --target wasm32-unknown-emscripten --no-self-update -echo "==> Emscripten frontend and Homebrew compiler backend" -EM_PREFIX="$(brew --prefix emscripten)" || { - echo "error: install the backend with 'brew install emscripten'." >&2 - exit 1 -} -# Resolve NODE before entering the checkout (it may be a relative path). +echo "==> wasm-bindgen CLI" +WASM_BINDGEN_VERSION="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["dependencies"]["wasm-bindgen"])' "$REPO/Cargo.toml")" +if [ "$("$WASM_BINDGEN_BIN/wasm-bindgen" --version 2>/dev/null || true)" != "wasm-bindgen $WASM_BINDGEN_VERSION" ]; then + cargo "+$RUST_CHANNEL" install --force --locked wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --root "$(dirname "$WASM_BINDGEN_BIN")" +fi + +echo "==> Emscripten" +# Frontend: upstream main plus the pending JSPI hooks, reentrant JSPI, and epoll +# listener PRs. LLVM comes from the emscripten-releases build paired with that +# main, and Binaryen from the branch carrying the jspi-hooks pass the frontend +# needs; setup builds it. EMSDK selects an activated emsdk for LLVM instead. +EMSDK_RELEASE=e8579ea489b44a6792f5abf95377a6ee38a16cce +checkout emscripten https://github.com/guybedford/emscripten cf-final a5013dd597ec9d48856370f5707d438937a7b4e8 NODE_PATH="$("$NODE" -p 'process.execPath')" -export PATH="$(dirname "$NODE_PATH"):$PATH" -(cd "$WORK/emscripten" && npm ci --no-audit --no-fund && python3 bootstrap.py) -python3 - "$WORK/emscripten/.emscripten_cf" "$EM_PREFIX" "$NODE_PATH" <<'PY' +(cd "$EMSCRIPTEN" && PATH="$(dirname "$NODE_PATH"):$PATH" npm ci --no-audit --no-fund && python3 bootstrap.py) +if [ -n "${EMSDK:-}" ]; then + LLVM_ROOT="$EMSDK/upstream/bin" +else + checkout emsdk https://github.com/emscripten-core/emsdk main 5eb0bde7585670252e8ba05e9d361627bffd08b5 + if [ "$(cat "$WORK/emsdk/upstream/.emsdk_version" 2>/dev/null)" != "releases-$EMSDK_RELEASE-64bit" ]; then + (cd "$WORK/emsdk" && ./emsdk install "$EMSDK_RELEASE" && ./emsdk activate "$EMSDK_RELEASE") + fi + LLVM_ROOT="$WORK/emsdk/upstream/bin" +fi +checkout binaryen https://github.com/guybedford/binaryen jspi-hooks d6483a04d7dab0ef83e5c43f87343061d97a3b8d +BINARYEN_ROOT="$WORK/binaryen/build" +if [ ! -x "$BINARYEN_ROOT/bin/wasm-opt" ] || [ "$(cat "$BINARYEN_ROOT/.pinned" 2>/dev/null)" != "$(git -C "$WORK/binaryen" rev-parse HEAD)" ]; then + cmake -S "$WORK/binaryen" -B "$BINARYEN_ROOT" -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF >/dev/null + cmake --build "$BINARYEN_ROOT" --target wasm-opt wasm-metadce wasm-emscripten-finalize wasm-split wasm-as wasm-dis wasm2js wasm-merge >/dev/null + git -C "$WORK/binaryen" rev-parse HEAD > "$BINARYEN_ROOT/.pinned" +fi +if [ ! -x "$LLVM_ROOT/clang" ] || [ ! -x "$BINARYEN_ROOT/bin/wasm-opt" ]; then + echo "error: no Emscripten backend at LLVM_ROOT=$LLVM_ROOT BINARYEN_ROOT=$BINARYEN_ROOT." >&2 + exit 1 +fi +python3 - "$EMSCRIPTEN/.emscripten_cf" "$LLVM_ROOT" "$BINARYEN_ROOT" "$NODE_PATH" <<'PY' from pathlib import Path import sys -config, prefix, node = sys.argv[1:] +config, llvm, binaryen, node = sys.argv[1:] Path(config).write_text( - f"LLVM_ROOT={prefix + '/libexec/llvm/bin'!r}\n" - f"BINARYEN_ROOT={prefix + '/libexec/binaryen'!r}\n" + f"LLVM_ROOT={llvm!r}\n" + f"BINARYEN_ROOT={binaryen!r}\n" f"NODE_JS={node!r}\n" ) PY - -echo "==> Patched wasm-bindgen CLI" -# Upstream does not commit this workspace lockfile. Preserve the proven CLI resolution. -cp "$REPO/toolchain/wasm-bindgen.Cargo.lock" "$WORK/wasm-bindgen/Cargo.lock" -# Override nested dependency toolchain files with the repository's dated nightly. -HOST_TARGET="$(rustc "+$RUST_CHANNEL" -vV | sed -n 's/^host: //p')" -(cd "$WORK/wasm-bindgen" && cargo "+$RUST_CHANNEL" build --locked --release -p wasm-bindgen-cli \ - --target "$HOST_TARGET" --target-dir "$WORK/wasm-bindgen/target") -mkdir -p "$WORK/bin" -cp "$WORK/wasm-bindgen/target/$HOST_TARGET/release/wasm-bindgen" "$WORK/bin/wasm-bindgen" echo "Setup complete. Run: bash scripts/test.sh or bash scripts/serve.sh" diff --git a/src/config.rs b/src/config.rs index 22360a8..e9106a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,8 +13,9 @@ pub fn configuration() -> (BasicConfiguration, AdvancedConfiguration) { } let mut adv = AdvancedConfiguration::default(); - // Offline Java server with a bounded view distance. - adv.networking.java.enabled = false; // Connections are supplied by the Durable Object. + // Offline Java server with a bounded view distance. The listener binds the + // default 0.0.0.0:25565 in the Durable Object's port table; the host routes + // inbound sockets to it by port. adv.networking.java.online_mode = false; adv.networking.java.encryption = false; adv.networking.java.view_distance = NonZero::new(3).unwrap(); diff --git a/src/main.rs b/src/main.rs index 5bee898..93bf34e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,71 +1,70 @@ +// `#[wasm_bindgen(jspi)]` is experimental and warns as deprecated. +#![allow(deprecated)] + mod config; mod memory; -use pumpkin::{data::VanillaData, PumpkinServer}; +use pumpkin::{data::VanillaData, server::Server, PumpkinServer}; use std::{cell::RefCell, sync::atomic::Ordering, sync::Arc}; use wasm_bindgen::prelude::*; -use worker::emscripten::{future_to_promise, js_error, socket_from_value}; fn main() {} thread_local! { static FAILURE: RefCell> = const { RefCell::new(None) }; - static SERVER: RefCell>> = const { RefCell::new(None) }; - static NEXT_CLIENT: RefCell = const { RefCell::new(0) }; + static SERVER: RefCell>> = const { RefCell::new(None) }; +} + +fn js_error(error: impl std::fmt::Display) -> JsValue { + js_sys::Error::new(&error.to_string()).into() } -fn server() -> Result, JsValue> { +fn server() -> Result, JsValue> { if let Some(error) = FAILURE.with(|failure| failure.borrow().clone()) { return Err(js_error(error)); } SERVER .with(|server| server.borrow().clone()) - .ok_or_else(|| js_error("Server is not started")) + .ok_or_else(|| js_error("Server is not running")) } -#[wasm_bindgen] -pub fn pumpkin_start() -> js_sys::Promise { - future_to_promise(async { - if SERVER.with(|server| server.borrow().is_some()) { - return Ok(JsValue::UNDEFINED); - } - std::panic::set_hook(Box::new(|info| { - FAILURE.with(|failure| { - failure.borrow_mut().get_or_insert_with(|| info.to_string()); - }); - eprintln!("RUST PANIC: {info}"); - })); - rayon::ThreadPoolBuilder::new() - .num_threads(1) - .use_current_thread() - .build_global() - .map_err(js_error)?; - let (basic, advanced) = config::configuration(); - pumpkin::init_logger(&advanced); - let server = Arc::new(PumpkinServer::new(basic, advanced, VanillaData::load()).await); +/// Runs the server on a Tokio runtime that parks by suspending this activation. +/// `ready` is called once the listener is bound. Resolves after `pumpkin_stop` +/// once the final save completes. +#[wasm_bindgen(jspi)] +pub fn pumpkin_run(ready: js_sys::Function) -> Result<(), JsValue> { + std::panic::set_hook(Box::new(|info| { + FAILURE.with(|failure| { + failure.borrow_mut().get_or_insert_with(|| info.to_string()); + }); + eprintln!("RUST PANIC: {info}"); + })); + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .use_current_thread() + .build_global() + .map_err(js_error)?; + let (basic, advanced) = config::configuration(); + pumpkin::init_logger(&advanced); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(js_error)?; + runtime.block_on(async { + let server = PumpkinServer::new(basic, advanced, VanillaData::load()).await; server.init_plugins().await; - SERVER.with(|slot| *slot.borrow_mut() = Some(server)); - Ok(JsValue::UNDEFINED) + SERVER.with(|slot| *slot.borrow_mut() = Some(server.server.clone())); + ready.call0(&JsValue::UNDEFINED)?; + server.start().await; + SERVER.with(|slot| slot.borrow_mut().take()); + Ok(()) }) } +/// Requests shutdown; `pumpkin_run` completes the save and returns. #[wasm_bindgen] -pub fn pumpkin_connect(raw: JsValue) -> js_sys::Promise { - future_to_promise(async move { - let server = server()?; - let socket = socket_from_value(raw)?; - let id = NEXT_CLIENT.with(|slot| { - let mut id = slot.borrow_mut(); - *id += 1; - *id - }); - // The SDK's socket is the injected Tokio byte stream. Its adapter handles - // backpressure, EOF and JS stream errors; no emulated TCP listener is used. - server - .serve_connection(socket, ([127, 0, 0, 1], 0).into(), id) - .await; - Ok(JsValue::UNDEFINED) - }) +pub fn pumpkin_stop() { + pumpkin::stop_server(); } #[wasm_bindgen] @@ -75,12 +74,12 @@ pub fn pumpkin_status() -> Result { js_sys::Reflect::set( &result, &"players".into(), - &(server.server.get_all_players().len() as f64).into(), + &(server.get_all_players().len() as f64).into(), )?; js_sys::Reflect::set( &result, &"ticks".into(), - &(server.server.tick_count.load(Ordering::Relaxed) as f64).into(), + &(server.tick_count.load(Ordering::Relaxed) as f64).into(), )?; js_sys::Reflect::set( &result, @@ -92,20 +91,3 @@ pub fn pumpkin_status() -> Result { } Ok(result.into()) } - -#[wasm_bindgen] -pub fn pumpkin_shutdown() -> js_sys::Promise { - future_to_promise(async { - let server = server()?; - if !server.server.get_all_players().is_empty() { - return Err(js_error( - "Disconnect clients before shutting down the world", - )); - } - pumpkin::stop_server(); - server.server.save_all().await.map_err(js_error)?; - server.server.shutdown().await; - SERVER.with(|slot| slot.borrow_mut().take()); - Ok(JsValue::UNDEFINED) - }) -} diff --git a/tests/fixtures/filesystem.rs b/tests/fixtures/filesystem.rs index cf6c210..37a044e 100644 --- a/tests/fixtures/filesystem.rs +++ b/tests/fixtures/filesystem.rs @@ -1,31 +1,42 @@ +#![allow(deprecated)] + use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; use wasm_bindgen::prelude::*; -use worker::emscripten::{future_to_promise, js_error, socket_from_value}; fn main() {} -#[wasm_bindgen] -pub fn echo(socket: JsValue) -> js_sys::Promise { - future_to_promise(async move { - let mut socket = socket_from_value(socket)?; - let mut bytes = [0u8; 16 * 1024]; - loop { - let count = socket.read(&mut bytes).await.map_err(js_error)?; - if count == 0 { - break; - } - socket.write_all(&bytes[..count]).await.map_err(js_error)?; - } - socket.shutdown().await.map_err(js_error)?; - Ok(JsValue::UNDEFINED) - }) +fn js_error(error: impl std::fmt::Display) -> JsValue { + js_sys::Error::new(&error.to_string()).into() } -#[wasm_bindgen] -pub fn tick_after(milliseconds: u32) -> js_sys::Promise { - future_to_promise(async move { - tokio::time::sleep(std::time::Duration::from_millis(milliseconds.into())).await; - Ok(JsValue::TRUE) +/// Echo every accepted connection for the lifetime of the instance. +#[wasm_bindgen(jspi)] +pub fn echo_server(ready: js_sys::Function) -> Result<(), JsValue> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(js_error)?; + runtime.block_on(async { + let listener = TcpListener::bind("0.0.0.0:25565").await.map_err(js_error)?; + ready.call0(&JsValue::UNDEFINED)?; + loop { + let (mut socket, _) = listener.accept().await.map_err(js_error)?; + tokio::spawn(async move { + let mut bytes = [0u8; 16 * 1024]; + loop { + match socket.read(&mut bytes).await { + Ok(0) | Err(_) => break, + Ok(count) => { + if socket.write_all(&bytes[..count]).await.is_err() { + break; + } + } + } + } + let _ = socket.shutdown().await; + }); + } }) } diff --git a/tests/fixtures/worker.mjs b/tests/fixtures/worker.mjs index f8300ac..dbde6d3 100644 --- a/tests/fixtures/worker.mjs +++ b/tests/fixtures/worker.mjs @@ -1,10 +1,13 @@ import { DurableObject, exports } from 'cloudflare:workers'; +import { handleAsNodeConnection } from 'cloudflare:node'; export { StartupFailure } from './startup-failure.mjs'; import createModule from '../../target/workers/wasm32-unknown-emscripten/release/filesystem-probe.js'; import wasm from '../../target/workers/wasm32-unknown-emscripten/release/filesystem_probe.wasm'; import { createWorldRuntime } from '../../worker/filesystem'; import { forwardSocket } from '../../worker/socket'; +const PORT = 25565; + export class FilesystemTest extends DurableObject { runtime; @@ -16,18 +19,23 @@ export class FilesystemTest extends DurableObject { receive(instance); return instance.exports; }, - })); + })).then(async runtime => { + await new Promise((ready, reject) => { + runtime.run(() => runtime.instance.echo_server(ready)).catch(reject); + }); + return runtime; + }); } async connect(socket) { void socket.closed.catch(() => undefined); - const runtime = await this.getRuntime(); - await runtime.run(() => runtime.instance.echo(socket)); + await this.getRuntime(); + await handleAsNodeConnection(socket); } async status() { - const runtime = await this.getRuntime(); - return { timer: await runtime.run(() => runtime.instance.tick_after(20)) }; + await this.getRuntime(); + return { listening: true }; } async filesystemProbe(value) { @@ -38,7 +46,7 @@ export class FilesystemTest extends DurableObject { export default { async connect(socket, env) { - const target = exports.FilesystemTest.getByName(env.WORLD_NAME).connect('world:25565', { allowHalfOpen: true }); + const target = exports.FilesystemTest.getByName(env.WORLD_NAME).connect(`world:${PORT}`, { allowHalfOpen: true }); await forwardSocket(socket, target); }, async fetch(request, env) { diff --git a/tests/integration.mjs b/tests/integration.mjs index ddeceb7..9c9a9eb 100644 --- a/tests/integration.mjs +++ b/tests/integration.mjs @@ -58,7 +58,7 @@ async function marker(world, value) { let worker = await startWorker(true); try { await Promise.all([echo(), echo()]); - assert.equal((await request('/')).timer, true); + assert.equal((await request('/')).listening, true); const failure = await request('/startup-failure'); assert.equal(failure.starting.phase, 'starting'); assert.equal(failure.failed.phase, 'failed'); diff --git a/toolchain/wasm-bindgen.Cargo.lock b/toolchain/wasm-bindgen.Cargo.lock deleted file mode 100644 index e772975..0000000 --- a/toolchain/wasm-bindgen.Cargo.lock +++ /dev/null @@ -1,4510 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "add" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits 0.2.19", -] - -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "askama" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" -dependencies = [ - "askama_macros", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.119", -] - -[[package]] -name = "askama_macros" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" -dependencies = [ - "askama_derive", -] - -[[package]] -name = "askama_parser" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" -dependencies = [ - "rustc-hash", - "serde", - "serde_derive", - "unicode-ident", - "winnow 1.0.4", -] - -[[package]] -name = "assert_cmd" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" -dependencies = [ - "anstyle", - "bstr", - "libc", - "predicates", - "predicates-core", - "predicates-tree", - "wait-timeout", -] - -[[package]] -name = "async-trait" -version = "0.1.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "brotli-decompressor" -version = "5.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bstr" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" -dependencies = [ - "memchr", - "regex-automata", - "serde_core", -] - -[[package]] -name = "buf_redux" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" -dependencies = [ - "memchr", - "safemem", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "canvas" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex 2.0.1", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core 0.10.1", -] - -[[package]] -name = "char" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits 0.2.19", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - -[[package]] -name = "clap" -version = "4.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "closures" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "console_log" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "console_log" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" -dependencies = [ - "log", - "web-sys", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "deflate" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4" -dependencies = [ - "adler32", - "byteorder", -] - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.19", -] - -[[package]] -name = "der" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dom" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "enum_primitive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4551092f4d519593039259a9ed8daedf0da12e5109c5280338073eaeb81180" -dependencies = [ - "num-traits 0.1.43", -] - -[[package]] -name = "env_filter" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "explicit-resource-management" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fetch" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "float-cmp" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" -dependencies = [ - "num-traits 0.2.19", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-channel-preview" -version = "0.3.0-alpha.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5e5f4df964fa9c1c2f8bddeb5c3611631cacd93baf810fc8bb2fb4b495c263a" -dependencies = [ - "futures-core-preview", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-core-preview" -version = "0.3.0-alpha.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35b6263fb1ef523c3056565fa67b1d16f0a8604ff12b11b08c25f28a734c60a" - -[[package]] -name = "futures-executor" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-timer" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gg-alloc" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee5134f1abeb59e9ba515150ddc26bf2b2207f8bce38e1f5cf8d1596efa22ba" - -[[package]] -name = "gif" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e41945ba23db3bf51b24756d73d81acb4f28d85c3dccc32c6fae904438c25f" -dependencies = [ - "color_quant", - "lzw", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -dependencies = [ - "fallible-iterator", - "indexmap", - "stable_deref_trait", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gloo" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" -dependencies = [ - "gloo-console", - "gloo-dialogs", - "gloo-events", - "gloo-file", - "gloo-history", - "gloo-net", - "gloo-render", - "gloo-storage", - "gloo-timers", - "gloo-utils", - "gloo-worker", -] - -[[package]] -name = "gloo-console" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" -dependencies = [ - "gloo-utils", - "js-sys", - "serde", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-dialogs" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-events" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-file" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" -dependencies = [ - "gloo-events", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-history" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" -dependencies = [ - "getrandom 0.2.17", - "gloo-events", - "gloo-utils", - "serde", - "serde-wasm-bindgen", - "serde_urlencoded", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-net" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils", - "http 0.2.12", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "gloo-render" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-storage" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" -dependencies = [ - "gloo-utils", - "js-sys", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "gloo-utils" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "gloo-worker" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" -dependencies = [ - "bincode", - "futures", - "gloo-utils", - "gloo-worker-macros", - "js-sys", - "pinned", - "serde", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "gloo-worker-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "guide-supported-types-examples" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.2", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash", - "serde", - "serde_core", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hello_world" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http 1.4.2", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.2", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http 1.4.2", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http 1.4.2", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.2", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -dependencies = [ - "rayon", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95816db758249fe16f23a4e23f1a3a817fe11892dbfd1c5836f625324702158" -dependencies = [ - "byteorder", - "enum_primitive", - "gif", - "jpeg-decoder", - "num-iter", - "num-rational 0.1.43", - "num-traits 0.1.43", - "png", - "scoped_threadpool", -] - -[[package]] -name = "import_js" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inflate" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e0062d2dc2f17d2f13750d95316ae8a2ff909af0fda957084f5defd87c43bb" - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" -dependencies = [ - "defmt", -] - -[[package]] -name = "jiff-static" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.19", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" -dependencies = [ - "rayon", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -dependencies = [ - "cfg-if", - "futures-channel", - "futures-core", - "futures-lite", - "futures-util", - "half", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", - "web-sys", -] - -[[package]] -name = "json" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" - -[[package]] -name = "julia_set" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzw" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084" - -[[package]] -name = "matrixmultiply" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minicov" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" -dependencies = [ - "cc", - "walkdir", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multipart" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" -dependencies = [ - "buf_redux", - "httparse", - "log", - "mime", - "mime_guess", - "quick-error", - "rand 0.8.7", - "safemem", - "tempfile", - "twoway", -] - -[[package]] -name = "nalgebra" -version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-rational 0.4.2", - "num-traits 0.2.19", - "rand 0.8.7", - "rand_distr", - "simba", - "typenum", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "no-std" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "node_and_deno" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "nodejs-threads" -version = "0.1.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "nom" -version = "5.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" -dependencies = [ - "memchr", - "version_check", -] - -[[package]] -name = "normalize-line-endings" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits 0.2.19", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits 0.2.19", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits 0.2.19", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits 0.2.19", -] - -[[package]] -name = "num-rational" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfff0773e8a07fb033d726b9ff1327466709820788e5298afce4d752965ff1e" -dependencies = [ - "autocfg", - "num-integer", - "num-traits 0.2.19", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits 0.2.19", -] - -[[package]] -name = "num-traits" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" -dependencies = [ - "num-traits 0.2.19", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "performance" -version = "0.0.0" -dependencies = [ - "humantime", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pinned" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" -dependencies = [ - "futures", - "rustversion", - "thiserror 1.0.69", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "png" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb773e9a557edb568ce9935cf783e3cdcabe06a9449d41b3e5506d88e582c82" -dependencies = [ - "bitflags 0.7.0", - "deflate", - "inflate", - "num-iter", -] - -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "predicates" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" -dependencies = [ - "anstyle", - "difflib", - "float-cmp", - "normalize-line-endings", - "predicates-core", - "regex", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.19", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.19", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits 0.2.19", - "rand 0.8.7", -] - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "raytrace-parallel" -version = "0.0.0" -dependencies = [ - "console_error_panic_hook", - "futures-channel-preview", - "js-sys", - "rayon", - "rayon-core", - "raytracer", - "serde-wasm-bindgen", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "raytracer" -version = "0.1.0" -source = "git+https://github.com/alexcrichton/raytracer?branch=update-deps#42faa13859f7d8d47fd18be785c108003a207786" -dependencies = [ - "image", - "serde", - "serde_derive", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - -[[package]] -name = "request-animation-frame" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "h2", - "http 1.4.2", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rouille" -version = "3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" -dependencies = [ - "base64 0.13.1", - "chrono", - "filetime", - "multipart", - "percent-encoding", - "rand 0.8.7", - "serde", - "serde_derive", - "serde_json", - "sha1_smol", - "threadpool", - "time", - "tiny_http", - "url", -] - -[[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.119", - "unicode-ident", -] - -[[package]] -name = "rust-duck-typed-interfaces" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "rust-webassembly-weather-reports" -version = "0.0.0" -dependencies = [ - "chrono", - "gloo", - "json", - "reqwest", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "safemem" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "sample" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scoped_threadpool" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simba" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" -dependencies = [ - "approx", - "num-complex", - "num-traits 0.2.19", - "paste", - "wide", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sourcefile" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654ee0ca20343b1b2d6cb222c3c136b6209e20324ef53f7bde19cdd1e766e986" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "statrs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" -dependencies = [ - "approx", - "nalgebra", - "num-traits 0.2.19", - "rand 0.8.7", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synchronous-instantiation" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "target-triple" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl 2.0.19", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "threadpool" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", -] - -[[package]] -name = "time" -version = "0.3.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "todomvc" -version = "0.0.0" -dependencies = [ - "askama", - "console_error_panic_hook", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.4", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.4", -] - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.13.1", - "bytes", - "futures-util", - "http 1.4.2", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "trybuild" -version = "1.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" -dependencies = [ - "glob", - "serde", - "serde_derive", - "serde_json", - "target-triple", - "termcolor", - "toml", -] - -[[package]] -name = "twoway" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" -dependencies = [ - "memchr", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "typescript-tests" -version = "0.0.0" -dependencies = [ - "js-sys", - "serde", - "serde-wasm-bindgen", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "brotli-decompressor", - "der", - "flate2", - "log", - "native-tls", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-root-certs", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http 1.4.2", - "httparse", - "log", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "walrus" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bfa49767bb3a9e1afb02aa95bbcbde8d82f2db4ca377afae94d688f14f62378" -dependencies = [ - "anyhow", - "gimli", - "id-arena", - "leb128", - "log", - "rayon", - "walrus-macro", - "wasm-encoder 0.245.1", - "wasmparser 0.245.1", -] - -[[package]] -name = "walrus-macro" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9b0525d7ea6e5f906aca581a172e5c91b4c595290dfa8ad4a2bc9ffef33b44" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-audio-worklet" -version = "0.0.0" -dependencies = [ - "console_error_panic_hook", - "console_log 1.1.0", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "paste", - "rustversion", - "serde", - "serde_derive", - "serde_json", - "wasm-bindgen-futures", - "wasm-bindgen-macro", - "wasm-bindgen-shared", - "wasm-bindgen-test", - "wasm-bindgen-test-crate-a", - "wasm-bindgen-test-crate-b", -] - -[[package]] -name = "wasm-bindgen-benchmark" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-cli" -version = "0.2.126" -dependencies = [ - "anyhow", - "assert_cmd", - "clap", - "env_logger", - "log", - "native-tls", - "predicates", - "pretty_assertions", - "regex", - "rouille", - "rstest", - "serde", - "serde_derive", - "serde_json", - "shlex 1.3.0", - "tempfile", - "ureq", - "walrus", - "wasm-bindgen-cli-support", - "wasm-bindgen-test-shared", - "wasmparser 0.245.1", - "wasmprinter 0.245.1", -] - -[[package]] -name = "wasm-bindgen-cli-support" -version = "0.2.126" -dependencies = [ - "anyhow", - "base64 0.22.1", - "leb128", - "log", - "rstest", - "rustc-demangle", - "serde", - "serde_json", - "walrus", - "wasm-bindgen-shared", - "wasmparser 0.245.1", - "wasmprinter 0.243.0", - "wast 243.0.0", - "wat", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -dependencies = [ - "futures-channel", - "futures-lite", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-test", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -dependencies = [ - "js-sys", - "quote", - "trybuild", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-macro-support", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-paint" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-bindgen-test" -version = "0.3.76" -dependencies = [ - "async-trait", - "cast", - "gg-alloc", - "js-sys", - "libm", - "minicov", - "nu-ansi-term", - "num-traits 0.2.19", - "oorandom", - "serde", - "serde_json", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test-macro", - "wasm-bindgen-test-shared", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-test-crate-a" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-test-crate-b" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-test-macro" -version = "0.3.76" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "tokio", - "trybuild", - "wasm-bindgen-test", -] - -[[package]] -name = "wasm-bindgen-test-shared" -version = "0.2.126" - -[[package]] -name = "wasm-bindgen-webidl" -version = "0.0.0" -dependencies = [ - "anyhow", - "clap", - "env_logger", - "heck", - "lazy_static", - "log", - "once_cell", - "proc-macro2", - "quote", - "sourcefile", - "syn 2.0.119", - "weedle", -] - -[[package]] -name = "wasm-encoder" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" -dependencies = [ - "leb128fmt", - "wasmparser 0.243.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" -dependencies = [ - "leb128fmt", - "wasmparser 0.245.1", -] - -[[package]] -name = "wasm-encoder" -version = "0.254.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" -dependencies = [ - "leb128fmt", - "wasmparser 0.254.0", -] - -[[package]] -name = "wasm-in-wasm" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "wasm-in-wasm-imports" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", -] - -[[package]] -name = "wasm-in-web-worker" -version = "0.0.0" -dependencies = [ - "console_error_panic_hook", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" -dependencies = [ - "bitflags 2.13.1", - "indexmap", - "semver", -] - -[[package]] -name = "wasmparser" -version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" -dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.16.1", - "indexmap", - "semver", - "serde", -] - -[[package]] -name = "wasmparser" -version = "0.254.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" -dependencies = [ - "bitflags 2.13.1", - "indexmap", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.243.0", -] - -[[package]] -name = "wasmprinter" -version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.245.1", -] - -[[package]] -name = "wast" -version = "243.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df21d01c2d91e46cb7a221d79e58a2d210ea02020d57c092e79255cc2999ca7f" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.243.0", -] - -[[package]] -name = "wast" -version = "254.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.254.0", -] - -[[package]] -name = "wat" -version = "1.254.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" -dependencies = [ - "wast 254.0.0", -] - -[[package]] -name = "wcodspeed" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde", - "serde_json", - "statrs", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -dependencies = [ - "futures", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webaudio" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "webgl" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "webidl-tests" -version = "0.0.0" -dependencies = [ - "env_logger", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", - "wasm-bindgen-webidl", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webrtc_datachannel" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "websockets" -version = "0.0.0" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "webxr" -version = "0.0.0" -dependencies = [ - "console_error_panic_hook", - "futures", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "weedle" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5352ce9558ad6aa2c4eca1b18329177f5f77ba71f54eac09709ef47701137eb" -dependencies = [ - "nom", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "without-a-bundler" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "without-a-bundler-no-modules" -version = "0.0.0" -dependencies = [ - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/worker/index.ts b/worker/index.ts index 142919f..e98fe11 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,10 +1,11 @@ import { exports } from 'cloudflare:workers'; +import { PORT } from './pumpkin'; import { forwardSocket } from './socket'; export { MinecraftWorld } from './world'; export default { async connect(socket: Socket, env: Env): Promise { - const target = exports.MinecraftWorld.getByName(env.WORLD_NAME).connect('world:25565', { allowHalfOpen: true }); + const target = exports.MinecraftWorld.getByName(env.WORLD_NAME).connect(`world:${PORT}`, { allowHalfOpen: true }); await forwardSocket(socket, target); }, async fetch(request: Request, env: Env): Promise { diff --git a/worker/modules.d.ts b/worker/modules.d.ts index 552bea7..3238267 100644 --- a/worker/modules.d.ts +++ b/worker/modules.d.ts @@ -11,10 +11,16 @@ declare module '*pumpkin-do.js' { printErr(message: string): void; } interface Pumpkin { - pumpkin_start(): Promise; - pumpkin_connect(socket: Socket): Promise; + pumpkin_run(ready: () => void): Promise; + pumpkin_stop(): void; pumpkin_status(): { players: number; ticks: number; wasm_memory_bytes: number }; - pumpkin_shutdown(): Promise; } export default function create(options: Options): Promise; } + +// Routes an inbound socket to the net.Server listening on its local address's +// port within the current Durable Object's port table; resolves when the +// connection closes. +declare module 'cloudflare:node' { + export function handleAsNodeConnection(socket: Socket): Promise; +} diff --git a/worker/pumpkin.ts b/worker/pumpkin.ts index c8b86fd..9928a22 100644 --- a/worker/pumpkin.ts +++ b/worker/pumpkin.ts @@ -1,8 +1,11 @@ +import { handleAsNodeConnection } from 'cloudflare:node'; import createPumpkin from '../target/workers/wasm32-unknown-emscripten/release/pumpkin-do.js'; import pumpkinWasm from '../target/workers/wasm32-unknown-emscripten/release/pumpkin_do.wasm'; import { createWorldRuntime } from './filesystem'; -/** Bind the server API to its instance's filesystem and async context once. */ +export const PORT = 25565; + +/** Start the server in its instance's filesystem context and resolve once it is listening. */ export async function startPumpkin(storage: DurableObjectStorage) { const { instance, run } = await createWorldRuntime(storage, options => createPumpkin({ ...options, @@ -13,10 +16,18 @@ export async function startPumpkin(storage: DurableObjectStorage) { }, printErr: console.error, })); - await run(() => instance.pumpkin_start()); + // The single JSPI activation runs the whole server lifetime, suspending + // whenever Tokio parks. It settles after `stop` completes the final save, or + // rejects if the server fails. + let finished!: Promise; + await new Promise((ready, reject) => { + finished = run(() => instance.pumpkin_run(ready)); + finished.catch(reject); + }); return { - connect: (socket: Socket) => run(() => instance.pumpkin_connect(socket)), - stop: () => run(() => instance.pumpkin_shutdown()), - status: () => run(() => instance.pumpkin_status()), + finished, + connect: (socket: Socket) => handleAsNodeConnection(socket), + stop: () => { instance.pumpkin_stop(); return finished; }, + status: () => instance.pumpkin_status(), }; } diff --git a/worker/world.ts b/worker/world.ts index f457cfa..75d7bca 100644 --- a/worker/world.ts +++ b/worker/world.ts @@ -39,6 +39,7 @@ export class MinecraftWorld extends DurableObject { const pending = Promise.resolve().then(() => this.loadRuntime()).then(runtime => { this.startupMilliseconds = Date.now() - started; this.state = { phase: 'running', runtime }; + runtime.finished.catch(error => this.fail(error)); return runtime; }).catch(error => { this.fail(error); throw error; }); this.state = { phase: 'starting', pending }; From caaa63b41bf54607e1983fe748d13123eebff1f0 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 02:37:14 -0700 Subject: [PATCH 03/12] Run the Durable Object on Tokio's EventLoopRuntime The whole Worker is now Rust linked directly by cargo and emcc, with no JSPI and no JS-side driver. The Durable Object owns an EventLoopRuntime (tokio-rs/tokio#8479): Tokio's current-thread scheduler and drivers wait on the host event loop through Emscripten's epoll listeners (emscripten-core/emscripten#27547). Exports return promises; the server lifetime is a scheduled root that completes by callback after the final save and checkpoint. Connections arriving while the server starts or stops wait for the next phase change and retry. worker/index.mjs re-exports the generated module and derives the object class from DurableObject for RPC. The worker and worker-build dependencies, the TypeScript worker, and the filesystem fixtures are removed. The Pumpkin patch makes the stop token replaceable behind a StopSignal and adds reset_stop so a server can start again in the same instance. Under NODERAWFS the host enforces permissions; src/workerd.js forces FS.ignorePermissions since workerd reports directories without execute bits, and keeps stdio on the console callbacks. Documentation describes the event-loop model and the workerd node:fs requirements (positional buffer I/O, O_TRUNC, O_CREAT, rename over an existing path). --- .cargo/config.toml | 17 +- AGENTS.md | 14 +- Cargo.lock | 25 +-- Cargo.toml | 10 +- README.md | 18 +- build.rs | 19 +- docs/architecture.md | 119 +++++------ docs/dependencies.md | 39 ++-- docs/development.md | 15 +- package-lock.json | 66 ------- package.json | 15 +- patches/README.md | 2 +- patches/pumpkin-emscripten.patch | 83 +++++++- scripts/setup.sh | 4 +- scripts/test.sh | 3 - src/host.rs | 67 +++++++ src/main.rs | 149 +++++++------- src/persist.rs | 93 +++++++++ src/workerd.js | 54 +++++ src/world.rs | 303 +++++++++++++++++++++++++++++ tests/fixtures/filesystem.rs | 87 --------- tests/fixtures/startup-failure.mjs | 14 -- tests/fixtures/worker.mjs | 64 ------ tests/fixtures/wrangler.jsonc | 14 -- tests/integration.mjs | 50 +---- tests/server.mjs | 6 +- tsconfig.json | 12 -- worker/filesystem.ts | 16 -- worker/fs-library.js | 114 ----------- worker/index.mjs | 16 ++ worker/index.ts | 18 -- worker/modules.d.ts | 26 --- worker/pumpkin.ts | 33 ---- worker/socket.ts | 25 --- worker/world.ts | 93 --------- wrangler.jsonc | 5 +- 36 files changed, 817 insertions(+), 891 deletions(-) create mode 100644 src/host.rs create mode 100644 src/persist.rs create mode 100644 src/workerd.js create mode 100644 src/world.rs delete mode 100644 tests/fixtures/filesystem.rs delete mode 100644 tests/fixtures/startup-failure.mjs delete mode 100644 tests/fixtures/worker.mjs delete mode 100644 tests/fixtures/wrangler.jsonc delete mode 100644 tsconfig.json delete mode 100644 worker/filesystem.ts delete mode 100644 worker/fs-library.js create mode 100644 worker/index.mjs delete mode 100644 worker/index.ts delete mode 100644 worker/modules.d.ts delete mode 100644 worker/pumpkin.ts delete mode 100644 worker/socket.ts delete mode 100644 worker/world.ts diff --git a/.cargo/config.toml b/.cargo/config.toml index ddb88b3..8afc927 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,18 +5,18 @@ target-dir = "target/workers" [target.wasm32-unknown-emscripten] rustflags = [ "-Crelocation-model=static", + "--cfg=tokio_unstable", # exnref exception handling throughout: the C side gets `-fwasm-exceptions - # -sWASM_LEGACY_EXCEPTIONS=0` from EMCC_CFLAGS (scripts/common.sh), emcc - # translates the prebuilt std at link, and wasm-bindgen's JSPI wrappers use - # try_table. V8 rejects a module mixing the two. + # -sWASM_LEGACY_EXCEPTIONS=0` from EMCC_CFLAGS (scripts/common.sh) and the + # prebuilt std is translated at link. V8 rejects a module mixing the two. "-Cllvm-args=-wasm-use-legacy-eh=false", + "-Clink-arg=-sBINARYEN_EXTRA_PASSES=--translate-to-exnref", # Link-only settings stay out of EMCC_CFLAGS so they do not reach C compiles. "-Clink-arg=-sWASM_BINDGEN", - "-Clink-arg=-sJSPI", - # Each promising activation runs on its own shadow stack, so a request or - # timer entering the module while the server is suspended cannot clobber it. - "-Clink-arg=-sREENTRANT_JSPI", + "-Clink-arg=-sMODULARIZE=instance", "-Clink-arg=-sEXPORT_ES6", + "-Clink-arg=-sAUTO_INIT", + "-Clink-arg=-sSOURCE_PHASE_IMPORTS", # Generated host glue uses Workers' Node compatibility APIs. "-Clink-arg=-sENVIRONMENT=node", "-Clink-arg=-sNODERAWSOCKETS", @@ -24,7 +24,4 @@ rustflags = [ "-Clink-arg=-sASSERTIONS=0", "-Clink-arg=-sSTACK_SIZE=8MB", "-Clink-arg=-sALLOW_MEMORY_GROWTH=1", - # Each Durable Object creates its own wasm instance and mounted filesystem. - "-Clink-arg=-sMODULARIZE=1", - "-Clink-arg=-sEXPORT_NAME=createPumpkinModule", ] diff --git a/AGENTS.md b/AGENTS.md index 1096947..5cbf3f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,15 +5,16 @@ Read [README.md](README.md), [architecture](docs/architecture.md), and ## Project layout -- `src/`: Pumpkin entry points and configuration. -- `worker/`: TCP ingress, Durable Object lifecycle, and SQLite filesystem. -- `tests/`: protocol clients, integration tests, and isolated filesystem fixtures. +- `src/`: the Worker: TCP ingress, the Durable Object, world persistence, + Pumpkin configuration, and the Emscripten JS library (`workerd.js`). +- `worker/index.mjs`: the module Wrangler loads; re-exports the generated build. +- `tests/`: protocol clients and the integration test. - `scripts/setup.sh`: provision pinned sources and build the toolchain. - `scripts/{build,serve,test}.sh`: build, run, and validate the Workers server. Use Node 24+ (26 recommended) and the pinned Rust toolchain. Run `npm test` after build/runtime changes; it must print `PUMPKIN-DO-SQLITE-RESTART-OK` after verifying -player and chunk restoration. Keep test fixtures out of the production bundle. +player and chunk restoration. Scripts bind to loopback and fail if their ports are occupied. Do not kill another process to free a port. @@ -21,8 +22,9 @@ process to free a port. Changes to the patched Pumpkin checkout must be reflected in `patches/`. Keep unpatched checkouts unmodified; update pins for upstream changes. Preserve the unified ticker and cooperative scheduler unless -the task requires a runtime change. Do not reintroduce old Tokio/libc networking -patches. +the task requires a runtime change. Keep the `EventLoopRuntime` model: exports +return promises and nothing blocks or suspends; do not reintroduce JSPI, old +Tokio/libc networking patches, or a JS-side driver. Worlds under `.data/` are user data; do not remove or copy them into commits. Playable databases live in `.data/workers/server/`; tests use `.data/probes/`. diff --git a/Cargo.lock b/Cargo.lock index da0cad1..7cc9763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.46" +version = "0.4.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" +checksum = "ef217a77a86a6e3dab9a5b3c81dc445b603fe743a90c1cb10a2f2144628d8cfa" dependencies = [ "compression-codecs", "compression-core", @@ -164,9 +164,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "shlex", @@ -250,9 +250,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" +checksum = "257c7085cbb71be72d8fb97edff08b03d86d5d9f2222b9cc34a6bec87093bc10" dependencies = [ "compression-core", "flate2", @@ -313,9 +313,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -1812,6 +1812,7 @@ dependencies = [ "rayon", "tokio", "wasm-bindgen", + "web-sys", ] [[package]] @@ -2036,9 +2037,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -2352,9 +2353,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" diff --git a/Cargo.toml b/Cargo.toml index 1e7f0ee..3c53cd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,14 +12,6 @@ autobins = false exclude = [".work"] resolver = "2" -[features] -test-fixtures = [] - -[[bin]] -name = "filesystem-probe" -path = "tests/fixtures/filesystem.rs" -required-features = ["test-fixtures"] - [[bin]] name = "pumpkin-do" path = "src/main.rs" @@ -27,6 +19,7 @@ path = "src/main.rs" [dependencies] wasm-bindgen = "0.2.128" js-sys = "0.3.105" +web-sys = { version = "0.3.105", features = ["AbortController", "AbortSignal", "Headers", "ReadableStream", "Request", "Response", "ResponseInit", "StreamPipeOptions", "Url", "WritableStream", "console"] } tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync", "time", "net", "io-util"] } pumpkin = { path = ".work/pumpkin/crates/pumpkin", default-features = false, features = ["single-threaded"] } pumpkin-config = { path = ".work/pumpkin/crates/pumpkin-config", default-features = false } @@ -34,6 +27,7 @@ rayon = "1.12" # wasm32-unknown-emscripten support pending upstream releases. [patch.crates-io] +# EventLoopRuntime: the host event loop drives Tokio (tokio-rs/tokio#8479). tokio = { path = ".work/tokio/tokio" } tokio-macros = { path = ".work/tokio/tokio-macros" } # tokio-rs/mio#1969; its emscripten epoll bindings come from libc's unreleased libc-0.2 branch. diff --git a/README.md b/README.md index 3a03ec5..f64bae2 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,13 @@ npm run dev Setup installs the pinned Rust toolchain, fetches dependency sources and the Emscripten toolchain under `.work/` (building Binaryen), and installs the -wasm-bindgen CLI. `npm run -dev` compiles Pumpkin and starts Wrangler. The first build takes several -minutes; later builds use Cargo's cache. +wasm-bindgen CLI. `npm run dev` compiles Pumpkin and starts Wrangler. The first +build takes several minutes; later builds use Cargo's cache. Until Wrangler's bundled workerd routes inbound sockets to `net.Server` listeners -inside Durable Objects, point Miniflare at a workerd build that does: -`MINIFLARE_WORKERD_PATH=/path/to/workerd npm run dev`. +inside Durable Objects and carries the `node:fs` fixes listed in +[dependency pins](docs/dependencies.md#host-tools), point Miniflare at a workerd +build that does: `MINIFLARE_WORKERD_PATH=/path/to/workerd npm run dev`. Connect **Minecraft Java 26.2** to **`localhost:25565`**. Status is available at **http://localhost:8787/**. @@ -48,10 +48,9 @@ Wrangler; changes held in memory can be lost if the process is terminated early. ## Build and contribute ```sh -npm run build # Compile the server without starting Wrangler +npm run build # Compile the Worker without starting Wrangler npm test # Two-player gameplay, a persistent block edit, and restart npm run test:scheduler -npm run typecheck ``` The tests use separate data under `.data/probes/`. They verify that two clients @@ -77,8 +76,9 @@ Provision the public TCP endpoint separately and route it to this Worker's ## Credits and license Built on [Pumpkin](https://github.com/Pumpkin-MC/Pumpkin), -[Guy Bedford's Rust/Emscripten work](https://github.com/guybedford), and -[worker-fs-mount](https://github.com/danlapid/worker-fs-mount). +[Tokio](https://github.com/tokio-rs/tokio), [Emscripten](https://emscripten.org/), +[wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen), and +[Guy Bedford's Rust/Emscripten work](https://github.com/guybedford). Licensed under [GPL-3.0-only](LICENSE), consistent with Pumpkin. The [original MIT notice](LICENSES/rust-workers-minecraft-MIT.txt) is retained for diff --git a/build.rs b/build.rs index 413100c..10f8c3d 100644 --- a/build.rs +++ b/build.rs @@ -1,17 +1,18 @@ use std::{env, path::PathBuf}; fn main() { - println!("cargo::rerun-if-changed=worker/fs-library.js"); + println!("cargo::rerun-if-changed=src/workerd.js"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("emscripten") { return; } - // Keep growth headroom bounded after generation's temporary allocations. - println!("cargo::rustc-link-arg-bins=-sMEMORY_GROWTH_LINEAR_STEP=2097152"); - let library = - PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("worker/fs-library.js"); - println!( - "cargo::rustc-link-arg-bins=-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=[\"$workerFilesystem\"]" - ); - println!("cargo::rustc-link-arg-bins=--js-library"); + for arg in [ + // Keep growth headroom bounded after generation's temporary allocations. + "-sMEMORY_GROWTH_LINEAR_STEP=2097152", + "-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=[\"$workerdStdio\"]", + "--js-library", + ] { + println!("cargo::rustc-link-arg-bins={arg}"); + } + let library = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("src/workerd.js"); println!("cargo::rustc-link-arg-bins={}", library.display()); } diff --git a/docs/architecture.md b/docs/architecture.md index 1427a9b..d43e7d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,32 +2,44 @@ ## Runtime and connections -The Worker forwards TCP streams to a named Durable Object using the platform's -stream-piping API. The object lazily creates an Emscripten module and calls its -single `pumpkin_run` export. That export is a `#[wasm_bindgen(jspi)]` function: it -builds a current-thread Tokio runtime and `block_on`s the whole server lifetime, -and every park (`epoll_wait`, timers) suspends the Wasm stack on the host event -loop. There is no host-driven scheduling and no Rust promise adapter; the run -promise settles only when the server has stopped and saved. `-sREENTRANT_JSPI` -gives each promising activation its own shadow stack, so status calls and -connection routing enter the module while the server is suspended without -touching its frames. +The whole Worker is Rust. Cargo links the `pumpkin-do` bin for +`wasm32-unknown-emscripten` with emcc, which emits an ES module instance +(`-sMODULARIZE=instance`, `-sWASM_BINDGEN`) that `worker/index.mjs` re-exports +for Wrangler. The entrypoint's `connect` handler forwards each TCP connection to +the named `MinecraftWorld` object with `stub.connect` and pipes both directions; +`fetch` serves status. Both are plain exports composing the platform's promises. + +`MinecraftWorld` is a `#[wasm_bindgen]` class constructed with the object's state +and owning Tokio's `EventLoopRuntime` (tokio-rs/tokio#8479): the current-thread +scheduler and drivers with the host event loop as their wait. Nothing blocks or +suspends. Work is scheduled as roots that complete by callback, and each export +wraps its root in a Promise. The first `connect` after idle schedules the server +lifetime as a root: it restores the world, starts Pumpkin, awaits the server's +return after its final save, checkpoints, and settles the connection's promise. +Later connections and `status` calls enter the same instance as ordinary calls +while that root is parked. A connection arriving while the server is starting +or stopping waits for the next phase change and then retries, so a client that +connects as the previous server checkpoints starts the next one. The runtime's epoll and timer waits register with the +host through Emscripten's Node backend (emscripten-core/emscripten#27547), so a +readiness or timer callback resumes the scheduler on the host loop. Pumpkin binds its stock `TcpListener` on port 25565. Emscripten's Node socket backend implements that with `net.BoundSocket`/`net.Server`, which workerd scopes -to the Durable Object's own port table, so every object binds the same port. The -object's `connect` handler routes each inbound socket to that listener with -`handleAsNodeConnection`; the accepted connection then surfaces through epoll -readiness and Pumpkin's normal accept loop. Accepted sockets report the bound +to the Durable Object's own port table. `connect` routes each inbound socket to +that listener with `handleAsNodeConnection`; the accepted connection surfaces +through epoll readiness and Pumpkin's normal accept loop, reporting the bound address and an unspecified peer. -Each object owns separate wasm memory, Rust statics, filesystem descriptors, and -its Tokio runtime. Pumpkin's `single-threaded` feature selects cooperative inline -chunk generation and the async ticker. The same scheduler loop serves native -builds, which dispatch generation onto Rayon. Save batches await queue capacity; -shutdown drains results with an elapsed-time timeout before the final flush. -Bounded CPU tasks use regular Tokio tasks because `spawn_blocking` requires OS -threads. JSPI supplies stack suspension, not threads. +One wasm instance serves the isolate, so one object hosts one server at a time; +`WORLD_NAME` selects it. When the last connection closes, the server root calls +`stop_server`, Pumpkin saves and returns, and `reset_stop` clears the process-wide +stop state so the next connection can start a fresh server in the same instance. + +Pumpkin's `single-threaded` feature selects cooperative inline chunk generation +and the async ticker. The same scheduler loop serves native builds, which +dispatch generation onto Rayon. Save batches await queue capacity; shutdown +drains results with an elapsed-time timeout before the final flush. Bounded CPU +tasks use regular Tokio tasks because `spawn_blocking` requires OS threads. Structure templates store palette indices and share their block arrays between the placement and query APIs. Block-entity NBT is shared until a placement needs @@ -37,41 +49,19 @@ neighborhood compact without changing its dependency rules. Emscripten grows memory in 2 MiB increments, configured by `build.rs`, while retaining the 8 MiB stack. See [memory measurements](memory-reduction.md). -Emscripten factory mode (`MODULARIZE=1`) isolates module instances. Wrangler -provides the compiled wasm through `instantiateWasm`; the generated host glue uses -Workers' Node compatibility APIs. The SDK's standalone-loader initialization and -recovery hook is disabled for this embedding. - ## Filesystem and checkpoints -The host mounts `LocalDOFilesystem(ctx.storage)` from durable-object-fs at `/data`. -Emscripten's `NODERAWFS` routes Rust filesystem calls through worker-fs-mount's -synchronous Node API, including descriptors, positional I/O, rename, and sync. -World metadata, chunks, entities, players, and configuration use this mount. -Logging goes to the host console. - -Each wasm instance retains its own mount context across requests and Tokio -callbacks using `AsyncLocalStorage.snapshot()`. Descriptors opened at startup -therefore remain valid until that runtime shuts down, without exposing one world's -mount to another object. - -The Emscripten compatibility library in `worker/fs-library.js` supplies the mounted Node -filesystem to the factory: the generated loader's runtime `createRequire` calls -cannot use Wrangler's build-time aliases. It also initializes file flags from -public `fs.constants` and implements synchronous `fd_sync`. Paths resolve against -the wasm instance's working directory (initially `/data`), without changing the -isolate-wide Node process directory. - -The upstream `entries` table stores metadata, and `file_pages` stores contents in -64 KiB rows. Descriptor writes update only affected pages. Truncation supports -sparse files, and file writes and native rename use SQLite transactions. Whole-file -reads still require a buffer large enough for the result. - -After the final connection leaves, the object requests a stop; Pumpkin saves, -`pumpkin_run` resolves, and the object awaits `storage.sync()`. New connections wait for this checkpoint, then create a -fresh runtime from the stored files. Checkpoint failures remain visible in status. -This preserves issued writes; terminating a server with active clients can still -lose changes in Pumpkin's in-memory caches. +Pumpkin runs against workerd's own Node filesystem through Emscripten's +`NODERAWFS`, with the world under `/tmp/world`. That tree lives for the Durable +Object's lifetime. Between runs it lives in the object's SQLite storage, one row +per file: the server root restores the tree before starting Pumpkin and, after +the server's final save, writes every changed file back, deletes rows for removed +files, and awaits `storage.sync()` before the last connection is reported closed. +Status shows the checkpoint time. Terminating a server with active clients can +lose changes since the previous checkpoint. + +Logging goes to the host console: `src/workerd.js` keeps Emscripten's stdio +descriptors on its console callbacks, since workerd exposes no process stdio. ## Development interfaces @@ -85,28 +75,17 @@ Runtime statistics include Wasm capacity and allocator in-use/free/arena bytes. Allocator counters include allocation metadata and unused container capacity; Wasm capacity additionally includes static data, stack, and growth headroom. -The public status path reads no filesystem internals and remains available when -startup fails. Checkpointing is internal to the connection lifecycle. The -`worker/pumpkin.ts` facade binds exports to the module context once, so the -Durable Object deals with `connect`, `stop`, and `status` methods. - Local databases live in `.data/workers/server/`. Integration tests use separate -storage under `.data/probes/` and restart Wrangler to verify restoration. The echo -and large-file test fixture has its own Worker configuration and wasm binary; it -is excluded from the application bundle. - -Rust panics, Rust error logs, and runtime crashes fail the integration test. +storage under `.data/probes/` and restart Wrangler to verify restoration. Rust +panics, Rust error logs, and runtime crashes fail the integration test. ## Build constraints - Use the pinned Rust toolchain, Emscripten, and wasm-bindgen CLI from setup. - Keep static relocation, exnref exception handling on both the C and Rust - sides, the 8 MiB stack, and memory growth. -- Pass Emscripten link settings through rustc so they do not affect C compilation. -- Keep the compatibility initializer linked even when Rust does not call `fd_sync`; - `build.rs` configures this and tracks the library as a build input. + sides, the 8 MiB stack, and memory growth. `.cargo/config.toml` holds the link + settings; `build.rs` adds the JS library and growth step. +- Build with `--cfg tokio_unstable`; `EventLoopRuntime` is unstable API. - Prefix Rust exports to avoid collisions with libc and Emscripten symbols. -- The synchronous SQLite mount supplies `fd_sync`; checkpoint completion separately - awaits Durable Object storage synchronization. [Dependency pins and patch maintenance](dependencies.md) diff --git a/docs/dependencies.md b/docs/dependencies.md index a2d9ed5..95fe69f 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -8,10 +8,10 @@ locked. | Component | Source / branch | Commit | Local changes or purpose | | --- | --- | --- | --- | -| pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Headless embedding, shared async scheduler, compact templates/generation chunks, and build-script fixes | -| tokio | [emscripten-epoll](https://github.com/guybedford/tokio) | `8d0a2a845c546e93a4cf0e8ff2c21ac50a3d1931` | JSPI parking and `net` over epoll on Emscripten (tokio-rs/tokio#8281 follow-ons); unmodified | -| emscripten | [cf-final](https://github.com/guybedford/emscripten) | `a5013dd597ec9d48856370f5707d438937a7b4e8` | Upstream main plus emscripten-core/emscripten#27698, #27699 (`REENTRANT_JSPI`), #27547, and two fixes pending for #27699; unmodified | -| binaryen | [jspi-hooks](https://github.com/guybedford/binaryen) | `d6483a04d7dab0ef83e5c43f87343061d97a3b8d` | WebAssembly/binaryen#9102, the `--jspi-hooks` pass; built by setup | +| pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Headless embedding, shared async scheduler, restartable stop signal, compact templates/generation chunks, and build-script fixes | +| tokio | [emscripten-event-loop](https://github.com/guybedford/tokio) | `5315798b6a62a47d03dc40e7d04fbf83c80f3246` | `EventLoopRuntime` (tokio-rs/tokio#8479) and `net` over epoll on Emscripten; unmodified | +| emscripten | [cf-final](https://github.com/guybedford/emscripten) | `4e034c65a18c0034c68e7eb628477fe4428fa1f9` | Upstream main plus emscripten-core/emscripten#27547 (epoll listeners on the host loop), #27698, #27699, and pending fixes: side-module accept, exnref ordering, `fs.constants` in NODEFS; unmodified | +| binaryen | [jspi-hooks](https://github.com/guybedford/binaryen) | `d6483a04d7dab0ef83e5c43f87343061d97a3b8d` | The frontend pin expects this Binaryen; built by setup | | emsdk | [main](https://github.com/emscripten-core/emsdk) | `5eb0bde7585670252e8ba05e9d361627bffd08b5` | LLVM from emscripten-releases build `e8579ea489b44a6792f5abf95377a6ee38a16cce`, paired with the frontend's main | Patches are relative to the pinned commits above. Setup checks reverse application @@ -31,7 +31,9 @@ commit. | ring | https://github.com/guybedford/ring branch `emscripten` | getrandom-backed `SystemRandom` on Emscripten | The root Cargo.toml applies these overrides and the local checkouts. Cargo.lock -pins the full application graph, including the branch commits. +pins the full application graph, including the branch commits. The Worker uses +`wasm-bindgen`, `js-sys`, and `web-sys` from crates.io directly; there is no +`worker` crate dependency. ## Host tools @@ -40,18 +42,19 @@ pins the full application graph, including the branch commits. larger compile-thread stack for pumpkin-data's generated tables; the scripts set `RUST_MIN_STACK`. - wasm-bindgen CLI: the release matching the `wasm-bindgen` crate version in - Cargo.toml, installed by setup into `.work/bin/` (or `WASM_BINDGEN_BIN`). emcc - runs it as a post-link step under `-sWASM_BINDGEN`. + Cargo.toml, installed by setup into `.work/bin/`. emcc runs it as a post-link + step under `-sWASM_BINDGEN`. - Node: 24+; 26 recommended and selected in CI. Used for build tools and tests. - Python: 3.11+ (Emscripten scripts and TOML parsing). - Emscripten backend: LLVM through emsdk under `.work/emsdk` (or an activated - emsdk selected with `EMSDK`), and Binaryen built from the checkout above with - CMake and Ninja. Setup writes the frontend's machine-local `.emscripten_cf` - pointing at both. Homebrew's release cannot be used while the frontend is - ahead of the tagged releases. -- workerd: Wrangler must run a workerd with per-Durable-Object port tables and - `net.Server` inbound routing (`handleAsNodeConnection`). Until that ships in - Wrangler's bundled version, set `MINIFLARE_WORKERD_PATH`. + emsdk selected with `EMSDK` during setup), and Binaryen built from the checkout + above with CMake and Ninja. Setup writes the frontend's `.emscripten_cf` config + pointing at both, and `scripts/common.sh` selects it with `EM_CONFIG`. +- workerd: Wrangler must run a workerd with per-Durable-Object port tables, + `net.Server` inbound routing (`handleAsNodeConnection`), and the `node:fs` + fixes for positional buffer I/O, `O_TRUNC`, `O_CREAT`, and rename over an + existing path. Until those ship in Wrangler's bundled version, set + `MINIFLARE_WORKERD_PATH`. ## Updating a patch @@ -91,11 +94,9 @@ bash scripts/setup.sh --sources-only ## JavaScript dependencies -`package-lock.json` pins Wrangler 4.129.0, worker-fs-mount 0.2.0, -durable-object-fs 1.0.0, TypeScript, and their dependencies. The npm override for -durable-object-fs replaces its published `workspace:*` peer dependency with the -selected worker-fs-mount version. Generated runtime/binding types are recreated by -`npm run types` and are not committed. +`package-lock.json` pins Wrangler 4.129.0. The only application JavaScript is +`worker/index.mjs`, which re-exports the generated module and derives the +Durable Object class from `DurableObject` for RPC. After moving a checkout with cached build output, run `cargo clean` before rebuilding. Generated data can contain absolute paths. This leaves databases under `.data/` intact. diff --git a/docs/development.md b/docs/development.md index f05481d..0bcd4c8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,12 +4,13 @@ Follow the [README setup instructions](../README.md#try-it) to install the pinne Rust toolchain, provision dependency sources and Emscripten, and install the wasm-bindgen CLI. The initial Pumpkin compilation takes several minutes. Later builds use Cargo's -cache. `build.rs` owns the Emscripten library arguments and tracks changes to the -compatibility library. +cache. `.cargo/config.toml` owns the link settings; `build.rs` adds the JS library +(`src/workerd.js`) and tracks changes to it. The build output Wrangler serves is +`target/workers/wasm32-unknown-emscripten/release/pumpkin-do.js`, re-exported by +`worker/index.mjs`. ```sh -npm run build # Compile the production server -npm run typecheck # Regenerate Worker types and check TypeScript +npm run build # Compile the production Worker npm test # Full Wrangler integration suite npm run test:scheduler # Native queue/backpressure and shutdown regressions npm run test:memory # Two-player memory probe on a fresh, fixed-seed world @@ -19,9 +20,9 @@ Tests fail if their loopback ports (25565 and 8787) are occupied. They start and stop only their own Wrangler process groups. Their databases and logs live in `.data/probes/`; playable worlds are separate under `.data/workers/server/`. -`npm test` must finish with `PUMPKIN-DO-SQLITE-RESTART-OK`. It checks filesystem -isolation, synchronous descriptors, large files, failed-startup status, two-player -gameplay, a real block edit, and player/block restoration after a restart. +`npm test` must finish with `PUMPKIN-DO-SQLITE-RESTART-OK`. It checks two-player +gameplay, a real block edit, and player/block restoration after a restart, which +exercises the tree-to-SQLite checkpoint and restore. The memory probe waits for both clients to receive 81 chunks, observes 30 seconds of stationary play, then checkpoints. Each run creates a fresh isolated world diff --git a/package-lock.json b/package-lock.json index 5c4e381..18ad428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,14 +5,7 @@ "packages": { "": { "name": "rust-workers-minecraft", - "dependencies": { - "durable-object-fs": "1.0.0", - "worker-fs-mount": "0.2.0" - }, "devDependencies": { - "@cloudflare/workers-types": "5.20260905.1", - "@types/node": "26.4.1", - "typescript": "~5.9.3", "wrangler": "4.129.0" }, "engines": { @@ -130,13 +123,6 @@ "node": ">=16" } }, - "node_modules/@cloudflare/workers-types": { - "version": "5.20260905.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260905.1.tgz", - "integrity": "sha512-ebDpVTgrnee245IRBTazyfjfmZas+HJehZyVQkKmUozsBN8RzvXXMI0XjjcT2rQjw+0USewfEGGLYiHO+TL0Sg==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1255,16 +1241,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/@types/node": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", - "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1296,18 +1272,6 @@ "node": ">=8" } }, - "node_modules/durable-object-fs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/durable-object-fs/-/durable-object-fs-1.0.0.tgz", - "integrity": "sha512-r4XOu7vlnC3kg8HiFYO3sQweO3eUJLa7q1tZHUy6Oj1ifHR8So4W5oljz4YUD2sIzWuWgEt+ljirqPVj/WhbwA==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "worker-fs-mount": "workspace:*" - } - }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -1496,20 +1460,6 @@ "license": "0BSD", "optional": true }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/undici": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", @@ -1520,13 +1470,6 @@ "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -1537,15 +1480,6 @@ "pathe": "^2.0.3" } }, - "node_modules/worker-fs-mount": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/worker-fs-mount/-/worker-fs-mount-0.2.0.tgz", - "integrity": "sha512-SHnIcUkBIyojQNtMdpIBDM0/JDD58xgTMcyJMTamU2z9WbG8cuoAWs5SBzyBfCcp+xPMXu8h+Ou3yqC9kUxFdw==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/workerd": { "version": "1.20260903.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", diff --git a/package.json b/package.json index eac72cc..c6e507e 100644 --- a/package.json +++ b/package.json @@ -5,28 +5,15 @@ "engines": { "node": ">=24" }, - "dependencies": { - "durable-object-fs": "1.0.0", - "worker-fs-mount": "0.2.0" - }, + "dependencies": {}, "devDependencies": { - "@cloudflare/workers-types": "5.20260905.1", - "@types/node": "26.4.1", - "typescript": "~5.9.3", "wrangler": "4.129.0" }, - "overrides": { - "durable-object-fs": { - "worker-fs-mount": "$worker-fs-mount" - } - }, "scripts": { "build": "bash scripts/build.sh", "dev": "bash scripts/serve.sh", "test": "bash scripts/test.sh", "test:memory": "npm run build && node tests/memory.mjs", - "types": "wrangler types worker/env.d.ts --strict-vars=false", - "typecheck": "npm run types && tsc --noEmit", "test:scheduler": "bash scripts/test-scheduler.sh" } } diff --git a/patches/README.md b/patches/README.md index 882110c..5d6f0da 100644 --- a/patches/README.md +++ b/patches/README.md @@ -9,7 +9,7 @@ diff below it. Preserve the description when regenerating the diff. | Patch | Checkout | Purpose | | --- | --- | --- | -| `pumpkin-emscripten.patch` | `.work/pumpkin` | Headless optional subsystems, single-threaded runtime and blocking-task helper, shared async chunk scheduler with backpressure and timed draining, unified ticker, and platform guards | +| `pumpkin-emscripten.patch` | `.work/pumpkin` | Headless optional subsystems, single-threaded runtime and blocking-task helper, shared async chunk scheduler with backpressure and timed draining, unified ticker, restartable stop signal, and platform guards | | `pumpkin-memory.patch` | `.work/pumpkin` | Shared indexed structure templates, immutable block-entity NBT, paletted generation chunks, and a temporary dense noise buffer | Setup applies `pumpkin-emscripten.patch` before `pumpkin-memory.patch`. The two patches use the diff --git a/patches/pumpkin-emscripten.patch b/patches/pumpkin-emscripten.patch index fc4bfd6..b516429 100644 --- a/patches/pumpkin-emscripten.patch +++ b/patches/pumpkin-emscripten.patch @@ -12,6 +12,10 @@ and track cooperative tasks through shutdown. Drop the Tokio features that need OS threads and signals from single-threaded builds and guard unsupported Emscripten system calls and synchronous HTTP lookups. +Make the process-wide stop token replaceable behind a StopSignal wrapper +and add reset_stop, so an embedding can start another server in the same +process after one has stopped. + Reserve save-queue capacity before transferring chunks or marking writes pending, and widen pending-write counters to usize. Drain late generation results with an elapsed-time timeout before the final save. Regression @@ -1276,7 +1280,7 @@ index a19825bc..45d6783e 100644 || { self.chunk_sender diff --git a/crates/pumpkin/src/lib.rs b/crates/pumpkin/src/lib.rs -index 7110bc9e..060225ea 100644 +index 7110bc9e..f45cadbe 100644 --- a/crates/pumpkin/src/lib.rs +++ b/crates/pumpkin/src/lib.rs @@ -8,12 +8,14 @@ extern crate pumpkin_macros; @@ -1340,7 +1344,66 @@ index 7110bc9e..060225ea 100644 }); assert!( -@@ -251,7 +263,7 @@ impl PumpkinServer { +@@ -204,16 +216,57 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { + } + + pub static SHOULD_STOP: AtomicBool = AtomicBool::new(false); +-pub static STOP_INTERRUPT: LazyLock = LazyLock::new(CancellationToken::new); ++pub static STOP_INTERRUPT: StopSignal = StopSignal(std::sync::RwLock::new(None)); + pub static SERVER_IS_STOPPING: AtomicBool = AtomicBool::new(false); + pub static CRASH_REPORT: OnceLock = OnceLock::new(); + pub static SERVER_EXIT_CODE: AtomicI32 = AtomicI32::new(0); + ++/// The process-wide stop token, replaceable so an embedding can start another ++/// server in the same process after one has stopped. ++pub struct StopSignal(std::sync::RwLock>); ++ ++impl StopSignal { ++ fn token(&self) -> CancellationToken { ++ if let Some(token) = self.0.read().unwrap_or_else(|e| e.into_inner()).as_ref() { ++ return token.clone(); ++ } ++ self.0 ++ .write() ++ .unwrap_or_else(|e| e.into_inner()) ++ .get_or_insert_with(CancellationToken::new) ++ .clone() ++ } ++ ++ pub fn cancel(&self) { ++ self.token().cancel(); ++ } ++ ++ pub fn is_cancelled(&self) -> bool { ++ self.token().is_cancelled() ++ } ++ ++ pub fn cancelled(&self) -> tokio_util::sync::WaitForCancellationFutureOwned { ++ self.token().cancelled_owned() ++ } ++ ++ #[allow(clippy::should_implement_trait)] ++ pub fn clone(&self) -> CancellationToken { ++ self.token() ++ } ++} ++ + pub fn stop_server() { + SHOULD_STOP.store(true, Ordering::Relaxed); + STOP_INTERRUPT.cancel(); + } + ++/// Clears the stop state so `PumpkinServer::start` can run again in this process. ++pub fn reset_stop() { ++ SHOULD_STOP.store(false, Ordering::Relaxed); ++ SERVER_IS_STOPPING.store(false, Ordering::Release); ++ *STOP_INTERRUPT.0.write().unwrap_or_else(|e| e.into_inner()) = Some(CancellationToken::new()); ++} ++ + pub fn stop_or_exit_server() { + if SERVER_IS_STOPPING.load(Ordering::Acquire) { + // Server is already stopping, so we forcefully exit. +@@ -251,7 +304,7 @@ impl PumpkinServer { ) -> Self { let server = Server::new(basic_config, advanced_config, vanilla_data).await; @@ -1349,7 +1412,7 @@ index 7110bc9e..060225ea 100644 adjust_file_descriptor_limit(); let rcon = server.advanced_config.networking.rcon.clone(); -@@ -320,19 +332,30 @@ impl PumpkinServer { +@@ -320,19 +373,30 @@ impl PumpkinServer { None }; @@ -1382,7 +1445,7 @@ index 7110bc9e..060225ea 100644 let (bedrock_status, ice_socket) = Self::bind_bedrock_status(&server).await; let nethernet_listener = Self::bind_nethernet(&server, ice_socket).await; -@@ -345,6 +368,15 @@ impl PumpkinServer { +@@ -345,6 +409,15 @@ impl PumpkinServer { } } @@ -1398,7 +1461,7 @@ index 7110bc9e..060225ea 100644 async fn bind_nethernet( server: &Arc, ice_socket: Option, -@@ -441,6 +473,7 @@ impl PumpkinServer { +@@ -441,6 +514,7 @@ impl PumpkinServer { } pub async fn start(&self) { @@ -1406,7 +1469,7 @@ index 7110bc9e..060225ea 100644 if self.server.advanced_config.commands.use_console && let Some((wrapper, _, _)) = LOGGER_IMPL.wait() { -@@ -527,6 +560,7 @@ impl PumpkinServer { +@@ -527,6 +601,7 @@ impl PumpkinServer { info!("Completed save!"); @@ -1414,7 +1477,7 @@ index 7110bc9e..060225ea 100644 if let Some((wrapper, _, _)) = LOGGER_IMPL.wait() && let Some(rl) = wrapper.take_readline() { -@@ -618,7 +652,7 @@ impl PumpkinServer { +@@ -618,7 +693,7 @@ impl PumpkinServer { }); } Err(e) => { @@ -1423,7 +1486,7 @@ index 7110bc9e..060225ea 100644 if e.raw_os_error() == Some(libc::EMFILE) { error!( "Too many open files! Server reached file descriptor limit. \ -@@ -719,6 +753,7 @@ impl PumpkinServer { +@@ -719,6 +794,7 @@ impl PumpkinServer { } } @@ -1431,7 +1494,7 @@ index 7110bc9e..060225ea 100644 fn setup_stdin_console(server: &Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel(1); let rt = tokio::runtime::Handle::current(); -@@ -759,6 +794,7 @@ fn setup_stdin_console(server: &Arc) { +@@ -759,6 +835,7 @@ fn setup_stdin_console(server: &Arc) { }); } @@ -1439,7 +1502,7 @@ index 7110bc9e..060225ea 100644 fn setup_console(mut rl: Editor, server: Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel(1); let (tx_reply, mut rx_reply) = tokio::sync::mpsc::channel(1); -@@ -839,7 +875,7 @@ fn scrub_address(ip: &str) -> String { +@@ -839,7 +916,7 @@ fn scrub_address(ip: &str) -> String { .collect() } diff --git a/scripts/setup.sh b/scripts/setup.sh index 6faeb84..42a158a 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -44,7 +44,7 @@ apply_patch() { echo "==> Pinned dependencies" checkout pumpkin https://github.com/Pumpkin-MC/Pumpkin master b5b9b9d7010e793806a83c495af223c67e1d35ee -checkout tokio https://github.com/guybedford/tokio emscripten-epoll 8d0a2a845c546e93a4cf0e8ff2c21ac50a3d1931 +checkout tokio https://github.com/guybedford/tokio emscripten-event-loop 5315798b6a62a47d03dc40e7d04fbf83c80f3246 apply_patch pumpkin pumpkin-emscripten.patch apply_patch pumpkin pumpkin-memory.patch @@ -66,7 +66,7 @@ echo "==> Emscripten" # main, and Binaryen from the branch carrying the jspi-hooks pass the frontend # needs; setup builds it. EMSDK selects an activated emsdk for LLVM instead. EMSDK_RELEASE=e8579ea489b44a6792f5abf95377a6ee38a16cce -checkout emscripten https://github.com/guybedford/emscripten cf-final a5013dd597ec9d48856370f5707d438937a7b4e8 +checkout emscripten https://github.com/guybedford/emscripten cf-final 4e034c65a18c0034c68e7eb628477fe4428fa1f9 NODE_PATH="$("$NODE" -p 'process.execPath')" (cd "$EMSCRIPTEN" && PATH="$(dirname "$NODE_PATH"):$PATH" npm ci --no-audit --no-fund && python3 bootstrap.py) if [ -n "${EMSDK:-}" ]; then diff --git a/scripts/test.sh b/scripts/test.sh index 6d67bd4..5ef4a42 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -7,7 +7,4 @@ require_toolchain "$NODE" "$REPO/scripts/check-ports.mjs" 25565 8787 bash "$REPO/scripts/build.sh" cd "$REPO" -cargo build --locked --release --features test-fixtures --bin filesystem-probe -"$NODE" node_modules/wrangler/bin/wrangler.js types worker/env.d.ts --strict-vars=false -"$NODE" node_modules/typescript/bin/tsc --noEmit exec "$NODE" tests/integration.mjs diff --git a/src/host.rs b/src/host.rs new file mode 100644 index 0000000..b674fb3 --- /dev/null +++ b/src/host.rs @@ -0,0 +1,67 @@ +//! Platform calls, each returning the platform's own Promise or value. + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "cloudflare:node")] +extern "C" { + /// Routes an inbound socket to the `net.Server` listening on its local + /// port within the current Durable Object's port table; resolves when the + /// connection closes. + #[wasm_bindgen(js_name = handleAsNodeConnection, catch)] + pub fn handle_as_node_connection(socket: &JsValue) -> Result; +} + +pub fn property(target: &JsValue, name: &str) -> Result { + js_sys::Reflect::get(target, &name.into()) +} + +pub fn method(target: &JsValue, name: &str, args: &[&JsValue]) -> Result { + let function: js_sys::Function = property(target, name)?.unchecked_into(); + let array = js_sys::Array::new(); + for arg in args { + array.push(arg); + } + js_sys::Reflect::apply(&function, target, &array) +} + +/// `promise.then(on_fulfilled, on_rejected)` on any thenable; the callbacks +/// are one-shot closures already converted with `Closure::once_into_js`. +pub fn then( + promise: &JsValue, + on_fulfilled: Option<&JsValue>, + on_rejected: Option<&JsValue>, +) -> Result { + let then: js_sys::Function = property(promise, "then")?.unchecked_into(); + then.call2( + promise, + on_fulfilled.unwrap_or(&JsValue::UNDEFINED), + on_rejected.unwrap_or(&JsValue::UNDEFINED), + ) +} + +/// `stub.connect(authority, { allowHalfOpen: true })`. +pub fn stub_connect(stub: &JsValue, authority: &str) -> Result { + let options = js_sys::Object::new(); + js_sys::Reflect::set(&options, &"allowHalfOpen".into(), &JsValue::TRUE)?; + method(stub, "connect", &[&authority.into(), &options]) +} + +pub fn js_error(error: impl std::fmt::Display) -> JsValue { + js_sys::Error::new(&error.to_string()).into() +} + +/// `storage.sql.exec(query, ...bindings)` with the rows read out eagerly as +/// arrays of column values. +pub fn sql(storage: &JsValue, query: &str, bindings: &[JsValue]) -> Result>, JsValue> { + let sql = property(storage, "sql")?; + let exec: js_sys::Function = property(&sql, "exec")?.unchecked_into(); + let args = js_sys::Array::new(); + args.push(&query.into()); + for binding in bindings { + args.push(binding); + } + let cursor = js_sys::Reflect::apply(&exec, &sql, &args)?; + let raw = method(&cursor, "raw", &[])?; + let rows = js_sys::Array::from(&method(&raw, "toArray", &[])?); + Ok(rows.iter().map(|row| js_sys::Array::from(&row).to_vec()).collect()) +} diff --git a/src/main.rs b/src/main.rs index 93bf34e..a4114cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,93 +1,92 @@ -// `#[wasm_bindgen(jspi)]` is experimental and warns as deprecated. -#![allow(deprecated)] - mod config; +mod host; mod memory; +mod persist; +mod world; -use pumpkin::{data::VanillaData, server::Server, PumpkinServer}; -use std::{cell::RefCell, sync::atomic::Ordering, sync::Arc}; +use host::{method, property, then}; use wasm_bindgen::prelude::*; fn main() {} -thread_local! { - static FAILURE: RefCell> = const { RefCell::new(None) }; - static SERVER: RefCell>> = const { RefCell::new(None) }; -} - -fn js_error(error: impl std::fmt::Display) -> JsValue { - js_sys::Error::new(&error.to_string()).into() +fn stub(env: &JsValue) -> Result { + let name = property(env, "WORLD_NAME")?; + let namespace = property(env, "WORLD")?; + method(&namespace, "getByName", &[&name]) } -fn server() -> Result, JsValue> { - if let Some(error) = FAILURE.with(|failure| failure.borrow().clone()) { - return Err(js_error(error)); +/// Pipes an inbound TCP connection to the world's object until either side closes. +#[wasm_bindgen] +pub fn connect(socket: JsValue, env: JsValue, _ctx: JsValue) -> Result { + let target = host::stub_connect(&stub(&env)?, &world::authority())?; + // Read/write failures are observed by the pumps; a normal peer disconnect + // must not surface as an unhandled rejection of the lifetime promise. + let swallow = Closure::::new(|_| ()).into_js_value(); + for side in [&socket, &target] { + then(&property(side, "closed")?, None, Some(&swallow))?; } - SERVER - .with(|server| server.borrow().clone()) - .ok_or_else(|| js_error("Server is not running")) -} - -/// Runs the server on a Tokio runtime that parks by suspending this activation. -/// `ready` is called once the listener is bound. Resolves after `pumpkin_stop` -/// once the final save completes. -#[wasm_bindgen(jspi)] -pub fn pumpkin_run(ready: js_sys::Function) -> Result<(), JsValue> { - std::panic::set_hook(Box::new(|info| { - FAILURE.with(|failure| { - failure.borrow_mut().get_or_insert_with(|| info.to_string()); + let abort = web_sys::AbortController::new()?; + let pipe = |from: &JsValue, to: &JsValue| -> Result { + let readable: web_sys::ReadableStream = property(from, "readable")?.unchecked_into(); + let writable: web_sys::WritableStream = property(to, "writable")?.unchecked_into(); + let options = web_sys::StreamPipeOptions::new(); + options.set_signal(&abort.signal()); + let abort = abort.clone(); + let on_error = Closure::once_into_js(move |error: JsValue| -> Result { + abort.abort(); + Err(error) }); - eprintln!("RUST PANIC: {info}"); - })); - rayon::ThreadPoolBuilder::new() - .num_threads(1) - .use_current_thread() - .build_global() - .map_err(js_error)?; - let (basic, advanced) = config::configuration(); - pumpkin::init_logger(&advanced); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(js_error)?; - runtime.block_on(async { - let server = PumpkinServer::new(basic, advanced, VanillaData::load()).await; - server.init_plugins().await; - SERVER.with(|slot| *slot.borrow_mut() = Some(server.server.clone())); - ready.call0(&JsValue::UNDEFINED)?; - server.start().await; - SERVER.with(|slot| slot.borrow_mut().take()); - Ok(()) - }) + then(&readable.pipe_to_with_options(&writable, &options), None, Some(&on_error)) + }; + let pumps = js_sys::Array::of2(&pipe(&socket, &target)?, &pipe(&target, &socket)?); + let close_both = Closure::once_into_js(move |results: JsValue| -> Result { + for result in js_sys::Array::from(&results).iter() { + if property(&result, "status")? != "rejected" { + continue; + } + let reason = property(&result, "reason")?; + let text = reason + .as_string() + .unwrap_or_else(|| js_sys::Error::from(reason.clone()).message().into()) + .to_lowercase(); + let expected = ["closed", "closing", "abort", "cancel", "reset", "network connection lost"]; + if !expected.iter().any(|e| text.contains(e)) { + web_sys::console::error_2(&"TCP forwarding failed".into(), &reason); + } + } + let closes = js_sys::Array::new(); + for side in [&socket, &target] { + closes.push(&method(side, "close", &[])?); + } + Ok(js_sys::Promise::all_settled(&closes).into()) + }); + then(&js_sys::Promise::all_settled(&pumps).into(), Some(&close_both), None) } -/// Requests shutdown; `pumpkin_run` completes the save and returns. -#[wasm_bindgen] -pub fn pumpkin_stop() { - pumpkin::stop_server(); +fn json_response(value: &JsValue, status: u16) -> Result { + let init = web_sys::ResponseInit::new(); + init.set_status(status); + let headers = web_sys::Headers::new()?; + headers.set("content-type", "application/json")?; + init.set_headers(&headers); + let body = js_sys::JSON::stringify(value)?; + Ok(web_sys::Response::new_with_opt_str_and_init(body.as_string().as_deref(), &init)?.into()) } #[wasm_bindgen] -pub fn pumpkin_status() -> Result { - let server = server()?; - let result = js_sys::Object::new(); - js_sys::Reflect::set( - &result, - &"players".into(), - &(server.get_all_players().len() as f64).into(), - )?; - js_sys::Reflect::set( - &result, - &"ticks".into(), - &(server.tick_count.load(Ordering::Relaxed) as f64).into(), - )?; - js_sys::Reflect::set( - &result, - &"wasm_memory_bytes".into(), - &((core::arch::wasm32::memory_size::<0>() * 65536) as f64).into(), - )?; - for (name, bytes) in memory::heap_usage() { - js_sys::Reflect::set(&result, &name.into(), &(bytes as f64).into())?; +pub fn fetch(request: web_sys::Request, env: JsValue, _ctx: JsValue) -> Result { + let url = web_sys::Url::new(&request.url())?; + match url.pathname().as_str() { + "/health" => { + let body = js_sys::Object::new(); + js_sys::Reflect::set(&body, &"ready".into(), &JsValue::TRUE)?; + json_response(&body, 200) + } + "/" if request.method() == "GET" => { + let status = method(&stub(&env)?, "status", &[])?; + let respond = Closure::once_into_js(|value: JsValue| json_response(&value, 200)); + then(&status, Some(&respond), None) + } + _ => json_response(&"Not found".into(), 404), } - Ok(result.into()) } diff --git a/src/persist.rs b/src/persist.rs new file mode 100644 index 0000000..ef67f08 --- /dev/null +++ b/src/persist.rs @@ -0,0 +1,93 @@ +//! The world lives in the object's `/tmp` tree while it runs and in its SQLite +//! storage between runs: one row per file, written at checkpoint, read at start. + +use crate::host::{js_error, sql}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; +use wasm_bindgen::JsValue; + +pub const ROOT: &str = "/tmp/world"; + +pub fn prepare(storage: &JsValue) -> Result<(), JsValue> { + sql( + storage, + "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, data BLOB NOT NULL)", + &[], + )?; + Ok(()) +} + +fn stored(storage: &JsValue) -> Result>, JsValue> { + let mut files = HashMap::new(); + for row in sql(storage, "SELECT path, data FROM files", &[])? { + let [path, data] = row.as_slice() else { + return Err(js_error("unexpected files row")); + }; + let path = path.as_string().ok_or_else(|| js_error("path is not text"))?; + let data = js_sys::Uint8Array::new(&data).to_vec(); + files.insert(path, data); + } + Ok(files) +} + +/// Recreates the stored tree under `ROOT`. +pub fn restore(storage: &JsValue) -> Result { + let _ = fs::remove_dir_all(ROOT); + fs::create_dir_all(ROOT).map_err(js_error)?; + let files = stored(storage)?; + for (path, data) in &files { + let target = Path::new(ROOT).join(path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(js_error)?; + } + fs::write(target, data).map_err(js_error)?; + } + Ok(files.len()) +} + +/// Writes every file under `ROOT` that differs from storage and removes rows for +/// files that no longer exist. Returns (written, removed). +pub fn save(storage: &JsValue) -> Result<(usize, usize), JsValue> { + let mut previous = stored(storage)?; + let mut files = Vec::new(); + walk(Path::new(ROOT), &mut files).map_err(js_error)?; + let mut written = 0; + for file in files { + let key = file + .strip_prefix(ROOT) + .map_err(js_error)? + .to_string_lossy() + .into_owned(); + let data = fs::read(&file).map_err(js_error)?; + if previous.remove(&key).is_some_and(|old| old == data) { + continue; + } + sql( + storage, + "INSERT OR REPLACE INTO files (path, data) VALUES (?, ?)", + &[key.into(), js_sys::Uint8Array::from(data.as_slice()).into()], + )?; + written += 1; + } + let removed = previous.len(); + for key in previous.into_keys() { + sql(storage, "DELETE FROM files WHERE path = ?", &[key.into()])?; + } + Ok((written, removed)) +} + +fn walk(dir: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + walk(&path, out)?; + } else { + out.push(path); + } + } + Ok(()) +} diff --git a/src/workerd.js b/src/workerd.js new file mode 100644 index 0000000..fd37705 --- /dev/null +++ b/src/workerd.js @@ -0,0 +1,54 @@ +// Emscripten library adjustments for NODERAWFS on workerd's Node filesystem. +addToLibrary({ + // Node's deprecated process.binding is absent; fs.constants is the public + // form of the same table (emscripten-core/emscripten cf-final 4e034c65a). + $NODEFS__postset: ` + NODEFS.isWindows = false; + NODEFS.flagsForNodeMap = Object.fromEntries([ + [{{{ cDefs.O_APPEND }}}, 'O_APPEND'], + [{{{ cDefs.O_CREAT }}}, 'O_CREAT'], + [{{{ cDefs.O_EXCL }}}, 'O_EXCL'], + [{{{ cDefs.O_NOCTTY }}}, 'O_NOCTTY'], + [{{{ cDefs.O_RDONLY }}}, 'O_RDONLY'], + [{{{ cDefs.O_RDWR }}}, 'O_RDWR'], + [{{{ cDefs.O_DSYNC }}}, 'O_SYNC'], + [{{{ cDefs.O_TRUNC }}}, 'O_TRUNC'], + [{{{ cDefs.O_WRONLY }}}, 'O_WRONLY'], + [{{{ cDefs.O_NOFOLLOW }}}, 'O_NOFOLLOW'], + ].map(([flag, name]) => [flag, fs.constants[name]])); + `, + + // workerd has no process stdio descriptors: keep fds 0-2 on Emscripten's + // console callbacks so Pumpkin's log and Rust panics reach the host console. + // Under NODERAWFS the host enforces permissions; workerd reports directories + // as 0666, which would otherwise fail the VFS's execute check in getdents. + $workerdStdio__deps: ['$FS', '$TTY'], + $workerdStdio__postset: () => addAtPreRun('workerdStdio()'), + $workerdStdio: () => { + Object.defineProperty(FS, 'ignorePermissions', { get: () => true, set() {} }); + const consoles = [ + null, + { output: [], ops: TTY.default_tty_ops }, + { output: [], ops: TTY.default_tty1_ops }, + ]; + const fstat = FS.fstat; + FS.fstat = (fd) => { + const stream = FS.getStreamChecked(fd); + if (stream.nfd === 0 || stream.nfd === 1 || stream.nfd === 2) { + return { + dev: 0, ino: stream.nfd, mode: 0o020666, nlink: 1, uid: 0, gid: 0, rdev: 0, + size: 0, blksize: 4096, blocks: 0, + atime: new Date(0), mtime: new Date(0), ctime: new Date(0), + }; + } + return fstat(fd); + }; + const write = FS.write; + FS.write = (stream, buffer, offset, length, position) => { + const tty = consoles[stream.nfd]; + if (!tty) return write(stream, buffer, offset, length, position); + for (let i = offset; i < offset + length; i++) tty.ops.put_char(tty, buffer[i]); + return length; + }; + }, +}); diff --git a/src/world.rs b/src/world.rs new file mode 100644 index 0000000..6215c00 --- /dev/null +++ b/src/world.rs @@ -0,0 +1,303 @@ +//! The Durable Object hosting one Pumpkin server. +//! +//! The object owns an `EventLoopRuntime`: Tokio's current-thread scheduler and +//! drivers with the host event loop as its wait. Work is scheduled as roots +//! that complete by callback, so every export returns a Promise and nothing +//! blocks or suspends. The server lifetime is one such root: the first +//! `connect` after idle schedules it, and its completion (after the final save) +//! is the checkpoint. Later connections are routed to the running listener. + +use crate::{config, host, memory, persist}; +use host::{js_error, method, property, then}; +use pumpkin::{data::VanillaData, server::Server, PumpkinServer}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::{atomic::Ordering, Arc}, +}; +use tokio::runtime::EventLoopRuntime; +use wasm_bindgen::prelude::*; + +pub const PORT: u16 = 25565; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Phase { + Idle, + Starting, + Running, + Stopping, + Failed, +} + +impl Phase { + fn name(self) -> &'static str { + match self { + Phase::Idle => "idle", + Phase::Starting => "starting", + Phase::Running => "running", + Phase::Stopping => "stopping", + Phase::Failed => "failed", + } + } +} + +thread_local! { + static PANIC: RefCell> = const { RefCell::new(None) }; + static SERVER: RefCell>> = const { RefCell::new(None) }; + /// Pumpkin's logger is process-wide and installed once. + static LOGGER: Cell = const { Cell::new(false) }; +} + +/// State shared between the exports and the scheduled roots. +struct Shared { + /// `ctx.storage` + storage: JsValue, + runtime: EventLoopRuntime, + phase: Cell, + failure: RefCell>, + connections: Cell, + startup_ms: Cell>, + checkpointed_at: RefCell>, + /// Signals the server root that the last connection has closed. + idle: Arc, + /// Resolves at the next phase change; connections arriving mid-transition + /// wait on it and retry. + transition: RefCell>, +} + +// Closures run under catch_unwind; the object is only ever touched from this +// thread through the runtime's calls, so a poisoned borrow cannot be observed. +impl std::panic::RefUnwindSafe for Shared {} + +#[wasm_bindgen] +pub struct MinecraftWorld { + shared: Rc, +} + +#[wasm_bindgen] +impl MinecraftWorld { + #[wasm_bindgen(constructor)] + pub fn new(state: JsValue, _env: JsValue) -> Result { + std::panic::set_hook(Box::new(|info| { + PANIC.with(|slot| { + slot.borrow_mut().get_or_insert_with(|| info.to_string()); + }); + eprintln!("RUST PANIC: {info}"); + })); + let storage = property(&state, "storage")?; + persist::prepare(&storage)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build_event_loop_runtime() + .map_err(js_error)?; + Ok(MinecraftWorld { + shared: Rc::new(Shared { + storage, + runtime, + phase: Cell::new(Phase::Idle), + failure: RefCell::new(None), + connections: Cell::new(0), + startup_ms: Cell::new(None), + checkpointed_at: RefCell::new(None), + idle: Arc::new(tokio::sync::Notify::new()), + transition: RefCell::new(None), + }), + }) + } + + /// Serves one inbound connection; resolves when it closes. The first call + /// while idle also runs the server, resolving once the world is checkpointed. + pub fn connect(&self, socket: JsValue) -> Result { + self.shared.connect(socket) + } + + pub fn status(&self) -> Result { + let shared = &self.shared; + let result = js_sys::Object::new(); + let set = |key: &str, value: JsValue| js_sys::Reflect::set(&result, &key.into(), &value); + set("phase", shared.phase.get().name().into())?; + set("connections", (shared.connections.get() as f64).into())?; + set( + "failure", + shared.failure.borrow().as_deref().map_or(JsValue::NULL, JsValue::from), + )?; + set("startup_ms", shared.startup_ms.get().map_or(JsValue::NULL, JsValue::from))?; + set( + "checkpointed_at", + shared.checkpointed_at.borrow().as_deref().map_or(JsValue::NULL, JsValue::from), + )?; + let server = SERVER.with(|slot| slot.borrow().clone()); + set( + "server", + match (shared.phase.get(), server) { + (Phase::Running, Some(server)) => server_status(&server)?, + _ => JsValue::NULL, + }, + )?; + Ok(result.into()) + } +} + +impl Shared { + fn connect(self: &Rc, socket: JsValue) -> Result { + match self.phase.get() { + Phase::Running => return self.route(socket), + Phase::Failed => { + return Err(js_error(self.failure.borrow().as_deref().unwrap_or("failed"))) + } + Phase::Starting | Phase::Stopping => { + let shared = self.clone(); + let retry = Closure::once_into_js(move |_: JsValue| shared.connect(socket)); + return then(&self.transition()?.into(), Some(&retry), None); + } + Phase::Idle => {} + } + self.set_phase(Phase::Starting); + let started = js_sys::Date::now(); + let root = self.clone(); + self.promise(async move { root.run(socket, started).await }) + } + + /// The promise of the next phase change. + fn transition(&self) -> Result { + let mut slot = self.transition.borrow_mut(); + if let Some((promise, _)) = slot.as_ref() { + return Ok(promise.clone()); + } + let resolvers = with_resolvers()?; + let promise: js_sys::Promise = property(&resolvers, "promise")?.unchecked_into(); + let resolve: js_sys::Function = property(&resolvers, "resolve")?.unchecked_into(); + Ok(slot.insert((promise, resolve)).0.clone()) + } + + fn set_phase(&self, phase: Phase) { + self.phase.set(phase); + if let Some((_, resolve)) = self.transition.borrow_mut().take() { + let _ = resolve.call0(&JsValue::UNDEFINED); + } + } + + /// Schedules `future` as a root and returns the Promise of its outcome. + fn promise( + self: &Rc, + future: impl std::future::Future> + 'static, + ) -> Result { + let resolvers = with_resolvers()?; + let promise = property(&resolvers, "promise")?; + let resolve: js_sys::Function = property(&resolvers, "resolve")?.unchecked_into(); + let reject: js_sys::Function = property(&resolvers, "reject")?.unchecked_into(); + let shared = self.clone(); + self.runtime.schedule(future, move |outcome| { + let outcome = + outcome.unwrap_or_else(|panic| Err(js_error(format!("server task failed: {panic}")))); + match outcome { + Ok(value) => { + let _ = resolve.call1(&JsValue::UNDEFINED, &value); + } + Err(error) => { + shared.fail(&error); + let _ = reject.call1(&JsValue::UNDEFINED, &error); + } + } + }); + Ok(promise) + } + + fn fail(&self, error: &JsValue) { + if self.phase.get() != Phase::Failed { + self.set_phase(Phase::Failed); + let message = js_sys::Error::from(error.clone()).message(); + *self.failure.borrow_mut() = Some(String::from(message)); + } + } + + /// Routes a socket to Pumpkin's listener and tracks it until it closes. + fn route(self: &Rc, socket: JsValue) -> Result { + self.connections.set(self.connections.get() + 1); + let promise = host::handle_as_node_connection(&socket)?; + let shared = self.clone(); + let done = Closure::once_into_js(move |_: JsValue| { + shared.connections.set(shared.connections.get() - 1); + if shared.connections.get() == 0 { + shared.idle.notify_one(); + } + }); + then(&promise, Some(&done), Some(&done)) + } + + async fn run(self: Rc, first: JsValue, started: f64) -> Result { + let restored = persist::restore(&self.storage)?; + std::env::set_current_dir(persist::ROOT).map_err(js_error)?; + eprintln!("Restored {restored} world files"); + pumpkin::reset_stop(); + let (basic, advanced) = config::configuration(); + if !LOGGER.replace(true) { + pumpkin::init_logger(&advanced); + } + let server = PumpkinServer::new(basic, advanced, VanillaData::load()).await; + server.init_plugins().await; + SERVER.with(|slot| *slot.borrow_mut() = Some(server.server.clone())); + self.set_phase(Phase::Running); + self.startup_ms.set(Some(js_sys::Date::now() - started)); + + // The first connection's promise belongs to the platform; the server + // root tracks it only through `connections`. + drop(self.route(first)?); + let idle = self.idle.clone(); + let shared = self.clone(); + tokio::task::spawn_local(async move { + loop { + idle.notified().await; + // A connection may have been routed since the notification. + if shared.connections.get() == 0 { + break; + } + } + // Stopping before the listener closes, so no connection is routed + // to a server that will not accept it. + shared.set_phase(Phase::Stopping); + pumpkin::stop_server(); + }); + // Returns after the final save once stopped. + server.start().await; + SERVER.with(|slot| slot.borrow_mut().take()); + if let Some(panic) = PANIC.with(|slot| slot.borrow_mut().take()) { + return Err(js_error(panic)); + } + let (written, removed) = persist::save(&self.storage)?; + eprintln!("Checkpoint: {written} files written, {removed} removed"); + // Issued writes become durable before the connection is reported closed. + let synced = method(&self.storage, "sync", &[])?; + let shared = self.clone(); + let done = Closure::once_into_js(move |_: JsValue| { + shared + .checkpointed_at + .replace(Some(String::from(js_sys::Date::new_0().to_iso_string()))); + shared.set_phase(Phase::Idle); + }); + then(&synced, Some(&done), None) + } +} + +fn with_resolvers() -> Result { + method(&js_sys::Promise::resolve(&JsValue::UNDEFINED).constructor().into(), "withResolvers", &[]) +} + +fn server_status(server: &Server) -> Result { + let result = js_sys::Object::new(); + let set = |key: &str, value: f64| js_sys::Reflect::set(&result, &key.into(), &value.into()); + set("players", server.get_all_players().len() as f64)?; + set("ticks", server.tick_count.load(Ordering::Relaxed) as f64)?; + set( + "wasm_memory_bytes", + (core::arch::wasm32::memory_size::<0>() * 65536) as f64, + )?; + for (name, bytes) in memory::heap_usage() { + set(name, bytes as f64)?; + } + Ok(result.into()) +} + +pub fn authority() -> String { + format!("world:{PORT}") +} diff --git a/tests/fixtures/filesystem.rs b/tests/fixtures/filesystem.rs deleted file mode 100644 index 37a044e..0000000 --- a/tests/fixtures/filesystem.rs +++ /dev/null @@ -1,87 +0,0 @@ -#![allow(deprecated)] - -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use wasm_bindgen::prelude::*; - -fn main() {} - -fn js_error(error: impl std::fmt::Display) -> JsValue { - js_sys::Error::new(&error.to_string()).into() -} - -/// Echo every accepted connection for the lifetime of the instance. -#[wasm_bindgen(jspi)] -pub fn echo_server(ready: js_sys::Function) -> Result<(), JsValue> { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(js_error)?; - runtime.block_on(async { - let listener = TcpListener::bind("0.0.0.0:25565").await.map_err(js_error)?; - ready.call0(&JsValue::UNDEFINED)?; - loop { - let (mut socket, _) = listener.accept().await.map_err(js_error)?; - tokio::spawn(async move { - let mut bytes = [0u8; 16 * 1024]; - loop { - match socket.read(&mut bytes).await { - Ok(0) | Err(_) => break, - Ok(count) => { - if socket.write_all(&bytes[..count]).await.is_err() { - break; - } - } - } - } - let _ = socket.shutdown().await; - }); - } - }) -} - -/// Exercise the filesystem syscall path before loading Pumpkin's world data. -#[wasm_bindgen] -pub fn filesystem_probe(value: String) -> Result { - use std::io::{Read, Seek, SeekFrom, Write}; - std::fs::create_dir_all("transport-probe").map_err(js_error)?; - let path = "transport-probe/state.bin"; - let previous = std::fs::read_to_string(path).unwrap_or_default(); - let mut file = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .read(true) - .write(true) - .open("transport-probe/pending.bin") - .map_err(js_error)?; - file.write_all(b"----").map_err(js_error)?; - file.seek(SeekFrom::Start(0)).map_err(js_error)?; - file.write_all(value.as_bytes()).map_err(js_error)?; - file.set_len(value.len() as u64).map_err(js_error)?; - file.sync_all().map_err(js_error)?; - file.seek(SeekFrom::Start(0)).map_err(js_error)?; - let mut check = String::new(); - file.read_to_string(&mut check).map_err(js_error)?; - if check != value { - return Err(js_error("Filesystem descriptor roundtrip failed")); - } - drop(file); - std::fs::rename("transport-probe/pending.bin", path).map_err(js_error)?; - let mut large = vec![0x5a; 3 * 1024 * 1024]; - std::fs::write("transport-probe/large.bin", &large).map_err(js_error)?; - let mut file = std::fs::OpenOptions::new() - .write(true) - .open("transport-probe/large.bin") - .map_err(js_error)?; - file.seek(SeekFrom::Start(65534)).map_err(js_error)?; - file.write_all(&[1, 2, 3, 4]).map_err(js_error)?; - large[65534..65538].copy_from_slice(&[1, 2, 3, 4]); - file.set_len(large.len() as u64 + 3).map_err(js_error)?; - large.extend_from_slice(&[0, 0, 0]); - file.sync_all().map_err(js_error)?; - drop(file); - if std::fs::read("transport-probe/large.bin").map_err(js_error)? != large { - return Err(js_error("Large file roundtrip failed")); - } - Ok(previous) -} diff --git a/tests/fixtures/startup-failure.mjs b/tests/fixtures/startup-failure.mjs deleted file mode 100644 index 10849da..0000000 --- a/tests/fixtures/startup-failure.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { MinecraftWorld } from '../../worker/world'; - -export class StartupFailure extends MinecraftWorld { - loadRuntime() { throw new Error('Expected startup failure'); } - - async verify() { - const attempt = this.getRuntime(); - const starting = this.status(); - try { await attempt; } catch {} - const failed = this.status(); - try { await this.getRuntime(); } catch {} - return { starting, failed, repeated: this.status() }; - } -} diff --git a/tests/fixtures/worker.mjs b/tests/fixtures/worker.mjs deleted file mode 100644 index dbde6d3..0000000 --- a/tests/fixtures/worker.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import { DurableObject, exports } from 'cloudflare:workers'; -import { handleAsNodeConnection } from 'cloudflare:node'; -export { StartupFailure } from './startup-failure.mjs'; -import createModule from '../../target/workers/wasm32-unknown-emscripten/release/filesystem-probe.js'; -import wasm from '../../target/workers/wasm32-unknown-emscripten/release/filesystem_probe.wasm'; -import { createWorldRuntime } from '../../worker/filesystem'; -import { forwardSocket } from '../../worker/socket'; - -const PORT = 25565; - -export class FilesystemTest extends DurableObject { - runtime; - - getRuntime() { - return this.runtime ??= createWorldRuntime(this.ctx.storage, options => createModule({ - ...options, - instantiateWasm(imports, receive) { - const instance = new WebAssembly.Instance(wasm, imports); - receive(instance); - return instance.exports; - }, - })).then(async runtime => { - await new Promise((ready, reject) => { - runtime.run(() => runtime.instance.echo_server(ready)).catch(reject); - }); - return runtime; - }); - } - - async connect(socket) { - void socket.closed.catch(() => undefined); - await this.getRuntime(); - await handleAsNodeConnection(socket); - } - - async status() { - await this.getRuntime(); - return { listening: true }; - } - - async filesystemProbe(value) { - const runtime = await this.getRuntime(); - return runtime.run(() => runtime.instance.filesystem_probe(value)); - } -} - -export default { - async connect(socket, env) { - const target = exports.FilesystemTest.getByName(env.WORLD_NAME).connect(`world:${PORT}`, { allowHalfOpen: true }); - await forwardSocket(socket, target); - }, - async fetch(request, env) { - const url = new URL(request.url); - if (url.pathname === '/health') return Response.json({ ready: true }); - if (url.pathname === '/startup-failure') { - return Response.json(await exports.StartupFailure.getByName('failure').verify()); - } - const stub = exports.FilesystemTest.getByName(url.searchParams.get('world') ?? env.WORLD_NAME); - if (request.method === 'POST' && url.pathname === '/filesystem-probe') { - return Response.json({ previous: await stub.filesystemProbe(await request.text()) }); - } - return Response.json(await stub.status()); - }, -}; diff --git a/tests/fixtures/wrangler.jsonc b/tests/fixtures/wrangler.jsonc deleted file mode 100644 index 8ab097a..0000000 --- a/tests/fixtures/wrangler.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "../../node_modules/wrangler/config-schema.json", - "name": "minecraft-filesystem-tests", - "main": "worker.mjs", - "compatibility_date": "2026-09-05", - "compatibility_flags": ["nodejs_compat", "new_module_registry", "no_handle_cross_request_promise_resolution"], - "dev": { "ip": "127.0.0.1", "port": 8787 }, - "connect": [{ "protocol": "tcp", "address": "127.0.0.1", "port": 25565 }], - "exports": { - "FilesystemTest": { "type": "durable-object", "storage": "sqlite" }, - "StartupFailure": { "type": "durable-object", "storage": "sqlite" } - }, - "vars": { "WORLD_NAME": "integration" } -} diff --git a/tests/integration.mjs b/tests/integration.mjs index 9c9a9eb..fcc0f51 100644 --- a/tests/integration.mjs +++ b/tests/integration.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict'; -import net from 'node:net'; import { readFile } from 'node:fs/promises'; import { setTimeout as delay } from 'node:timers/promises'; import { status, play, waitFor } from './client.mjs'; @@ -30,55 +29,8 @@ function editableBlock(client) { throw new Error(`No stable block within reach of ${JSON.stringify(client.position)}`); } -async function echo() { - const socket = net.connect({ host: '127.0.0.1', port: 25565 }); - socket.setTimeout(15000, () => socket.destroy(new Error('Echo timed out'))); - const payload = Buffer.from(Array.from({ length: 65536 }, (_, index) => index & 255)); - const chunks = []; - let length = 0; - try { - await new Promise((resolve, reject) => { - socket.once('connect', resolve); - socket.once('error', reject); - }); - for (let offset = 0; offset < payload.length; offset += 997) socket.write(payload.subarray(offset, offset + 997)); - for await (const chunk of socket) { - chunks.push(chunk); - length += chunk.length; - if (length >= payload.length) break; - } - assert.ok(Buffer.concat(chunks).equals(payload), `TCP echo differed: received ${length} of ${payload.length} bytes`); - } finally { socket.destroy(); } -} - -async function marker(world, value) { - return (await request(`/filesystem-probe?world=${world}`, { method: 'POST', body: value })).previous; -} - -let worker = await startWorker(true); -try { - await Promise.all([echo(), echo()]); - assert.equal((await request('/')).listening, true); - const failure = await request('/startup-failure'); - assert.equal(failure.starting.phase, 'starting'); - assert.equal(failure.failed.phase, 'failed'); - assert.equal(failure.failed.failure, 'Expected startup failure'); - assert.deepEqual(failure.repeated, failure.failed); - assert.equal(await marker('isolation-a', 'alpha'), ''); - assert.equal(await marker('isolation-b', 'beta'), ''); - assert.equal(await marker('isolation-a', 'alpha-2'), 'alpha'); - worker.healthy(); -} finally { await worker.stop(); } -worker = await startWorker(true); -try { - assert.equal(await marker('isolation-a', 'alpha-3'), 'alpha-2'); - assert.equal(await marker('isolation-b', 'beta-2'), 'beta'); - worker.healthy(); -} finally { await worker.stop(); } -console.log('Filesystem, socket, and isolation tests passed'); - let savedChunk, savedPosition, changedBlock; -worker = await startWorker(); +let worker = await startWorker(); const clients = []; try { assert.equal((await status()).version.protocol, 776); diff --git a/tests/server.mjs b/tests/server.mjs index 37ac929..b78e406 100644 --- a/tests/server.mjs +++ b/tests/server.mjs @@ -23,11 +23,11 @@ export async function testServer({ seed = '1789195700909824175' } = {}) { } let phase = 0; - async function startWorker(fixture = false) { - const log = path.join(probe, `${++phase}-${fixture ? "filesystem" : "minecraft"}.log`); + async function startWorker() { + const log = path.join(probe, `${++phase}-minecraft.log`); const child = spawn(process.execPath, [ path.join(repo, 'node_modules/wrangler/bin/wrangler.js'), 'dev', '--local', - '--config', path.join(repo, fixture ? 'tests/fixtures/wrangler.jsonc' : 'wrangler.jsonc'), + '--config', path.join(repo, 'wrangler.jsonc'), '--persist-to', persistence, '--var', 'WORLD_NAME:integration', // Known terrain with a stable editable spawn block; random water spawns // cannot exercise the integration test's shared block-edit assertions. diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index afefe46..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2022"], - "strict": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["worker/**/*.ts"] -} diff --git a/worker/filesystem.ts b/worker/filesystem.ts deleted file mode 100644 index 79dcf6e..0000000 --- a/worker/filesystem.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { LocalDOFilesystem } from 'durable-object-fs/local'; -import { mount, withMounts } from 'worker-fs-mount'; -import * as nodeFs from 'worker-fs-mount/fs-sync'; - -/** Each wasm instance keeps one mount context across requests and Tokio callbacks. */ -export function createWorldRuntime( - storage: DurableObjectStorage, - create: (options: { nodeFs: typeof nodeFs; cwd: string }) => Promise, -) { - return withMounts(async () => { - mount('/data', new LocalDOFilesystem(storage)); - const run = AsyncLocalStorage.snapshot(); - return { instance: await create({ nodeFs, cwd: '/data' }), run }; - }); -} diff --git a/worker/fs-library.js b/worker/fs-library.js deleted file mode 100644 index fd7153d..0000000 --- a/worker/fs-library.js +++ /dev/null @@ -1,114 +0,0 @@ -// Emscripten's Node loader uses createRequire at runtime, so Wrangler's build-time -// aliases cannot replace its fs object. Supply the mounted node:fs implementation -// to each factory, with a cwd that does not change the isolate's process.cwd. -addToLibrary({ - // Workers exposes public fs.constants, but not Node's internal process.binding. - $NODEFS__postset: ` - NODEFS.isWindows = false; - NODEFS.flagsForNodeMap = Object.fromEntries([ - [{{{ cDefs.O_APPEND }}}, 'O_APPEND'], - [{{{ cDefs.O_CREAT }}}, 'O_CREAT'], - [{{{ cDefs.O_EXCL }}}, 'O_EXCL'], - [{{{ cDefs.O_NOCTTY }}}, 'O_NOCTTY'], - [{{{ cDefs.O_RDONLY }}}, 'O_RDONLY'], - [{{{ cDefs.O_RDWR }}}, 'O_RDWR'], - [{{{ cDefs.O_DSYNC }}}, 'O_SYNC'], - [{{{ cDefs.O_TRUNC }}}, 'O_TRUNC'], - [{{{ cDefs.O_WRONLY }}}, 'O_WRONLY'], - [{{{ cDefs.O_NOFOLLOW }}}, 'O_NOFOLLOW'], - ].map(([flag, name]) => [flag, fs.constants[name]])); - `, - - $workerFilesystem__deps: ['$FS', '$PATH_FS', '$TTY'], - $workerFilesystem__postset: () => addAtPreRun('workerFilesystem()'), - $workerFilesystem: () => { - // biome-ignore lint/complexity/useLiteralKeys: preserve the external Emscripten property name - fs = Module['nodeFs']; - // biome-ignore lint/complexity/useLiteralKeys: preserve the external Emscripten property name - let workerCwd = Module['cwd']; - FS.cwd = () => workerCwd; - // Keep standard output on Emscripten's console callbacks, including printErr - // so the host can record Rust panics. Native Worker fds are not stdio handles. - const consoles = [ - null, - { output: [], ops: TTY.default_tty_ops }, - { output: [], ops: TTY.default_tty1_ops }, - ]; - // NODERAWFS's stdio streams have no nodes; expose character-device metadata - // without asking the native Workers filesystem about those descriptor numbers. - const fstat = FS.fstat; - FS.fstat = (fd) => { - const stream = FS.getStreamChecked(fd); - if (stream.nfd === 0 || stream.nfd === 1 || stream.nfd === 2) { - return { - dev: 0, - ino: stream.nfd, - mode: 0o020666, - nlink: 1, - uid: 0, - gid: 0, - rdev: 0, - size: 0, - blksize: 4096, - blocks: 0, - atime: new Date(0), - mtime: new Date(0), - ctime: new Date(0), - }; - } - return fstat(fd); - }; - const write = FS.write; - FS.write = (stream, buffer, offset, length, position) => { - const tty = consoles[stream.nfd]; - if (!tty) return write(stream, buffer, offset, length, position); - for (let i = offset; i < offset + length; i++) tty.ops.put_char(tty, buffer[i]); - return length; - }; - // Non-*at syscalls can pass relative paths directly to NODERAWFS. Resolve - // those against this wasm instance, not the shared Node process directory. - for (const name of [ - 'lookupPath', - 'stat', - 'chmod', - 'chown', - 'truncate', - 'utime', - 'open', - 'mkdir', - 'rmdir', - 'readdir', - 'unlink', - 'readlink', - ]) { - const operation = FS[name]; - FS[name] = (path, ...args) => operation(PATH_FS.resolve(path), ...args); - } - const rename = FS.rename; - FS.rename = (from, to) => rename(PATH_FS.resolve(from), PATH_FS.resolve(to)); - const symlink = FS.symlink; - FS.symlink = (target, path) => symlink(target, PATH_FS.resolve(path)); - FS.chdir = (path) => { - path = PATH_FS.resolve(path); - if (!FS.isDir(FS.stat(path).mode)) throw new FS.ErrnoError(ERRNO_CODES.ENOTDIR); - workerCwd = path; - }; - }, - - // SQLite operations are synchronous. Checkpoint completion separately awaits - // storage.sync(); ordinary Rust File::sync_all must not suspend a sync export. - fd_sync__deps: ['$FS', '$workerFilesystem'], - fd_sync__async: false, - fd_sync: (fd) => { - try { - const stream = FS.getStreamChecked(fd); - if (typeof stream.nfd === 'number') fs.fsyncSync(stream.nfd); - else stream.stream_ops?.fsync?.(stream); - return 0; - } catch (error) { - if (typeof error.errno === 'number') return error.errno; - if (error.code) return ERRNO_CODES[error.code]; - throw error; - } - }, -}); diff --git a/worker/index.mjs b/worker/index.mjs new file mode 100644 index 0000000..0bd31de --- /dev/null +++ b/worker/index.mjs @@ -0,0 +1,16 @@ +import { DurableObject } from 'cloudflare:workers'; +import { connect, fetch, MinecraftWorld as World } from '../target/workers/wasm32-unknown-emscripten/release/pumpkin-do.js'; + +export default { connect, fetch }; + +// The runtime enables RPC on a Durable Object class only when it derives from +// DurableObject; the wasm-bindgen class is plain, so wrap it. +export class MinecraftWorld extends DurableObject { + #world; + constructor(state, env) { + super(state, env); + this.#world = new World(state, env); + } + connect(socket) { return this.#world.connect(socket); } + status() { return this.#world.status(); } +} diff --git a/worker/index.ts b/worker/index.ts deleted file mode 100644 index e98fe11..0000000 --- a/worker/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { exports } from 'cloudflare:workers'; -import { PORT } from './pumpkin'; -import { forwardSocket } from './socket'; -export { MinecraftWorld } from './world'; - -export default { - async connect(socket: Socket, env: Env): Promise { - const target = exports.MinecraftWorld.getByName(env.WORLD_NAME).connect(`world:${PORT}`, { allowHalfOpen: true }); - await forwardSocket(socket, target); - }, - async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - if (url.pathname === '/health') return Response.json({ ready: true }); - const stub = exports.MinecraftWorld.getByName(env.WORLD_NAME); - if (request.method === 'GET' && url.pathname === '/') return Response.json(await stub.status()); - return new Response('Not found', { status: 404 }); - }, -} satisfies ExportedHandler; diff --git a/worker/modules.d.ts b/worker/modules.d.ts deleted file mode 100644 index 3238267..0000000 --- a/worker/modules.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -declare module '*.wasm' { - const module: WebAssembly.Module; - export default module; -} - -declare module '*pumpkin-do.js' { - interface Options { - cwd: string; - nodeFs: typeof import('worker-fs-mount/fs-sync'); - instantiateWasm(imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance) => void): WebAssembly.Exports; - printErr(message: string): void; - } - interface Pumpkin { - pumpkin_run(ready: () => void): Promise; - pumpkin_stop(): void; - pumpkin_status(): { players: number; ticks: number; wasm_memory_bytes: number }; - } - export default function create(options: Options): Promise; -} - -// Routes an inbound socket to the net.Server listening on its local address's -// port within the current Durable Object's port table; resolves when the -// connection closes. -declare module 'cloudflare:node' { - export function handleAsNodeConnection(socket: Socket): Promise; -} diff --git a/worker/pumpkin.ts b/worker/pumpkin.ts deleted file mode 100644 index 9928a22..0000000 --- a/worker/pumpkin.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { handleAsNodeConnection } from 'cloudflare:node'; -import createPumpkin from '../target/workers/wasm32-unknown-emscripten/release/pumpkin-do.js'; -import pumpkinWasm from '../target/workers/wasm32-unknown-emscripten/release/pumpkin_do.wasm'; -import { createWorldRuntime } from './filesystem'; - -export const PORT = 25565; - -/** Start the server in its instance's filesystem context and resolve once it is listening. */ -export async function startPumpkin(storage: DurableObjectStorage) { - const { instance, run } = await createWorldRuntime(storage, options => createPumpkin({ - ...options, - instantiateWasm(imports, receive) { - const instance = new WebAssembly.Instance(pumpkinWasm, imports); - receive(instance); - return instance.exports; - }, - printErr: console.error, - })); - // The single JSPI activation runs the whole server lifetime, suspending - // whenever Tokio parks. It settles after `stop` completes the final save, or - // rejects if the server fails. - let finished!: Promise; - await new Promise((ready, reject) => { - finished = run(() => instance.pumpkin_run(ready)); - finished.catch(reject); - }); - return { - finished, - connect: (socket: Socket) => handleAsNodeConnection(socket), - stop: () => { instance.pumpkin_stop(); return finished; }, - status: () => instance.pumpkin_status(), - }; -} diff --git a/worker/socket.ts b/worker/socket.ts deleted file mode 100644 index 47f9d3b..0000000 --- a/worker/socket.ts +++ /dev/null @@ -1,25 +0,0 @@ -export async function forwardSocket(socket: Socket, target: Socket): Promise { - // Read/write failures are observed by the pumps below. Also consume the - // lifetime promises so a normal peer disconnect is not an unhandled rejection. - void socket.closed.catch(() => undefined); - void target.closed.catch(() => undefined); - const abort = new AbortController(); - const pipe = (source: ReadableStream, destination: WritableStream) => - source.pipeTo(destination, { signal: abort.signal }).catch(error => { - abort.abort(); - throw error; - }); - try { - const results = await Promise.allSettled([ - pipe(socket.readable, target.writable), - pipe(target.readable, socket.writable), - ]); - for (const result of results) { - if (result.status === 'rejected' && !/closed|closing|abort|cancel|reset|network connection lost/i.test(String(result.reason))) { - console.error('TCP forwarding failed', result.reason); - } - } - } finally { - await Promise.allSettled([socket.close(), target.close()]); - } -} diff --git a/worker/world.ts b/worker/world.ts deleted file mode 100644 index 75d7bca..0000000 --- a/worker/world.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { DurableObject } from 'cloudflare:workers'; -import { startPumpkin } from './pumpkin'; - -type Runtime = Awaited>; -type State = - | { phase: 'idle' } - | { phase: 'starting'; pending: Promise } - | { phase: 'running'; runtime: Runtime } - | { phase: 'stopping'; pending: Promise } - | { phase: 'failed'; error: string }; - -export class MinecraftWorld extends DurableObject { - private state: State = { phase: 'idle' }; - private connections = 0; - private startupMilliseconds?: number; - private checkpointedAt?: string; - - protected loadRuntime(): Promise { - return startPumpkin(this.ctx.storage); - } - - private fail(error: unknown): void { - if (this.state.phase !== 'failed') { - this.state = { phase: 'failed', error: error instanceof Error ? error.message : String(error) }; - } - } - - protected async getRuntime(): Promise { - const state = this.state; - switch (state.phase) { - case 'running': return state.runtime; - case 'starting': return state.pending; - case 'failed': throw new Error(state.error); - case 'stopping': - await state.pending; - return this.getRuntime(); - case 'idle': { - const started = Date.now(); - const pending = Promise.resolve().then(() => this.loadRuntime()).then(runtime => { - this.startupMilliseconds = Date.now() - started; - this.state = { phase: 'running', runtime }; - runtime.finished.catch(error => this.fail(error)); - return runtime; - }).catch(error => { this.fail(error); throw error; }); - this.state = { phase: 'starting', pending }; - return pending; - } - } - } - - async connect(socket: Socket): Promise { - void socket.closed.catch(() => undefined); - if (this.state.phase === 'stopping') await this.state.pending; - this.connections++; - try { - await (await this.getRuntime()).connect(socket); - } finally { - this.connections--; - await socket.close().catch(() => undefined); - if (this.connections === 0) await this.checkpoint(); - } - } - - private async checkpoint(): Promise { - const state = this.state; - if (state.phase === 'stopping') return state.pending; - if (state.phase !== 'running') return; - const pending = (async () => { - await state.runtime.stop(); - await this.ctx.storage.sync(); - this.checkpointedAt = new Date().toISOString(); - this.state = { phase: 'idle' }; - })().catch(error => { this.fail(error); throw error; }); - this.state = { phase: 'stopping', pending }; - return pending; - } - - status() { - let server: ReturnType | null = null; - if (this.state.phase === 'running') { - try { server = this.state.runtime.status(); } - catch (error) { this.fail(error); } - } - return { - phase: this.state.phase, - connections: this.connections, - failure: this.state.phase === 'failed' ? this.state.error : null, - startup_ms: this.startupMilliseconds ?? null, - checkpointed_at: this.checkpointedAt ?? null, - server, - }; - } -} diff --git a/wrangler.jsonc b/wrangler.jsonc index 7fb22ab..3bf5f27 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,12 +1,13 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "rust-workers-minecraft", - "main": "worker/index.ts", + "main": "worker/index.mjs", "compatibility_date": "2026-09-05", "compatibility_flags": ["nodejs_compat", "new_module_registry", "no_handle_cross_request_promise_resolution"], "dev": { "ip": "127.0.0.1", "port": 8787 }, "connect": [{ "protocol": "tcp", "address": "127.0.0.1", "port": 25565 }], - "exports": { "MinecraftWorld": { "type": "durable-object", "storage": "sqlite" } }, + "durable_objects": { "bindings": [{ "name": "WORLD", "class_name": "MinecraftWorld" }] }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MinecraftWorld"] }], "vars": { "WORLD_NAME": "world" }, "observability": { "enabled": true, "head_sampling_rate": 1 } } From 3ef289252414c67838a17399eabe69bde4f9e031 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:01:41 -0700 Subject: [PATCH 04/12] Build with the worker crate and worker-build --emscripten The Worker is now written against the `worker` crate with its `experimental_tokio` feature, built by worker-build's Emscripten mode: worker-build owns the codegen and link settings, wraps the exports into the entrypoint and derives the Durable Object class for RPC, so the hand-written JavaScript entrypoint goes away. `connect` is a `#[wasm_bindgen(experimental_tokio)]` export on Tokio's host-driven event loop. The toolchain follows the sealed releases: worker-build (cloudflare/workers-rs #1061, installed by setup from the revision Cargo.toml pins the `worker` crate to) provisions Emscripten 6.0.10 with the epoll-listener and async-DNS patches (tag 6.0.10-cf.emscripten) and the wasm-bindgen 0.2.129 CLI itself; Tokio 1.53.1 with the event loop (tag 1.53.1-cf.emscripten) and mio 1.2.3 with the Emscripten selector (tag 1.2.3-cf.emscripten) are git dependencies, and wasm-bindgen and wasm-streams 0.7 come from crates.io. Setup keeps only the patched Pumpkin checkout under .work; Wrangler's build command runs scripts/build.sh. --- .cargo/config.toml | 23 +--- .github/workflows/ci.yml | 4 +- .gitignore | 1 + AGENTS.md | 3 +- Cargo.lock | 195 ++++++++++++++++++++++++++++--- Cargo.toml | 16 +-- README.md | 21 ++-- build.rs | 4 + docs/architecture.md | 50 ++++---- docs/dependencies.md | 68 +++++------ docs/development.md | 16 ++- patches/pumpkin-emscripten.patch | 47 ++++---- scripts/build.sh | 8 +- scripts/common.sh | 20 +--- scripts/setup.sh | 79 ++++++------- src/main.rs | 52 ++++----- src/world.rs | 90 ++++++-------- worker/index.mjs | 16 --- wrangler.jsonc | 3 +- 19 files changed, 408 insertions(+), 308 deletions(-) delete mode 100644 worker/index.mjs diff --git a/.cargo/config.toml b/.cargo/config.toml index 8afc927..9549db1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,26 +2,13 @@ target = "wasm32-unknown-emscripten" target-dir = "target/workers" +# worker-build --emscripten supplies these (and the link settings) through +# RUSTFLAGS when building with the `worker/experimental_tokio` feature; they are repeated +# here so `cargo check` sees the same cfgs. [target.wasm32-unknown-emscripten] rustflags = [ "-Crelocation-model=static", - "--cfg=tokio_unstable", - # exnref exception handling throughout: the C side gets `-fwasm-exceptions - # -sWASM_LEGACY_EXCEPTIONS=0` from EMCC_CFLAGS (scripts/common.sh) and the - # prebuilt std is translated at link. V8 rejects a module mixing the two. "-Cllvm-args=-wasm-use-legacy-eh=false", - "-Clink-arg=-sBINARYEN_EXTRA_PASSES=--translate-to-exnref", - # Link-only settings stay out of EMCC_CFLAGS so they do not reach C compiles. - "-Clink-arg=-sWASM_BINDGEN", - "-Clink-arg=-sMODULARIZE=instance", - "-Clink-arg=-sEXPORT_ES6", - "-Clink-arg=-sAUTO_INIT", - "-Clink-arg=-sSOURCE_PHASE_IMPORTS", - # Generated host glue uses Workers' Node compatibility APIs. - "-Clink-arg=-sENVIRONMENT=node", - "-Clink-arg=-sNODERAWSOCKETS", - "-Clink-arg=-sNODERAWFS", - "-Clink-arg=-sASSERTIONS=0", - "-Clink-arg=-sSTACK_SIZE=8MB", - "-Clink-arg=-sALLOW_MEMORY_GROWTH=1", + "--cfg=tokio_unstable", + "--cfg=wasm_bindgen_unstable_tokio", ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4778200..087180b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,14 +25,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.13' - - run: sudo apt-get install -y ninja-build - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git - .work/emsdk - .work/binaryen/build + ~/.cache/worker-build key: minecraft-toolchain-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', 'scripts/setup.sh') }} - name: Setup pinned sources and toolchain run: bash scripts/setup.sh diff --git a/.gitignore b/.gitignore index 4a53fb4..cca1d50 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.work/ /target/ **/target/ +/build/ node_modules/ .wrangler/ /worker/env.d.ts diff --git a/AGENTS.md b/AGENTS.md index 5cbf3f8..95d7336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,6 @@ Read [README.md](README.md), [architecture](docs/architecture.md), and - `src/`: the Worker: TCP ingress, the Durable Object, world persistence, Pumpkin configuration, and the Emscripten JS library (`workerd.js`). -- `worker/index.mjs`: the module Wrangler loads; re-exports the generated build. - `tests/`: protocol clients and the integration test. - `scripts/setup.sh`: provision pinned sources and build the toolchain. - `scripts/{build,serve,test}.sh`: build, run, and validate the Workers server. @@ -22,7 +21,7 @@ process to free a port. Changes to the patched Pumpkin checkout must be reflected in `patches/`. Keep unpatched checkouts unmodified; update pins for upstream changes. Preserve the unified ticker and cooperative scheduler unless -the task requires a runtime change. Keep the `EventLoopRuntime` model: exports +the task requires a runtime change. Keep the event-loop model: exports return promises and nothing blocks or suspends; do not reintroduce JSPI, old Tokio/libc networking patches, or a JS-side driver. diff --git a/Cargo.lock b/Cargo.lock index 7cc9763..efe745b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,17 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "js-sys", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "cipher" version = "0.5.2" @@ -1186,9 +1197,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1204,7 +1215,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.189" -source = "git+https://github.com/rust-lang/libc?branch=libc-0.2#31503352774deadf65cc53257b59ee4f3299f644" +source = "git+https://github.com/rust-lang/libc?branch=libc-0.2#b29beb056cb7cea51dd1b9e68342e30a956951a9" [[package]] name = "litemap" @@ -1252,6 +1263,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -1296,8 +1313,8 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" -source = "git+https://github.com/guybedford/mio?rev=a62c9e46833fc255c9217ab9aa362c6221ed4401#a62c9e46833fc255c9217ab9aa362c6221ed4401" +version = "1.2.3" +source = "git+https://github.com/guybedford/mio?tag=1.2.3-cf.emscripten#0788dbb67883cb6191503af999471def54e3fb77" dependencies = [ "libc", "wasi", @@ -1483,6 +1500,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1812,7 +1849,9 @@ dependencies = [ "rayon", "tokio", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", + "worker", ] [[package]] @@ -2121,6 +2160,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2198,6 +2243,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -2251,6 +2307,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" @@ -2404,6 +2472,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2526,6 +2615,7 @@ dependencies = [ [[package]] name = "tokio" version = "1.53.1" +source = "git+https://github.com/guybedford/tokio?tag=1.53.1-cf.emscripten#d56f1eff2c3fd72a946e1e536a3b93e32b002a80" dependencies = [ "bytes", "libc", @@ -2539,6 +2629,7 @@ dependencies = [ [[package]] name = "tokio-macros" version = "2.7.2" +source = "git+https://github.com/guybedford/tokio?tag=1.53.1-cf.emscripten#d56f1eff2c3fd72a946e1e536a3b93e32b002a80" dependencies = [ "proc-macro2", "quote", @@ -2811,9 +2902,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2824,19 +2915,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2844,9 +2936,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -2857,18 +2949,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ce9bdac782bbca70d6ac48a3ba18b873c848a706d6dc3bd9a03c1b96f04693" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2925,6 +3030,62 @@ dependencies = [ "primefield", ] +[[package]] +name = "worker" +version = "0.8.6" +source = "git+https://github.com/cloudflare/workers-rs?rev=490b6baa6e45d98a4c6f40565e9eb7d214ee2a93#490b6baa6e45d98a4c6f40565e9eb7d214ee2a93" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.6" +source = "git+https://github.com/cloudflare/workers-rs?rev=490b6baa6e45d98a4c6f40565e9eb7d214ee2a93#490b6baa6e45d98a4c6f40565e9eb7d214ee2a93" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 3.0.5", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.6" +source = "git+https://github.com/cloudflare/workers-rs?rev=490b6baa6e45d98a4c6f40565e9eb7d214ee2a93#490b6baa6e45d98a4c6f40565e9eb7d214ee2a93" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 3c53cd0..6cd5f1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,11 @@ name = "pumpkin-do" path = "src/main.rs" [dependencies] -wasm-bindgen = "0.2.128" -js-sys = "0.3.105" -web-sys = { version = "0.3.105", features = ["AbortController", "AbortSignal", "Headers", "ReadableStream", "Request", "Response", "ResponseInit", "StreamPipeOptions", "Url", "WritableStream", "console"] } +worker = { git = "https://github.com/cloudflare/workers-rs", rev = "490b6baa6e45d98a4c6f40565e9eb7d214ee2a93", features = ["experimental_tokio"] } +wasm-bindgen = "0.2.129" +wasm-bindgen-futures = "0.4.79" +js-sys = "0.3.106" +web-sys = { version = "0.3.106", features = ["AbortController", "AbortSignal", "ReadableStream", "StreamPipeOptions", "WritableStream", "console"] } tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync", "time", "net", "io-util"] } pumpkin = { path = ".work/pumpkin/crates/pumpkin", default-features = false, features = ["single-threaded"] } pumpkin-config = { path = ".work/pumpkin/crates/pumpkin-config", default-features = false } @@ -27,11 +29,11 @@ rayon = "1.12" # wasm32-unknown-emscripten support pending upstream releases. [patch.crates-io] -# EventLoopRuntime: the host event loop drives Tokio (tokio-rs/tokio#8479). -tokio = { path = ".work/tokio/tokio" } -tokio-macros = { path = ".work/tokio/tokio-macros" } +# LocalEventLoop: the host event loop drives Tokio (tokio-rs/tokio#8484). +tokio = { git = "https://github.com/guybedford/tokio", tag = "1.53.1-cf.emscripten" } +tokio-macros = { git = "https://github.com/guybedford/tokio", tag = "1.53.1-cf.emscripten" } # tokio-rs/mio#1969; its emscripten epoll bindings come from libc's unreleased libc-0.2 branch. -mio = { git = "https://github.com/guybedford/mio", rev = "a62c9e46833fc255c9217ab9aa362c6221ed4401" } +mio = { git = "https://github.com/guybedford/mio", tag = "1.2.3-cf.emscripten" } libc = { git = "https://github.com/rust-lang/libc", branch = "libc-0.2" } # getrandom-backed SystemRandom on emscripten. ring = { git = "https://github.com/guybedford/ring", branch = "emscripten" } diff --git a/README.md b/README.md index f64bae2..ede9f3d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,7 @@ Minecraft → Workers TCP ingress → MinecraftWorld → Pumpkin + SQLite ## Try it Linux and macOS build hosts are supported. Install Git, -[rustup](https://rustup.rs/), Python 3.11+, CMake and Ninja, and Node 24+ (26 -recommended). From a checkout of this repository: +[rustup](https://rustup.rs/), Python 3.11+, and Node 24+ (26 recommended). From a checkout of this repository: ```sh npm ci @@ -23,15 +22,16 @@ bash scripts/setup.sh npm run dev ``` -Setup installs the pinned Rust toolchain, fetches dependency sources and the -Emscripten toolchain under `.work/` (building Binaryen), and installs the -wasm-bindgen CLI. `npm run dev` compiles Pumpkin and starts Wrangler. The first -build takes several minutes; later builds use Cargo's cache. +Setup installs the pinned Rust toolchain, fetches and patches Pumpkin under +`.work/`, and installs worker-build, which provisions Emscripten and +wasm-bindgen itself. `npm run dev` compiles Pumpkin with +`worker-build --emscripten` and starts Wrangler. The first build takes several +minutes; later builds use Cargo's cache. -Until Wrangler's bundled workerd routes inbound sockets to `net.Server` listeners -inside Durable Objects and carries the `node:fs` fixes listed in -[dependency pins](docs/dependencies.md#host-tools), point Miniflare at a workerd -build that does: `MINIFLARE_WORKERD_PATH=/path/to/workerd npm run dev`. +The runtime support this needs (`net.Server` routing into Durable Objects and +the `node:fs` fixes listed in [dependency pins](docs/dependencies.md#host-tools)) +is in workerd main. Until Wrangler's bundled workerd catches up, point Miniflare +at a build of main: `MINIFLARE_WORKERD_PATH=/path/to/workerd npm run dev`. Connect **Minecraft Java 26.2** to **`localhost:25565`**. Status is available at **http://localhost:8787/**. @@ -76,6 +76,7 @@ Provision the public TCP endpoint separately and route it to this Worker's ## Credits and license Built on [Pumpkin](https://github.com/Pumpkin-MC/Pumpkin), +[workers-rs](https://github.com/cloudflare/workers-rs), [Tokio](https://github.com/tokio-rs/tokio), [Emscripten](https://emscripten.org/), [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen), and [Guy Bedford's Rust/Emscripten work](https://github.com/guybedford). diff --git a/build.rs b/build.rs index 10f8c3d..3239053 100644 --- a/build.rs +++ b/build.rs @@ -1,11 +1,15 @@ use std::{env, path::PathBuf}; +// worker-build supplies the common emscripten link settings; these are the +// application's own. fn main() { println!("cargo::rerun-if-changed=src/workerd.js"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("emscripten") { return; } for arg in [ + "-sNODERAWFS", + "-sSTACK_SIZE=8MB", // Keep growth headroom bounded after generation's temporary allocations. "-sMEMORY_GROWTH_LINEAR_STEP=2097152", "-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=[\"$workerdStdio\"]", diff --git a/docs/architecture.md b/docs/architecture.md index d43e7d6..40d033f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,26 +2,26 @@ ## Runtime and connections -The whole Worker is Rust. Cargo links the `pumpkin-do` bin for -`wasm32-unknown-emscripten` with emcc, which emits an ES module instance -(`-sMODULARIZE=instance`, `-sWASM_BINDGEN`) that `worker/index.mjs` re-exports -for Wrangler. The entrypoint's `connect` handler forwards each TCP connection to -the named `MinecraftWorld` object with `stub.connect` and pipes both directions; -`fetch` serves status. Both are plain exports composing the platform's promises. - -`MinecraftWorld` is a `#[wasm_bindgen]` class constructed with the object's state -and owning Tokio's `EventLoopRuntime` (tokio-rs/tokio#8479): the current-thread -scheduler and drivers with the host event loop as their wait. Nothing blocks or -suspends. Work is scheduled as roots that complete by callback, and each export -wraps its root in a Promise. The first `connect` after idle schedules the server -lifetime as a root: it restores the world, starts Pumpkin, awaits the server's -return after its final save, checkpoints, and settles the connection's promise. -Later connections and `status` calls enter the same instance as ordinary calls -while that root is parked. A connection arriving while the server is starting -or stopping waits for the next phase change and then retries, so a client that -connects as the previous server checkpoints starts the next one. The runtime's epoll and timer waits register with the -host through Emscripten's Node backend (emscripten-core/emscripten#27547), so a -readiness or timer callback resumes the scheduler on the host loop. +The whole Worker is Rust: `worker-build --emscripten`, with the `worker` crate's +`experimental_tokio` feature, links the `pumpkin-do` bin for `wasm32-unknown-emscripten` +through emcc and emits the module Wrangler serves. The entrypoint's `connect` handler forwards each TCP +connection to the named `MinecraftWorld` object with `stub.connect` and pipes +both directions; `fetch` (a `worker` crate `#[event(fetch)]`) serves status. + +`MinecraftWorld` is a `#[wasm_bindgen]` class constructed with the object's +state. Its `connect` is a `#[wasm_bindgen(experimental_tokio)]` export: the future runs on +the thread's Tokio `LocalEventLoop` (tokio-rs/tokio#8484), the current-thread +scheduler and drivers with the host event loop as their wait, and the export +returns the Promise of its outcome. Nothing blocks or suspends. The first +`connect` after idle runs the server lifetime: it restores the world, starts +Pumpkin, awaits the server's return after its final save, checkpoints, and +settles the connection's promise. Later connections and `status` calls enter +the same instance as ordinary calls while that future is parked. A connection +arriving while the server is starting or stopping waits for the next phase +change and then retries, so a client that connects as the previous server +checkpoints starts the next one. The runtime's epoll and timer waits register +with the host through Emscripten's Node backend (emscripten-core/emscripten#27547), +so a readiness or timer callback resumes the scheduler on the host loop. Pumpkin binds its stock `TcpListener` on port 25565. Emscripten's Node socket backend implements that with `net.BoundSocket`/`net.Server`, which workerd scopes @@ -81,11 +81,13 @@ panics, Rust error logs, and runtime crashes fail the integration test. ## Build constraints -- Use the pinned Rust toolchain, Emscripten, and wasm-bindgen CLI from setup. +- Use the pinned Rust toolchain and the worker-build from setup, which + provisions Emscripten and the wasm-bindgen CLI. - Keep static relocation, exnref exception handling on both the C and Rust - sides, the 8 MiB stack, and memory growth. `.cargo/config.toml` holds the link - settings; `build.rs` adds the JS library and growth step. -- Build with `--cfg tokio_unstable`; `EventLoopRuntime` is unstable API. + sides, the 8 MiB stack, and memory growth. worker-build supplies the common + codegen and link settings (including `--cfg tokio_unstable`; `LocalEventLoop` + is unstable API); `build.rs` adds the application's own: `NODERAWFS`, the + stack size, the growth step, and the JS library. - Prefix Rust exports to avoid collisions with libc and Emscripten symbols. [Dependency pins and patch maintenance](dependencies.md) diff --git a/docs/dependencies.md b/docs/dependencies.md index 95fe69f..3d52912 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -1,21 +1,22 @@ # Dependency sources and pins -A clean checkout is self-contained after `bash scripts/setup.sh`. Dependency sources -and toolchains are ignored under `.work/`. Both Cargo and npm dependency graphs are -locked. +A clean checkout is self-contained after `bash scripts/setup.sh`. The patched +Pumpkin checkout and the worker-build CLI live under `.work/` (ignored); +worker-build provisions its own Emscripten and wasm-bindgen CLI under +`~/.cache/worker-build`. Both Cargo and npm dependency graphs are locked. -## Direct checkouts +## Pinned sources -| Component | Source / branch | Commit | Local changes or purpose | +| Component | Source / ref | Commit or tag | Local changes or purpose | | --- | --- | --- | --- | -| pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Headless embedding, shared async scheduler, restartable stop signal, compact templates/generation chunks, and build-script fixes | -| tokio | [emscripten-event-loop](https://github.com/guybedford/tokio) | `5315798b6a62a47d03dc40e7d04fbf83c80f3246` | `EventLoopRuntime` (tokio-rs/tokio#8479) and `net` over epoll on Emscripten; unmodified | -| emscripten | [cf-final](https://github.com/guybedford/emscripten) | `4e034c65a18c0034c68e7eb628477fe4428fa1f9` | Upstream main plus emscripten-core/emscripten#27547 (epoll listeners on the host loop), #27698, #27699, and pending fixes: side-module accept, exnref ordering, `fs.constants` in NODEFS; unmodified | -| binaryen | [jspi-hooks](https://github.com/guybedford/binaryen) | `d6483a04d7dab0ef83e5c43f87343061d97a3b8d` | The frontend pin expects this Binaryen; built by setup | -| emsdk | [main](https://github.com/emscripten-core/emsdk) | `5eb0bde7585670252e8ba05e9d361627bffd08b5` | LLVM from emscripten-releases build `e8579ea489b44a6792f5abf95377a6ee38a16cce`, paired with the frontend's main | +| pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Checkout under `.work/pumpkin`: headless embedding, shared async scheduler, restartable stop signal, compact templates/generation chunks, and build-script fixes | +| workers-rs | [gbedford/worker-build-emscripten](https://github.com/cloudflare/workers-rs/pull/1061) | `490b6baa6e45d98a4c6f40565e9eb7d214ee2a93` | cloudflare/workers-rs#1061: the `worker` crate (`experimental_tokio` feature, a git dependency in Cargo.toml) and `worker-build --emscripten` (installed by setup from the same revision); unmodified | +| tokio | [guybedford/tokio](https://github.com/guybedford/tokio) | tag `1.53.1-cf.emscripten` | `LocalEventLoop` (tokio-rs/tokio#8484) and `net` over epoll on Emscripten; a `[patch.crates-io]` git dependency; unmodified | +| emscripten | [guybedford/emscripten](https://github.com/guybedford/emscripten) | tag `6.0.10-cf.emscripten` | The 6.0.10 release plus emscripten-core/emscripten#27547 (epoll listeners on the host loop) and #27742 (async DNS lookup). worker-build installs emsdk 6.0.10 and applies these as its bundled patches | -Patches are relative to the pinned commits above. Setup checks reverse application -before applying a patch and refuses to repin a modified checkout. +The Pumpkin patches are relative to the pinned commit above. Setup checks +reverse application before applying a patch and refuses to repin a modified +checkout. Pumpkin has two patches: `pumpkin-emscripten.patch` for embedding/runtime support, followed by `pumpkin-memory.patch` for compact templates and generation chunks. @@ -26,14 +27,14 @@ commit. | Crate | Source | Purpose | | --- | --- | --- | -| mio | https://github.com/guybedford/mio `a62c9e46833fc255c9217ab9aa362c6221ed4401` | Emscripten epoll selector (tokio-rs/mio#1969) | +| mio | https://github.com/guybedford/mio tag `1.2.3-cf.emscripten` | Emscripten epoll selector (tokio-rs/mio#1969) | | libc | https://github.com/rust-lang/libc branch `libc-0.2` | Emscripten epoll bindings, unreleased | | ring | https://github.com/guybedford/ring branch `emscripten` | getrandom-backed `SystemRandom` on Emscripten | -The root Cargo.toml applies these overrides and the local checkouts. Cargo.lock -pins the full application graph, including the branch commits. The Worker uses -`wasm-bindgen`, `js-sys`, and `web-sys` from crates.io directly; there is no -`worker` crate dependency. +The root Cargo.toml applies these overrides and the local checkouts; the +wasm-bindgen crates (0.2.129, with `#[wasm_bindgen(experimental_tokio)]`) and +wasm-streams 0.7 come from crates.io. Cargo.lock pins the full application +graph, including the branch commits. ## Host tools @@ -41,20 +42,21 @@ pins the full application graph, including the branch commits. The Worker uses `wasm32-unknown-emscripten`; setup installs both through rustup. rustc needs a larger compile-thread stack for pumpkin-data's generated tables; the scripts set `RUST_MIN_STACK`. -- wasm-bindgen CLI: the release matching the `wasm-bindgen` crate version in - Cargo.toml, installed by setup into `.work/bin/`. emcc runs it as a post-link - step under `-sWASM_BINDGEN`. +- worker-build: installed by setup with `cargo install --git` at the workers-rs + revision Cargo.toml pins, into `.work/bin/`. It provisions emsdk 6.0.10 with + its bundled Emscripten patches and the matching wasm-bindgen CLI under + `~/.cache/worker-build`, drives cargo and emcc with the common link settings, + wraps the exports into the entrypoint and Durable Object classes, and emits + `build/`. `EMSCRIPTEN`/`EMSDK` select a local toolchain instead. - Node: 24+; 26 recommended and selected in CI. Used for build tools and tests. -- Python: 3.11+ (Emscripten scripts and TOML parsing). -- Emscripten backend: LLVM through emsdk under `.work/emsdk` (or an activated - emsdk selected with `EMSDK` during setup), and Binaryen built from the checkout - above with CMake and Ninja. Setup writes the frontend's `.emscripten_cf` config - pointing at both, and `scripts/common.sh` selects it with `EM_CONFIG`. -- workerd: Wrangler must run a workerd with per-Durable-Object port tables, - `net.Server` inbound routing (`handleAsNodeConnection`), and the `node:fs` - fixes for positional buffer I/O, `O_TRUNC`, `O_CREAT`, and rename over an - existing path. Until those ship in Wrangler's bundled version, set - `MINIFLARE_WORKERD_PATH`. +- Python: 3.11+ (Emscripten scripts, emsdk and TOML parsing). +- workerd: Wrangler must run a workerd with `net.Server` inbound routing into + Durable Objects (`handleAsNodeConnection`, cloudflare/workerd#7306, #7313) and + the `node:fs` fixes for positional buffer I/O (#7368), `O_TRUNC` (#7369), + `O_CREAT` (#7393), and rename over an existing path (#7394). All are in + workerd main; until Wrangler's bundled version catches up, build main + (`bazel build //src/workerd/server:workerd`) and set `MINIFLARE_WORKERD_PATH` + to `bazel-bin/src/workerd/server/workerd`. ## Updating a patch @@ -94,9 +96,9 @@ bash scripts/setup.sh --sources-only ## JavaScript dependencies -`package-lock.json` pins Wrangler 4.129.0. The only application JavaScript is -`worker/index.mjs`, which re-exports the generated module and derives the -Durable Object class from `DurableObject` for RPC. +`package-lock.json` pins Wrangler 4.129.0. There is no application JavaScript; +worker-build generates `build/index.js`, which wraps the exports into the +entrypoint and derives the Durable Object class from `DurableObject` for RPC. After moving a checkout with cached build output, run `cargo clean` before rebuilding. Generated data can contain absolute paths. This leaves databases under `.data/` intact. diff --git a/docs/development.md b/docs/development.md index 0bcd4c8..090c4ec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,13 +1,17 @@ # Development Follow the [README setup instructions](../README.md#try-it) to install the pinned -Rust toolchain, provision dependency sources and Emscripten, and install the -wasm-bindgen CLI. +Rust toolchain, provision the patched Pumpkin checkout and install worker-build. +On macOS the build runs wasm-opt single-threaded (`BINARYEN_CORES=1`, main +thread at the hard stack limit): Binaryen's worker threads overflow their stack +on this module and crash with SIGBUS. + The initial Pumpkin compilation takes several minutes. Later builds use Cargo's -cache. `.cargo/config.toml` owns the link settings; `build.rs` adds the JS library -(`src/workerd.js`) and tracks changes to it. The build output Wrangler serves is -`target/workers/wasm32-unknown-emscripten/release/pumpkin-do.js`, re-exported by -`worker/index.mjs`. +cache. worker-build owns the common codegen and link settings; `build.rs` adds +the application's own and the JS library (`src/workerd.js`), tracking changes to +it. `.cargo/config.toml` repeats the codegen cfgs so `cargo check` sees them. +Wrangler runs `worker-build --emscripten --release` as its build command +and serves `build/index.js`. ```sh npm run build # Compile the production Worker diff --git a/patches/pumpkin-emscripten.patch b/patches/pumpkin-emscripten.patch index b516429..7ddb092 100644 --- a/patches/pumpkin-emscripten.patch +++ b/patches/pumpkin-emscripten.patch @@ -6,9 +6,10 @@ plugin loading, the console, and system crash reporting. Native builds retain these subsystems and thread-backed execution by default. Share asynchronous ticker and chunk-scheduler loops between native and -single-threaded builds. Route Rayon and blocking jobs through helpers that -can run on the current thread, await scheduler notifications and results, -and track cooperative tasks through shutdown. Drop the Tokio features that +single-threaded builds. Run Rayon, chunk-generation and blocking jobs as +Tokio tasks on single-threaded builds so each takes its own turn of the +event loop, await scheduler notifications and results, and track +cooperative tasks through shutdown. Drop the Tokio features that need OS threads and signals from single-threaded builds and guard unsupported Emscripten system calls and synchronous HTTP lookups. @@ -70,25 +71,23 @@ index f4fb121f..0a4a3076 100644 [[bench]] diff --git a/crates/pumpkin-util/src/lib.rs b/crates/pumpkin-util/src/lib.rs -index 5119797b..a1ae947a 100644 +index 5119797b..35d78184 100644 --- a/crates/pumpkin-util/src/lib.rs +++ b/crates/pumpkin-util/src/lib.rs -@@ -310,3 +310,42 @@ impl TryFrom for Hand { +@@ -310,3 +310,39 @@ impl TryFrom for Hand { } } } + +/// Spawn a fire-and-forget CPU task. +/// -+/// On normal targets this hands the closure to rayon's global thread pool. On -+/// `wasm32-unknown-emscripten` there are no OS threads and rayon has no worker to -+/// run an injected job (a bare `rayon::spawn` would never execute, so any code that -+/// then `.await`s the result over a channel would deadlock). There we run the -+/// closure inline on the calling thread instead. ++/// On normal targets this hands the closure to rayon's global thread pool. ++/// Single-threaded builds have no rayon workers (a bare `rayon::spawn` would never ++/// run), so the closure becomes a Tokio task and runs on a later turn of the loop. +pub fn rayon_spawn(f: F) { + #[cfg(feature = "single-threaded")] + { -+ f(); ++ tokio::spawn(async move { f() }); + } + #[cfg(not(feature = "single-threaded"))] + { @@ -101,7 +100,6 @@ index 5119797b..a1ae947a 100644 +/// Single-threaded builds run the closure in a regular Tokio task, preserving +/// panic reporting through `JoinError` without creating a blocking-pool thread. +/// The closure itself must finish without waiting for another task on this thread. -+/// JSPI does not make the public Tokio `spawn_blocking` API threadless. +pub fn spawn_blocking(f: F) -> tokio::task::JoinHandle +where + F: FnOnce() -> R + Send + 'static, @@ -297,7 +295,7 @@ index f670ad37..b9d9a867 100644 )>; diff --git a/crates/pumpkin-world/src/chunk_system/schedule.rs b/crates/pumpkin-world/src/chunk_system/schedule.rs -index 145e15de..2942a1d3 100644 +index 145e15de..0d388726 100644 --- a/crates/pumpkin-world/src/chunk_system/schedule.rs +++ b/crates/pumpkin-world/src/chunk_system/schedule.rs @@ -69,13 +69,14 @@ pub struct GenerationSchedule { @@ -568,7 +566,7 @@ index 145e15de..2942a1d3 100644 break 'out2; } -@@ -1552,12 +1567,19 @@ impl GenerationSchedule { +@@ -1552,12 +1567,16 @@ impl GenerationSchedule { let send_chunk = self.send_chunk.clone(); let level = level.clone(); @@ -583,14 +581,11 @@ index 145e15de..2942a1d3 100644 + #[cfg(not(feature = "single-threaded"))] + self.generation_pool.spawn(generate); + #[cfg(feature = "single-threaded")] -+ { -+ generate(); -+ tokio::task::yield_now().await; -+ } ++ tokio::spawn(async move { generate() }); } } } -@@ -1566,82 +1588,39 @@ impl GenerationSchedule { +@@ -1566,82 +1585,39 @@ impl GenerationSchedule { if !io_batch.is_empty() && self .io_read @@ -659,11 +654,11 @@ index 145e15de..2942a1d3 100644 - if self.resort_work(self.send_level.wait_and_get(level)) { - self.garbage_collect_dependencies(); - } - } +- } - if self.queue_dirty { - self.sort_queue(); - self.queue_dirty = false; -- } + } - } else if self.running_task_count >= self.max_in_flight { - // Queue has tasks, but we are at maximum in-flight capacity. - // Wait for an in-flight worker to finish instead of busy-spinning. @@ -678,12 +673,12 @@ index 145e15de..2942a1d3 100644 - if self.resort_work(self.send_level.get()) { - self.garbage_collect_dependencies(); - } -- } + } - Err(crossbeam::channel::RecvTimeoutError::Timeout) => { - if self.resort_work(self.send_level.get()) { - self.garbage_collect_dependencies(); - } - } +- } - Err(crossbeam::channel::RecvTimeoutError::Disconnected) => break, + () = self.send_level.changed() => {} + () = tokio::time::sleep(Duration::from_millis(50)) => {} @@ -696,7 +691,7 @@ index 145e15de..2942a1d3 100644 } } } -@@ -1649,24 +1628,10 @@ impl GenerationSchedule { +@@ -1649,24 +1625,10 @@ impl GenerationSchedule { "schedule: waiting for {} generation tasks to finish", self.running_task_count ); @@ -725,7 +720,7 @@ index 145e15de..2942a1d3 100644 if self.running_task_count > 0 { warn!( -@@ -1716,6 +1681,15 @@ impl GenerationSchedule { +@@ -1716,6 +1678,15 @@ impl GenerationSchedule { self.graph.edges.clear(); } @@ -741,7 +736,7 @@ index 145e15de..2942a1d3 100644 fn debug_check(&self) -> bool { if !self.graph.nodes.is_empty() { for (key, value) in &self.graph.nodes { -@@ -1746,3 +1720,123 @@ impl GenerationSchedule { +@@ -1746,3 +1717,123 @@ impl GenerationSchedule { true } } diff --git a/scripts/build.sh b/scripts/build.sh index 52c7d3f..57b716e 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -4,4 +4,10 @@ source "$(dirname "${BASH_SOURCE[0]}")/common.sh" require_node require_toolchain cd "$REPO" -cargo build --locked --release --bin pumpkin-do +if [ "$(uname -s)" = Darwin ]; then + # Binaryen's worker threads overflow their stack optimizing this module on + # macOS; run wasm-opt on the main thread with the largest stack allowed. + ulimit -s hard + export BINARYEN_CORES=1 +fi +worker-build --emscripten --release -- --locked diff --git a/scripts/common.sh b/scripts/common.sh index 8a7f652..b976991 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -4,10 +4,9 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$REPO/.work" NODE="${NODE:-node}" -# Setup provisions the frontend checkout, compiler backend config, and the -# wasm-bindgen CLI under .work/; WASM_BINDGEN_BIN may point at another CLI directory. -EMSCRIPTEN="$WORK/emscripten" -WASM_BINDGEN_BIN="${WASM_BINDGEN_BIN:-$WORK/bin}" +# Setup provisions the patched Pumpkin checkout and the worker-build CLI under +# .work/; worker-build provisions Emscripten and the wasm-bindgen CLI itself. +BIN="$WORK/bin" require_node() { local major @@ -19,19 +18,12 @@ require_node() { } require_toolchain() { - if [ ! -f "$EMSCRIPTEN/.emscripten_cf" ] || - [ ! -x "$EMSCRIPTEN/emcc" ] || - [ ! -x "$WASM_BINDGEN_BIN/wasm-bindgen" ] || - [ ! -f "$WORK/pumpkin/crates/pumpkin/Cargo.toml" ] || - [ ! -f "$WORK/tokio/tokio/Cargo.toml" ]; then + if [ ! -x "$BIN/worker-build" ] || + [ ! -f "$WORK/pumpkin/crates/pumpkin/Cargo.toml" ]; then echo "error: run bash scripts/setup.sh first." >&2 exit 1 fi - export EM_CONFIG="$EMSCRIPTEN/.emscripten_cf" - export PATH="$EMSCRIPTEN:$WASM_BINDGEN_BIN:$PATH" - export CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER="$EMSCRIPTEN/emcc" - # Uniform exnref exception handling for C and Rust objects (see .cargo/config.toml). - export EMCC_CFLAGS="${EMCC_CFLAGS:-} -fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=0" + export PATH="$BIN:$PATH" export CARGO_TARGET_DIR="$REPO/target/workers" # rustc's LLVM const emission recurses deeply on pumpkin-data's generated tables # and overflows its default 8 MiB compile-thread stack on current toolchains. diff --git a/scripts/setup.sh b/scripts/setup.sh index 42a158a..edd8a3a 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -20,7 +20,8 @@ checkout() { # name url branch commit git clone --filter=blob:none --branch "$branch" --single-branch "$url" "$dir" fi if [ "$(git -C "$dir" rev-parse HEAD)" != "$commit" ]; then - if [ -n "$(git -C "$dir" status --porcelain)" ]; then + # Submodule pointers move with the pin and are not local changes. + if [ -n "$(git -C "$dir" status --porcelain --ignore-submodules=all)" ]; then echo "error: refusing to repin modified checkout $dir; preserve your changes first." >&2 exit 1 fi @@ -32,6 +33,29 @@ checkout() { # name url branch commit echo " $name @ $commit" } +checkout_tag() { # name url tag + local name="$1" url="$2" tag="$3" dir="$WORK/$1" + if [ ! -d "$dir/.git" ] && [ ! -f "$dir/.git" ]; then + if [ -e "$dir" ]; then + echo "error: $dir exists but is not a Git checkout." >&2 + exit 1 + fi + git clone --filter=blob:none --branch "$tag" --single-branch "$url" "$dir" + fi + if ! git -C "$dir" cat-file -e "refs/tags/$tag^{commit}" 2>/dev/null; then + git -C "$dir" fetch --filter=blob:none origin "tag" "$tag" --no-tags + fi + if [ "$(git -C "$dir" rev-parse HEAD)" != "$(git -C "$dir" rev-parse "refs/tags/$tag^{commit}")" ]; then + # Submodule pointers move with the pin and are not local changes. + if [ -n "$(git -C "$dir" status --porcelain --ignore-submodules=all)" ]; then + echo "error: refusing to repin modified checkout $dir; preserve your changes first." >&2 + exit 1 + fi + git -C "$dir" switch --detach "refs/tags/$tag" + fi + echo " $name @ $tag ($(git -C "$dir" rev-parse --short HEAD))" +} + apply_patch() { local dir="$WORK/$1" patch="$REPO/patches/$2" if git -C "$dir" apply --reverse --check "$patch" 2>/dev/null; then @@ -44,7 +68,6 @@ apply_patch() { echo "==> Pinned dependencies" checkout pumpkin https://github.com/Pumpkin-MC/Pumpkin master b5b9b9d7010e793806a83c495af223c67e1d35ee -checkout tokio https://github.com/guybedford/tokio emscripten-event-loop 5315798b6a62a47d03dc40e7d04fbf83c80f3246 apply_patch pumpkin pumpkin-emscripten.patch apply_patch pumpkin pumpkin-memory.patch @@ -54,49 +77,15 @@ echo "==> Rust toolchain" RUST_CHANNEL="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["toolchain"]["channel"])' "$REPO/rust-toolchain.toml")" rustup toolchain install "$RUST_CHANNEL" --profile minimal --target wasm32-unknown-emscripten --no-self-update -echo "==> wasm-bindgen CLI" -WASM_BINDGEN_VERSION="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["dependencies"]["wasm-bindgen"])' "$REPO/Cargo.toml")" -if [ "$("$WASM_BINDGEN_BIN/wasm-bindgen" --version 2>/dev/null || true)" != "wasm-bindgen $WASM_BINDGEN_VERSION" ]; then - cargo "+$RUST_CHANNEL" install --force --locked wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --root "$(dirname "$WASM_BINDGEN_BIN")" +echo "==> worker-build" +# cloudflare/workers-rs#1061, the same revision Cargo.toml takes the `worker` +# crate from. Built for the host, overriding the wasm target .cargo/config.toml sets. +WORKERS_RS_REV="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["dependencies"]["worker"]["rev"])' "$REPO/Cargo.toml")" +if [ "$(cat "$BIN/.worker-build-rev" 2>/dev/null)" != "$WORKERS_RS_REV" ]; then + HOST="$(rustc "+$RUST_CHANNEL" -vV | sed -n 's/^host: //p')" + cargo "+$RUST_CHANNEL" install --force --locked --target "$HOST" \ + --git https://github.com/cloudflare/workers-rs --rev "$WORKERS_RS_REV" worker-build --root "$WORK" + echo "$WORKERS_RS_REV" > "$BIN/.worker-build-rev" fi -echo "==> Emscripten" -# Frontend: upstream main plus the pending JSPI hooks, reentrant JSPI, and epoll -# listener PRs. LLVM comes from the emscripten-releases build paired with that -# main, and Binaryen from the branch carrying the jspi-hooks pass the frontend -# needs; setup builds it. EMSDK selects an activated emsdk for LLVM instead. -EMSDK_RELEASE=e8579ea489b44a6792f5abf95377a6ee38a16cce -checkout emscripten https://github.com/guybedford/emscripten cf-final 4e034c65a18c0034c68e7eb628477fe4428fa1f9 -NODE_PATH="$("$NODE" -p 'process.execPath')" -(cd "$EMSCRIPTEN" && PATH="$(dirname "$NODE_PATH"):$PATH" npm ci --no-audit --no-fund && python3 bootstrap.py) -if [ -n "${EMSDK:-}" ]; then - LLVM_ROOT="$EMSDK/upstream/bin" -else - checkout emsdk https://github.com/emscripten-core/emsdk main 5eb0bde7585670252e8ba05e9d361627bffd08b5 - if [ "$(cat "$WORK/emsdk/upstream/.emsdk_version" 2>/dev/null)" != "releases-$EMSDK_RELEASE-64bit" ]; then - (cd "$WORK/emsdk" && ./emsdk install "$EMSDK_RELEASE" && ./emsdk activate "$EMSDK_RELEASE") - fi - LLVM_ROOT="$WORK/emsdk/upstream/bin" -fi -checkout binaryen https://github.com/guybedford/binaryen jspi-hooks d6483a04d7dab0ef83e5c43f87343061d97a3b8d -BINARYEN_ROOT="$WORK/binaryen/build" -if [ ! -x "$BINARYEN_ROOT/bin/wasm-opt" ] || [ "$(cat "$BINARYEN_ROOT/.pinned" 2>/dev/null)" != "$(git -C "$WORK/binaryen" rev-parse HEAD)" ]; then - cmake -S "$WORK/binaryen" -B "$BINARYEN_ROOT" -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF >/dev/null - cmake --build "$BINARYEN_ROOT" --target wasm-opt wasm-metadce wasm-emscripten-finalize wasm-split wasm-as wasm-dis wasm2js wasm-merge >/dev/null - git -C "$WORK/binaryen" rev-parse HEAD > "$BINARYEN_ROOT/.pinned" -fi -if [ ! -x "$LLVM_ROOT/clang" ] || [ ! -x "$BINARYEN_ROOT/bin/wasm-opt" ]; then - echo "error: no Emscripten backend at LLVM_ROOT=$LLVM_ROOT BINARYEN_ROOT=$BINARYEN_ROOT." >&2 - exit 1 -fi -python3 - "$EMSCRIPTEN/.emscripten_cf" "$LLVM_ROOT" "$BINARYEN_ROOT" "$NODE_PATH" <<'PY' -from pathlib import Path -import sys -config, llvm, binaryen, node = sys.argv[1:] -Path(config).write_text( - f"LLVM_ROOT={llvm!r}\n" - f"BINARYEN_ROOT={binaryen!r}\n" - f"NODE_JS={node!r}\n" -) -PY echo "Setup complete. Run: bash scripts/test.sh or bash scripts/serve.sh" diff --git a/src/main.rs b/src/main.rs index a4114cd..9460aeb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,18 +6,20 @@ mod world; use host::{method, property, then}; use wasm_bindgen::prelude::*; +use worker::{event, Context, Env, Request, Response}; fn main() {} -fn stub(env: &JsValue) -> Result { - let name = property(env, "WORLD_NAME")?; - let namespace = property(env, "WORLD")?; - method(&namespace, "getByName", &[&name]) +/// The world's Durable Object stub. +fn stub(env: &Env) -> worker::Result { + let name = env.var("WORLD_NAME")?.to_string(); + Ok(env.durable_object("WORLD")?.get_by_name(&name)?.into_rpc()) } -/// Pipes an inbound TCP connection to the world's object until either side closes. +/// Pipes an inbound TCP connection to the world's object until either side +/// closes. worker-build exports it as the entrypoint's `connect` handler. #[wasm_bindgen] -pub fn connect(socket: JsValue, env: JsValue, _ctx: JsValue) -> Result { +pub fn connect(socket: JsValue, env: Env, _ctx: JsValue) -> Result { let target = host::stub_connect(&stub(&env)?, &world::authority())?; // Read/write failures are observed by the pumps; a normal peer disconnect // must not surface as an unhandled rejection of the lifetime promise. @@ -63,30 +65,22 @@ pub fn connect(socket: JsValue, env: JsValue, _ctx: JsValue) -> Result Result { - let init = web_sys::ResponseInit::new(); - init.set_status(status); - let headers = web_sys::Headers::new()?; - headers.set("content-type", "application/json")?; - init.set_headers(&headers); - let body = js_sys::JSON::stringify(value)?; - Ok(web_sys::Response::new_with_opt_str_and_init(body.as_string().as_deref(), &init)?.into()) -} - -#[wasm_bindgen] -pub fn fetch(request: web_sys::Request, env: JsValue, _ctx: JsValue) -> Result { - let url = web_sys::Url::new(&request.url())?; - match url.pathname().as_str() { - "/health" => { - let body = js_sys::Object::new(); - js_sys::Reflect::set(&body, &"ready".into(), &JsValue::TRUE)?; - json_response(&body, 200) - } - "/" if request.method() == "GET" => { +#[event(fetch)] +async fn fetch(request: Request, env: Env, _ctx: Context) -> worker::Result { + match request.path().as_str() { + "/health" => Response::ok(r#"{"ready":true}"#).map(json), + "/" if request.method() == worker::Method::Get => { let status = method(&stub(&env)?, "status", &[])?; - let respond = Closure::once_into_js(|value: JsValue| json_response(&value, 200)); - then(&status, Some(&respond), None) + let status = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(status)).await?; + let body = js_sys::JSON::stringify(&status)?; + Response::ok(String::from(body)).map(json) } - _ => json_response(&"Not found".into(), 404), + _ => Response::error("Not found", 404), } } + +fn json(response: Response) -> Response { + let headers = worker::Headers::new(); + let _ = headers.set("content-type", "application/json"); + response.with_headers(headers) +} diff --git a/src/world.rs b/src/world.rs index 6215c00..c7cb1f0 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,11 +1,11 @@ //! The Durable Object hosting one Pumpkin server. //! -//! The object owns an `EventLoopRuntime`: Tokio's current-thread scheduler and -//! drivers with the host event loop as its wait. Work is scheduled as roots -//! that complete by callback, so every export returns a Promise and nothing -//! blocks or suspends. The server lifetime is one such root: the first -//! `connect` after idle schedules it, and its completion (after the final save) -//! is the checkpoint. Later connections are routed to the running listener. +//! `connect` is a `#[wasm_bindgen(experimental_tokio)]` export: its future runs on the +//! thread's Tokio event loop, whose wait is the host event loop, so nothing +//! blocks or suspends and the export returns a Promise. The server lifetime is +//! one such future: the first `connect` after idle runs it, and its completion +//! (after the final save) is the checkpoint. Later connections are routed to +//! the running listener. use crate::{config, host, memory, persist}; use host::{js_error, method, property, then}; @@ -15,8 +15,8 @@ use std::{ rc::Rc, sync::{atomic::Ordering, Arc}, }; -use tokio::runtime::EventLoopRuntime; use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; pub const PORT: u16 = 25565; @@ -48,11 +48,10 @@ thread_local! { static LOGGER: Cell = const { Cell::new(false) }; } -/// State shared between the exports and the scheduled roots. +/// State shared between the exports and the server future. struct Shared { /// `ctx.storage` storage: JsValue, - runtime: EventLoopRuntime, phase: Cell, failure: RefCell>, connections: Cell, @@ -66,7 +65,7 @@ struct Shared { } // Closures run under catch_unwind; the object is only ever touched from this -// thread through the runtime's calls, so a poisoned borrow cannot be observed. +// thread, so a poisoned borrow cannot be observed. impl std::panic::RefUnwindSafe for Shared {} #[wasm_bindgen] @@ -86,14 +85,9 @@ impl MinecraftWorld { })); let storage = property(&state, "storage")?; persist::prepare(&storage)?; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build_event_loop_runtime() - .map_err(js_error)?; Ok(MinecraftWorld { shared: Rc::new(Shared { storage, - runtime, phase: Cell::new(Phase::Idle), failure: RefCell::new(None), connections: Cell::new(0), @@ -107,8 +101,10 @@ impl MinecraftWorld { /// Serves one inbound connection; resolves when it closes. The first call /// while idle also runs the server, resolving once the world is checkpointed. - pub fn connect(&self, socket: JsValue) -> Result { - self.shared.connect(socket) + #[wasm_bindgen(experimental_tokio)] + pub async fn connect(&self, socket: JsValue) -> Result { + let shared = self.shared.clone(); + shared.connect(socket).await } pub fn status(&self) -> Result { @@ -139,23 +135,31 @@ impl MinecraftWorld { } impl Shared { - fn connect(self: &Rc, socket: JsValue) -> Result { - match self.phase.get() { - Phase::Running => return self.route(socket), - Phase::Failed => { - return Err(js_error(self.failure.borrow().as_deref().unwrap_or("failed"))) - } - Phase::Starting | Phase::Stopping => { - let shared = self.clone(); - let retry = Closure::once_into_js(move |_: JsValue| shared.connect(socket)); - return then(&self.transition()?.into(), Some(&retry), None); + async fn connect(self: Rc, socket: JsValue) -> Result { + loop { + match self.phase.get() { + Phase::Running => return self.route(socket), + Phase::Failed => { + return Err(js_error(self.failure.borrow().as_deref().unwrap_or("failed"))) + } + Phase::Starting | Phase::Stopping => { + JsFuture::from(self.transition()?).await?; + } + Phase::Idle => break, } - Phase::Idle => {} } self.set_phase(Phase::Starting); let started = js_sys::Date::now(); - let root = self.clone(); - self.promise(async move { root.run(socket, started).await }) + // A task of its own so a panic arrives as a `JoinError` and fails the + // object rather than escaping the export. + let outcome = match tokio::task::spawn_local(self.clone().run(socket, started)).await { + Ok(outcome) => outcome, + Err(panic) => Err(js_error(format!("server task failed: {panic}"))), + }; + if let Err(error) = &outcome { + self.fail(error); + } + outcome } /// The promise of the next phase change. @@ -177,32 +181,6 @@ impl Shared { } } - /// Schedules `future` as a root and returns the Promise of its outcome. - fn promise( - self: &Rc, - future: impl std::future::Future> + 'static, - ) -> Result { - let resolvers = with_resolvers()?; - let promise = property(&resolvers, "promise")?; - let resolve: js_sys::Function = property(&resolvers, "resolve")?.unchecked_into(); - let reject: js_sys::Function = property(&resolvers, "reject")?.unchecked_into(); - let shared = self.clone(); - self.runtime.schedule(future, move |outcome| { - let outcome = - outcome.unwrap_or_else(|panic| Err(js_error(format!("server task failed: {panic}")))); - match outcome { - Ok(value) => { - let _ = resolve.call1(&JsValue::UNDEFINED, &value); - } - Err(error) => { - shared.fail(&error); - let _ = reject.call1(&JsValue::UNDEFINED, &error); - } - } - }); - Ok(promise) - } - fn fail(&self, error: &JsValue) { if self.phase.get() != Phase::Failed { self.set_phase(Phase::Failed); diff --git a/worker/index.mjs b/worker/index.mjs deleted file mode 100644 index 0bd31de..0000000 --- a/worker/index.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { DurableObject } from 'cloudflare:workers'; -import { connect, fetch, MinecraftWorld as World } from '../target/workers/wasm32-unknown-emscripten/release/pumpkin-do.js'; - -export default { connect, fetch }; - -// The runtime enables RPC on a Durable Object class only when it derives from -// DurableObject; the wasm-bindgen class is plain, so wrap it. -export class MinecraftWorld extends DurableObject { - #world; - constructor(state, env) { - super(state, env); - this.#world = new World(state, env); - } - connect(socket) { return this.#world.connect(socket); } - status() { return this.#world.status(); } -} diff --git a/wrangler.jsonc b/wrangler.jsonc index 3bf5f27..507a959 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,8 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "rust-workers-minecraft", - "main": "worker/index.mjs", + "main": "build/index.js", + "build": { "command": "bash scripts/build.sh" }, "compatibility_date": "2026-09-05", "compatibility_flags": ["nodejs_compat", "new_module_registry", "no_handle_cross_request_promise_resolution"], "dev": { "ip": "127.0.0.1", "port": 8787 }, From cb0dae91d1f5324f7962c547a70a6596b9b1d0ac Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 23:17:42 -0700 Subject: [PATCH 05/12] Mount the world's SQLite filesystem and write stdio through NODERAWFS The world is again a live SQLite-backed filesystem instead of a tree copied to and from storage at start and stop: the object constructor mounts durable-object-fs at /data through worker-fs-mount, and src/workerd.js rebinds Emscripten's NODERAWFS to that node:fs implementation, so every Pumpkin write is durable as it happens. Relative paths are resolved against Emscripten's own working directory, which is the object's rather than the isolate's. The restore/checkpoint code and the files table go away; status reports the last save time. The stdio override is removed: workerd's node:fs supports the process's stdout and stderr descriptors, and Wrangler relays them as plain stdout and stderr lines rather than console errors. Wrangler's build command runs scripts/build.sh so it resolves the provisioned worker-build regardless of PATH. --- AGENTS.md | 5 ++- README.md | 9 +++-- build.rs | 2 +- docs/architecture.md | 33 ++++++++------- docs/dependencies.md | 8 ++-- docs/development.md | 2 +- package-lock.json | 73 ++++++++++++--------------------- package.json | 10 ++++- src/host.rs | 25 +++++------- src/js/mount.js | 13 ++++++ src/main.rs | 1 - src/persist.rs | 93 ------------------------------------------- src/workerd.js | 64 ++++++++++++++--------------- src/world.rs | 33 ++++++++------- tests/integration.mjs | 2 +- tests/server.mjs | 2 +- 16 files changed, 140 insertions(+), 235 deletions(-) create mode 100644 src/js/mount.js delete mode 100644 src/persist.rs diff --git a/AGENTS.md b/AGENTS.md index 95d7336..21bbc74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,9 @@ Read [README.md](README.md), [architecture](docs/architecture.md), and ## Project layout -- `src/`: the Worker: TCP ingress, the Durable Object, world persistence, - Pumpkin configuration, and the Emscripten JS library (`workerd.js`). +- `src/`: the Worker: TCP ingress, the Durable Object, the SQLite filesystem + mount (`js/mount.js`), Pumpkin configuration, and the Emscripten JS library + (`workerd.js`). - `tests/`: protocol clients and the integration test. - `scripts/setup.sh`: provision pinned sources and build the toolchain. - `scripts/{build,serve,test}.sh`: build, run, and validate the Workers server. diff --git a/README.md b/README.md index ede9f3d..1555051 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,11 @@ The example uses offline mode, with encryption and compression disabled. Local listeners bind to loopback. `WORLD_NAME` in [wrangler.jsonc](wrangler.jsonc) selects the Durable Object; server settings are in [src/config.rs](src/config.rs). -World data lives under `.data/workers/server/`. After the last connection closes, -the server saves and stops. The next connection restores the same world. Wait for -status to report `phase: "idle"` and a checkpoint timestamp before stopping -Wrangler; changes held in memory can be lost if the process is terminated early. +World data lives under `.data/workers/server/`, written through to the Durable +Object's SQLite storage as Pumpkin saves. After the last connection closes, the +server saves and stops; the next connection starts it on the same world. Wait for +status to report `phase: "idle"` before stopping Wrangler so the final save +completes. ## Build and contribute diff --git a/build.rs b/build.rs index 3239053..a6139d6 100644 --- a/build.rs +++ b/build.rs @@ -12,7 +12,7 @@ fn main() { "-sSTACK_SIZE=8MB", // Keep growth headroom bounded after generation's temporary allocations. "-sMEMORY_GROWTH_LINEAR_STEP=2097152", - "-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=[\"$workerdStdio\"]", + "-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=[\"$workerdFs\"]", "--js-library", ] { println!("cargo::rustc-link-arg-bins={arg}"); diff --git a/docs/architecture.md b/docs/architecture.md index 40d033f..1a8413c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,9 +13,9 @@ state. Its `connect` is a `#[wasm_bindgen(experimental_tokio)]` export: the futu the thread's Tokio `LocalEventLoop` (tokio-rs/tokio#8484), the current-thread scheduler and drivers with the host event loop as their wait, and the export returns the Promise of its outcome. Nothing blocks or suspends. The first -`connect` after idle runs the server lifetime: it restores the world, starts -Pumpkin, awaits the server's return after its final save, checkpoints, and -settles the connection's promise. Later connections and `status` calls enter +`connect` after idle runs the server lifetime: it starts Pumpkin on the mounted +world, awaits the server's return after its final save, awaits `storage.sync()`, +and settles the connection's promise. Later connections and `status` calls enter the same instance as ordinary calls while that future is parked. A connection arriving while the server is starting or stopping waits for the next phase change and then retries, so a client that connects as the previous server @@ -49,19 +49,22 @@ neighborhood compact without changing its dependency rules. Emscripten grows memory in 2 MiB increments, configured by `build.rs`, while retaining the 8 MiB stack. See [memory measurements](memory-reduction.md). -## Filesystem and checkpoints +## Filesystem -Pumpkin runs against workerd's own Node filesystem through Emscripten's -`NODERAWFS`, with the world under `/tmp/world`. That tree lives for the Durable -Object's lifetime. Between runs it lives in the object's SQLite storage, one row -per file: the server root restores the tree before starting Pumpkin and, after -the server's final save, writes every changed file back, deletes rows for removed -files, and awaits `storage.sync()` before the last connection is reported closed. -Status shows the checkpoint time. Terminating a server with active clients can -lose changes since the previous checkpoint. +The world is a SQLite-backed filesystem: the object constructor mounts +`durable-object-fs`'s `LocalDOFilesystem(storage)` at `/data` through +`worker-fs-mount`, whose `node:fs` implementation routes that prefix to the +object's storage and every other path (stdio, `/tmp`) to workerd's own +filesystem. `src/workerd.js` rebinds Emscripten's `NODERAWFS` to it, resolves +relative paths against Emscripten's working directory (`/data`, distinct from +the isolate's `process.cwd()`), and disables the VFS's own permission checks +since the host enforces them. Every Pumpkin write is therefore durable as it +happens, under the object's transaction semantics; after the final save the +server awaits `storage.sync()` before the last connection is reported closed, +and status shows that time. -Logging goes to the host console: `src/workerd.js` keeps Emscripten's stdio -descriptors on its console callbacks, since workerd exposes no process stdio. +Logging goes to the process's stdout and stderr through `NODERAWFS`, which +Wrangler relays as `stdout:` and `stderr:` lines. ## Development interfaces @@ -69,7 +72,7 @@ The supplied configuration listens on loopback TCP port 25565 and HTTP port 8787 The world name comes from `WORLD_NAME` in `wrangler.jsonc`. - `GET /health`: Worker readiness. -- `GET /`: phase, connection count, runtime statistics, and last checkpoint time. +- `GET /`: phase, connection count, runtime statistics, and last save time. Runtime statistics include Wasm capacity and allocator in-use/free/arena bytes. Allocator counters include allocation metadata and unused container capacity; diff --git a/docs/dependencies.md b/docs/dependencies.md index 3d52912..bd8edb6 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -96,9 +96,11 @@ bash scripts/setup.sh --sources-only ## JavaScript dependencies -`package-lock.json` pins Wrangler 4.129.0. There is no application JavaScript; -worker-build generates `build/index.js`, which wraps the exports into the -entrypoint and derives the Durable Object class from `DurableObject` for RPC. +`package-lock.json` pins Wrangler 4.129.0, `worker-fs-mount` 0.2.0 and +`durable-object-fs` 1.0.0 (the SQLite filesystem mount, imported by +`src/js/mount.js` and bundled by worker-build). worker-build generates +`build/index.js`, which wraps the exports into the entrypoint and derives the +Durable Object class from `DurableObject` for RPC. After moving a checkout with cached build output, run `cargo clean` before rebuilding. Generated data can contain absolute paths. This leaves databases under `.data/` intact. diff --git a/docs/development.md b/docs/development.md index 090c4ec..001a26f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,7 +26,7 @@ stop only their own Wrangler process groups. Their databases and logs live in `npm test` must finish with `PUMPKIN-DO-SQLITE-RESTART-OK`. It checks two-player gameplay, a real block edit, and player/block restoration after a restart, which -exercises the tree-to-SQLite checkpoint and restore. +exercises the SQLite-mounted world across a restart. The memory probe waits for both clients to receive 81 chunks, observes 30 seconds of stationary play, then checkpoints. Each run creates a fresh isolated world diff --git a/package-lock.json b/package-lock.json index 18ad428..a1125fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,10 @@ "packages": { "": { "name": "rust-workers-minecraft", + "dependencies": { + "durable-object-fs": "1.0.0", + "worker-fs-mount": "0.2.0" + }, "devDependencies": { "wrangler": "4.129.0" }, @@ -707,9 +711,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -727,9 +728,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -747,9 +745,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -767,9 +762,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -787,9 +779,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -807,9 +796,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -827,9 +813,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -847,9 +830,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -867,9 +847,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -893,9 +870,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -919,9 +893,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -945,9 +916,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -971,9 +939,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -997,9 +962,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1023,9 +985,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1049,9 +1008,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1272,6 +1228,18 @@ "node": ">=8" } }, + "node_modules/durable-object-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/durable-object-fs/-/durable-object-fs-1.0.0.tgz", + "integrity": "sha512-r4XOu7vlnC3kg8HiFYO3sQweO3eUJLa7q1tZHUy6Oj1ifHR8So4W5oljz4YUD2sIzWuWgEt+ljirqPVj/WhbwA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "worker-fs-mount": "workspace:*" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -1480,6 +1448,15 @@ "pathe": "^2.0.3" } }, + "node_modules/worker-fs-mount": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/worker-fs-mount/-/worker-fs-mount-0.2.0.tgz", + "integrity": "sha512-SHnIcUkBIyojQNtMdpIBDM0/JDD58xgTMcyJMTamU2z9WbG8cuoAWs5SBzyBfCcp+xPMXu8h+Ou3yqC9kUxFdw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/workerd": { "version": "1.20260903.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", diff --git a/package.json b/package.json index c6e507e..f3ede69 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "engines": { "node": ">=24" }, - "dependencies": {}, + "dependencies": { + "durable-object-fs": "1.0.0", + "worker-fs-mount": "0.2.0" + }, "devDependencies": { "wrangler": "4.129.0" }, @@ -15,5 +18,10 @@ "test": "bash scripts/test.sh", "test:memory": "npm run build && node tests/memory.mjs", "test:scheduler": "bash scripts/test-scheduler.sh" + }, + "overrides": { + "durable-object-fs": { + "worker-fs-mount": "$worker-fs-mount" + } } } diff --git a/src/host.rs b/src/host.rs index b674fb3..cbb7de3 100644 --- a/src/host.rs +++ b/src/host.rs @@ -2,6 +2,16 @@ use wasm_bindgen::prelude::*; +#[wasm_bindgen(module = "/src/js/mount.js")] +extern "C" { + /// Mounts the object's SQLite storage as the world's filesystem and routes + /// NODERAWFS through it; `ROOT` is the mount path. + #[wasm_bindgen(js_name = mountStorage)] + pub fn mount_storage(storage: &JsValue); + #[wasm_bindgen(js_name = ROOT, thread_local_v2)] + pub static MOUNT_ROOT: JsValue; +} + #[wasm_bindgen(module = "cloudflare:node")] extern "C" { /// Routes an inbound socket to the `net.Server` listening on its local @@ -50,18 +60,3 @@ pub fn js_error(error: impl std::fmt::Display) -> JsValue { js_sys::Error::new(&error.to_string()).into() } -/// `storage.sql.exec(query, ...bindings)` with the rows read out eagerly as -/// arrays of column values. -pub fn sql(storage: &JsValue, query: &str, bindings: &[JsValue]) -> Result>, JsValue> { - let sql = property(storage, "sql")?; - let exec: js_sys::Function = property(&sql, "exec")?.unchecked_into(); - let args = js_sys::Array::new(); - args.push(&query.into()); - for binding in bindings { - args.push(binding); - } - let cursor = js_sys::Reflect::apply(&exec, &sql, &args)?; - let raw = method(&cursor, "raw", &[])?; - let rows = js_sys::Array::from(&method(&raw, "toArray", &[])?); - Ok(rows.iter().map(|row| js_sys::Array::from(&row).to_vec()).collect()) -} diff --git a/src/js/mount.js b/src/js/mount.js new file mode 100644 index 0000000..60f6c21 --- /dev/null +++ b/src/js/mount.js @@ -0,0 +1,13 @@ +// The world's filesystem: worker-fs-mount's node:fs implementation routes paths +// under a mount to the Durable Object's SQLite storage and everything else to +// workerd's node:fs. Emscripten's NODERAWFS is rebound to it (src/workerd.js). +import { LocalDOFilesystem } from 'durable-object-fs/local'; +import { mount } from 'worker-fs-mount'; +import * as fs from 'worker-fs-mount/fs-sync'; + +export const ROOT = '/data'; + +export function mountStorage(storage) { + mount(ROOT, new LocalDOFilesystem(storage)); + globalThis.__pumpkin_fs = fs; +} diff --git a/src/main.rs b/src/main.rs index 9460aeb..9245c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ mod config; mod host; mod memory; -mod persist; mod world; use host::{method, property, then}; diff --git a/src/persist.rs b/src/persist.rs deleted file mode 100644 index ef67f08..0000000 --- a/src/persist.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The world lives in the object's `/tmp` tree while it runs and in its SQLite -//! storage between runs: one row per file, written at checkpoint, read at start. - -use crate::host::{js_error, sql}; -use std::{ - collections::HashMap, - fs, - path::{Path, PathBuf}, -}; -use wasm_bindgen::JsValue; - -pub const ROOT: &str = "/tmp/world"; - -pub fn prepare(storage: &JsValue) -> Result<(), JsValue> { - sql( - storage, - "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, data BLOB NOT NULL)", - &[], - )?; - Ok(()) -} - -fn stored(storage: &JsValue) -> Result>, JsValue> { - let mut files = HashMap::new(); - for row in sql(storage, "SELECT path, data FROM files", &[])? { - let [path, data] = row.as_slice() else { - return Err(js_error("unexpected files row")); - }; - let path = path.as_string().ok_or_else(|| js_error("path is not text"))?; - let data = js_sys::Uint8Array::new(&data).to_vec(); - files.insert(path, data); - } - Ok(files) -} - -/// Recreates the stored tree under `ROOT`. -pub fn restore(storage: &JsValue) -> Result { - let _ = fs::remove_dir_all(ROOT); - fs::create_dir_all(ROOT).map_err(js_error)?; - let files = stored(storage)?; - for (path, data) in &files { - let target = Path::new(ROOT).join(path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(js_error)?; - } - fs::write(target, data).map_err(js_error)?; - } - Ok(files.len()) -} - -/// Writes every file under `ROOT` that differs from storage and removes rows for -/// files that no longer exist. Returns (written, removed). -pub fn save(storage: &JsValue) -> Result<(usize, usize), JsValue> { - let mut previous = stored(storage)?; - let mut files = Vec::new(); - walk(Path::new(ROOT), &mut files).map_err(js_error)?; - let mut written = 0; - for file in files { - let key = file - .strip_prefix(ROOT) - .map_err(js_error)? - .to_string_lossy() - .into_owned(); - let data = fs::read(&file).map_err(js_error)?; - if previous.remove(&key).is_some_and(|old| old == data) { - continue; - } - sql( - storage, - "INSERT OR REPLACE INTO files (path, data) VALUES (?, ?)", - &[key.into(), js_sys::Uint8Array::from(data.as_slice()).into()], - )?; - written += 1; - } - let removed = previous.len(); - for key in previous.into_keys() { - sql(storage, "DELETE FROM files WHERE path = ?", &[key.into()])?; - } - Ok((written, removed)) -} - -fn walk(dir: &Path, out: &mut Vec) -> std::io::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if entry.file_type()?.is_dir() { - walk(&path, out)?; - } else { - out.push(path); - } - } - Ok(()) -} diff --git a/src/workerd.js b/src/workerd.js index fd37705..dc86445 100644 --- a/src/workerd.js +++ b/src/workerd.js @@ -1,7 +1,7 @@ // Emscripten library adjustments for NODERAWFS on workerd's Node filesystem. addToLibrary({ // Node's deprecated process.binding is absent; fs.constants is the public - // form of the same table (emscripten-core/emscripten cf-final 4e034c65a). + // form of the same table. $NODEFS__postset: ` NODEFS.isWindows = false; NODEFS.flagsForNodeMap = Object.fromEntries([ @@ -18,37 +18,37 @@ addToLibrary({ ].map(([flag, name]) => [flag, fs.constants[name]])); `, - // workerd has no process stdio descriptors: keep fds 0-2 on Emscripten's - // console callbacks so Pumpkin's log and Rust panics reach the host console. - // Under NODERAWFS the host enforces permissions; workerd reports directories - // as 0666, which would otherwise fail the VFS's execute check in getdents. - $workerdStdio__deps: ['$FS', '$TTY'], - $workerdStdio__postset: () => addAtPreRun('workerdStdio()'), - $workerdStdio: () => { + // The host enforces permissions; workerd reports directories as 0666, which + // would otherwise fail the VFS's execute check in getdents. Once the object + // mounts its storage (src/js/mount.js), NODERAWFS reads and writes through + // worker-fs-mount's fs, which routes the mount to SQLite and the rest to + // workerd's own fs. + $workerdFs__deps: ['$FS', '$PATH_FS'], + $workerdFs__postset: () => addAtPreRun('workerdFs()'), + $workerdFs: () => { Object.defineProperty(FS, 'ignorePermissions', { get: () => true, set() {} }); - const consoles = [ - null, - { output: [], ops: TTY.default_tty_ops }, - { output: [], ops: TTY.default_tty1_ops }, - ]; - const fstat = FS.fstat; - FS.fstat = (fd) => { - const stream = FS.getStreamChecked(fd); - if (stream.nfd === 0 || stream.nfd === 1 || stream.nfd === 2) { - return { - dev: 0, ino: stream.nfd, mode: 0o020666, nlink: 1, uid: 0, gid: 0, rdev: 0, - size: 0, blksize: 4096, blocks: 0, - atime: new Date(0), mtime: new Date(0), ctime: new Date(0), - }; - } - return fstat(fd); - }; - const write = FS.write; - FS.write = (stream, buffer, offset, length, position) => { - const tty = consoles[stream.nfd]; - if (!tty) return write(stream, buffer, offset, length, position); - for (let i = offset; i < offset + length; i++) tty.ops.put_char(tty, buffer[i]); - return length; - }; + // The working directory is the object's, not the isolate process's, so + // relative paths (which NODERAWFS otherwise leaves to the host's cwd) are + // resolved here before they reach the fs implementation. + let cwd = '/'; + FS.cwd = () => cwd; + FS.chdir = (path) => { cwd = PATH_FS.resolve(path); }; + const resolve = (path) => (typeof path === 'string' && path[0] !== '/' ? PATH_FS.resolve(path) : path); + const twoPaths = new Set(['renameSync', 'copyFileSync', 'linkSync']); + const resolving = (impl) => new Proxy(impl, { + get(target, name) { + const value = target[name]; + if (typeof value !== 'function' || !String(name).endsWith('Sync')) return value; + return (first, second, ...rest) => + value.call(target, resolve(first), twoPaths.has(name) ? resolve(second) : second, ...rest); + }, + }); + const nodeFs = fs; + fs = resolving(nodeFs); + Object.defineProperty(globalThis, '__pumpkin_fs', { + configurable: true, + get: () => nodeFs, + set: (mounted) => { fs = resolving(mounted); }, + }); }, }); diff --git a/src/world.rs b/src/world.rs index c7cb1f0..b7f20af 100644 --- a/src/world.rs +++ b/src/world.rs @@ -3,11 +3,11 @@ //! `connect` is a `#[wasm_bindgen(experimental_tokio)]` export: its future runs on the //! thread's Tokio event loop, whose wait is the host event loop, so nothing //! blocks or suspends and the export returns a Promise. The server lifetime is -//! one such future: the first `connect` after idle runs it, and its completion -//! (after the final save) is the checkpoint. Later connections are routed to -//! the running listener. +//! one such future: the first `connect` after idle runs it on the world mounted +//! from the object's storage, and it completes after the final save is synced. +//! Later connections are routed to the running listener. -use crate::{config, host, memory, persist}; +use crate::{config, host, memory}; use host::{js_error, method, property, then}; use pumpkin::{data::VanillaData, server::Server, PumpkinServer}; use std::{ @@ -56,7 +56,7 @@ struct Shared { failure: RefCell>, connections: Cell, startup_ms: Cell>, - checkpointed_at: RefCell>, + saved_at: RefCell>, /// Signals the server root that the last connection has closed. idle: Arc, /// Resolves at the next phase change; connections arriving mid-transition @@ -84,7 +84,7 @@ impl MinecraftWorld { eprintln!("RUST PANIC: {info}"); })); let storage = property(&state, "storage")?; - persist::prepare(&storage)?; + host::mount_storage(&storage); Ok(MinecraftWorld { shared: Rc::new(Shared { storage, @@ -92,7 +92,7 @@ impl MinecraftWorld { failure: RefCell::new(None), connections: Cell::new(0), startup_ms: Cell::new(None), - checkpointed_at: RefCell::new(None), + saved_at: RefCell::new(None), idle: Arc::new(tokio::sync::Notify::new()), transition: RefCell::new(None), }), @@ -100,7 +100,7 @@ impl MinecraftWorld { } /// Serves one inbound connection; resolves when it closes. The first call - /// while idle also runs the server, resolving once the world is checkpointed. + /// while idle also runs the server, resolving once the final save is synced. #[wasm_bindgen(experimental_tokio)] pub async fn connect(&self, socket: JsValue) -> Result { let shared = self.shared.clone(); @@ -119,8 +119,8 @@ impl MinecraftWorld { )?; set("startup_ms", shared.startup_ms.get().map_or(JsValue::NULL, JsValue::from))?; set( - "checkpointed_at", - shared.checkpointed_at.borrow().as_deref().map_or(JsValue::NULL, JsValue::from), + "saved_at", + shared.saved_at.borrow().as_deref().map_or(JsValue::NULL, JsValue::from), )?; let server = SERVER.with(|slot| slot.borrow().clone()); set( @@ -204,9 +204,9 @@ impl Shared { } async fn run(self: Rc, first: JsValue, started: f64) -> Result { - let restored = persist::restore(&self.storage)?; - std::env::set_current_dir(persist::ROOT).map_err(js_error)?; - eprintln!("Restored {restored} world files"); + let root = host::MOUNT_ROOT.with(|root| root.as_string()).unwrap_or_default(); + std::fs::create_dir_all(&root).map_err(js_error)?; + std::env::set_current_dir(&root).map_err(js_error)?; pumpkin::reset_stop(); let (basic, advanced) = config::configuration(); if !LOGGER.replace(true) { @@ -242,14 +242,13 @@ impl Shared { if let Some(panic) = PANIC.with(|slot| slot.borrow_mut().take()) { return Err(js_error(panic)); } - let (written, removed) = persist::save(&self.storage)?; - eprintln!("Checkpoint: {written} files written, {removed} removed"); - // Issued writes become durable before the connection is reported closed. + // Writes issued through the mount become durable before the connection + // is reported closed. let synced = method(&self.storage, "sync", &[])?; let shared = self.clone(); let done = Closure::once_into_js(move |_: JsValue| { shared - .checkpointed_at + .saved_at .replace(Some(String::from(js_sys::Date::new_0().to_iso_string()))); shared.set_phase(Phase::Idle); }); diff --git a/tests/integration.mjs b/tests/integration.mjs index fcc0f51..0b29590 100644 --- a/tests/integration.mjs +++ b/tests/integration.mjs @@ -59,7 +59,7 @@ try { assert.equal(b.block(changedBlock), 0, 'Second client lost the edit before checkpoint'); await a.close(); await b.close(); clients.length = 0; const saved = await checkpoint(worker); - console.log(`World checkpoint completed at ${saved.checkpointed_at}`); + console.log(`World saved at ${saved.saved_at}`); } finally { await Promise.all(clients.map(client => client.close())); await worker.stop(); diff --git a/tests/server.mjs b/tests/server.mjs index b78e406..7fd5d8b 100644 --- a/tests/server.mjs +++ b/tests/server.mjs @@ -95,7 +95,7 @@ export async function testServer({ seed = '1789195700909824175' } = {}) { throw error; } if (state.failure) throw new Error(state.failure); - return state.phase === 'idle' && state.checkpointed_at && state.connections === 0 && state; + return state.phase === 'idle' && state.saved_at && state.connections === 0 && state; }, 'world checkpoint'); } From e65b513bf6c0993b0f834d08dcf68f044ff03bea Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 18 Sep 2026 15:41:59 -0700 Subject: [PATCH 06/12] Run on Wrangler's bundled workerd workerd 1.20260918.1 ships the net.Server Durable Object routing and the node:fs fixes this server needs, so Wrangler 4.135.0's bundled workerd is used as is; the MINIFLARE_WORKERD_PATH instructions for running a workerd main build go away. The version is also pinned directly in package.json. --- .github/workflows/ci.yml | 2 - README.md | 5 - docs/dependencies.md | 14 +- package-lock.json | 316 ++++++++++++++++++++------------------- package.json | 3 +- 5 files changed, 171 insertions(+), 169 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 087180b..9f7b775 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,6 @@ jobs: run: bash scripts/setup.sh - name: Install Worker dependencies run: npm ci - # Inbound net.Server routing inside Durable Objects is not yet in Wrangler's - # bundled workerd; set MINIFLARE_WORKERD_PATH to a build that has it. - name: Worker TCP, shared world, and SQLite restart run: | set -o pipefail diff --git a/README.md b/README.md index 1555051..2bb0259 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,6 @@ wasm-bindgen itself. `npm run dev` compiles Pumpkin with `worker-build --emscripten` and starts Wrangler. The first build takes several minutes; later builds use Cargo's cache. -The runtime support this needs (`net.Server` routing into Durable Objects and -the `node:fs` fixes listed in [dependency pins](docs/dependencies.md#host-tools)) -is in workerd main. Until Wrangler's bundled workerd catches up, point Miniflare -at a build of main: `MINIFLARE_WORKERD_PATH=/path/to/workerd npm run dev`. - Connect **Minecraft Java 26.2** to **`localhost:25565`**. Status is available at **http://localhost:8787/**. diff --git a/docs/dependencies.md b/docs/dependencies.md index bd8edb6..fd43306 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -50,13 +50,15 @@ graph, including the branch commits. `build/`. `EMSCRIPTEN`/`EMSDK` select a local toolchain instead. - Node: 24+; 26 recommended and selected in CI. Used for build tools and tests. - Python: 3.11+ (Emscripten scripts, emsdk and TOML parsing). -- workerd: Wrangler must run a workerd with `net.Server` inbound routing into +- Emscripten backend: LLVM, Binaryen and Node through emsdk under `.work/emsdk` + (or an activated emsdk selected with `EMSDK`). `scripts/common.sh` hands the + frontend checkout and emsdk to worker-build through `EMSCRIPTEN` and `EMSDK`; + it uses the frontend unpatched. +- workerd 1.20260918.1 (Wrangler's bundled version, also pinned directly in + `package.json`): the first release with `net.Server` inbound routing into Durable Objects (`handleAsNodeConnection`, cloudflare/workerd#7306, #7313) and the `node:fs` fixes for positional buffer I/O (#7368), `O_TRUNC` (#7369), - `O_CREAT` (#7393), and rename over an existing path (#7394). All are in - workerd main; until Wrangler's bundled version catches up, build main - (`bazel build //src/workerd/server:workerd`) and set `MINIFLARE_WORKERD_PATH` - to `bazel-bin/src/workerd/server/workerd`. + `O_CREAT` (#7393), and rename over an existing path (#7394). ## Updating a patch @@ -96,7 +98,7 @@ bash scripts/setup.sh --sources-only ## JavaScript dependencies -`package-lock.json` pins Wrangler 4.129.0, `worker-fs-mount` 0.2.0 and +`package-lock.json` pins Wrangler 4.135.0, workerd 1.20260918.1, `worker-fs-mount` 0.2.0 and `durable-object-fs` 1.0.0 (the SQLite filesystem mount, imported by `src/js/mount.js` and bundled by worker-build). worker-build generates `build/index.js`, which wraps the exports into the entrypoint and derives the diff --git a/package-lock.json b/package-lock.json index a1125fb..cb12ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "worker-fs-mount": "0.2.0" }, "devDependencies": { - "wrangler": "4.129.0" + "workerd": "1.20260918.1", + "wrangler": "4.135.0" }, "engines": { "node": ">=24" @@ -43,9 +44,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260903.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260903.1.tgz", - "integrity": "sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260918.1.tgz", + "integrity": "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ==", "cpu": [ "x64" ], @@ -60,9 +61,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260903.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260903.1.tgz", - "integrity": "sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260918.1.tgz", + "integrity": "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ==", "cpu": [ "arm64" ], @@ -77,9 +78,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260903.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260903.1.tgz", - "integrity": "sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260918.1.tgz", + "integrity": "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw==", "cpu": [ "x64" ], @@ -94,9 +95,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260903.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260903.1.tgz", - "integrity": "sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260918.1.tgz", + "integrity": "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A==", "cpu": [ "arm64" ], @@ -111,9 +112,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260903.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260903.1.tgz", - "integrity": "sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260918.1.tgz", + "integrity": "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q==", "cpu": [ "x64" ], @@ -604,9 +605,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", - "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -623,13 +624,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.1" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", - "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -646,13 +647,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.1" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", - "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -660,7 +661,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -670,9 +671,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", - "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -687,9 +688,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", - "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -704,9 +705,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", - "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -721,9 +722,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", - "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -738,9 +739,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", - "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -755,9 +756,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", - "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -772,9 +773,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", - "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -789,9 +790,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", - "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -806,9 +807,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", - "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -823,9 +824,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", - "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -840,9 +841,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", - "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -859,13 +860,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.1" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", - "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -882,13 +883,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.1" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", - "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -905,13 +906,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.1" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", - "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -928,13 +929,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.1" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", - "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -951,13 +952,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.1" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", - "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -974,13 +975,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.1" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", - "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -997,13 +998,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", - "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -1020,18 +1021,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", - "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1041,9 +1042,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", - "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], @@ -1051,7 +1052,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.2" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1061,9 +1062,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", - "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1081,9 +1082,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", - "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1101,9 +1102,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", - "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1318,16 +1319,16 @@ } }, "node_modules/miniflare": { - "version": "5.20260903.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260903.0-alpha.tgz", - "integrity": "sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==", + "version": "5.20260918.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260918.0-alpha.tgz", + "integrity": "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.35.2", + "sharp": "0.35.4", "undici": "7.29.0", - "workerd": "1.20260903.1", + "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -1363,15 +1364,15 @@ } }, "node_modules/sharp": { - "version": "0.35.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", - "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -1380,31 +1381,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.2", - "@img/sharp-darwin-x64": "0.35.2", - "@img/sharp-freebsd-wasm32": "0.35.2", - "@img/sharp-libvips-darwin-arm64": "1.3.1", - "@img/sharp-libvips-darwin-x64": "1.3.1", - "@img/sharp-libvips-linux-arm": "1.3.1", - "@img/sharp-libvips-linux-arm64": "1.3.1", - "@img/sharp-libvips-linux-ppc64": "1.3.1", - "@img/sharp-libvips-linux-riscv64": "1.3.1", - "@img/sharp-libvips-linux-s390x": "1.3.1", - "@img/sharp-libvips-linux-x64": "1.3.1", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", - "@img/sharp-libvips-linuxmusl-x64": "1.3.1", - "@img/sharp-linux-arm": "0.35.2", - "@img/sharp-linux-arm64": "0.35.2", - "@img/sharp-linux-ppc64": "0.35.2", - "@img/sharp-linux-riscv64": "0.35.2", - "@img/sharp-linux-s390x": "0.35.2", - "@img/sharp-linux-x64": "0.35.2", - "@img/sharp-linuxmusl-arm64": "0.35.2", - "@img/sharp-linuxmusl-x64": "0.35.2", - "@img/sharp-webcontainers-wasm32": "0.35.2", - "@img/sharp-win32-arm64": "0.35.2", - "@img/sharp-win32-ia32": "0.35.2", - "@img/sharp-win32-x64": "0.35.2" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/supports-color": { @@ -1458,9 +1464,9 @@ } }, "node_modules/workerd": { - "version": "1.20260903.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", - "integrity": "sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==", + "version": "1.20260918.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260918.1.tgz", + "integrity": "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1471,17 +1477,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260903.1", - "@cloudflare/workerd-darwin-arm64": "1.20260903.1", - "@cloudflare/workerd-linux-64": "1.20260903.1", - "@cloudflare/workerd-linux-arm64": "1.20260903.1", - "@cloudflare/workerd-windows-64": "1.20260903.1" + "@cloudflare/workerd-darwin-64": "1.20260918.1", + "@cloudflare/workerd-darwin-arm64": "1.20260918.1", + "@cloudflare/workerd-linux-64": "1.20260918.1", + "@cloudflare/workerd-linux-arm64": "1.20260918.1", + "@cloudflare/workerd-windows-64": "1.20260918.1" } }, "node_modules/wrangler": { - "version": "4.129.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.129.0.tgz", - "integrity": "sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==", + "version": "4.135.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.135.0.tgz", + "integrity": "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -1489,10 +1495,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260903.0-alpha", + "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260903.1" + "workerd": "1.20260918.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -1506,7 +1512,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260903.1" + "@cloudflare/workers-types": "^5.20260918.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index f3ede69..68cd6dd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "worker-fs-mount": "0.2.0" }, "devDependencies": { - "wrangler": "4.129.0" + "workerd": "1.20260918.1", + "wrangler": "4.135.0" }, "scripts": { "build": "bash scripts/build.sh", From a244fb5619bd21c25d0d4c04c11b81bb691abad8 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 24 Sep 2026 12:32:20 -0700 Subject: [PATCH 07/12] Bring in the memory and configuration work from dlapid/moreMemoryOpts Ports Dan Lapid's dlapid/moreMemoryOpts (ec02ab2, cd83251) onto the event-loop runtime, keeping what applies and dropping what the runtime change superseded. Pumpkin patches: packed chunk palettes with copy-on-write light arrays, on-demand carving masks, bounded density pools, pruned structure caches and lazily grown pathfinder heaps (pumpkin-memory.patch); byte-bounded outgoing chunk batches with backpressure, idle generation-graph release, and feature-gated imports so headless builds compile without warnings (pumpkin-emscripten.patch). The injected-stream connection entry point, the checkpoint barrier through the chunk writer, and the inline Rayon shim are not taken: the stock accept loop, the mounted filesystem and Tokio-spawned jobs cover them. Worker: operator settings (view and simulation distance, player limit, compression, MOTD, seed, idle timeout) are Worker variables, validated in the object's constructor and reported in status. The `#[event(connect)]` handler reads the Minecraft handshake over the `worker::Socket`: server-list pings are answered from the object's metadata without starting a server, and login connections are forwarded with `Stub::connect`, the consumed bytes written ahead and both directions copied with Tokio. The object's `connect` hands each socket to Pumpkin's listener with `Socket::handle_as_node_connection`. After the last player leaves the server saves the world and stays up for the reconnect window before stopping; status gains runtime_starts, idle_deadline and settings. Packet compression is on by default. worker-fs-mount rejected the S_IFREG | perms mode Emscripten's FS.open passes to its backend, so a player's data file could not be created on disconnect; npm install now applies patches/worker-fs-mount-open-mode.patch (masking numeric modes to their permission bits, as Node does) until danlapid/worker-fs-mount#10 ships. Tests: compression-aware protocol client, unit tests for framing, an experience probe for measuring configurations, and an integration flow covering pings that must not start the runtime, the player limit, a reconnect during the idle window and restart without compression. Docs: configuration guide with the measurement results. --- Cargo.lock | 2 + Cargo.toml | 2 + README.md | 14 +- docs/architecture.md | 49 +- docs/configuration-results.json | 174 ++++ docs/configuration.md | 136 +++ docs/dependencies.md | 9 +- docs/development.md | 19 +- docs/memory-exploration-results.json | 417 ++++++++ docs/memory-reduction.md | 146 ++- docs/memory-retention-results.json | 487 +++++++++ package.json | 4 +- patches/README.md | 7 +- patches/pumpkin-emscripten.patch | 1232 ++++++++++++++++++++--- patches/pumpkin-memory.patch | 995 ++++++++++++++++-- patches/worker-fs-mount-open-mode.patch | 30 + scripts/patch-packages.mjs | 25 + scripts/test-scheduler.sh | 3 + scripts/test.sh | 3 +- src/config.rs | 21 +- src/handshake.rs | 180 ++++ src/host.rs | 32 +- src/main.rs | 104 +- src/settings.rs | 88 ++ src/world.rs | 206 +++- tests/client.mjs | 76 +- tests/experience.mjs | 121 +++ tests/integration.mjs | 27 +- tests/memory.mjs | 5 +- tests/protocol.mjs | 32 +- tests/protocol.test.mjs | 23 + tests/server.mjs | 43 +- wrangler.jsonc | 16 +- 33 files changed, 4321 insertions(+), 407 deletions(-) create mode 100644 docs/configuration-results.json create mode 100644 docs/configuration.md create mode 100644 docs/memory-exploration-results.json create mode 100644 docs/memory-retention-results.json create mode 100644 patches/worker-fs-mount-open-mode.patch create mode 100644 scripts/patch-packages.mjs create mode 100644 src/handshake.rs create mode 100644 src/settings.rs create mode 100644 tests/experience.mjs create mode 100644 tests/protocol.test.mjs diff --git a/Cargo.lock b/Cargo.lock index efe745b..d446ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1846,7 +1846,9 @@ dependencies = [ "js-sys", "pumpkin", "pumpkin-config", + "pumpkin-world", "rayon", + "serde_json", "tokio", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/Cargo.toml b/Cargo.toml index 6cd5f1a..e203aad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,9 @@ web-sys = { version = "0.3.106", features = ["AbortController", "AbortSignal", " tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync", "time", "net", "io-util"] } pumpkin = { path = ".work/pumpkin/crates/pumpkin", default-features = false, features = ["single-threaded"] } pumpkin-config = { path = ".work/pumpkin/crates/pumpkin-config", default-features = false } +pumpkin-world = { path = ".work/pumpkin/crates/pumpkin-world", default-features = false, features = ["single-threaded"] } rayon = "1.12" +serde_json = "1" # wasm32-unknown-emscripten support pending upstream releases. [patch.crates-io] diff --git a/README.md b/README.md index 2bb0259..4a8e35c 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,17 @@ minutes; later builds use Cargo's cache. Connect **Minecraft Java 26.2** to **`localhost:25565`**. Status is available at **http://localhost:8787/**. -The example uses offline mode, with encryption and compression disabled. Local +The example uses offline mode with encryption disabled and fast packet compression. Local listeners bind to loopback. `WORLD_NAME` in [wrangler.jsonc](wrangler.jsonc) selects -the Durable Object; server settings are in [src/config.rs](src/config.rs). +the Durable Object; [gameplay settings](docs/configuration.md) such as view +distance and the player limit are Worker variables. World data lives under `.data/workers/server/`, written through to the Durable -Object's SQLite storage as Pumpkin saves. After the last connection closes, the -server saves and stops; the next connection starts it on the same world. Wait for -status to report `phase: "idle"` before stopping Wrangler so the final save -completes. +Object's SQLite storage as Pumpkin saves. After the last player leaves, the +server saves and keeps a short reconnect window before stopping; the next +connection starts it on the same world. Server-list pings do not start the +runtime. Wait for status to report `phase: "idle"` before stopping Wrangler so +the final save completes. ## Build and contribute diff --git a/docs/architecture.md b/docs/architecture.md index 1a8413c..d169b03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,15 +4,21 @@ The whole Worker is Rust: `worker-build --emscripten`, with the `worker` crate's `experimental_tokio` feature, links the `pumpkin-do` bin for `wasm32-unknown-emscripten` -through emcc and emits the module Wrangler serves. The entrypoint's `connect` handler forwards each TCP -connection to the named `MinecraftWorld` object with `stub.connect` and pipes -both directions; `fetch` (a `worker` crate `#[event(fetch)]`) serves status. - -`MinecraftWorld` is a `#[wasm_bindgen]` class constructed with the object's -state. Its `connect` is a `#[wasm_bindgen(experimental_tokio)]` export: the future runs on -the thread's Tokio `LocalEventLoop` (tokio-rs/tokio#8484), the current-thread -scheduler and drivers with the host event loop as their wait, and the export -returns the Promise of its outcome. Nothing blocks or suspends. The first +through emcc and emits the module Wrangler serves. The entrypoint's +`#[event(connect)]` handler reads a bounded Minecraft handshake over the +`worker::Socket` (`src/handshake.rs`): server-list requests are answered from +the object's metadata (`serverList`) without starting a server, and gameplay +connections are forwarded to the named `MinecraftWorld` object with +`Stub::connect`, writing the consumed bytes ahead and copying both directions +with Tokio. `fetch` (`#[event(fetch)]`) serves status. + +`MinecraftWorld` is a `#[durable_object(connect)]`; its constructor mounts +`state.storage().as_raw()` and the server root awaits `Storage::sync`. The +macro exports the object's handlers with `experimental_tokio` (ambient): the +future runs on the thread's Tokio `LocalEventLoop` (tokio-rs/tokio#8484), the +current-thread scheduler and drivers with the host event loop as their wait, +and the export returns the Promise of its outcome. Nothing blocks or suspends. +`status` and `serverList` are plain `#[wasm_bindgen]` RPC methods. The first `connect` after idle runs the server lifetime: it starts Pumpkin on the mounted world, awaits the server's return after its final save, awaits `storage.sync()`, and settles the connection's promise. Later connections and `status` calls enter @@ -25,18 +31,24 @@ so a readiness or timer callback resumes the scheduler on the host loop. Pumpkin binds its stock `TcpListener` on port 25565. Emscripten's Node socket backend implements that with `net.BoundSocket`/`net.Server`, which workerd scopes -to the Durable Object's own port table. `connect` routes each inbound socket to -that listener with `handleAsNodeConnection`; the accepted connection surfaces +to the Durable Object's own port table. The object's `connect` routes each +inbound socket to that listener with `Socket::handle_as_node_connection`; the +accepted connection surfaces through epoll readiness and Pumpkin's normal accept loop, reporting the bound address and an unspecified peer. One wasm instance serves the isolate, so one object hosts one server at a time; -`WORLD_NAME` selects it. When the last connection closes, the server root calls -`stop_server`, Pumpkin saves and returns, and `reset_stop` clears the process-wide -stop state so the next connection can start a fresh server in the same instance. - -Pumpkin's `single-threaded` feature selects cooperative inline chunk generation -and the async ticker. The same scheduler loop serves native builds, which +`WORLD_NAME` selects it. When the last connection closes, the server root saves +players, level data and dirty chunks, then keeps the server running for the +`IDLE_TIMEOUT_SECONDS` reconnect window (`idle_deadline` in status); a new +connection cancels the shutdown. When the window expires the root calls +`stop_server`, Pumpkin performs its final save and returns, and `reset_stop` +clears the process-wide stop state so the next connection can start a fresh +server in the same instance. Operator settings ([configuration](configuration.md)) +are read from the environment in the object's constructor. + +Pumpkin's `single-threaded` feature runs chunk generation as Tokio tasks +and selects the async ticker. The same scheduler loop serves native builds, which dispatch generation onto Rayon. Save batches await queue capacity; shutdown drains results with an elapsed-time timeout before the final flush. Bounded CPU tasks use regular Tokio tasks because `spawn_blocking` requires OS threads. @@ -72,7 +84,8 @@ The supplied configuration listens on loopback TCP port 25565 and HTTP port 8787 The world name comes from `WORLD_NAME` in `wrangler.jsonc`. - `GET /health`: Worker readiness. -- `GET /`: phase, connection count, runtime statistics, and last save time. +- `GET /`: phase, connection count, runtime statistics, the runtime start + count, last shutdown save time, reconnect deadline, and effective settings. Runtime statistics include Wasm capacity and allocator in-use/free/arena bytes. Allocator counters include allocation metadata and unused container capacity; diff --git a/docs/configuration-results.json b/docs/configuration-results.json new file mode 100644 index 0000000..1e88cab --- /dev/null +++ b/docs/configuration-results.json @@ -0,0 +1,174 @@ +{ + "date": "2026-09-12", + "environment": "macOS arm64, Node 26.4.0, Wrangler 4.129.0, pinned Rust/Emscripten", + "scope": "Single local runs with synthetic movement. Wasm capacity excludes JS memory. Samples may miss transient peaks.", + "runs": [ + { + "label": "size-v2-two", + "settings": { + "VIEW_DISTANCE": "2", + "SIMULATION_DISTANCE": "2", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "seed": "1789195700909824175", + "steps": 32, + "wasm_sha256": "4288be9cd44fe68e57bb358e8d041b886b4b43a8f30719477a76c27df97fa0c6", + "join_ms": 5174.268208, + "peak_wasm_mib": 107.75, + "received_mb": 1.5, + "exploration_tps": 18.28, + "status_p95_ms": 246.2 + }, + { + "label": "size-v3-compressed", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "seed": "1789195700909824175", + "steps": 32, + "wasm_sha256": "4288be9cd44fe68e57bb358e8d041b886b4b43a8f30719477a76c27df97fa0c6", + "join_ms": 6301.3658749999995, + "peak_wasm_mib": 121.75, + "received_mb": 2.16, + "exploration_tps": 16.7, + "status_p95_ms": 301.1 + }, + { + "label": "size-v3-four", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "seed": "1789195700909824175", + "steps": 32, + "wasm_sha256": "4288be9cd44fe68e57bb358e8d041b886b4b43a8f30719477a76c27df97fa0c6", + "join_ms": 6185.213540999999, + "peak_wasm_mib": 159.75, + "received_mb": 4.33, + "exploration_tps": 15.2, + "status_p95_ms": 1200.8 + }, + { + "label": "size-v3-uncompressed", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "-1", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "seed": "1789195700909824175", + "steps": 32, + "wasm_sha256": "4288be9cd44fe68e57bb358e8d041b886b4b43a8f30719477a76c27df97fa0c6", + "join_ms": 6294.287249999999, + "peak_wasm_mib": 123.75, + "received_mb": 19.62, + "exploration_tps": 16.5, + "status_p95_ms": 380.9 + }, + { + "label": "size-v4-two", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "seed": "1789195700909824175", + "steps": 32, + "wasm_sha256": "4288be9cd44fe68e57bb358e8d041b886b4b43a8f30719477a76c27df97fa0c6", + "join_ms": 7427.388416, + "peak_wasm_mib": 129.75, + "received_mb": 2.76, + "exploration_tps": 16.68, + "status_p95_ms": 354.3 + } + ], + "optimization_experiment": { + "opt_level": 3, + "result": "Build failed: wasm-metadce received SIGBUS in the pinned Binaryen toolchain." + }, + "final_preset_validation": { + "wasm_sha256": "b182e11d18a2694f6cf72e0444576873f498aef184aee7672baf14cd58efc74e", + "settings": { + "VIEW_DISTANCE": "2", + "SIMULATION_DISTANCE": "2", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "seed": "1789195700909824175", + "players": 2, + "steps": 32, + "join_ms": 4923.233542, + "peak_wasm_mib": 107.81, + "received_mb": 1.48, + "exploration_tps": 17.78, + "status_p95_ms": 228.9 + }, + "extended_validation": [ + { + "label": "default-long", + "failure": null, + "seed": "1789195700909824175", + "steps": 128, + "settings": { + "VIEW_DISTANCE": "2", + "SIMULATION_DISTANCE": "2", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_mib": 125.75, + "peak_allocated_heap_mib": 88.47, + "received_mb": 4.18, + "exploration_tps": 18.3, + "status_p95_ms": 249.9 + }, + { + "label": "default-second-seed", + "failure": null, + "seed": "1789200079352125165", + "steps": 32, + "settings": { + "VIEW_DISTANCE": "2", + "SIMULATION_DISTANCE": "2", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_mib": 113.75, + "peak_allocated_heap_mib": 81.76, + "received_mb": 1.89, + "exploration_tps": 16.91, + "status_p95_ms": 204.5 + } + ] +} diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..430eb6d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,136 @@ +# Server configuration + +Gameplay settings live in `wrangler.jsonc` under `vars`. Use string values. +Changing these settings restarts the local Worker, but does not require rebuilding +Rust. The object reads and validates them in its constructor; invalid settings +fail the next connection and appear in the HTTP status response's `failure` field. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `WORLD_NAME` | `world` | Selects the Durable Object and its separate saved world. | +| `WORLD_SEED` | Unset | Seed for a new world; an existing world keeps its saved seed. | +| `VIEW_DISTANCE` | `4` | Maximum client view distance, from 2 to 32 chunks. | +| `SIMULATION_DISTANCE` | `3` | Simulation radius, from 2 to the configured view distance. | +| `MAX_PLAYERS` | `20` | Login limit, from 1 to 1,000; capacity depends on the world and workload. | +| `COMPRESSION_THRESHOLD` | `512` | Compress packets at or above this byte count; `-1` disables compression. | +| `COMPRESSION_LEVEL` | `1` | Compression level, from 0 to 9; 1 favors speed. | +| `IDLE_TIMEOUT_SECONDS` | `10` | Reconnect window after the last player leaves, from 0 to 30 seconds; 0 stops immediately. | +| `MOTD` | `Minecraft on Cloudflare Workers` | Description displayed in the Minecraft server list. | + +The accepted ranges describe valid protocol settings, not a supported capacity +for every world. Chunk generation, separate player locations, and world content +can substantially increase memory usage. A larger view distance does not imply +a larger simulation distance is needed. + +The default view is now 4 with simulation distance 3. Pumpkin's +[memory optimizations](memory-reduction.md#exploration-comparison-september-12-2026) +made room for the larger view: a two-player exploration probe commanding 512 +blocks of travel each completed at 113.81 MiB of Wasm capacity. That is one +workload, not an unrestricted exploration guarantee. + +To try a larger view while measuring your world's memory usage: + +```sh +npm run dev -- --var VIEW_DISTANCE:5 --var SIMULATION_DISTANCE:3 +``` + +For packet debugging without compression: + +```sh +npm run dev -- --var COMPRESSION_THRESHOLD:-1 +``` + +The server still runs at 20 ticks per second in Survival with Normal difficulty. +It uses offline authentication; the example's listeners bind to loopback. + +## Status and reconnects + +Server-list requests are answered before forwarding a gameplay connection to +Pumpkin. They report the configured description and player limit, and the current +player count, without creating a Wasm instance. Status requests have a bounded +packet size and a five-second handshake/ping deadline. + +World writes go straight to the object's SQLite storage through the mounted +filesystem. After the final gameplay connection closes, the server saves players, +level data and dirty chunks, then keeps running for the reconnect window. A new +connection cancels the pending shutdown. When the window expires, it performs its +final save, awaits `storage.sync()`, and shuts down. + +The HTTP status endpoint reports `runtime_starts`, `saved_at` (the last completed +shutdown save), `idle_deadline`, the effective `settings`, and runtime memory/tick +statistics. During the reconnect window, `phase` is `running`, `connections` is +zero, and `idle_deadline` is set. Wait for `phase: "idle"` before stopping +Wrangler so the final shutdown also completes. + +Changing configuration reloads the Worker. Wait for players to leave and the +world to become idle before changing settings on a world you care about. + +## Measuring a configuration + +The experience probe starts an isolated world with a fixed seed. It measures +initial view delivery, stationary play, and synthetic movement in different +directions, recording traffic, Wasm capacity, allocator usage, status latency, +and observed tick rate. It writes `experience.json` under `.data/probes/`. + +```sh +npm run test:experience -- --view 3 --simulation 3 --players 2 --compression=512 +npm run test:experience -- --view 4 --simulation 3 --players 2 --compression=512 +npm run test:experience -- --view 3 --simulation 3 --players 2 --compression=-1 +``` + +Use the same seed, workload, and build when comparing settings. These are local +synthetic probes, not player-capacity guarantees. Wasm capacity excludes +JavaScript allocations, and sampling can miss short-lived peaks. + +Tests can use separate loopback ports while the playable server is running: + +```sh +TEST_TCP_PORT=25566 TEST_HTTP_PORT=8788 TEST_INSPECTOR_PORT=9230 npm test +TEST_TCP_PORT=25566 TEST_HTTP_PORT=8788 TEST_INSPECTOR_PORT=9230 npm run test:experience -- --view 4 +``` + +`TEST_INSPECTOR_PORT` separates the probe's debugger listener from another local +Wrangler instance. The probes fail if their chosen ports are occupied and only +stop processes they started. Playable world databases are never used by the probes. + +## Local comparison (September 12, 2026) + +This historical sweep predates the palette, light, carving-mask, and pathfinder +optimizations. It used the same size-optimized build, seed, and 32 movement +steps per player. Each step commanded four blocks of movement, with players +moving in different directions. Results are single runs: + +| View / simulation | Players | Compression | Initial views | Peak Wasm capacity | Received traffic | +| --- | ---: | --- | ---: | ---: | ---: | +| 3 / 3 | 2 | Off | 6.29 s | 123.75 MiB | 19.62 MB | +| 3 / 3 | 2 | Level 1 | 6.30 s | 121.75 MiB | 2.16 MB | +| 4 / 3 | 2 | Level 1 | 7.43 s | 129.75 MiB | 2.76 MB | +| 3 / 3 | 4 | Level 1 | 6.19 s | 159.75 MiB | 4.33 MB | +| 2 / 2 | 2 | Level 1 | 5.17 s | 107.75 MiB | 1.50 MB | + +Compression reduced traffic by about 89% in the comparable view-distance-3 +runs, with essentially unchanged initial load time. The larger view and +four-player runs left insufficient headroom for a standard Workers isolate; +Wasm capacity does not include JavaScript memory. The temporary 2/2 preset was +rerun after adding the acknowledged checkpoint and reached 107.81 MiB. + +`opt-level=3` failed to build: the pinned Binaryen `wasm-metadce` process exited +with SIGBUS. The working `opt-level="s"` profile is retained; no speed improvement +is claimed for the failed experiment. + +[Machine-readable measurements](configuration-results.json) include the settings, +Wasm hashes, sampled status latency, and observed tick rate. A run interrupted +by a development-server reload was excluded. The early-checkpoint change was +validated separately with 179 native world tests and the full restart suite, +including terminating the test Worker before the reconnect deadline. + +A longer run commanded 512 blocks of travel per player and reached **125.75 MiB +of Wasm capacity**, despite the smaller preset. A separate seed reached 113.75 +MiB. Both completed, but the long run leaves very little space for JavaScript +inside the standard isolate memory budget. These controls reduce demand; they +do not establish unrestricted exploration or a general two-player capacity +bound. The subsequent [memory work](memory-reduction.md#exploration-comparison-september-12-2026) +reduced allocation at the same gameplay settings and replaced that temporary +preset with the current 4/3 defaults. The original measurements remain in +`configuration-results.json`; the newer [exploration results](memory-exploration-results.json) +record the optimized builds and unsuccessful attempts separately. diff --git a/docs/dependencies.md b/docs/dependencies.md index fd43306..5ecbf73 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -50,10 +50,6 @@ graph, including the branch commits. `build/`. `EMSCRIPTEN`/`EMSDK` select a local toolchain instead. - Node: 24+; 26 recommended and selected in CI. Used for build tools and tests. - Python: 3.11+ (Emscripten scripts, emsdk and TOML parsing). -- Emscripten backend: LLVM, Binaryen and Node through emsdk under `.work/emsdk` - (or an activated emsdk selected with `EMSDK`). `scripts/common.sh` hands the - frontend checkout and emsdk to worker-build through `EMSCRIPTEN` and `EMSDK`; - it uses the frontend unpatched. - workerd 1.20260918.1 (Wrangler's bundled version, also pinned directly in `package.json`): the first release with `net.Server` inbound routing into Durable Objects (`handleAsNodeConnection`, cloudflare/workerd#7306, #7313) and @@ -100,7 +96,10 @@ bash scripts/setup.sh --sources-only `package-lock.json` pins Wrangler 4.135.0, workerd 1.20260918.1, `worker-fs-mount` 0.2.0 and `durable-object-fs` 1.0.0 (the SQLite filesystem mount, imported by -`src/js/mount.js` and bundled by worker-build). worker-build generates +`src/js/mount.js` and bundled by worker-build). `npm install` applies +`patches/worker-fs-mount-open-mode.patch` to the installed `worker-fs-mount` +(numeric open modes masked to their permission bits, as Node does; pending +upstream). worker-build generates `build/index.js`, which wraps the exports into the entrypoint and derives the Durable Object class from `DurableObject` for RPC. diff --git a/docs/development.md b/docs/development.md index 001a26f..1c081c8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,21 +16,26 @@ and serves `build/index.js`. ```sh npm run build # Compile the production Worker npm test # Full Wrangler integration suite -npm run test:scheduler # Native queue/backpressure and shutdown regressions +npm run test:scheduler # Native scheduler, chunk-queue and shutdown regressions npm run test:memory # Two-player memory probe on a fresh, fixed-seed world +npm run test:experience # Configuration and movement/traffic probe ``` -Tests fail if their loopback ports (25565 and 8787) are occupied. They start and +Tests fail if their loopback ports (25565 and 8787 by default) are occupied; set +`TEST_TCP_PORT` and `TEST_HTTP_PORT` to use others. They start and stop only their own Wrangler process groups. Their databases and logs live in `.data/probes/`; playable worlds are separate under `.data/workers/server/`. -`npm test` must finish with `PUMPKIN-DO-SQLITE-RESTART-OK`. It checks two-player -gameplay, a real block edit, and player/block restoration after a restart, which -exercises the SQLite-mounted world across a restart. +`npm test` must finish with `PUMPKIN-DO-SQLITE-RESTART-OK`. It runs the unit +tests (`tests/*.test.mjs`: protocol framing), then +two-player gameplay with compression, server-list pings that must not start the +runtime, the player limit, a real block edit, a reconnect during the idle window, +and player/block restoration after a restart from the SQLite-mounted world. -The memory probe waits for both clients to receive 81 chunks, observes 30 seconds +The memory probe waits for both clients to receive their configured view, observes 30 seconds of stationary play, then checkpoints. Each run creates a fresh isolated world -with a fixed seed; use the same seed when comparing builds: +with a fixed seed; use the same seed when comparing builds. See +[configuration](configuration.md) for the exploration probe: ```sh npm run test:memory -- --seed 1789200079352125165 diff --git a/docs/memory-exploration-results.json b/docs/memory-exploration-results.json new file mode 100644 index 0000000..9347c41 --- /dev/null +++ b/docs/memory-exploration-results.json @@ -0,0 +1,417 @@ +{ + "recorded": "2026-09-12", + "environment": { + "node": "26", + "wrangler": "4.129.0", + "workerd": "1.20260903.1", + "host": "macOS arm64" + }, + "workload": { + "blocks_commanded_per_step": 4, + "step_interval_ms": 500, + "stationary_before_ms": 5000, + "settling_after_ms": 5000, + "directions": "Players move in separate cardinal directions." + }, + "scope": "Local synthetic runs. Movement commands do not verify actual terrain traversal. Wasm capacity excludes JavaScript; samples can miss transient peaks. Failed attempts retain partial measurements and are not comparable to completed runs.", + "runs": [ + { + "label": "baseline-v3-four-long-isolated", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_bytes": 47972352, + "peak_heap_bytes": 1167696, + "join_ms": null, + "exploration_tps": null, + "failure": "Error: ProbeA: server closed the connection", + "completed": false + }, + { + "label": "baseline-v3-four-long-recheck", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_bytes": 115081216, + "peak_heap_bytes": 82226312, + "join_ms": 6340.188582999999, + "exploration_tps": 11.36957727176025, + "failure": "AssertionError [ERR_ASSERTION]: Wrangler exited", + "completed": false, + "diagnostic": "esbuild reported: all goroutines are asleep - deadlock" + }, + { + "label": "baseline-v3-four-long", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_bytes": 47972352, + "peak_heap_bytes": 1167680, + "join_ms": null, + "exploration_tps": null, + "failure": "Error: ProbeA: server closed the connection", + "completed": false + }, + { + "label": "baseline-v3-four-repeat", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 32, + "seed": "1789195700909824175", + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_bytes": 167510016, + "peak_heap_bytes": 135631840, + "join_ms": 7865.266250000001, + "exploration_tps": 15.257301744867481, + "failure": null, + "completed": true + }, + { + "label": "baseline-v3-four", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 32, + "seed": "1789195700909824175", + "wasm_sha256": "5cc604276208e8d5ce72e4e05b9c45343a33f0974487c86f76ebf69e1b77b63f", + "peak_wasm_bytes": 169607168, + "peak_heap_bytes": 136446024, + "join_ms": 6950.489290999999, + "exploration_tps": 15.284518065214796, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-startup-recheck", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 0, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 75300864, + "peak_heap_bytes": 41347192, + "join_ms": 6282.028833, + "exploration_tps": null, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-v3-four-long-isolated", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 167575552, + "peak_heap_bytes": 124633688, + "join_ms": 6664.49625, + "exploration_tps": 14.729428031607982, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-v3-four-long", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 152895488, + "peak_heap_bytes": 114727672, + "join_ms": 6314.068791000001, + "exploration_tps": 15.02771301167962, + "failure": "Error: ProbeD: disconnected during Play", + "completed": false + }, + { + "label": "optimized-v2-v3-four", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 32, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 117243904, + "peak_heap_bytes": 85542872, + "join_ms": 6258.018834, + "exploration_tps": 15.41217864693511, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-v4-four", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 32, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 131923968, + "peak_heap_bytes": 98050136, + "join_ms": 7551.753375, + "exploration_tps": 15.109774055309819, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-v4-two-inspector-isolated", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 119341056, + "peak_heap_bytes": 79627424, + "join_ms": 8522.887792, + "exploration_tps": 16.911632990999518, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-v4-two-long-recheck", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 48037888, + "peak_heap_bytes": 1167808, + "join_ms": null, + "exploration_tps": null, + "failure": "Error: ProbeA: server closed the connection", + "completed": false + }, + { + "label": "optimized-v2-v4-two-long", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 48037888, + "peak_heap_bytes": 1167640, + "join_ms": null, + "exploration_tps": null, + "failure": "Error: read ECONNRESET", + "completed": false + }, + { + "label": "optimized-v2-v4-two-seed2-isolated", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "steps": 32, + "seed": "1789200079352125165", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 104660992, + "peak_heap_bytes": 71445384, + "join_ms": 7133.787125000001, + "exploration_tps": 16.824789895965726, + "failure": null, + "completed": true + }, + { + "label": "optimized-v2-v4-two-seed2-recheck", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "steps": 32, + "seed": "1789200079352125165", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 48037888, + "peak_heap_bytes": 1167632, + "join_ms": null, + "exploration_tps": null, + "failure": "Error: ProbeA: server closed the connection", + "completed": false, + "diagnostic": "esbuild reported: all goroutines are asleep - deadlock" + }, + { + "label": "optimized-v2-v4-two-seed2", + "build": "optimized", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "2", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 2, + "steps": 32, + "seed": "1789200079352125165", + "wasm_sha256": "c0e6347eed76026976853fe0fce2f5ee18f18296caa6ee8680d8c6203f4a3b44", + "peak_wasm_bytes": 104660992, + "peak_heap_bytes": 71498208, + "join_ms": 7088.9845000000005, + "exploration_tps": 14.3798795236709, + "failure": null, + "completed": true + }, + { + "label": "optimized-v3-four", + "build": "intermediate-before-light-compaction", + "settings": { + "VIEW_DISTANCE": "3", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 32, + "seed": "1789195700909824175", + "wasm_sha256": "7c8a2496d31e03dfc9f40344085a8cf40609dc0dbea4c4202f5d8224a8edc5d6", + "peak_wasm_bytes": 140312576, + "peak_heap_bytes": 106684768, + "join_ms": 6691.6392909999995, + "exploration_tps": 14.701649679242797, + "failure": null, + "completed": true + }, + { + "label": "optimized-v4-four", + "build": "intermediate-before-light-compaction", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "4", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 32, + "seed": "1789195700909824175", + "wasm_sha256": "7c8a2496d31e03dfc9f40344085a8cf40609dc0dbea4c4202f5d8224a8edc5d6", + "peak_wasm_bytes": 152895488, + "peak_heap_bytes": 118868144, + "join_ms": 8154.795958999999, + "exploration_tps": 14.824272313788612, + "failure": null, + "completed": true + } + ] +} diff --git a/docs/memory-reduction.md b/docs/memory-reduction.md index e969183..14f09e0 100644 --- a/docs/memory-reduction.md +++ b/docs/memory-reduction.md @@ -2,9 +2,10 @@ The optimized server runs on standard SQLite-backed Durable Objects. -The memory optimizations are maintained in -[`pumpkin-memory.patch`](../patches/pumpkin-memory.patch), separately from the -Emscripten embedding patch. Setup applies both to the pinned Pumpkin revision. +Storage and cache optimizations are maintained in +[`pumpkin-memory.patch`](../patches/pumpkin-memory.patch). Scheduler and network +queue changes live in the embedding patch alongside their runtime integration. +Setup applies both to the pinned Pumpkin revision. Structure templates share indexed block arrays and store block-state descriptions once per palette. Block-entity NBT is shared until placement needs a mutable copy. @@ -12,7 +13,142 @@ Generation chunks retain uniform or indexed sections; a temporary dense buffer keeps the active noise pass efficient. Emscripten grows memory in 2 MiB increments while retaining the 8 MiB stack. -## Recorded comparison +Exploration profiling added four further changes: + +- Pack small chunk-palette indices into 1, 2, 4, or 8 bits instead of one byte + each. Palette growth still falls back to dense storage when needed; Minecraft's + separate network and disk packing is unchanged. +- Share uniform initialized light arrays and detach on writes. Compact arrays + after initial light propagation, when many have become uniform. Initialized + light remains distinct from missing light, preserving packet masks and saves. +- Allocate carving-mask words only as carving reaches them, retaining every bit + and the original dimension bounds. +- Let pathfinder heaps grow when searching. Each idle navigator previously + reserved 1,024 nodes before using any of them; search limits are unchanged. + +`LightContainer::Full` now holds `Arc<[u8]>` instead of `Box<[u8]>`. This changes +the Rust enum's public payload type and needs consideration when upstreaming; +the serialized light bytes remain unchanged. + +## Exploration comparison (September 12, 2026) + +A matched comparison used four clients, view/simulation distance 3, seed +`1789195700909824175`, and level-1 compression above 512 bytes. Each client sent +32 four-block movement steps in a different direction, spaced 500 ms apart, +with five seconds stationary before and after movement. The baseline already +included the earlier template and generation-chunk optimizations. These runs +used Node 26, Wrangler 4.129.0, and workerd 1.20260903.1 on macOS arm64. + +| Measurement | Baseline | Baseline repeat | Optimized | +| --- | ---: | ---: | ---: | +| Peak sampled Wasm capacity | 161.75 MiB | 159.75 MiB | 111.81 MiB | +| Peak sampled allocated heap | 130.13 MiB | 129.35 MiB | 81.58 MiB | +| Time to receive initial views | 6.95 s | 7.87 s | 6.26 s | +| Observed exploration ticks/second | 15.28 | 15.26 | 15.41 | + +At identical gameplay settings, Wasm capacity fell about **30%** and allocated +heap about **37%**. Tick performance was similar; these individual runs do not +establish a CPU speedup. + +An allocator walk of the baseline's Wasm memory found 31.55 MiB in 4 KiB palette +buffers, 27.61 MiB in 2 KiB light buffers (about 17.6 MiB held uniform values), +7.04 MiB in carving masks, and 8.44 MiB in idle pathfinder buffers. Heap +backreferences identified the latter as vectors with length 1 and capacity 1,024. +These measurements guided the changes rather than reducing gameplay settings. + +Additional optimized runs checked the larger view and longer movement: + +| View / simulation | Players | Movement steps each | Seed | Peak Wasm | Result | +| --- | ---: | ---: | --- | ---: | --- | +| 4 / 3 | 2 | 128 | Same | 113.81 MiB | Passed | +| 4 / 3 | 2 | 32 | `1789200079352125165` | 99.81 MiB | Passed | +| 4 / 3 | 4 | 32 | Same | 125.81 MiB | Passed | +| 3 / 3 | 4 | 128 | Same | 159.81 MiB | Passed on retry | + +The configuration initially chosen after these measurements used view 4/simulation +3 with a two-player limit. The current limit is 20; that capacity has not been +load-tested. The long four-player +result is still too large for the standard isolate budget, and even the +two-player results do not establish a general capacity bound. Movement counts +describe commands sent by synthetic clients, not verified terrain traversal. +Wasm capacity excludes JavaScript allocations; sampling can miss transient peaks. + +Some attempts failed: the first long optimized four-player run disconnected a +client, and several baseline and optimized attempts failed during startup or +initial view delivery. One baseline log also reported an esbuild deadlock. +Later runs used a separate inspector port and completed, but the cause of the +failures was not established. Failed attempts are retained in the +[measurement report](memory-exploration-results.json), with intermediate builds +identified separately; their partial peaks are not comparable to completed runs. + +The final source passed 186 native world tests, 101 protocol tests, and the +pathfinder heap growth/ordering test. The full integration suite passed at the +new 4/3 defaults, including a shared block edit and restoration of the player +position and edited chunk after restarting Wrangler. + +## Cache lifetime and outgoing data (September 22, 2026) + +This pass kept view distance 4, simulation distance 3, and the configured player +limit of 20. Four clients each commanded 128 four-block movement steps in +different directions. The baseline includes the earlier memory optimizations +and the matching LLVM 24/Binaryen 131 backend. + +| Measurement | Baseline | Baseline repeat | Optimized | +| --- | ---: | ---: | ---: | +| Peak sampled Wasm capacity | 173.81 MiB | 175.88 MiB | 155.94 MiB | +| Peak sampled allocated heap | 129.86 MiB | 130.98 MiB | 113.19 MiB | +| Initial views | 8.63 s | 8.82 s | 7.49 s | +| Observed exploration ticks/second | 14.31 | 14.17 | 14.92 | + +That is approximately **10–11% less Wasm capacity** and **13–14% less allocated +heap** at the same settings. The optimized run received 1,876 distinct chunks +across the clients, compared with 1,277 in the baseline repeat. The lower memory +use did not come from delivering less terrain. Timing results remain individual +observations, not a statistical CPU benchmark. + +With **20 nearby clients** and no movement, the optimized build peaked at +**83.94 MiB** of Wasm capacity versus **91.69 MiB** in both baseline runs. All +clients received their initial 117 chunks. Initial view delivery took 9.24 s +optimized and 8.64–8.73 s baseline. A second seed's optimized four-client long +run completed at 161.94 MiB; its baseline attempts failed, so it is not a paired +comparison. These results do not establish a 20-player exploration capacity. + +The changes address retained memory and large bursts of outgoing data: + +- Release large generation-graph arrays once no queued or running task can hold + a key. Clear stale holder keys before reusing the graph. +- After the structure-start cache passes 2,048 entries, periodically remove + entries that active generation no longer references. Keep shared collector + identity intact, including concurrent cache misses. +- Limit pooled density-buffer payloads to 4 MiB per thread while retaining + reusable buffers that fit. +- Give each Java client's queued chunk payloads a 2 MiB byte budget. Reserve + capacity for a whole chunk batch before marking chunks sent. When capacity is + unavailable, chunks stay pending for retry. A single larger chunk can occupy + the whole budget so valid packets still make progress. +- Trim serialized packet capacity and release raw packet data after writing its + frame. Keep completion acknowledgements until the writer flushes. Asynchronous + resends serialize small groups and share the same batch-ordering guard. + +The [complete report](memory-retention-results.json) includes unsuccessful +attempts and intermediate builds. Some heap captures caused client timeouts; +stopped-world dumps were excluded. Several baseline and candidate runs ended +with Wrangler proxy errors. Their cause was not established. An additional +dependency-pruning experiment was left out because it showed no clear benefit +over the selected implementation. + +Native validation covered 191 world tests and six packet/chunk tests, including +atomic batch reservation, retrying unsent chunks, collector identity, and pool +accounting. The integration suite verifies player and edited-chunk restoration. + +To repeat the workloads with the current build: + +```sh +TEST_TCP_PORT=25566 TEST_HTTP_PORT=8788 TEST_INSPECTOR_PORT=9241 npm run test:experience -- --players 4 --max-players 20 --view 4 --simulation 3 --steps 128 +TEST_TCP_PORT=25566 TEST_HTTP_PORT=8788 TEST_INSPECTOR_PORT=9241 npm run test:experience -- --players 20 --max-players 20 --view 4 --simulation 3 --steps 0 +``` + +## Earlier stationary comparison (September 5, 2026) A local comparison on September 5, 2026 used the same world seed, two nearby Java 26.2 clients, and view/simulation distance 3. Both clients received 81 chunks, @@ -46,7 +182,7 @@ Status reports `wasm_memory_bytes`, `heap_allocated_bytes`, `heap_free_bytes`, a containers. Wasm capacity also includes static data, the stack, and growth headroom. Use a DevTools heap snapshot to account for JavaScript allocations. -The optimization was validated with 114 native generation tests, including +The earlier optimization was validated with 114 native generation tests, including structure/template round trips, and the full integration suite's shared block edit and player/block restoration. Future changes must continue to pass `npm test` with `PUMPKIN-DO-SQLITE-RESTART-OK`. diff --git a/docs/memory-retention-results.json b/docs/memory-retention-results.json new file mode 100644 index 0000000..981fcc7 --- /dev/null +++ b/docs/memory-retention-results.json @@ -0,0 +1,487 @@ +{ + "recorded_local_date": "2026-09-22", + "environment": { + "node": "v26.4.0", + "platform": "darwin", + "arch": "arm64", + "wrangler": "4.129.0", + "workerd": "1.20260903.1", + "custom_workerd": false + }, + "backend": "emsdk 6.0.6 / LLVM 24 / Binaryen 131", + "selected_wasm_sha256": "3b5e4411faa7b230116fdfabe0bde4e280c94355a7cc329f6299424e99f45dd3", + "workload": { + "view_distance": 4, + "simulation_distance": 3, + "configured_player_limit": 20, + "blocks_commanded_per_step": 4, + "step_interval_ms": 500, + "stationary_before_ms": 5000, + "settling_after_ms": 5000 + }, + "scope": "Single local synthetic runs. Wasm capacity excludes JavaScript and sampling can miss transient peaks. Chunk counts measure received terrain packets, not verified player traversal. Nearby twenty-client runs do not establish twenty-player exploration capacity. Failed attempts retain partial measurements and are excluded from successful comparisons. Sampling ends before checkpoint/shutdown; a completed run also finishes its checkpoint.", + "profiling_notes": [ + "Initial heap captures caused client timeouts or found a stopped world; those final dumps are excluded from gameplay comparisons.", + "A valid nearby allocator walk identified about 5 MiB retained in generation graph edge storage and 3.49 MiB of reusable density buffers by wasm32 layouts and pointer backreferences.", + "Several baseline and candidate attempts ended with Wrangler proxy/connection failures; the underlying cause was not established. Later probes used unique Worker names.", + "The extra completed-stage dependency-pruning experiment was left out: the simpler selected implementation had lower observed memory use in the successful long comparison." + ], + "runs": [ + { + "label": "baseline-long", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": true, + "failure": null, + "peak_wasm_bytes": 182255616, + "peak_heap_bytes": 136163472, + "join_ms": 8631.871042, + "exploration_tps": 14.30899306879655, + "chunks_received": 1273, + "received_wire_bytes": 8728023 + }, + { + "label": "baseline-long-repeat", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": true, + "failure": null, + "peak_wasm_bytes": 184418304, + "peak_heap_bytes": 137346400, + "join_ms": 8823.353667, + "exploration_tps": 14.167904180156748, + "chunks_received": 1277, + "received_wire_bytes": 8853334 + }, + { + "label": "baseline-long-seed2", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": false, + "failure": "AssertionError [ERR_ASSERTION]: HTTP 500: Error: Network connection lost.", + "peak_wasm_bytes": 135987200, + "peak_heap_bytes": 103214704, + "join_ms": 7156.3352079999995, + "exploration_tps": 13.40691957914532, + "chunks_received": 699, + "received_wire_bytes": 5658443 + }, + { + "label": "baseline-long-seed2-recheck", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": false, + "failure": "Error: ProbeA: server closed the connection", + "peak_wasm_bytes": 47906816, + "peak_heap_bytes": 1167672, + "join_ms": null, + "exploration_tps": null, + "chunks_received": 0, + "received_wire_bytes": 0 + }, + { + "label": "baseline-long-seed2-unique", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": false, + "failure": "Error: ProbeD: server closed the connection", + "peak_wasm_bytes": 47906816, + "peak_heap_bytes": 1167744, + "join_ms": null, + "exploration_tps": null, + "chunks_received": 115, + "received_wire_bytes": 1303815 + }, + { + "label": "baseline-nearby20", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 20, + "steps": 0, + "seed": "1789195700909824175", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": true, + "failure": null, + "peak_wasm_bytes": 96141312, + "peak_heap_bytes": 54240944, + "join_ms": 8731.120708, + "exploration_tps": null, + "chunks_received": 2340, + "received_wire_bytes": 16224372 + }, + { + "label": "baseline-nearby20-repeat", + "build": "baseline", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 20, + "steps": 0, + "seed": "1789195700909824175", + "wasm_sha256": "498196d6fe8b5857b6db9474eae6b7938b60909434139f395252582217e8cafc", + "completed": true, + "failure": null, + "peak_wasm_bytes": 96141312, + "peak_heap_bytes": 54756112, + "join_ms": 8639.415500000001, + "exploration_tps": null, + "chunks_received": 2340, + "received_wire_bytes": 16523340 + }, + { + "label": "candidate-v1-long", + "build": "intermediate-cache-and-resend-changes", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "238886c03f50c79fc240aab7b17aeffbf9bbfbdc09cee4b8597868dd6e33fa41", + "completed": true, + "failure": null, + "peak_wasm_bytes": 182190080, + "peak_heap_bytes": 133187688, + "join_ms": 7977.920416999999, + "exploration_tps": 14.209825772457, + "chunks_received": 1669, + "received_wire_bytes": 11489670 + }, + { + "label": "candidate-v1-nearby20", + "build": "intermediate-cache-and-resend-changes", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 20, + "steps": 0, + "seed": "1789195700909824175", + "wasm_sha256": "238886c03f50c79fc240aab7b17aeffbf9bbfbdc09cee4b8597868dd6e33fa41", + "completed": true, + "failure": null, + "peak_wasm_bytes": 87818240, + "peak_heap_bytes": 48901464, + "join_ms": 9162.850625000001, + "exploration_tps": null, + "chunks_received": 2340, + "received_wire_bytes": 16253325 + }, + { + "label": "candidate-v2-long", + "build": "selected-optimization", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "3b5e4411faa7b230116fdfabe0bde4e280c94355a7cc329f6299424e99f45dd3", + "completed": true, + "failure": null, + "peak_wasm_bytes": 163512320, + "peak_heap_bytes": 118689336, + "join_ms": 7485.766084000001, + "exploration_tps": 14.920846126541553, + "chunks_received": 1876, + "received_wire_bytes": 13268900 + }, + { + "label": "candidate-v3-long", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": false, + "failure": "AssertionError [ERR_ASSERTION]: HTTP 500: Error: Network connection lost.", + "peak_wasm_bytes": 153026560, + "peak_heap_bytes": 118278944, + "join_ms": 8116.79725, + "exploration_tps": 15.347543187969494, + "chunks_received": 1066, + "received_wire_bytes": 7254643 + }, + { + "label": "candidate-v3-long-recheck", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": false, + "failure": "Error: ProbeB: server closed the connection", + "peak_wasm_bytes": 48168960, + "peak_heap_bytes": 1167704, + "join_ms": null, + "exploration_tps": null, + "chunks_received": 9, + "received_wire_bytes": 174527 + }, + { + "label": "candidate-v3-long-seed2", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": false, + "failure": "AssertionError [ERR_ASSERTION]: Wrangler exited", + "peak_wasm_bytes": 165609472, + "peak_heap_bytes": 122126696, + "join_ms": 8757.433041, + "exploration_tps": 14.93088013020327, + "chunks_received": 1230, + "received_wire_bytes": 10066807 + }, + { + "label": "candidate-v3-long-seed2-recheck", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": false, + "failure": "Error: ProbeA: server closed the connection", + "peak_wasm_bytes": 48168960, + "peak_heap_bytes": 1167856, + "join_ms": null, + "exploration_tps": null, + "chunks_received": 0, + "received_wire_bytes": 0 + }, + { + "label": "candidate-v3-long-seed2-unique", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": true, + "failure": null, + "peak_wasm_bytes": 169803776, + "peak_heap_bytes": 123455824, + "join_ms": 7019.83625, + "exploration_tps": 15.179562150849492, + "chunks_received": 1833, + "received_wire_bytes": 14175975 + }, + { + "label": "candidate-v3-long-unique", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789195700909824175", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": true, + "failure": null, + "peak_wasm_bytes": 169803776, + "peak_heap_bytes": 122208224, + "join_ms": 7683.319583999999, + "exploration_tps": 14.523260021964266, + "chunks_received": 1686, + "received_wire_bytes": 11749454 + }, + { + "label": "candidate-v3-nearby20", + "build": "rejected-additional-dependency-pruning", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 20, + "steps": 0, + "seed": "1789195700909824175", + "wasm_sha256": "5678c4547905980256f3bd20ef6e8c673b3899e3fd6f14aaf39d983284eb41a0", + "completed": true, + "failure": null, + "peak_wasm_bytes": 88014848, + "peak_heap_bytes": 52300336, + "join_ms": 9841.464042, + "exploration_tps": null, + "chunks_received": 2340, + "received_wire_bytes": 16432368 + }, + { + "label": "final-long-seed2", + "build": "selected-optimization", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 4, + "steps": 128, + "seed": "1789200079352125165", + "wasm_sha256": "3b5e4411faa7b230116fdfabe0bde4e280c94355a7cc329f6299424e99f45dd3", + "completed": true, + "failure": null, + "peak_wasm_bytes": 169803776, + "peak_heap_bytes": 122074488, + "join_ms": 7275.794417000001, + "exploration_tps": 14.470819241483792, + "chunks_received": 1319, + "received_wire_bytes": 10389366 + }, + { + "label": "final-nearby20", + "build": "selected-optimization", + "settings": { + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "0" + }, + "players": 20, + "steps": 0, + "seed": "1789195700909824175", + "wasm_sha256": "3b5e4411faa7b230116fdfabe0bde4e280c94355a7cc329f6299424e99f45dd3", + "completed": true, + "failure": null, + "peak_wasm_bytes": 88014848, + "peak_heap_bytes": 48976504, + "join_ms": 9236.968666, + "exploration_tps": null, + "chunks_received": 2340, + "received_wire_bytes": 16117981 + } + ] +} diff --git a/package.json b/package.json index 68cd6dd..070f311 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "wrangler": "4.135.0" }, "scripts": { + "postinstall": "node scripts/patch-packages.mjs", "build": "bash scripts/build.sh", "dev": "bash scripts/serve.sh", "test": "bash scripts/test.sh", "test:memory": "npm run build && node tests/memory.mjs", - "test:scheduler": "bash scripts/test-scheduler.sh" + "test:scheduler": "bash scripts/test-scheduler.sh", + "test:experience": "npm run build && node tests/experience.mjs" }, "overrides": { "durable-object-fs": { diff --git a/patches/README.md b/patches/README.md index 5d6f0da..17ee30a 100644 --- a/patches/README.md +++ b/patches/README.md @@ -9,9 +9,12 @@ diff below it. Preserve the description when regenerating the diff. | Patch | Checkout | Purpose | | --- | --- | --- | -| `pumpkin-emscripten.patch` | `.work/pumpkin` | Headless optional subsystems, single-threaded runtime and blocking-task helper, shared async chunk scheduler with backpressure and timed draining, unified ticker, restartable stop signal, and platform guards | -| `pumpkin-memory.patch` | `.work/pumpkin` | Shared indexed structure templates, immutable block-entity NBT, paletted generation chunks, and a temporary dense noise buffer | +| `pumpkin-emscripten.patch` | `.work/pumpkin` | Headless optional subsystems, single-threaded runtime and blocking-task helper, shared async chunk scheduler with backpressure, timed draining and idle graph release, byte-bounded outgoing chunk batches, unified ticker, restartable stop signal, and platform guards | +| `worker-fs-mount-open-mode.patch` | `node_modules/worker-fs-mount` (via `npm install`) | Mask numeric `openSync`/`fchmodSync` modes to `0o7777` like Node, so Emscripten's `S_IFREG \| perms` creates files | +| `pumpkin-memory.patch` | `.work/pumpkin` | Shared indexed structure templates, immutable block-entity NBT, paletted generation chunks, a temporary dense noise buffer, packed palettes with copy-on-write light arrays, on-demand carving masks, bounded density pools, and pruned structure caches | +Patches whose paths start with `node_modules/` are applied by +`scripts/patch-packages.mjs` from npm's `postinstall`; the rest by setup. Setup applies `pumpkin-emscripten.patch` before `pumpkin-memory.patch`. The two patches use the same pinned base and currently modify separate files, so each can also be applied and checked independently. diff --git a/patches/pumpkin-emscripten.patch b/patches/pumpkin-emscripten.patch index 7ddb092..f711f73 100644 --- a/patches/pumpkin-emscripten.patch +++ b/patches/pumpkin-emscripten.patch @@ -27,6 +27,12 @@ paths do not invalidate Cargo's cache on every build. The application Cargo.lock supplies the embedded dependency resolution; Pumpkin's lockfile is excluded from this patch. +Reserve outgoing chunk batches by bytes and queue slots before marking them +sent. Keep backpressured chunks pending, release framed packet data promptly, +and preserve batch ordering for asynchronous resends. Release idle generation +graph allocations once no task holds a key. Keep imports and helpers behind +the same feature gates as their consumers so headless builds compile cleanly. + Base-commit: b5b9b9d7010e793806a83c495af223c67e1d35ee --- @@ -295,7 +301,7 @@ index f670ad37..b9d9a867 100644 )>; diff --git a/crates/pumpkin-world/src/chunk_system/schedule.rs b/crates/pumpkin-world/src/chunk_system/schedule.rs -index 145e15de..0d388726 100644 +index 145e15de..eca8ecde 100644 --- a/crates/pumpkin-world/src/chunk_system/schedule.rs +++ b/crates/pumpkin-world/src/chunk_system/schedule.rs @@ -69,13 +69,14 @@ pub struct GenerationSchedule { @@ -474,18 +480,36 @@ index 145e15de..0d388726 100644 let mut chunks = Vec::with_capacity(self.chunk_map.len()); for (pos, holder) in &mut self.chunk_map { -@@ -969,9 +987,7 @@ impl GenerationSchedule { +@@ -969,9 +987,26 @@ impl GenerationSchedule { } drop(data); - if let Err(e) = self.io_write.blocking_send(chunks) { - error!("Failed to send chunks to io write thread: {:?}", e); -- } + permit.send(chunks); ++ } ++ ++ /// SlotMap retains its largest backing arrays after tasks finish. When no ++ /// task can hold a key, release those arrays and remove all stale holder keys. ++ fn release_idle_graph(&mut self) { ++ if self.running_task_count != 0 ++ || !self.graph.nodes.is_empty() ++ || !self.queue.is_empty() ++ || !self.waiting_for_chunks.is_empty() ++ || (self.graph.nodes.capacity() < 4096 && self.graph.edges.capacity() < 8192) ++ { ++ return; ++ } ++ for holder in self.chunk_map.values_mut() { ++ holder.tasks.fill(NodeKey::null()); ++ holder.occupied = NodeKey::null(); ++ holder.occupied_by = EdgeKey::null(); + } ++ self.graph = DAG::default(); } fn drop_node(&mut self, node: NodeKey) { -@@ -1254,25 +1270,22 @@ impl GenerationSchedule { +@@ -1254,25 +1289,22 @@ impl GenerationSchedule { } #[expect(clippy::too_many_lines)] @@ -518,7 +542,7 @@ index 145e15de..0d388726 100644 break; } -@@ -1287,7 +1300,7 @@ impl GenerationSchedule { +@@ -1287,7 +1319,7 @@ impl GenerationSchedule { // is what puts stale dependency holders into the queue in the first place. if self.last_unload.elapsed() >= std::time::Duration::from_secs(1) { self.garbage_collect_dependencies(); @@ -527,7 +551,7 @@ index 145e15de..0d388726 100644 self.last_unload = std::time::Instant::now(); } -@@ -1308,7 +1321,7 @@ impl GenerationSchedule { +@@ -1308,7 +1340,7 @@ impl GenerationSchedule { if level.shut_down_chunk_system.load(Relaxed) { self.queue.push(task); info!("Shutdown detected during task processing, saving chunks..."); @@ -536,7 +560,7 @@ index 145e15de..0d388726 100644 break 'out2; } -@@ -1400,11 +1413,12 @@ impl GenerationSchedule { +@@ -1400,11 +1432,12 @@ impl GenerationSchedule { if io_batch.len() >= 16 && self .io_read @@ -551,7 +575,7 @@ index 145e15de..0d388726 100644 break 'out2; } } else { -@@ -1412,11 +1426,12 @@ impl GenerationSchedule { +@@ -1412,11 +1445,12 @@ impl GenerationSchedule { if !io_batch.is_empty() && self .io_read @@ -566,7 +590,7 @@ index 145e15de..0d388726 100644 break 'out2; } -@@ -1552,12 +1567,16 @@ impl GenerationSchedule { +@@ -1552,12 +1586,16 @@ impl GenerationSchedule { let send_chunk = self.send_chunk.clone(); let level = level.clone(); @@ -585,7 +609,7 @@ index 145e15de..0d388726 100644 } } } -@@ -1566,82 +1585,39 @@ impl GenerationSchedule { +@@ -1566,82 +1604,40 @@ impl GenerationSchedule { if !io_batch.is_empty() && self .io_read @@ -658,6 +682,7 @@ index 145e15de..0d388726 100644 - if self.queue_dirty { - self.sort_queue(); - self.queue_dirty = false; ++ self.release_idle_graph(); } - } else if self.running_task_count >= self.max_in_flight { - // Queue has tasks, but we are at maximum in-flight capacity. @@ -691,7 +716,7 @@ index 145e15de..0d388726 100644 } } } -@@ -1649,24 +1625,10 @@ impl GenerationSchedule { +@@ -1649,24 +1645,10 @@ impl GenerationSchedule { "schedule: waiting for {} generation tasks to finish", self.running_task_count ); @@ -720,7 +745,7 @@ index 145e15de..0d388726 100644 if self.running_task_count > 0 { warn!( -@@ -1716,6 +1678,15 @@ impl GenerationSchedule { +@@ -1716,6 +1698,15 @@ impl GenerationSchedule { self.graph.edges.clear(); } @@ -736,7 +761,7 @@ index 145e15de..0d388726 100644 fn debug_check(&self) -> bool { if !self.graph.nodes.is_empty() { for (key, value) in &self.graph.nodes { -@@ -1746,3 +1717,123 @@ impl GenerationSchedule { +@@ -1746,3 +1737,160 @@ impl GenerationSchedule { true } } @@ -801,6 +826,43 @@ index 145e15de..0d388726 100644 + ) + } + ++ #[test] ++ fn idle_graph_release_clears_stale_keys_and_never_drops_live_tasks() { ++ let (mut scheduler, _receiver) = scheduler(); ++ let pos = ChunkPos::new(0, 0); ++ let node = scheduler ++ .graph ++ .nodes ++ .insert(Node::new(pos, StagedChunkEnum::Noise)); ++ scheduler.graph.nodes.reserve(8192); ++ scheduler.graph.edges.reserve(16384); ++ let edge = scheduler ++ .graph ++ .edges ++ .insert(super::super::dag::Edge::new(node, EdgeKey::null())); ++ let mut holder = ChunkHolder { ++ chunk: Some(chunk(StagedChunkEnum::Biomes)), ++ occupied_by: edge, ++ ..ChunkHolder::default() ++ }; ++ holder.tasks[StagedChunkEnum::Noise as usize] = node; ++ scheduler.chunk_map.insert(pos, holder); ++ scheduler.release_idle_graph(); ++ assert!(scheduler.graph.nodes.contains_key(node)); ++ scheduler.graph.nodes.remove(node); ++ scheduler.running_task_count = 1; ++ scheduler.release_idle_graph(); ++ assert!(scheduler.graph.edges.contains_key(edge)); ++ scheduler.running_task_count = 0; ++ scheduler.release_idle_graph(); ++ assert!(scheduler.graph.nodes.capacity() < 4096); ++ assert!(scheduler.graph.edges.is_empty()); ++ let holder = &scheduler.chunk_map[&pos]; ++ assert!(holder.tasks.iter().all(Key::is_null)); ++ assert!(holder.occupied_by.is_null()); ++ assert!(holder.chunk.is_some()); ++ } ++ + #[tokio::test] + async fn full_save_queue_retains_the_batch_until_capacity_is_available() { + let (mut scheduler, mut receiver) = scheduler(); @@ -874,10 +936,31 @@ index b50ac05c..281fa41f 100644 lock: IOLock, ) { diff --git a/crates/pumpkin-world/src/level.rs b/crates/pumpkin-world/src/level.rs -index fbc69749..533b09d2 100644 +index fbc69749..52fe29fb 100644 --- a/crates/pumpkin-world/src/level.rs +++ b/crates/pumpkin-world/src/level.rs -@@ -316,7 +316,7 @@ impl Level { +@@ -28,15 +28,19 @@ use pumpkin_util::math::{position::BlockPos, vector2::Vector2}; + use pumpkin_util::world_seed::Seed; + use rustc_hash::FxHashSet; + use std::sync::{Arc, Mutex, Weak}; ++#[cfg(not(feature = "single-threaded"))] + use std::time::Duration; + use std::{ + path::PathBuf, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + thread, + }; ++#[cfg(not(feature = "single-threaded"))] + use tokio::time::timeout; + use tokio_util::sync::CancellationToken; +-use tracing::{debug, error, info, trace, warn}; ++#[cfg(not(feature = "single-threaded"))] ++use tracing::warn; ++use tracing::{debug, error, info, trace}; + // use tokio::runtime::Handle; + use tokio::{ + select, +@@ -316,7 +320,7 @@ impl Level { pub fn spawn_entity_generation(self: &Arc, pos: Vector2) { let level = self.clone(); @@ -886,7 +969,7 @@ index fbc69749..533b09d2 100644 let arc_chunk = Arc::new(ChunkEntityData { x: pos.x, z: pos.y, -@@ -354,43 +354,46 @@ impl Level { +@@ -354,43 +358,46 @@ impl Level { self.tasks.close(); self.chunk_system_tasks.close(); @@ -1135,7 +1218,7 @@ index 5314180a..a6accb81 100644 println!("cargo::rustc-env=GIT_HASH_FULL={git_hash_full}"); } diff --git a/crates/pumpkin/src/command/commands/pumpkin.rs b/crates/pumpkin/src/command/commands/pumpkin.rs -index 0045511a..9586e5dc 100644 +index 0045511a..326c0c64 100644 --- a/crates/pumpkin/src/command/commands/pumpkin.rs +++ b/crates/pumpkin/src/command/commands/pumpkin.rs @@ -56,6 +56,7 @@ fn fetch_all_contributors_cached() -> Vec { @@ -1156,7 +1239,63 @@ index 0045511a..9586e5dc 100644 if !contributors.is_empty() { if let Ok(mut guard) = CONTRIBUTORS_CACHE.lock() { -@@ -251,6 +255,7 @@ fn fetch_donators_hover_cached() -> TextComponent { +@@ -80,6 +84,7 @@ fn fetch_all_contributors_cached() -> Vec { + contributors + } + ++#[cfg(not(feature = "single-threaded"))] + async fn fetch_all_contributors() -> Vec { + let client = pumpkin_util::client_builder() + .user_agent("Pumpkin-MC") +@@ -121,6 +126,7 @@ async fn fetch_all_contributors() -> Vec { + all_contributors + } + ++#[cfg(not(feature = "single-threaded"))] + fn extract_next_url(header: &str) -> Option { + header + .split(',') +@@ -132,6 +138,7 @@ fn extract_next_url(header: &str) -> Option { + }) + } + ++#[cfg(not(feature = "single-threaded"))] + #[derive(Deserialize)] + struct Donator { + name: String, +@@ -139,6 +146,7 @@ struct Donator { + tier_slug: String, + } + ++#[cfg(not(feature = "single-threaded"))] + #[derive(Deserialize)] + struct DonatorResponse { + current: Vec, +@@ -146,6 +154,7 @@ struct DonatorResponse { + past: Vec, + } + ++#[cfg(not(feature = "single-threaded"))] + fn tier_priority(tier_slug: &str, tier_name: &str) -> u32 { + let s = format!("{tier_slug} {tier_name}").to_lowercase(); + +@@ -174,6 +183,7 @@ fn tier_priority(tier_slug: &str, tier_name: &str) -> u32 { + } + } + ++#[cfg(not(feature = "single-threaded"))] + fn tier_color(tier_slug: &str, tier_name: &str) -> NamedColor { + let s = format!("{tier_slug} {tier_name}").to_lowercase(); + +@@ -194,6 +204,7 @@ fn tier_color(tier_slug: &str, tier_name: &str) -> NamedColor { + } + } + ++#[cfg(not(feature = "single-threaded"))] + async fn fetch_donators_hover() -> TextComponent { + let url = "https://market.pumpkinmc.org/api/v1/rest/donators"; + let client = pumpkin_util::client_builder() +@@ -251,6 +262,7 @@ fn fetch_donators_hover_cached() -> TextComponent { return cache.data.clone(); } @@ -1164,7 +1303,7 @@ index 0045511a..9586e5dc 100644 let donators = tokio::runtime::Handle::try_current().map_or_else( |_| { tokio::runtime::Runtime::new().map_or_else( -@@ -260,6 +265,8 @@ fn fetch_donators_hover_cached() -> TextComponent { +@@ -260,6 +272,8 @@ fn fetch_donators_hover_cached() -> TextComponent { }, |handle| tokio::task::block_in_place(|| handle.block_on(fetch_donators_hover())), ); @@ -1174,18 +1313,20 @@ index 0045511a..9586e5dc 100644 if let Ok(mut guard) = DONATORS_CACHE.lock() { *guard = Some(DonatorCache { diff --git a/crates/pumpkin/src/crash.rs b/crates/pumpkin/src/crash.rs -index 6f88670e..9969598c 100644 +index 6f88670e..0fc4b3a1 100644 --- a/crates/pumpkin/src/crash.rs +++ b/crates/pumpkin/src/crash.rs -@@ -13,6 +13,7 @@ use pumpkin_util::text::{ +@@ -12,7 +12,9 @@ use pumpkin_util::text::{ + color::{Color, NamedColor}, }; use pumpkin_world::CURRENT_MC_VERSION; ++#[cfg(feature = "crash-report")] use rustc_hash::FxHashMap; +#[cfg(feature = "crash-report")] use sysinfo::{Cpu, System}; use time::OffsetDateTime; use tracing::error; -@@ -206,6 +207,7 @@ impl CrashReport { +@@ -206,6 +208,7 @@ impl CrashReport { rustc_version_runtime::version() ); @@ -1193,7 +1334,7 @@ index 6f88670e..9969598c 100644 if sysinfo::IS_SUPPORTED_SYSTEM { writeln_output!(&mut output, "\n--- System Details ---"); -@@ -243,6 +245,7 @@ impl CrashReport { +@@ -243,6 +246,7 @@ impl CrashReport { output } @@ -1241,44 +1382,88 @@ index 7d64f9b1..ca28e83b 100644 let Some(entity_arc) = world_clone.get_entity_by_id(entity_id) else { return; diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs -index a19825bc..45d6783e 100644 +index a19825bc..ec620557 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs -@@ -572,6 +572,7 @@ impl Player { - .and_then(|m| m.model.as_deref()) - .is_some_and(|model| model == "slim"); +@@ -523,26 +523,33 @@ pub struct Player { + pub inbound_packets: SegQueue, + } -+ #[cfg(not(feature = "single-threaded"))] - let bytes = if let Ok(handle) = tokio::runtime::Handle::try_current() { - tokio::task::block_in_place(|| { - handle.block_on(async { -@@ -585,6 +586,13 @@ impl Player { - client.get(&url).send().await.ok()?.bytes().await.ok() - })? - }; -+ // Emscripten: no sync-blocking HTTP from the single-thread runtime; skip the -+ // skin fetch (players still join, just without a fetched skin texture). -+ #[cfg(feature = "single-threaded")] -+ let bytes: Vec = { -+ let _ = &url; -+ return None; -+ }; - let img = image::load_from_memory(&bytes).ok()?; ++#[cfg(not(feature = "single-threaded"))] + use base64::prelude::*; + use pumpkin_protocol::Property; ++#[cfg(not(feature = "single-threaded"))] + use serde::Deserialize; - let width = img.width(); -@@ -2583,6 +2591,7 @@ impl Player { - sender.prepare_batch(&world.level, player_chunk, epoch, version) - }); + // Bit masks for the Java skin pixels that Bedrock requires to be opaque. + // Adapted from Geyser's SkinProvider under the MIT License. ++#[cfg(not(feature = "single-threaded"))] + const SKIN_OPAQUE_MASK: &str = "AP//AAAAAAAA//8AAAAAAAD//wAAAAAAAP//AAAAAAAA//8AAAAAAAD//wAAAAAAAP//AAAAAAAA//8AAAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAADwD/D/D/8AAPAP8P8P/wAA8A/w/w//AADwD/D/D/8AAP///////w8A////////DwD///////8PAP///////w8A////////DwD///////8PAP///////w8A////////DwD///////8PAP///////w8A////////DwD///////8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwD/APAAAAAPAP8A8AAAAA8A/wDwAAAADwD/APAAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AAA="; ++#[cfg(not(feature = "single-threaded"))] + const LEGACY_SKIN_OPAQUE_MASK: &str = "AP//AAAAAAAA//8AAAAAAAD//wAAAAAAAP//AAAAAAAA//8AAAAAAAD//wAAAAAAAP//AAAAAAAA//8AAAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wAAAADwD/D/D/APAPAP8P8P8A8A8A/w/w/wDwDwD/D/D/APAP////////8A/////////wD/////////AP////////8A/////////wD/////////AP////////8A/////////wD/////////AP////////8A/////////wD/////////AA=="; + ++#[cfg(not(feature = "single-threaded"))] + #[derive(Deserialize)] + struct TexturesProperty { + textures: Textures, + } + ++#[cfg(not(feature = "single-threaded"))] + #[derive(Deserialize)] + struct Textures { + #[serde(rename = "SKIN")] + skin: Option, + } + ++#[cfg(not(feature = "single-threaded"))] + #[derive(Deserialize)] + struct SkinTexture { + url: String, +@@ -550,6 +557,7 @@ struct SkinTexture { + metadata: Option, + } ++#[cfg(not(feature = "single-threaded"))] + #[derive(Deserialize)] + struct SkinMetadata { + #[serde(default)] +@@ -557,6 +565,14 @@ struct SkinMetadata { + } + + impl Player { ++ #[cfg(feature = "single-threaded")] ++ #[must_use] ++ pub fn fetch_skin(_properties: &[Property]) -> Option { ++ // A synchronous skin fetch cannot block the hosted runtime. ++ None ++ } + - let total_sent_chunks = prepared_batch.map_or_else( - || { - self.chunk_sender ++ #[cfg(not(feature = "single-threaded"))] + #[must_use] + pub fn fetch_skin(properties: &[Property]) -> Option { + let textures_prop = properties.iter().find(|p| &*p.name == "textures")?; +@@ -2590,10 +2606,13 @@ impl Player { + .map_or(0, |s| s.sent_chunks_count()) + }, + |batch| match self.client.as_ref() { +- ClientPlatform::Java(_) => { ++ ClientPlatform::Java(java) => { + let mut per_player_cache = rustc_hash::FxHashMap::default(); +- let encoded = +- crate::net::ChunkSender::encode_batch(&batch, &mut per_player_cache); ++ let encoded = crate::net::ChunkSender::encode_batch_limited( ++ &batch, ++ &mut per_player_cache, ++ java.available_chunk_bytes(), ++ ); + let current_epoch = self.chunk_send_epoch.load(Ordering::Relaxed); + self.chunk_sender.try_lock().map_or(0, |mut sender| { + sender.commit_batch(&batch, &encoded, &self.client, current_epoch); diff --git a/crates/pumpkin/src/lib.rs b/crates/pumpkin/src/lib.rs -index 7110bc9e..f45cadbe 100644 +index 7110bc9e..e7e9ecb4 100644 --- a/crates/pumpkin/src/lib.rs +++ b/crates/pumpkin/src/lib.rs -@@ -8,12 +8,14 @@ extern crate pumpkin_macros; +@@ -8,29 +8,37 @@ extern crate pumpkin_macros; use crate::crash::CrashReport; use crate::data::VanillaData; @@ -1297,7 +1482,13 @@ index 7110bc9e..f45cadbe 100644 status::{IceSocket, StatusResponder}, }; use crate::net::java::JavaClient; -@@ -26,8 +28,11 @@ use plugin::server::server_load::{LoadType, ServerLoadEvent}; + use crate::net::java::pending::PendingConnection; + use crate::net::{ClientPlatform, DisconnectReason, PacketHandlerResult, PacketRateLimiter}; + use crate::net::{lan_broadcast::LANBroadcast, query, rcon::RCONServer}; ++#[cfg(feature = "console")] + use crate::plugin::server::server_command::ServerCommandEvent; + use crate::server::{Server, ticker::Ticker}; + use plugin::server::server_load::{LoadType, ServerLoadEvent}; use pumpkin_config::{AdvancedConfiguration, BasicConfiguration}; use pumpkin_util::text::TextComponent; use pumpkin_util::text::color::{Color, NamedColor}; @@ -1308,8 +1499,14 @@ index 7110bc9e..f45cadbe 100644 +#[cfg(feature = "console")] use rustyline::{Config, error::ReadlineError}; use std::collections::HashMap; - use std::io::{ErrorKind, IsTerminal, stdin}; -@@ -112,6 +117,7 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { +-use std::io::{ErrorKind, IsTerminal, stdin}; ++use std::io::ErrorKind; ++#[cfg(feature = "console")] ++use std::io::{IsTerminal, stdin}; + use std::process::exit; + use std::str::FromStr; + use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +@@ -112,6 +120,7 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { } }; @@ -1317,7 +1514,7 @@ index 7110bc9e..f45cadbe 100644 let (logger, rl): ( ConsoleWriter, Option>, -@@ -142,6 +148,8 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { +@@ -142,6 +151,8 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { } else { (ConsoleWriter::new(None), None) }; @@ -1326,7 +1523,7 @@ index 7110bc9e..f45cadbe 100644 let fmt_layer = fmt::layer() .with_writer(std::sync::Mutex::new(logger)) -@@ -194,7 +202,11 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { +@@ -194,7 +205,11 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { timestamp: advanced_config.logging.timestamp, }; @@ -1339,7 +1536,7 @@ index 7110bc9e..f45cadbe 100644 }); assert!( -@@ -204,16 +216,57 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { +@@ -204,16 +219,57 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { } pub static SHOULD_STOP: AtomicBool = AtomicBool::new(false); @@ -1398,7 +1595,7 @@ index 7110bc9e..f45cadbe 100644 pub fn stop_or_exit_server() { if SERVER_IS_STOPPING.load(Ordering::Acquire) { // Server is already stopping, so we forcefully exit. -@@ -251,7 +304,7 @@ impl PumpkinServer { +@@ -251,7 +307,7 @@ impl PumpkinServer { ) -> Self { let server = Server::new(basic_config, advanced_config, vanilla_data).await; @@ -1407,7 +1604,7 @@ index 7110bc9e..f45cadbe 100644 adjust_file_descriptor_limit(); let rcon = server.advanced_config.networking.rcon.clone(); -@@ -320,19 +373,30 @@ impl PumpkinServer { +@@ -320,19 +376,30 @@ impl PumpkinServer { None }; @@ -1440,7 +1637,7 @@ index 7110bc9e..f45cadbe 100644 let (bedrock_status, ice_socket) = Self::bind_bedrock_status(&server).await; let nethernet_listener = Self::bind_nethernet(&server, ice_socket).await; -@@ -345,6 +409,15 @@ impl PumpkinServer { +@@ -345,6 +412,15 @@ impl PumpkinServer { } } @@ -1456,7 +1653,7 @@ index 7110bc9e..f45cadbe 100644 async fn bind_nethernet( server: &Arc, ice_socket: Option, -@@ -441,6 +514,7 @@ impl PumpkinServer { +@@ -441,6 +517,7 @@ impl PumpkinServer { } pub async fn start(&self) { @@ -1464,7 +1661,7 @@ index 7110bc9e..f45cadbe 100644 if self.server.advanced_config.commands.use_console && let Some((wrapper, _, _)) = LOGGER_IMPL.wait() { -@@ -527,6 +601,7 @@ impl PumpkinServer { +@@ -527,6 +604,7 @@ impl PumpkinServer { info!("Completed save!"); @@ -1472,7 +1669,7 @@ index 7110bc9e..f45cadbe 100644 if let Some((wrapper, _, _)) = LOGGER_IMPL.wait() && let Some(rl) = wrapper.take_readline() { -@@ -618,7 +693,7 @@ impl PumpkinServer { +@@ -618,7 +696,7 @@ impl PumpkinServer { }); } Err(e) => { @@ -1481,7 +1678,7 @@ index 7110bc9e..f45cadbe 100644 if e.raw_os_error() == Some(libc::EMFILE) { error!( "Too many open files! Server reached file descriptor limit. \ -@@ -719,6 +794,7 @@ impl PumpkinServer { +@@ -719,6 +797,7 @@ impl PumpkinServer { } } @@ -1489,7 +1686,7 @@ index 7110bc9e..f45cadbe 100644 fn setup_stdin_console(server: &Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel(1); let rt = tokio::runtime::Handle::current(); -@@ -759,6 +835,7 @@ fn setup_stdin_console(server: &Arc) { +@@ -759,6 +838,7 @@ fn setup_stdin_console(server: &Arc) { }); } @@ -1497,7 +1694,7 @@ index 7110bc9e..f45cadbe 100644 fn setup_console(mut rl: Editor, server: Arc) { let (tx, mut rx) = tokio::sync::mpsc::channel(1); let (tx_reply, mut rx_reply) = tokio::sync::mpsc::channel(1); -@@ -839,7 +916,7 @@ fn scrub_address(ip: &str) -> String { +@@ -839,7 +919,7 @@ fn scrub_address(ip: &str) -> String { .collect() } @@ -1507,10 +1704,10 @@ index 7110bc9e..f45cadbe 100644 let mut rlim = libc::rlimit { rlim_cur: 0, diff --git a/crates/pumpkin/src/logging.rs b/crates/pumpkin/src/logging.rs -index 85b51a45..4006f6ec 100644 +index 85b51a45..033870ff 100644 --- a/crates/pumpkin/src/logging.rs +++ b/crates/pumpkin/src/logging.rs -@@ -2,11 +2,17 @@ +@@ -2,24 +2,35 @@ #![allow(clippy::print_stdout)] use flate2::write::GzEncoder; @@ -1526,9 +1723,27 @@ index 85b51a45..4006f6ec 100644 use rustyline::validate::Validator; +#[cfg(feature = "console")] use rustyline::{Editor, Helper}; ++#[cfg(feature = "console")] use std::borrow::Cow; use std::fs::File; -@@ -85,15 +91,18 @@ const MAX_ATTEMPTS: u32 = 1000; + use std::io::{self, BufWriter, Write}; + use std::path::PathBuf; ++#[cfg(feature = "console")] + use std::sync::Arc; + use time::{Duration, OffsetDateTime}; + use tracing::Subscriber; + use tracing_subscriber::Layer; + use tracing_subscriber::filter::LevelFilter; + ++#[cfg(feature = "console")] + use crate::command::CommandSender; ++#[cfg(feature = "console")] + use crate::command::string_reader::StringReader; ++#[cfg(feature = "console")] + use crate::server::Server; + + #[macro_export] +@@ -85,15 +96,19 @@ const MAX_ATTEMPTS: u32 = 1000; /// A wrapper for our logger to hold the terminal input while no input is expected in order to /// properly flush logs to the output while they happen instead of batched pub struct ReadlineLogWrapper { @@ -1539,6 +1754,7 @@ index 85b51a45..4006f6ec 100644 pub struct ConsoleWriter { + #[cfg(feature = "console")] printer: Option>, ++ #[cfg(feature = "console")] buffer: Vec, } @@ -1547,7 +1763,7 @@ index 85b51a45..4006f6ec 100644 #[must_use] pub fn new(printer: Option>) -> Self { Self { -@@ -101,9 +110,16 @@ impl ConsoleWriter { +@@ -101,9 +116,16 @@ impl ConsoleWriter { buffer: Vec::new(), } } @@ -1555,7 +1771,7 @@ index 85b51a45..4006f6ec 100644 + #[cfg(not(feature = "console"))] + #[must_use] + pub fn new() -> Self { -+ Self { buffer: Vec::new() } ++ Self {} + } } @@ -1564,7 +1780,7 @@ index 85b51a45..4006f6ec 100644 fn write(&mut self, buf: &[u8]) -> io::Result { if let Some(ref mut printer) = self.printer { self.buffer.extend_from_slice(buf); -@@ -122,6 +138,12 @@ impl Write for ConsoleWriter { +@@ -122,6 +144,12 @@ impl Write for ConsoleWriter { } } @@ -1577,7 +1793,7 @@ index 85b51a45..4006f6ec 100644 fn flush(&mut self) -> io::Result<()> { if let Some(ref mut printer) = self.printer { if !self.buffer.is_empty() { -@@ -138,6 +160,11 @@ impl Write for ConsoleWriter { +@@ -138,6 +166,11 @@ impl Write for ConsoleWriter { io::stdout().flush() } } @@ -1589,7 +1805,7 @@ index 85b51a45..4006f6ec 100644 } struct GzipRollingLoggerData { -@@ -362,6 +389,7 @@ impl tracing::field::Visit for StringVisitor { +@@ -362,6 +395,7 @@ impl tracing::field::Visit for StringVisitor { } impl ReadlineLogWrapper { @@ -1597,7 +1813,7 @@ index 85b51a45..4006f6ec 100644 #[must_use] pub const fn new(rl: Option>) -> Self { Self { -@@ -369,6 +397,13 @@ impl ReadlineLogWrapper { +@@ -369,6 +403,13 @@ impl ReadlineLogWrapper { } } @@ -1611,7 +1827,7 @@ index 85b51a45..4006f6ec 100644 pub fn take_readline(&self) -> Option> { self.readline .lock() -@@ -377,6 +412,7 @@ impl ReadlineLogWrapper { +@@ -377,6 +418,7 @@ impl ReadlineLogWrapper { // This isn't really dead code. It is just only used by the lib and not the bin for this // crate, and as such creates a compiler warning. @@ -1619,7 +1835,7 @@ index 85b51a45..4006f6ec 100644 #[allow(dead_code)] pub fn return_readline(&self, rl: Editor) { if let Ok(mut result) = self.readline.lock() { -@@ -385,12 +421,14 @@ impl ReadlineLogWrapper { +@@ -385,12 +427,14 @@ impl ReadlineLogWrapper { } } @@ -1634,7 +1850,7 @@ index 85b51a45..4006f6ec 100644 impl PumpkinCommandCompleter { #[must_use] pub fn new() -> Self { -@@ -401,7 +439,9 @@ impl PumpkinCommandCompleter { +@@ -401,7 +445,9 @@ impl PumpkinCommandCompleter { } } @@ -1644,7 +1860,7 @@ index 85b51a45..4006f6ec 100644 impl Highlighter for PumpkinCommandCompleter { fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { line.find(' ').map_or_else( -@@ -417,6 +457,7 @@ impl Highlighter for PumpkinCommandCompleter { +@@ -417,6 +463,7 @@ impl Highlighter for PumpkinCommandCompleter { Cow::Owned(format!("\x1b[90m{hint}\x1b[0m")) } } @@ -1652,7 +1868,7 @@ index 85b51a45..4006f6ec 100644 impl Hinter for PumpkinCommandCompleter { type Hint = String; fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option { -@@ -440,8 +481,10 @@ impl Hinter for PumpkinCommandCompleter { +@@ -440,8 +487,10 @@ impl Hinter for PumpkinCommandCompleter { } } @@ -1767,6 +1983,354 @@ index 00000000..90f0989c + None + } +} +diff --git a/crates/pumpkin/src/net/chunk_sender.rs b/crates/pumpkin/src/net/chunk_sender.rs +index efaa2a65..c2e4832b 100644 +--- a/crates/pumpkin/src/net/chunk_sender.rs ++++ b/crates/pumpkin/src/net/chunk_sender.rs +@@ -1,4 +1,5 @@ + use bytes::Bytes; ++#[cfg(not(feature = "single-threaded"))] + use rayon::prelude::*; + use rustc_hash::{FxHashMap, FxHashSet}; + use std::sync::{Arc, Weak}; +@@ -15,6 +16,7 @@ use pumpkin_world::chunk::ChunkData; + use pumpkin_world::level::{Level, SyncChunk}; + + use crate::net::ClientPlatform; ++use crate::net::java::MAX_CHUNK_QUEUE_BYTES; + + const MIN_CHUNKS_PER_TICK: f32 = 0.1; + const MAX_CHUNKS_PER_TICK: f32 = 500.0; +@@ -188,65 +190,91 @@ impl ChunkSender { + batch: &PreparedBatch, + cache: &mut FxHashMap, EncodedChunk>, + ) -> Vec { ++ Self::encode_batch_limited(batch, cache, usize::MAX) ++ } ++ ++ pub fn encode_batch_limited( ++ batch: &PreparedBatch, ++ cache: &mut FxHashMap, EncodedChunk>, ++ byte_limit: usize, ++ ) -> Vec { ++ if byte_limit <= 16 { ++ return Vec::new(); ++ } + let version = batch.target_version; + let cached_map = &*cache; + +- let encoded_results: Vec> = batch +- .chunks +- .par_iter() +- .map(|candidate| { +- let pos = candidate.position; +- if let Some(cached) = cached_map.get(&pos) +- && cached.is_fresh_for(candidate) +- { +- return Some(cached.clone()); +- } ++ let encode = |candidate: &PreparedChunk| { ++ let pos = candidate.position; ++ if let Some(cached) = cached_map.get(&pos) ++ && cached.is_fresh_for(candidate) ++ { ++ return Some(cached.clone()); ++ } + +- let chunk = &candidate.chunk; +- let mut chunk_buf = Vec::with_capacity(32 * 1024); +- if chunk_buf +- .write_var_int(&VarInt(CChunkData::to_id(version))) +- .is_err() +- { +- return None; +- } +- if CChunkData(chunk) +- .write_packet_data(&mut chunk_buf, &version) +- .is_err() +- { +- return None; +- } ++ let chunk = &candidate.chunk; ++ let mut chunk_buf = Vec::with_capacity(32 * 1024); ++ if chunk_buf ++ .write_var_int(&VarInt(CChunkData::to_id(version))) ++ .is_err() ++ { ++ return None; ++ } ++ if CChunkData(chunk) ++ .write_packet_data(&mut chunk_buf, &version) ++ .is_err() ++ { ++ return None; ++ } + +- let light_payload = if version >= JavaMinecraftVersion::V_1_14 +- && version < JavaMinecraftVersion::V_1_18 +- { +- CLightUpdate::from_chunk(chunk, version) +- .ok() +- .and_then(|light_packet| { +- let mut light_buf = Vec::new(); +- (light_buf +- .write_var_int(&VarInt(CLightUpdate::to_id(version))) +- .is_ok() +- && light_packet +- .write_packet_data(&mut light_buf, &version) +- .is_ok()) +- .then(|| Bytes::from(light_buf)) +- }) +- } else { +- None +- }; +- +- Some(EncodedChunk { +- position: pos, +- payload: Bytes::from(chunk_buf), +- light_payload, +- chunk_ref: Arc::downgrade(chunk), +- }) ++ let light_payload = if version >= JavaMinecraftVersion::V_1_14 ++ && version < JavaMinecraftVersion::V_1_18 ++ { ++ CLightUpdate::from_chunk(chunk, version) ++ .ok() ++ .and_then(|light_packet| { ++ let mut light_buf = Vec::new(); ++ (light_buf ++ .write_var_int(&VarInt(CLightUpdate::to_id(version))) ++ .is_ok() ++ && light_packet ++ .write_packet_data(&mut light_buf, &version) ++ .is_ok()) ++ .then(|| Bytes::from(light_buf.into_boxed_slice())) ++ }) ++ } else { ++ None ++ }; ++ ++ Some(EncodedChunk { ++ position: pos, ++ payload: Bytes::from(chunk_buf.into_boxed_slice()), ++ light_payload, ++ chunk_ref: Arc::downgrade(chunk), + }) +- .collect(); ++ }; ++ #[cfg(not(feature = "single-threaded"))] ++ let encoded_results: Vec<_> = batch.chunks.par_iter().filter_map(encode).collect(); ++ #[cfg(feature = "single-threaded")] ++ let encoded_results = { ++ let mut results = Vec::new(); ++ let mut bytes = 0usize; ++ for candidate in &batch.chunks { ++ if bytes >= byte_limit.saturating_sub(16) { ++ break; ++ } ++ if let Some(encoded) = encode(candidate) { ++ bytes = bytes ++ .saturating_add(encoded.payload.len()) ++ .saturating_add(encoded.light_payload.as_ref().map_or(0, Bytes::len)); ++ results.push(encoded); ++ } ++ } ++ results ++ }; + + let mut output = Vec::with_capacity(encoded_results.len()); +- for encoded in encoded_results.into_iter().flatten() { ++ for encoded in encoded_results { + cache.insert(encoded.position, encoded.clone()); + output.push(encoded); + } +@@ -261,47 +289,90 @@ impl ChunkSender { + client: &ClientPlatform, + current_epoch: u32, + ) -> Vec> { +- if current_epoch != batch.epoch_snapshot || encoded_chunks.is_empty() { ++ let ClientPlatform::Java(java) = client else { + return Vec::new(); +- } +- +- let mut dispatched_positions = Vec::with_capacity(encoded_chunks.len()); +- let version = batch.target_version; ++ }; ++ self.commit_java_batch( ++ batch, ++ encoded_chunks, ++ current_epoch, ++ java.available_chunk_bytes(), ++ |selected| { ++ let mut packets = Vec::with_capacity(selected.len() * 2 + 2); ++ if batch.target_version >= JavaMinecraftVersion::V_1_20_2 { ++ let Ok(start) = java.serialize_packet(&CChunkBatchStart) else { ++ return false; ++ }; ++ packets.push(start); ++ } ++ for chunk in selected { ++ packets.push(chunk.payload.clone()); ++ if let Some(light) = &chunk.light_payload { ++ packets.push(light.clone()); ++ } ++ } ++ if batch.target_version >= JavaMinecraftVersion::V_1_20_2 { ++ let Ok(end) = ++ java.serialize_packet(&CChunkBatchEnd::new(selected.len() as u16)) ++ else { ++ return false; ++ }; ++ packets.push(end); ++ } ++ java.try_enqueue_chunk_batch(packets) ++ }, ++ ) ++ } + +- if version >= JavaMinecraftVersion::V_1_20_2 +- && let ClientPlatform::Java(java_client) = client +- { +- java_client.try_send_packet(&CChunkBatchStart); ++ fn commit_java_batch( ++ &mut self, ++ batch: &PreparedBatch, ++ encoded_chunks: &[EncodedChunk], ++ current_epoch: u32, ++ available_bytes: usize, ++ enqueue: impl FnOnce(&[&EncodedChunk]) -> bool, ++ ) -> Vec> { ++ if current_epoch != batch.epoch_snapshot || available_bytes <= 16 { ++ return Vec::new(); + } +- ++ // Leave room for the batch markers. A single larger chunk may use the ++ // whole budget, but only once the previous batch has released all credit. ++ let payload_limit = available_bytes.saturating_sub(16); ++ let mut bytes = 0usize; ++ let mut selected = Vec::new(); + for chunk in encoded_chunks { + if !self.pending_chunks.contains(&chunk.position) { + continue; + } +- +- client.try_enqueue_packet(chunk.payload.clone()); +- if let Some(ref light) = chunk.light_payload { +- client.try_enqueue_packet(light.clone()); ++ let size = chunk ++ .payload ++ .len() ++ .saturating_add(chunk.light_payload.as_ref().map_or(0, Bytes::len)); ++ if bytes.saturating_add(size) > payload_limit ++ && (!selected.is_empty() || available_bytes != MAX_CHUNK_QUEUE_BYTES) ++ { ++ break; + } +- ++ bytes = bytes.saturating_add(size); ++ selected.push(chunk); ++ if bytes >= payload_limit { ++ break; ++ } ++ } ++ if selected.is_empty() || !enqueue(&selected) { ++ return Vec::new(); ++ } ++ let mut dispatched = Vec::with_capacity(selected.len()); ++ for chunk in selected { + self.pending_chunks.remove(&chunk.position); + self.sent_chunks.insert(chunk.position); +- dispatched_positions.push(chunk.position); ++ dispatched.push(chunk.position); + } +- +- let sent_count = dispatched_positions.len(); +- if sent_count > 0 { +- if version >= JavaMinecraftVersion::V_1_20_2 +- && let ClientPlatform::Java(java_client) = client +- { +- java_client.try_send_packet(&CChunkBatchEnd::new(sent_count as u16)); +- self.in_flight_batches = self.in_flight_batches.saturating_add(1); +- } +- +- self.send_quota -= sent_count as f32; ++ if batch.target_version >= JavaMinecraftVersion::V_1_20_2 { ++ self.in_flight_batches = self.in_flight_batches.saturating_add(1); + } +- +- dispatched_positions ++ self.send_quota -= dispatched.len() as f32; ++ dispatched + } + + /// Marks a prepared Bedrock batch as dispatched and returns its chunks for encoding. +@@ -343,6 +414,66 @@ impl Default for ChunkSender { + mod tests { + use super::*; + ++ #[test] ++ fn byte_backpressure_keeps_unsent_chunks_pending_for_retry() { ++ let chunks: Vec<_> = (0..3) ++ .map(|x| PreparedChunk { ++ position: Vector2::new(x, 0), ++ chunk: ChunkData::empty_sync(x, 0), ++ }) ++ .collect(); ++ let encoded: Vec<_> = chunks ++ .iter() ++ .map(|chunk| EncodedChunk { ++ position: chunk.position, ++ payload: Bytes::from(vec![0; 20]), ++ light_payload: None, ++ chunk_ref: Arc::downgrade(&chunk.chunk), ++ }) ++ .collect(); ++ let batch = PreparedBatch { ++ chunks, ++ epoch_snapshot: 7, ++ target_version: JavaMinecraftVersion::V_1_20_2, ++ }; ++ let mut sender = ChunkSender::new(); ++ sender.send_quota = 3.0; ++ for chunk in &batch.chunks { ++ sender.enqueue_chunk(chunk.position); ++ } ++ assert!( ++ sender ++ .commit_java_batch(&batch, &encoded, 7, 64, |_| false) ++ .is_empty() ++ ); ++ assert_eq!(sender.pending_chunks.len(), 3); ++ assert_eq!(sender.in_flight_batches, 0); ++ assert_eq!(sender.send_quota, 3.0); ++ let sent = sender.commit_java_batch(&batch, &encoded, 7, 64, |selected| { ++ assert_eq!(selected.len(), 2); ++ true ++ }); ++ assert_eq!(sent.len(), 2); ++ assert_eq!(sender.pending_chunks.len(), 1); ++ assert_eq!(sender.sent_chunks_count(), 2); ++ assert_eq!(sender.in_flight_batches, 1); ++ assert_eq!(sender.send_quota, 1.0); ++ assert!( ++ sender ++ .commit_java_batch(&batch, &encoded, 8, 64, |_| panic!( ++ "stale batch was queued" ++ )) ++ .is_empty() ++ ); ++ assert_eq!( ++ sender ++ .commit_java_batch(&batch, &encoded, 7, 64, |_| true) ++ .len(), ++ 1 ++ ); ++ assert!(sender.pending_chunks.is_empty()); ++ } ++ + #[test] + fn bedrock_batch_moves_pending_chunks_to_sent() { + let position = Vector2::new(3, -2); diff --git a/crates/pumpkin/src/net/java/config/known_packs.rs b/crates/pumpkin/src/net/java/config/known_packs.rs index 7e45c3db..8a4d5af4 100644 --- a/crates/pumpkin/src/net/java/config/known_packs.rs @@ -1781,10 +2345,41 @@ index 7e45c3db..8a4d5af4 100644 let mut packets = Vec::new(); let mut sent_dimension_type = false; diff --git a/crates/pumpkin/src/net/java/mod.rs b/crates/pumpkin/src/net/java/mod.rs -index fb0e9c90..6583eb12 100644 +index fb0e9c90..b1e2bfdf 100644 --- a/crates/pumpkin/src/net/java/mod.rs +++ b/crates/pumpkin/src/net/java/mod.rs -@@ -190,7 +190,7 @@ async fn frame_batch_maybe_offload( +@@ -43,7 +43,7 @@ use pumpkin_util::version::JavaMinecraftVersion; + use tokio::{ + io::{BufReader, BufWriter}, + net::tcp::{OwnedReadHalf, OwnedWriteHalf}, +- sync::oneshot, ++ sync::{OwnedSemaphorePermit, Semaphore, oneshot}, + }; + use tokio::{ + sync::mpsc::{Receiver, Sender, error::TryRecvError}, +@@ -102,6 +102,9 @@ pub struct JavaClient { + outgoing_packet_priority_send: Sender, + /// A high-priority queue of serialized packets to send to the network. + outgoing_packet_priority_recv: Option>, ++ // Keep chunk-batch start/end markers ordered when a batch yields between groups. ++ chunk_send_lock: tokio::sync::Mutex<()>, ++ chunk_queue_budget: Arc, + /// The packet encoder for outgoing packets. + network_writer: std::sync::Mutex>>>, + /// The packet decoder for incoming packets. +@@ -132,8 +135,11 @@ pub enum OutgoingPacketType { + struct OutgoingPacket { + data: Bytes, + completion: Option>, ++ _chunk_budget: Option>, + } + ++pub(crate) const MAX_CHUNK_QUEUE_BYTES: usize = 2 * 1024 * 1024; ++ + const MAX_FRAME_BATCH_DATA_SIZE: usize = MAX_PACKET_SIZE as usize; + + fn take_frame_batch(packets: &mut VecDeque) -> Vec { +@@ -190,7 +196,7 @@ async fn frame_batch_maybe_offload( .any(|packet| writer.is_compressing_packet(&packet.data)); if needs_offload { @@ -1793,15 +2388,345 @@ index fb0e9c90..6583eb12 100644 let (writer, frame, frame_err) = frame_packet_batch(writer, &packet_batch); (writer, packet_batch, frame, frame_err) }) -@@ -406,7 +406,7 @@ impl JavaClient { +@@ -206,6 +212,7 @@ impl OutgoingPacket { + Self { + data, + completion: None, ++ _chunk_budget: None, + } + } + +@@ -213,10 +220,44 @@ impl OutgoingPacket { + Self { + data, + completion: Some(completion), ++ _chunk_budget: None, + } + } + } + ++// Reserve the complete batch before publishing its start marker. Shared byte ++// credit remains held until every packet in the batch has left the writer. ++fn try_enqueue_chunk_packets( ++ sender: &Sender, ++ budget: &Arc, ++ limit: usize, ++ packets: Vec, ++) -> bool { ++ if packets.is_empty() { ++ return true; ++ } ++ let bytes = packets ++ .iter() ++ .fold(0usize, |total, packet| total.saturating_add(packet.len())); ++ // A single valid chunk larger than the target budget must still make progress. ++ let Ok(credit) = budget ++ .clone() ++ .try_acquire_many_owned(bytes.min(limit) as u32) ++ else { ++ return false; ++ }; ++ let Ok(slots) = sender.try_reserve_many(packets.len()) else { ++ return false; ++ }; ++ let credit = Arc::new(credit); ++ for (slot, data) in slots.zip(packets) { ++ let mut packet = OutgoingPacket::normal(data); ++ packet._chunk_budget = Some(credit.clone()); ++ slot.send(packet); ++ } ++ true ++} ++ + impl JavaClient { + #[must_use] + pub fn from_pending( +@@ -242,6 +283,8 @@ impl JavaClient { + outgoing_packet_priority_send: priority_send, + outgoing_packet_priority_recv: Some(priority_recv), + version: pending.version, ++ chunk_send_lock: tokio::sync::Mutex::new(()), ++ chunk_queue_budget: Arc::new(Semaphore::new(MAX_CHUNK_QUEUE_BYTES)), + network_writer: std::sync::Mutex::new(Some(pending.network_writer)), + network_reader: std::sync::Mutex::new(Some(pending.network_reader)), + brand: ArcSwap::from_pointee(pending.brand), +@@ -382,6 +425,48 @@ impl JavaClient { + } + } + ++ pub fn available_chunk_bytes(&self) -> usize { ++ if self.close_token.is_cancelled() || self.chunk_send_lock.try_lock().is_err() { ++ return 0; ++ } ++ self.chunk_queue_budget.available_permits() ++ } ++ ++ pub fn try_enqueue_chunk_batch(&self, packets: Vec) -> bool { ++ let Ok(_batch) = self.chunk_send_lock.try_lock() else { ++ return false; ++ }; ++ if self.close_token.is_cancelled() { ++ return false; ++ } ++ try_enqueue_chunk_packets( ++ &self.outgoing_packet_queue_send, ++ &self.chunk_queue_budget, ++ MAX_CHUNK_QUEUE_BYTES, ++ packets, ++ ) ++ } ++ ++ async fn enqueue_chunk_packet(&self, data: Bytes) { ++ let credit = tokio::select! { ++ biased; ++ () = self.close_token.cancelled() => return, ++ credit = self.chunk_queue_budget.clone().acquire_many_owned(data.len().min(MAX_CHUNK_QUEUE_BYTES) as u32) => credit, ++ }; ++ let Ok(credit) = credit else { ++ return; ++ }; ++ let mut packet = OutgoingPacket::normal(data); ++ packet._chunk_budget = Some(Arc::new(credit)); ++ tokio::select! { ++ biased; ++ () = self.close_token.cancelled() => {}, ++ result = self.outgoing_packet_queue_send.send(packet) => { ++ if result.is_err() { self.close(); } ++ } ++ } ++ } ++ + pub async fn send_chunks(&self, chunks: &[SyncChunk]) { + let player = self.player.load_full(); + let Some(player) = player.as_ref() else { +@@ -405,69 +490,78 @@ impl JavaClient { + } let version = self.version.load(); - let (tx, rx) = oneshot::channel(); +- let (tx, rx) = oneshot::channel(); - rayon::spawn(move || { -+ pumpkin_util::rayon_spawn(move || { - let mut serialized = Vec::with_capacity(valid_chunks.len()); - for chunk in valid_chunks { - let mut buf = Vec::with_capacity(32 * 1024); +- let mut serialized = Vec::with_capacity(valid_chunks.len()); +- for chunk in valid_chunks { +- let mut buf = Vec::with_capacity(32 * 1024); +- if let Err(err) = buf.write_var_int(&VarInt(CChunkData::to_id(version))) { +- error!("Failed to write chunk data id: {err:?}"); +- continue; +- } +- if let Err(err) = CChunkData(&chunk).write_packet_data(&mut buf, &version) { +- error!("Failed to write chunk data: {err:?}"); +- continue; +- } ++ let _batch_guard = self.chunk_send_lock.lock().await; ++ if self.close_token.is_cancelled() { ++ return; ++ } ++ if version >= JavaMinecraftVersion::V_1_20_2 { ++ self.send_packet(&CChunkBatchStart).await; ++ } ++ // Bound temporary serialization storage independently of the player's view. ++ for group in valid_chunks.chunks(8) { ++ if self.close_token.is_cancelled() { ++ return; ++ } ++ let valid_chunks = group.to_vec(); ++ let (tx, rx) = oneshot::channel(); ++ pumpkin_util::rayon_spawn(move || { ++ let mut serialized = Vec::with_capacity(valid_chunks.len()); ++ for chunk in valid_chunks { ++ let mut buf = Vec::with_capacity(32 * 1024); ++ if let Err(err) = buf.write_var_int(&VarInt(CChunkData::to_id(version))) { ++ error!("Failed to write chunk data id: {err:?}"); ++ continue; ++ } ++ if let Err(err) = CChunkData(&chunk).write_packet_data(&mut buf, &version) { ++ error!("Failed to write chunk data: {err:?}"); ++ continue; ++ } + +- let light_buf = if version >= JavaMinecraftVersion::V_1_14 +- && version < JavaMinecraftVersion::V_1_18 +- { +- match CLightUpdate::from_chunk(&chunk, version) { +- Ok(light_packet) => { +- let mut light_buf = Vec::new(); +- if let Err(err) = +- light_buf.write_var_int(&VarInt(CLightUpdate::to_id(version))) +- { +- error!("Failed to write light update id: {err:?}"); +- None +- } else if let Err(err) = +- light_packet.write_packet_data(&mut light_buf, &version) +- { +- error!("Failed to write light update data: {err:?}"); ++ let light_buf = if version >= JavaMinecraftVersion::V_1_14 ++ && version < JavaMinecraftVersion::V_1_18 ++ { ++ match CLightUpdate::from_chunk(&chunk, version) { ++ Ok(light_packet) => { ++ let mut light_buf = Vec::new(); ++ if let Err(err) = ++ light_buf.write_var_int(&VarInt(CLightUpdate::to_id(version))) ++ { ++ error!("Failed to write light update id: {err:?}"); ++ None ++ } else if let Err(err) = ++ light_packet.write_packet_data(&mut light_buf, &version) ++ { ++ error!("Failed to write light update data: {err:?}"); ++ None ++ } else { ++ Some(Bytes::from(light_buf.into_boxed_slice())) ++ } ++ } ++ Err(err) => { ++ error!("Failed to create light update packet: {err:?}"); + None +- } else { +- Some(Bytes::from(light_buf)) + } + } +- Err(err) => { +- error!("Failed to create light update packet: {err:?}"); +- None +- } +- } +- } else { +- None +- }; +- +- serialized.push((Bytes::from(buf), light_buf)); +- } +- let _ = tx.send(serialized); +- }); ++ } else { ++ None ++ }; + +- let Ok(serialized) = rx.await else { +- return; +- }; ++ serialized.push((Bytes::from(buf.into_boxed_slice()), light_buf)); ++ } ++ let _ = tx.send(serialized); ++ }); + +- if version >= JavaMinecraftVersion::V_1_20_2 { +- self.send_packet(&CChunkBatchStart).await; +- } ++ let Ok(serialized) = rx.await else { ++ return; ++ }; + +- for (chunk_data, light_data) in serialized { +- self.enqueue_packet(chunk_data).await; +- if let Some(light_data) = light_data { +- self.enqueue_packet(light_data).await; ++ for (chunk_data, light_data) in serialized { ++ self.enqueue_chunk_packet(chunk_data).await; ++ if let Some(light_data) = light_data { ++ self.enqueue_chunk_packet(light_data).await; ++ } + } + } +- + if version >= JavaMinecraftVersion::V_1_20_2 { + self.send_packet(&CChunkBatchEnd::new(chunks.len() as u16)) + .await; +@@ -746,7 +840,7 @@ impl JavaClient { + } + + let mut packets_to_frame = VecDeque::from(packet_batch); +- let mut written_packets = Vec::with_capacity(packets_to_frame.len()); ++ let mut completions = Vec::with_capacity(packets_to_frame.len()); + let mut send_failed = false; + + while !packets_to_frame.is_empty() { +@@ -780,7 +874,11 @@ impl JavaClient { + break; + } + +- written_packets.extend(returned_batch); ++ for packet in returned_batch { ++ if let Some(completion) = packet.completion { ++ completions.push(completion); ++ } ++ } + } + + if !send_failed && let Err(err) = writer.flush().await { +@@ -796,10 +894,8 @@ impl JavaClient { + break; + } + +- for packet in written_packets { +- if let Some(completion) = packet.completion { +- let _ = completion.send(()); +- } ++ for completion in completions { ++ let _ = completion.send(()); + } + } + }); +@@ -1311,3 +1407,59 @@ impl JavaClient { + Ok(()) + } + } ++ ++#[cfg(test)] ++mod chunk_queue_tests { ++ use super::*; ++ ++ #[test] ++ fn full_byte_budget_rejects_the_whole_batch_without_publishing_a_marker() { ++ let (sender, mut receiver) = tokio::sync::mpsc::channel(4); ++ let budget = Arc::new(Semaphore::new(10)); ++ let busy = budget.clone().try_acquire_many_owned(6).unwrap(); ++ assert!(!try_enqueue_chunk_packets( ++ &sender, ++ &budget, ++ 10, ++ vec![Bytes::from_static(b"start"), Bytes::from_static(b"data")] ++ )); ++ assert!(receiver.try_recv().is_err()); ++ assert_eq!(sender.capacity(), 4); ++ assert_eq!(budget.available_permits(), 4); ++ drop(busy); ++ assert!(try_enqueue_chunk_packets( ++ &sender, ++ &budget, ++ 10, ++ vec![Bytes::from_static(b"start"), Bytes::from_static(b"data")] ++ )); ++ assert_eq!(budget.available_permits(), 1); ++ drop(receiver.try_recv().unwrap()); ++ assert_eq!(budget.available_permits(), 1); ++ drop(receiver.try_recv().unwrap()); ++ assert_eq!(budget.available_permits(), 10); ++ } ++ ++ #[test] ++ fn insufficient_slots_return_byte_credit_and_leave_the_queue_unchanged() { ++ let (sender, mut receiver) = tokio::sync::mpsc::channel(1); ++ let budget = Arc::new(Semaphore::new(10)); ++ assert!(!try_enqueue_chunk_packets( ++ &sender, ++ &budget, ++ 10, ++ vec![Bytes::from_static(b"a"), Bytes::from_static(b"b")] ++ )); ++ assert!(receiver.try_recv().is_err()); ++ assert_eq!(budget.available_permits(), 10); ++ assert!(try_enqueue_chunk_packets( ++ &sender, ++ &budget, ++ 10, ++ vec![Bytes::from(vec![0; 20])] ++ )); ++ assert_eq!(budget.available_permits(), 0); ++ drop(receiver.try_recv().unwrap()); ++ assert_eq!(budget.available_permits(), 10); ++ } ++} diff --git a/crates/pumpkin/src/net/java/play/chat_message.rs b/crates/pumpkin/src/net/java/play/chat_message.rs index 58927731..ba28c745 100644 --- a/crates/pumpkin/src/net/java/play/chat_message.rs @@ -1842,32 +2767,66 @@ index fa7f8c5a..61bbe63f 100644 WasmInitializationError(#[from] PluginInitError), } diff --git a/crates/pumpkin/src/plugin/mod.rs b/crates/pumpkin/src/plugin/mod.rs -index b686898d..33ed73b3 100644 +index b686898d..ed4d4cec 100644 --- a/crates/pumpkin/src/plugin/mod.rs +++ b/crates/pumpkin/src/plugin/mod.rs @@ -1,6 +1,9 @@ use arc_swap::ArcSwap; use futures::future::join_all; -use loader::{LoaderError, PluginLoader, native::NativePluginLoader}; -+use loader::{LoaderError, PluginLoader}; +#[cfg(feature = "plugin-loaders")] +use loader::native::NativePluginLoader; ++use loader::{LoaderError, PluginLoader}; +#[cfg(feature = "plugin-loaders")] use notify::{EventKind, RecursiveMode, Watcher, event::ModifyKind}; use std::{ any::Any, -@@ -27,7 +30,9 @@ pub mod loader; +@@ -12,11 +15,12 @@ use std::{ + time::Duration, + }; + use thiserror::Error; +-use tokio::{ +- sync::{Notify, RwLock}, +- task::JoinHandle, +-}; +-use tracing::{debug, error, info, warn}; ++use tokio::sync::{Notify, RwLock}; ++#[cfg(feature = "plugin-loaders")] ++use tokio::task::JoinHandle; ++#[cfg(feature = "plugin-loaders")] ++use tracing::debug; ++use tracing::{error, info, warn}; + + pub mod api; + pub mod cache; +@@ -27,7 +31,9 @@ pub mod loader; /// host features. pub mod permissions; -use crate::{LOGGER_IMPL, plugin::loader::wasm::WasmPluginLoader, server::Server}; -+use crate::{LOGGER_IMPL, server::Server}; +#[cfg(feature = "plugin-loaders")] +use crate::plugin::loader::wasm::WasmPluginLoader; ++use crate::{LOGGER_IMPL, server::Server}; pub use api::*; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; -@@ -238,10 +243,20 @@ impl PluginManager { +@@ -192,6 +198,7 @@ pub struct PluginManager { + // Notification for plugin state changes + state_notify: Arc, + // Background task for hot reloading ++ #[cfg(feature = "plugin-loaders")] + hot_reload_task: RwLock>>, + hot_reload_enabled: AtomicBool, + // Thread ID of the thread that created the plugin manager. +@@ -210,6 +217,7 @@ struct LoadedPlugin { + loader_data: Option>, + is_active: bool, + context: Arc, ++ #[cfg(feature = "plugin-loaders")] + path: PathBuf, + } + +@@ -238,15 +246,26 @@ impl PluginManager { pub fn new(verify_plugin_signatures: bool) -> Self { Self { plugins: SyncRwLock::new(Vec::new()), @@ -1892,7 +2851,13 @@ index b686898d..33ed73b3 100644 handlers: Arc::new(ArcSwap::from_pointee(HashMap::new())), unloaded_files: RwLock::new(HashSet::new()), services: Arc::new(RwLock::new(HashMap::new())), -@@ -285,6 +300,7 @@ impl PluginManager { + plugin_states: RwLock::new(HashMap::new()), + state_notify: Arc::new(Notify::new()), ++ #[cfg(feature = "plugin-loaders")] + hot_reload_task: RwLock::new(None), + hot_reload_enabled: AtomicBool::new(false), + main_thread_id: std::thread::current().id(), +@@ -285,6 +304,7 @@ impl PluginManager { } /// Start watching the plugins directory for changes @@ -1900,7 +2865,7 @@ index b686898d..33ed73b3 100644 pub async fn start_watcher(self: &Arc, server: &Arc) -> Result<(), ManagerError> { if self.hot_reload_task.read().await.is_some() { return Ok(()); -@@ -368,6 +384,7 @@ impl PluginManager { +@@ -368,6 +388,7 @@ impl PluginManager { } /// Stop watching the plugins directory for changes @@ -1908,13 +2873,16 @@ index b686898d..33ed73b3 100644 pub async fn stop_watcher(&self) { let mut task_lock = self.hot_reload_task.write().await; if let Some(handle) = task_lock.take() { -@@ -376,6 +393,15 @@ impl PluginManager { +@@ -376,6 +397,18 @@ impl PluginManager { self.set_hot_reload_enabled(false); } + /// No-op watcher controls when plugin loaders are compiled out. + #[cfg(not(feature = "plugin-loaders"))] -+ pub async fn start_watcher(self: &Arc, _server: &Arc) -> Result<(), ManagerError> { ++ pub async fn start_watcher( ++ self: &Arc, ++ _server: &Arc, ++ ) -> Result<(), ManagerError> { + Ok(()) + } + @@ -1924,7 +2892,7 @@ index b686898d..33ed73b3 100644 pub fn set_hot_reload_enabled(&self, enabled: bool) { self.hot_reload_enabled .store(enabled, std::sync::atomic::Ordering::Relaxed); -@@ -512,6 +538,7 @@ impl PluginManager { +@@ -512,6 +545,7 @@ impl PluginManager { "Do you want to allow these permissions and load the plugin?".bold() ); @@ -1932,7 +2900,7 @@ index b686898d..33ed73b3 100644 let mut rl_taken = if let Some(logger_option) = crate::LOGGER_IMPL.get() && let Some((wrapper, _, _)) = logger_option && let Some(rl) = wrapper.take_readline() -@@ -520,7 +547,10 @@ impl PluginManager { +@@ -520,7 +554,10 @@ impl PluginManager { } else { None }; @@ -1943,7 +2911,7 @@ index b686898d..33ed73b3 100644 let result = if let Some((_, ref mut rl)) = rl_taken { rl.readline(&prompt).is_ok_and(|line| { let input = line.trim().to_lowercase(); -@@ -533,7 +563,17 @@ impl PluginManager { +@@ -533,7 +570,17 @@ impl PluginManager { ); false }; @@ -1961,7 +2929,26 @@ index b686898d..33ed73b3 100644 if let Some((wrapper, rl)) = rl_taken { wrapper.return_readline(rl); } -@@ -731,14 +771,17 @@ impl PluginManager { +@@ -550,7 +597,7 @@ impl PluginManager { + metadata: PluginMetadata, + loader_data: Box, + loader: Arc, +- path: PathBuf, ++ _path: PathBuf, + ) -> Result, ManagerError> { + // Mark plugin as loading + self.plugin_states +@@ -574,7 +621,8 @@ impl PluginManager { + loader_data: Some(loader_data), + is_active: false, // Will be set to true after successful initialization + context: context.clone(), +- path, ++ #[cfg(feature = "plugin-loaders")] ++ path: _path, + }; + + let plugin_index = { +@@ -731,14 +779,17 @@ impl PluginManager { .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("wasm")) { @@ -1987,7 +2974,7 @@ index b686898d..33ed73b3 100644 } } -@@ -940,16 +983,19 @@ impl PluginManager { +@@ -940,16 +991,19 @@ impl PluginManager { .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("wasm")) { @@ -2017,7 +3004,7 @@ index b686898d..33ed73b3 100644 } } -@@ -1257,6 +1303,7 @@ impl PluginManager { +@@ -1257,6 +1311,7 @@ impl PluginManager { return; } @@ -2025,7 +3012,7 @@ index b686898d..33ed73b3 100644 if tokio::runtime::Handle::try_current().is_ok() { tokio::task::block_in_place(|| { server.runtime.block_on(self.fire(server, event)); -@@ -1264,6 +1311,13 @@ impl PluginManager { +@@ -1264,6 +1319,13 @@ impl PluginManager { } else { server.runtime.block_on(self.fire(server, event)); } @@ -2094,15 +3081,24 @@ index 28690a86..5f58b8b5 100644 self.scheduled_functions.tick( self, diff --git a/crates/pumpkin/src/server/scheduler.rs b/crates/pumpkin/src/server/scheduler.rs -index e2056b48..f6221d64 100644 +index e2056b48..1cfb9bb5 100644 --- a/crates/pumpkin/src/server/scheduler.rs +++ b/crates/pumpkin/src/server/scheduler.rs -@@ -1,3 +1,4 @@ +@@ -1,12 +1,19 @@ +#[cfg(feature = "plugin-loaders")] use crate::plugin::loader::wasm::wasm_host::WasmPlugin; use crate::server::Server; use std::cmp::Ordering; -@@ -7,6 +8,7 @@ use std::sync::{Arc, Mutex, Weak}; +-use std::collections::{BinaryHeap, HashSet}; ++use std::collections::BinaryHeap; ++#[cfg(feature = "plugin-loaders")] ++use std::collections::HashSet; ++#[cfg(feature = "plugin-loaders")] ++use std::sync::Weak; ++#[cfg(feature = "plugin-loaders")] + use std::sync::atomic::Ordering as AtomicOrdering; +-use std::sync::{Arc, Mutex, Weak}; ++use std::sync::{Arc, Mutex}; pub type TaskId = u32; @@ -2110,7 +3106,7 @@ index e2056b48..f6221d64 100644 pub struct ScheduledTask { pub id: TaskId, pub plugin: Arc, -@@ -15,20 +17,24 @@ pub struct ScheduledTask { +@@ -15,20 +22,24 @@ pub struct ScheduledTask { pub period: Option, } @@ -2135,7 +3131,7 @@ index e2056b48..f6221d64 100644 impl Ord for ScheduledTask { fn cmp(&self, other: &Self) -> Ordering { // Reverse order so BinaryHeap is a min-heap -@@ -36,6 +42,7 @@ impl Ord for ScheduledTask { +@@ -36,6 +47,7 @@ impl Ord for ScheduledTask { } } @@ -2143,7 +3139,7 @@ index e2056b48..f6221d64 100644 pub struct TaskScheduler { tasks: Mutex>, cancelled_tasks: Mutex>, -@@ -43,12 +50,14 @@ pub struct TaskScheduler { +@@ -43,12 +55,14 @@ pub struct TaskScheduler { next_task_id: std::sync::atomic::AtomicU32, } @@ -2245,10 +3241,18 @@ index ae45c2b0..1f35b272 100644 if STOP_INTERRUPT.is_cancelled() { break 'ticker; diff --git a/crates/pumpkin/src/world/portal/mod.rs b/crates/pumpkin/src/world/portal/mod.rs -index aeadba75..85bf10af 100644 +index aeadba75..c62761b3 100644 --- a/crates/pumpkin/src/world/portal/mod.rs +++ b/crates/pumpkin/src/world/portal/mod.rs -@@ -92,6 +92,7 @@ impl PortalType { +@@ -4,6 +4,7 @@ use pumpkin_data::Block; + use pumpkin_data::block_properties::HorizontalAxis; + use pumpkin_data::dimension::Dimension; + use pumpkin_util::math::position::BlockPos; ++#[cfg(not(feature = "single-threaded"))] + use pumpkin_util::math::vector2::Vector2; + use pumpkin_util::math::vector3::Vector3; + use pumpkin_world::world::BlockFlags; +@@ -92,6 +93,7 @@ impl PortalType { let platform_pos = BlockPos::new(100, 49, 0); // Ensure chunks covering the platform are loaded/generated @@ -2256,7 +3260,7 @@ index aeadba75..85bf10af 100644 if let Ok(handle) = tokio::runtime::Handle::try_current() { tokio::task::block_in_place(|| { handle.block_on(async { -@@ -207,6 +208,7 @@ impl PortalType { +@@ -207,6 +209,7 @@ impl PortalType { ) .or_else(|| { // Ensure the chunks around approximate_exit_pos are generated/loaded in dest_world diff --git a/patches/pumpkin-memory.patch b/patches/pumpkin-memory.patch index 3edd278..adcd3fb 100644 --- a/patches/pumpkin-memory.patch +++ b/patches/pumpkin-memory.patch @@ -1,4 +1,4 @@ -Subject: [PATCH] Reduce Pumpkin structure-template and generation memory +Subject: [PATCH] Reduce Pumpkin template, chunk, and idle pathfinder memory Loaded structure templates previously retained a block-state description for every block and a second placement list. Store palette indices, share the first @@ -12,11 +12,559 @@ dense arrays. Use a dense scratch buffer only during the active noise pass to avoid per-voxel palette bookkeeping, then compact it before retaining the chunk. Generation dependencies, dimensions, and block values remain unchanged. +Use packed in-memory palette indices, share light arrays with copy-on-write, +allocate carving-mask words on demand, and grow pathfinding heaps only when +needed. Preserve block values, lighting initialization, and search behavior. + +Limit pooled density buffers by bytes and prune structure-start entries that +no active generation task references. Concurrent cache misses share a canonical +collector so pointer-based structure deduplication remains valid. + Base-commit: b5b9b9d7010e793806a83c495af223c67e1d35ee --- +diff --git a/crates/pumpkin-world/src/chunk/format/mod.rs b/crates/pumpkin-world/src/chunk/format/mod.rs +index 68432e19..cd52b03b 100644 +--- a/crates/pumpkin-world/src/chunk/format/mod.rs ++++ b/crates/pumpkin-world/src/chunk/format/mod.rs +@@ -2,7 +2,7 @@ use std::{ + path::PathBuf, + str::FromStr, + sync::{ +- RwLock, ++ Arc, LazyLock, RwLock, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + }; +@@ -256,9 +256,9 @@ impl ChunkData { + }); + + block_lights[index] = +- block_light.map_or(LightContainer::Empty(0), LightContainer::Full); ++ block_light.map_or(LightContainer::Empty(0), LightContainer::new); + sky_lights[index] = +- sky_light.map_or(LightContainer::Empty(0), LightContainer::Full); ++ sky_light.map_or(LightContainer::Empty(0), LightContainer::new); + + if let Some(bs_compound) = section_compound.get_compound("block_states") { + let data = bs_compound +@@ -810,7 +810,8 @@ pub struct ChunkSectionBlockStates { + #[derive(Debug, Clone)] + pub enum LightContainer { + Empty(u8), +- Full(Box<[u8]>), ++ // Clones share immutable data; writes detach through Arc::make_mut. ++ Full(Arc<[u8]>), + } + + impl LightContainer { +@@ -830,14 +831,38 @@ impl LightContainer { + "Data length must be {}", + Self::ARRAY_SIZE + ); +- Self::Full(data) ++ let value = data[0]; ++ if value >> 4 == value & 15 && data.iter().all(|&byte| byte == value) { ++ Self::new_filled(value & 15) ++ } else { ++ Self::Full(data.into()) ++ } + } + + #[must_use] + pub fn new_filled(default: u8) -> Self { + assert!(default <= 15, "Default value must be between 0 and 15"); +- let value = default << 4 | default; +- Self::Full([value; Self::ARRAY_SIZE].into()) ++ // Keep initialized uniform light distinct from Empty: packet masks and ++ // saved light data must retain the same semantics as a full array. ++ static UNIFORM: LazyLock<[Arc<[u8]>; 16]> = LazyLock::new(|| { ++ std::array::from_fn(|value| vec![(value as u8) * 17; LightContainer::ARRAY_SIZE].into()) ++ }); ++ Self::Full(Arc::clone(&UNIFORM[default as usize])) ++ } ++ ++ /// Intern arrays that became uniform while lighting was propagated. ++ pub fn compact(&mut self) { ++ if let Self::Full(data) = self { ++ // Shared uniform arrays are already interned. Other shared arrays ++ // remain unchanged until a writer detaches them. ++ if Arc::strong_count(data) != 1 || data.len() != Self::ARRAY_SIZE { ++ return; ++ } ++ let value = data[0]; ++ if value >> 4 == value & 15 && data.iter().all(|&byte| byte == value) { ++ *self = Self::new_filled(value & 15); ++ } ++ } + } + + #[must_use] +@@ -869,7 +894,10 @@ impl LightContainer { + let index = Self::index(x, y, z); + let shift = 4 * (index & 1); + let mask = 0x0F << shift; +- data[index >> 1] = (data[index >> 1] & !mask) | (value << shift); ++ let updated = (data[index >> 1] & !mask) | (value << shift); ++ if data[index >> 1] != updated { ++ Arc::make_mut(data)[index >> 1] = updated; ++ } + } + Self::Empty(default) => { + if value != *default { +@@ -898,6 +926,10 @@ impl LightContainer { + let mask = 0x0F << shift; + let val = (value & 0x0F) << shift; + let mut byte_idx = (y_start * 256 + z * 16 + x) >> 1; ++ if (0..y_end - y_start).all(|dy| data[byte_idx + dy * 128] & mask == val) { ++ return; ++ } ++ let data = Arc::make_mut(data); + for _ in y_start..y_end { + data[byte_idx] = (data[byte_idx] & !mask) | val; + byte_idx += 128; +@@ -931,6 +963,58 @@ mod tests { + use pumpkin_nbt::compound::NbtCompound; + use pumpkin_nbt::tag::NbtTag; + ++ #[test] ++ fn propagated_uniform_light_is_compacted_without_becoming_empty() { ++ let mut light = LightContainer::new_empty(0); ++ for x in 0..16 { ++ for z in 0..16 { ++ light.set_column_y_range(x, z, 0, 16, 15); ++ } ++ } ++ light.compact(); ++ let shared = LightContainer::new_filled(15); ++ let (LightContainer::Full(a), LightContainer::Full(b)) = (&light, &shared) else { ++ panic!("compaction must preserve initialized light"); ++ }; ++ assert!(Arc::ptr_eq(a, b)); ++ assert!(a.iter().all(|&value| value == 255)); ++ light.set(0, 0, 0, 0); ++ assert_eq!(shared.get(0, 0, 0), 15); ++ } ++ ++ #[test] ++ fn uniform_light_is_shared_without_changing_initialized_state() { ++ let mut a = LightContainer::new_filled(15); ++ let b = LightContainer::new(vec![255; LightContainer::ARRAY_SIZE].into_boxed_slice()); ++ assert!(!a.is_empty()); ++ let (LightContainer::Full(a_data), LightContainer::Full(b_data)) = (&a, &b) else { ++ panic!("initialized light must remain Full"); ++ }; ++ assert!(Arc::ptr_eq(a_data, b_data)); ++ a.set(1, 2, 3, 15); ++ let LightContainer::Full(a_data) = &a else { ++ unreachable!() ++ }; ++ assert!(Arc::ptr_eq(a_data, b_data)); ++ a.set(1, 2, 3, 7); ++ assert_eq!(a.get(1, 2, 3), 7); ++ assert_eq!(a.get(0, 2, 3), 15); ++ assert_eq!(b.get(1, 2, 3), 15); ++ } ++ ++ #[test] ++ fn cloned_light_columns_do_not_mutate_the_source() { ++ let mut source = LightContainer::new_filled(0); ++ source.set(4, 3, 2, 6); ++ let mut copy = source.clone(); ++ copy.set_column_y_range(4, 2, 0, 16, 12); ++ for y in 0..16 { ++ assert_eq!(copy.get(4, y, 2), 12); ++ assert_eq!(copy.get(5, y, 2), 0); ++ assert_eq!(source.get(4, y, 2), if y == 3 { 6 } else { 0 }); ++ } ++ } ++ + #[test] + fn extract_u16_array_from_vanilla_compound_palette() { + let mut entry1 = NbtCompound::new(); +diff --git a/crates/pumpkin-world/src/chunk/palette.rs b/crates/pumpkin-world/src/chunk/palette.rs +index 39ed39a7..911e320d 100644 +--- a/crates/pumpkin-world/src/chunk/palette.rs ++++ b/crates/pumpkin-world/src/chunk/palette.rs +@@ -51,7 +51,66 @@ pub struct HeterogeneousPaletteData { + #[derive(Clone)] + enum PaletteStorage { + Dense(Box>), +- Indexed(Box>), ++ Indexed(PackedIndices), ++} ++ ++/// In-memory palette indices use 1, 2, 4, or 8 bits per block. Wire-format ++/// packing remains independent because Minecraft pads entries within each u64. ++#[derive(Clone)] ++struct PackedIndices { ++ bytes: Box<[u8]>, ++ len: usize, ++ bits: u8, ++} ++ ++impl PackedIndices { ++ fn width(palette_len: usize) -> u8 { ++ match palette_len { ++ 0..=2 => 1, ++ 3..=4 => 2, ++ 5..=16 => 4, ++ _ => 8, ++ } ++ } ++ ++ fn new(len: usize, palette_len: usize) -> Self { ++ let bits = Self::width(palette_len); ++ Self { ++ bytes: vec![0; len.div_ceil(8 / bits as usize)].into_boxed_slice(), ++ len, ++ bits, ++ } ++ } ++ ++ fn from_slice(indices: &[u8], palette_len: usize) -> Self { ++ let mut packed = Self::new(indices.len(), palette_len); ++ for (i, &index) in indices.iter().enumerate() { ++ packed.set(i, index); ++ } ++ packed ++ } ++ ++ #[inline] ++ fn get(&self, index: usize) -> u8 { ++ let bit = index * self.bits as usize; ++ (self.bytes[bit >> 3] >> (bit & 7)) & (u8::MAX >> (8 - self.bits)) ++ } ++ ++ #[inline] ++ fn set(&mut self, index: usize, value: u8) { ++ if value > (u8::MAX >> (8 - self.bits)) { ++ let mut wider = Self::new(self.len, value as usize + 1); ++ for i in 0..self.len { ++ wider.set(i, self.get(i)); ++ } ++ *self = wider; ++ } ++ let bit = index * self.bits as usize; ++ let shift = bit & 7; ++ let mask = (u8::MAX >> (8 - self.bits)) << shift; ++ let byte = &mut self.bytes[bit >> 3]; ++ *byte = (*byte & !mask) | (value << shift); ++ } + } + + impl HeterogeneousPaletteData { +@@ -62,7 +121,9 @@ impl HeterogeneousPaletteData cube[y][z][x], +- PaletteStorage::Indexed(indices) => self.palette[indices[y][z][x] as usize], ++ PaletteStorage::Indexed(indices) => { ++ self.palette[indices.get((y * DIM + z) * DIM + x) as usize] ++ } + } + } + +@@ -101,7 +162,7 @@ impl HeterogeneousPaletteData { + if new_index <= 255 { +- indices[y][z][x] = new_index as u8; ++ indices.set((y * DIM + z) * DIM + x, new_index as u8); + } else { + // Upgrade to Dense + let mut cube = Box::new([[[V::default(); DIM]; DIM]; DIM]); +@@ -111,10 +172,7 @@ impl HeterogeneousPaletteData HeterogeneousPaletteData PalettedContainer + indices.as_flattened_mut().as_flattened_mut()[i] = idx as u8; + } + Self::Heterogeneous(Box::new(HeterogeneousPaletteData { +- storage: PaletteStorage::Indexed(indices), ++ storage: PaletteStorage::Indexed(PackedIndices::from_slice( ++ indices.as_flattened().as_flattened(), ++ palette.len(), ++ )), + palette, + counts, + })) +@@ -241,7 +298,10 @@ impl PalettedContainer + } + + let storage = if palette.len() <= 256 && std::mem::size_of::() > 1 { +- PaletteStorage::Indexed(indices) ++ PaletteStorage::Indexed(PackedIndices::from_slice( ++ indices.as_flattened().as_flattened(), ++ palette.len(), ++ )) + } else { + PaletteStorage::Dense(cube) + }; +@@ -286,19 +346,16 @@ impl PalettedContainer + }) + }) + .collect(), +- PaletteStorage::Indexed(indices) => indices +- .as_flattened() +- .as_flattened() +- .chunks(blocks_per_i64 as usize) +- .map(|chunk| { +- chunk.iter().enumerate().fold(0, |acc, (index, key_index)| { +- let key_index = *key_index as usize; +- debug_assert!((1 << bits_per_entry) > key_index); +- +- let packed_offset_index = +- (key_index as u64) << (bits_per_entry as u64 * index as u64); +- acc | packed_offset_index as i64 +- }) ++ PaletteStorage::Indexed(indices) => (0..indices.len) ++ .step_by(blocks_per_i64 as usize) ++ .map(|start| { ++ (start..(start + blocks_per_i64 as usize).min(indices.len)) ++ .enumerate() ++ .fold(0i64, |word, (offset, i)| { ++ word | ((indices.get(i) as u64) ++ << (bits_per_entry as usize * offset)) ++ as i64 ++ }) + }) + .collect(), + }; +@@ -352,14 +409,15 @@ impl PalettedContainer + counts[idx] += 1; + } else { + warn!("Lookup index out of bounds! Defaulting..."); +- // value is already 0, and counts[0] will be updated correctly if we track it ++ counts[0] += 1; + } + } +- // fix counts[0] if it was skipped in out-of-bounds cases (rare) +- // But actually we should just ensure it's correct. + + return Self::Heterogeneous(Box::new(HeterogeneousPaletteData { +- storage: PaletteStorage::Indexed(indices), ++ storage: PaletteStorage::Indexed(PackedIndices::from_slice( ++ indices.as_flattened().as_flattened(), ++ palette.len(), ++ )), + palette: palette.to_vec(), + counts, + })); +@@ -459,13 +517,9 @@ impl PalettedContainer + PaletteStorage::Dense(cube) => { + Box::new(cube.as_flattened().as_flattened().iter().copied()) + } +- PaletteStorage::Indexed(indices) => Box::new( +- indices +- .as_flattened() +- .as_flattened() +- .iter() +- .map(|&idx| data.palette[idx as usize]), +- ), ++ PaletteStorage::Indexed(indices) => { ++ Box::new((0..indices.len).map(move |i| data.palette[indices.get(i) as usize])) ++ } + }, + } + } +@@ -494,13 +548,9 @@ impl<'a, V: Hash + Eq + Copy + Default, const DIM: usize> IntoIterator + PaletteStorage::Dense(cube) => { + Box::new(cube.as_flattened().as_flattened().iter().copied()) + } +- PaletteStorage::Indexed(indices) => Box::new( +- indices +- .as_flattened() +- .as_flattened() +- .iter() +- .map(|&idx| data.palette[idx as usize]), +- ), ++ PaletteStorage::Indexed(indices) => { ++ Box::new((0..indices.len).map(move |i| data.palette[indices.get(i) as usize])) ++ } + }, + } + } +@@ -1004,7 +1054,7 @@ pub(crate) const BIOME_NETWORK_MAX_BITS: u8 = 7; + + #[cfg(test)] + mod tests { +- use super::{BlockPalette, NetworkPalette}; ++ use super::{BlockPalette, NetworkPalette, PackedIndices, PaletteStorage, PalettedContainer}; + use pumpkin_data::{Block, BlockStateId}; + + fn network_palette_values(palette: NetworkPalette) -> Option> { +@@ -1047,6 +1097,65 @@ mod tests { + ); + } + ++ #[test] ++ fn packed_palette_growth_remapping_and_serialization_preserve_values() { ++ for colors in [2usize, 3, 5, 17, 257] { ++ let mut palette = PalettedContainer::::from_fn(|x, y, z| { ++ (((y * 16 + z) * 16 + x) % colors) as u16 ++ }); ++ let mut expected: Vec = (0..4096).map(|i| (i % colors) as u16).collect(); ++ assert_eq!(palette.iter().collect::>(), expected); ++ if colors <= 256 { ++ let PalettedContainer::Heterogeneous(data) = &palette else { ++ unreachable!() ++ }; ++ let PaletteStorage::Indexed(indices) = &data.storage else { ++ unreachable!() ++ }; ++ assert_eq!( ++ indices.bytes.len(), ++ 4096 * PackedIndices::width(colors) as usize / 8 ++ ); ++ } ++ for i in [0, 1, 15, 16, 63, 255, 1024, 4095] { ++ let value = (colors + i % 7) as u16; ++ let old = palette.set(i % 16, i / 256, i / 16 % 16, value); ++ assert_eq!(old, expected[i]); ++ expected[i] = value; ++ } ++ assert_eq!(palette.iter().collect::>(), expected); ++ let bits = palette.bits_per_entry().max(4); ++ let (keys, words) = palette.to_palette_and_packed_data(bits); ++ let decoded = ++ PalettedContainer::::from_palette_and_packed_data(&keys, &words, 4); ++ assert_eq!(decoded.iter().collect::>(), expected); ++ for i in 0..4096 { ++ palette.set(i % 16, i / 256, i / 16 % 16, 7); ++ } ++ assert!(matches!(palette, PalettedContainer::Homogeneous(7))); ++ } ++ } ++ ++ #[test] ++ fn packed_indices_expand_across_all_widths() { ++ let mut indices = PackedIndices::new(27, 2); ++ for value in [1, 3, 15, 255] { ++ for i in [0, 1, 7, 8, 26] { ++ indices.set(i, value); ++ } ++ for i in 0..27 { ++ assert_eq!( ++ indices.get(i), ++ if [0, 1, 7, 8, 26].contains(&i) { ++ value ++ } else { ++ 0 ++ } ++ ); ++ } ++ } ++ } ++ + #[test] + fn bulk_palette_matches_individual_mutations() { + let states = [ +diff --git a/crates/pumpkin-world/src/generation/carver/mask.rs b/crates/pumpkin-world/src/generation/carver/mask.rs +index f9ccd277..8e47504f 100644 +--- a/crates/pumpkin-world/src/generation/carver/mask.rs ++++ b/crates/pumpkin-world/src/generation/carver/mask.rs +@@ -1,6 +1,7 @@ + pub struct CarvingMask { + min_y: i32, + mask: Vec, ++ words: usize, + } + + impl CarvingMask { +@@ -11,7 +12,8 @@ impl CarvingMask { + let num_u64s = (total_bits as usize).div_ceil(64); + Self { + min_y, +- mask: vec![0; num_u64s], ++ mask: Vec::new(), ++ words: num_u64s, + } + } + +@@ -27,6 +29,14 @@ impl CarvingMask { + let bit_index = self.get_bit_index(x, y, z); + let u64_idx = bit_index / 64; + let bit_in_u64 = bit_index % 64; ++ assert!( ++ u64_idx < self.words, ++ "Carving mask position is outside the chunk" ++ ); ++ if u64_idx >= self.mask.len() { ++ self.mask.reserve_exact(u64_idx + 1 - self.mask.len()); ++ self.mask.resize(u64_idx + 1, 0); ++ } + self.mask[u64_idx] |= 1 << bit_in_u64; + } + +@@ -35,6 +45,38 @@ impl CarvingMask { + let bit_index = self.get_bit_index(x, y, z); + let u64_idx = bit_index / 64; + let bit_in_u64 = bit_index % 64; +- (self.mask[u64_idx] >> bit_in_u64) & 1 != 0 ++ assert!( ++ u64_idx < self.words, ++ "Carving mask position is outside the chunk" ++ ); ++ self.mask ++ .get(u64_idx) ++ .is_some_and(|word| (word >> bit_in_u64) & 1 != 0) ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::CarvingMask; ++ ++ #[test] ++ fn uncarved_space_needs_no_backing_array() { ++ let mut mask = CarvingMask::new(384, -64); ++ assert!(!mask.get(0, 319, 0)); ++ assert!(mask.mask.is_empty()); ++ mask.set(-1, -64, 17); ++ assert!(mask.get(15, -64, 1)); ++ assert!(!mask.get(14, -64, 1)); ++ assert!(!mask.get(15, 319, 1)); ++ assert!(mask.mask.capacity() < mask.words); ++ mask.set(15, 319, 15); ++ assert!(mask.get(15, 319, 15)); ++ assert!(mask.get(15, -64, 1)); ++ } ++ ++ #[test] ++ #[should_panic(expected = "outside the chunk")] ++ fn reads_outside_the_dimension_still_fail() { ++ let _ = CarvingMask::new(384, -64).get(0, -65, 0); + } + } diff --git a/crates/pumpkin-world/src/generation/feature/features/fossil.rs b/crates/pumpkin-world/src/generation/feature/features/fossil.rs -index 226ee1833..0e774e8bc 100644 +index 226ee183..0e774e8b 100644 --- a/crates/pumpkin-world/src/generation/feature/features/fossil.rs +++ b/crates/pumpkin-world/src/generation/feature/features/fossil.rs @@ -169,7 +169,7 @@ fn place_fossil_template( @@ -27,9 +575,115 @@ index 226ee1833..0e774e8bc 100644 + for block in template.blocks.iter() { let local_pos = rotation.transform_pos(block.pos, template.size); let world_pos = origin + local_pos; - + +diff --git a/crates/pumpkin-world/src/generation/noise/router/density_volume.rs b/crates/pumpkin-world/src/generation/noise/router/density_volume.rs +index 106a65d5..10f24c74 100644 +--- a/crates/pumpkin-world/src/generation/noise/router/density_volume.rs ++++ b/crates/pumpkin-world/src/generation/noise/router/density_volume.rs +@@ -6,9 +6,10 @@ pub use pumpkin_util::noise::volume::DensityVolume; + const BUFFER_SIZE_INCREMENT: usize = 16; + const MAX_REUSE_SIZE_FACTOR: usize = 2; + const MAX_POOLED_BUFFERS: usize = 1024; ++const MAX_POOLED_BYTES: usize = 4 * 1024 * 1024; + + thread_local! { +- static DENSITY_BUFFER_POOL: RefCell>> = const { RefCell::new(Vec::new()) }; ++ static DENSITY_BUFFER_POOL: RefCell = const { RefCell::new(BufferPool { buffers: Vec::new(), bytes: 0 }) }; + } + + fn take_best( +@@ -31,6 +32,27 @@ fn take_best( + best_index.map(|i| pool.remove(i)) + } + ++struct BufferPool { ++ buffers: Vec>, ++ bytes: usize, ++} ++ ++impl BufferPool { ++ fn take(&mut self, min: usize, max: usize) -> Option> { ++ let buffer = take_best(&mut self.buffers, min, max)?; ++ self.bytes -= std::mem::size_of_val(&*buffer); ++ Some(buffer) ++ } ++ ++ fn put(&mut self, buffer: Box<[f32]>) { ++ let bytes = std::mem::size_of_val(&*buffer); ++ if self.buffers.len() < MAX_POOLED_BUFFERS && bytes <= MAX_POOLED_BYTES - self.bytes { ++ self.bytes += bytes; ++ self.buffers.push(buffer); ++ } ++ } ++} ++ + pub struct DensityBuffer { + values: Box<[f32]>, + len: usize, +@@ -47,11 +69,8 @@ impl DensityBuffer { + let min_capacity = len.next_multiple_of(BUFFER_SIZE_INCREMENT); + let values = DENSITY_BUFFER_POOL + .with(|pool| { +- take_best( +- &mut pool.borrow_mut(), +- min_capacity, +- min_capacity * MAX_REUSE_SIZE_FACTOR, +- ) ++ pool.borrow_mut() ++ .take(min_capacity, min_capacity * MAX_REUSE_SIZE_FACTOR) + }) + .unwrap_or_else(|| vec![0.0; min_capacity].into_boxed_slice()); + Self { values, len } +@@ -83,10 +102,43 @@ impl Drop for DensityBuffer { + fn drop(&mut self) { + let values = std::mem::take(&mut self.values); + DENSITY_BUFFER_POOL.with(|pool| { +- let mut pool = pool.borrow_mut(); +- if pool.len() < MAX_POOLED_BUFFERS { +- pool.push(values); +- } ++ pool.borrow_mut().put(values); + }); + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn retained_buffers_obey_byte_budget_and_return_credit_on_reuse() { ++ let mut pool = BufferPool { ++ buffers: Vec::new(), ++ bytes: 0, ++ }; ++ let len = MAX_POOLED_BYTES / std::mem::size_of::(); ++ pool.put(vec![7.0; len].into_boxed_slice()); ++ pool.put(vec![0.0; 16].into_boxed_slice()); ++ assert_eq!(pool.buffers.len(), 1); ++ assert_eq!(pool.bytes, MAX_POOLED_BYTES); ++ assert!(pool.take(16, 32).is_none()); ++ assert_eq!(pool.bytes, MAX_POOLED_BYTES); ++ let buffer = pool.take(len, len).unwrap(); ++ assert_eq!(pool.bytes, 0); ++ assert!(buffer.iter().all(|&value| value == 7.0)); ++ pool.put(buffer); ++ assert_eq!(pool.bytes, MAX_POOLED_BYTES); ++ } ++ ++ #[test] ++ fn oversized_buffers_are_not_retained() { ++ let mut pool = BufferPool { ++ buffers: Vec::new(), ++ bytes: 0, ++ }; ++ pool.put(vec![0.0; MAX_POOLED_BYTES / 4 + 16].into_boxed_slice()); ++ assert!(pool.buffers.is_empty()); ++ assert_eq!(pool.bytes, 0); ++ } ++} diff --git a/crates/pumpkin-world/src/generation/proto_chunk.rs b/crates/pumpkin-world/src/generation/proto_chunk.rs -index 727a58a31..cc4c8e77e 100644 +index 727a58a3..cc4c8e77 100644 --- a/crates/pumpkin-world/src/generation/proto_chunk.rs +++ b/crates/pumpkin-world/src/generation/proto_chunk.rs @@ -38,6 +38,7 @@ use super::{ @@ -68,7 +722,7 @@ index 727a58a31..cc4c8e77e 100644 @@ -506,13 +513,6 @@ impl ProtoChunk { x as usize * CHUNK_DIM as usize + z as usize } - + - #[inline] - const fn local_pos_to_block_index(&self, x: i32, y: i32, z: i32) -> usize { - self.height() as usize * CHUNK_DIM as usize * x as usize @@ -90,12 +744,12 @@ index 727a58a31..cc4c8e77e 100644 + } + self.block_sections[y as usize / 16].get(x as usize, y as usize & 15, z as usize) } - + #[inline] @@ -573,8 +575,17 @@ impl ProtoChunk { } } - + - let index = self.local_pos_to_block_index(local_x, local_y, local_z); - self.flat_block_map[index] = block_state.id; + if let Some(blocks) = &mut self.noise_blocks { @@ -110,12 +764,12 @@ index 727a58a31..cc4c8e77e 100644 + ); + } } - + #[inline] @@ -869,6 +880,18 @@ impl ProtoChunk { let volume = *noise_sampler.volume(); let densities = noise_sampler.sample_density(); - + + let height = self.height as usize; + let mut blocks = vec![BlockStateId::AIR; CHUNK_AREA * height]; + for x in 0..16 { @@ -141,10 +795,150 @@ index 727a58a31..cc4c8e77e 100644 + BlockPalette::from_fn(|x, y, z| blocks[(x * height + section * 16 + y) * 16 + z]); + } } - + pub fn spawn_mobs(cache: &mut T, block_registry: &dyn WorldPortalExt) { +diff --git a/crates/pumpkin-world/src/generation/structure/placement.rs b/crates/pumpkin-world/src/generation/structure/placement.rs +index 250263ff..c96f1ad3 100644 +--- a/crates/pumpkin-world/src/generation/structure/placement.rs ++++ b/crates/pumpkin-world/src/generation/structure/placement.rs +@@ -10,7 +10,8 @@ use pumpkin_util::{ + }, + }; + use std::f64::consts::PI; +-use std::sync::OnceLock; ++use std::sync::atomic::{AtomicUsize, Ordering}; ++use std::sync::{Arc, OnceLock}; + + use crate::ProtoChunk; + use dashmap::DashMap; +@@ -31,6 +32,7 @@ pub struct GlobalStructureCache { + /// world seed, so it is computed once here instead of being recomputed for every + /// surrounding chunk whose structure references overlap it. + structure_starts: OnceLock>>, ++ inserted_starts: AtomicUsize, + } + impl GlobalStructureCache { + /// Creates a new, empty global structure cache. +@@ -39,6 +41,7 @@ impl GlobalStructureCache { + Self { + stronghold_chunks: OnceLock::new(), + structure_starts: OnceLock::new(), ++ inserted_starts: AtomicUsize::new(0), + } + } + +@@ -66,8 +69,29 @@ impl GlobalStructureCache { + return cached.value().clone(); + } + let computed = compute(); +- cache.insert((key, chunk_x, chunk_z), computed.clone()); +- computed ++ // Concurrent requests must share the same collector: generation deduplicates ++ // structure references by Arc identity. ++ let result = cache ++ .entry((key, chunk_x, chunk_z)) ++ .or_insert(computed) ++ .clone(); ++ if self.inserted_starts.fetch_add(1, Ordering::Relaxed) % 256 == 255 && cache.len() > 2048 { ++ self.evict_unused_starts(); ++ } ++ result ++ } ++ ++ fn evict_unused_starts(&self) { ++ if let Some(cache) = self.structure_starts.get() { ++ // Active generation keeps its own Arc. Never replace a collector that ++ // another chunk can still use; idle placements can be recomputed from ++ // their world seed and start coordinates. ++ cache.retain(|_, position| { ++ position ++ .as_ref() ++ .is_some_and(|position| Arc::strong_count(&position.collector) > 1) ++ }); ++ } + } + + /// Retrieves the list of chunk coordinates for Concentric Ring structures. +@@ -450,3 +474,77 @@ mod tests { + )); + } + } ++ ++#[cfg(test)] ++mod cache_tests { ++ use super::*; ++ use crate::generation::structure::structures::StructurePiecesCollector; ++ use pumpkin_util::math::position::BlockPos; ++ use std::sync::Mutex; ++ ++ fn position() -> StructurePosition { ++ StructurePosition { ++ start_pos: BlockPos::new(1, 2, 3), ++ collector: Arc::new(Mutex::new(StructurePiecesCollector::new())), ++ } ++ } ++ ++ #[test] ++ fn exploration_releases_idle_entries_but_preserves_active_collectors() { ++ let cache = GlobalStructureCache::new(); ++ let active = cache ++ .get_or_compute_structure_start(StructureKeys::Monument, -1, -1, || Some(position())) ++ .unwrap(); ++ for x in 0..10_000 { ++ assert!( ++ cache ++ .get_or_compute_structure_start(StructureKeys::Monument, x, 0, || None) ++ .is_none() ++ ); ++ } ++ assert!(cache.structure_starts.get().unwrap().len() <= 2304); ++ cache.evict_unused_starts(); ++ let reused = cache ++ .get_or_compute_structure_start(StructureKeys::Monument, -1, -1, || { ++ panic!("active collector was evicted") ++ }) ++ .unwrap(); ++ assert!(Arc::ptr_eq(&active.collector, &reused.collector)); ++ drop(reused); ++ drop(active); ++ cache.evict_unused_starts(); ++ assert!(cache.structure_starts.get().unwrap().is_empty()); ++ let rebuilt = cache ++ .get_or_compute_structure_start(StructureKeys::Monument, -1, -1, || Some(position())) ++ .unwrap(); ++ assert_eq!(rebuilt.start_pos, BlockPos::new(1, 2, 3)); ++ } ++ ++ #[test] ++ fn concurrent_misses_return_one_canonical_collector() { ++ let cache = GlobalStructureCache::new(); ++ let barrier = std::sync::Barrier::new(2); ++ std::thread::scope(|scope| { ++ let a = scope.spawn(|| { ++ cache ++ .get_or_compute_structure_start(StructureKeys::Monument, 0, 0, || { ++ barrier.wait(); ++ Some(position()) ++ }) ++ .unwrap() ++ }); ++ let b = scope.spawn(|| { ++ cache ++ .get_or_compute_structure_start(StructureKeys::Monument, 0, 0, || { ++ barrier.wait(); ++ Some(position()) ++ }) ++ .unwrap() ++ }); ++ assert!(Arc::ptr_eq( ++ &a.join().unwrap().collector, ++ &b.join().unwrap().collector ++ )); ++ }); ++ } ++} diff --git a/crates/pumpkin-world/src/generation/structure/structures/end_city/layout.rs b/crates/pumpkin-world/src/generation/structure/structures/end_city/layout.rs -index a7f29d8dc..ca0dc47dd 100644 +index a7f29d8d..ca0dc47d 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/end_city/layout.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/end_city/layout.rs @@ -663,7 +663,7 @@ mod tests { @@ -157,7 +951,7 @@ index a7f29d8dc..ca0dc47dd 100644 Rotation::None, Rotation::Clockwise90, diff --git a/crates/pumpkin-world/src/generation/structure/structures/end_city/mod.rs b/crates/pumpkin-world/src/generation/structure/structures/end_city/mod.rs -index 8089b3e69..9bb82ed9c 100644 +index 8089b3e6..9bb82ed9 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/end_city/mod.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/end_city/mod.rs @@ -98,7 +98,7 @@ impl EndCityTemplatePiece { @@ -176,36 +970,36 @@ index 8089b3e69..9bb82ed9c 100644 - Self::place_block_entity(chunk, palette, block.nbt.as_ref(), position, random); + Self::place_block_entity(chunk, palette, block.nbt.as_deref(), position, random); } - + - for block in &self.descriptor.template.blocks { + for block in self.descriptor.template.blocks.iter() { let palette = &self.descriptor.template.palette[block.state as usize]; if palette.name != "minecraft:structure_block" { continue; diff --git a/crates/pumpkin-world/src/generation/structure/structures/igloo.rs b/crates/pumpkin-world/src/generation/structure/structures/igloo.rs -index 9130b4626..363993029 100644 +index 9130b462..36399302 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/igloo.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/igloo.rs @@ -190,7 +190,7 @@ impl IglooPiece { &mut context_rng, ); - + - for block in &self.template.blocks { + for block in self.template.blocks.iter() { let palette_entry = &self.template.palette[block.state as usize]; - + let local_pos = @@ -226,7 +226,7 @@ impl IglooPiece { continue; } - + - let mut block_entity_nbt = block.nbt.clone(); + let mut block_entity_nbt = block.nbt.as_deref().cloned(); let placed_entry = palette_entry.clone(); - + let Some(state) = BlockStateResolver::resolve(&placed_entry, rotation, mirror) else { diff --git a/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs b/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs -index ef4f28879..17adbfbf0 100644 +index ef4f2887..17adbfbf 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/jigsaw.rs @@ -310,7 +310,7 @@ impl PoolElementKind { @@ -218,7 +1012,7 @@ index ef4f28879..17adbfbf0 100644 block, &template.palette[block.state as usize], diff --git a/crates/pumpkin-world/src/generation/structure/structures/mansion/mod.rs b/crates/pumpkin-world/src/generation/structure/structures/mansion/mod.rs -index e4cb49032..53b67b4a6 100644 +index e4cb4903..53b67b4a 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/mansion/mod.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/mansion/mod.rs @@ -276,7 +276,7 @@ impl MansionTemplatePiece { @@ -240,68 +1034,68 @@ index e4cb49032..53b67b4a6 100644 random, ); diff --git a/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs b/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs -index 73047f6d6..da14d13c1 100644 +index 73047f6d..da14d13c 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/nether_fossil.rs @@ -182,10 +182,10 @@ impl NetherFossilPiece { &mut context_rng, ); - + - for block in &self.template.blocks { + for block in self.template.blocks.iter() { let palette_entry = &self.template.palette[block.state as usize]; - + - let mut block_entity_nbt = block.nbt.clone(); + let mut block_entity_nbt = block.nbt.as_deref().cloned(); let placed_entry = palette_entry.clone(); - + let Some(state) = BlockStateResolver::resolve(&placed_entry, rotation, mirror) else { diff --git a/crates/pumpkin-world/src/generation/structure/structures/ocean_ruin.rs b/crates/pumpkin-world/src/generation/structure/structures/ocean_ruin.rs -index 5823f93bd..abe91eba9 100644 +index 5823f93b..abe91eba 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/ocean_ruin.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/ocean_ruin.rs @@ -459,7 +459,7 @@ impl StructurePieceBase for OceanRuinPiece { - + let mut suspicious_count = 0; - + - for block in &self.template.blocks { + for block in self.template.blocks.iter() { let palette_entry = &self.template.palette[block.state as usize]; - + if palette_entry.name == "minecraft:structure_void" diff --git a/crates/pumpkin-world/src/generation/structure/structures/ruined_portal.rs b/crates/pumpkin-world/src/generation/structure/structures/ruined_portal.rs -index 404a63b21..a486ec2a9 100644 +index 404a63b2..a486ec2a 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/ruined_portal.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/ruined_portal.rs @@ -293,10 +293,10 @@ impl RuinedPortalPiece { &mut context_rng, ); - + - for block in &self.template.blocks { + for block in self.template.blocks.iter() { let palette_entry = &self.template.palette[block.state as usize]; - + - let mut block_entity_nbt = block.nbt.clone(); + let mut block_entity_nbt = block.nbt.as_deref().cloned(); let placed_entry = palette_entry.clone(); - + let Some(mut state) = BlockStateResolver::resolve(&placed_entry, rotation, mirror) diff --git a/crates/pumpkin-world/src/generation/structure/structures/shipwreck.rs b/crates/pumpkin-world/src/generation/structure/structures/shipwreck.rs -index 53ced600c..029775470 100644 +index 53ced600..02977547 100644 --- a/crates/pumpkin-world/src/generation/structure/structures/shipwreck.rs +++ b/crates/pumpkin-world/src/generation/structure/structures/shipwreck.rs @@ -307,7 +307,7 @@ impl StructurePieceBase for ShipwreckPiece { - + // Place non-marker blocks let box_limit = self.piece.bounding_box; - for block in &self.template.blocks { + for block in self.template.blocks.iter() { let palette_entry = &self.template.palette[block.state as usize]; - + if palette_entry.name == "minecraft:structure_block" @@ -393,7 +393,7 @@ impl StructurePieceBase for ShipwreckPiece { } - + // Process data markers - for block in &self.template.blocks { + for block in self.template.blocks.iter() { @@ -309,43 +1103,43 @@ index 53ced600c..029775470 100644 if palette_entry.name == "minecraft:structure_block" && let Some(nbt) = &block.nbt diff --git a/crates/pumpkin-world/src/generation/structure/template/mod.rs b/crates/pumpkin-world/src/generation/structure/template/mod.rs -index 5e1f6636c..aa7938006 100644 +index 5e1f6636..aa793800 100644 --- a/crates/pumpkin-world/src/generation/structure/template/mod.rs +++ b/crates/pumpkin-world/src/generation/structure/template/mod.rs @@ -117,7 +117,7 @@ pub fn place_template_with_options( let mut context_rng = LegacyRand::from_seed(hash_block_pos(world_x, origin.y, world_z) as u64); let mut context = processor::ProcessorContext::new(origin, processors, &mut context_rng); - + - for block in &template.blocks { + for block in template.blocks.iter() { let palette_entry = &template.palette[block.state as usize]; - + // Structure blocks are data markers. @@ -125,7 +125,7 @@ pub fn place_template_with_options( continue; } - + - let mut block_entity_nbt = block.nbt.clone(); + let mut block_entity_nbt = block.nbt.as_deref().cloned(); let mut placed_entry = palette_entry.clone(); - + // Jigsaw blocks are replaced during template processing, before block entities are diff --git a/crates/pumpkin-world/src/generation/structure/template/structure_template.rs b/crates/pumpkin-world/src/generation/structure/template/structure_template.rs -index 211c79e90..bd0406775 100644 +index 211c79e9..bd040677 100644 --- a/crates/pumpkin-world/src/generation/structure/template/structure_template.rs +++ b/crates/pumpkin-world/src/generation/structure/template/structure_template.rs @@ -4,6 +4,7 @@ //! into a runtime representation with palettes, entities, block info, and transformations. - + use std::io::Cursor; +use std::sync::Arc; - + use pumpkin_data::{Mirror, Rotation}; use pumpkin_nbt::{compound::NbtCompound, nbt_compress::read_gzip_compound_tag, tag::NbtTag}; @@ -195,9 +196,9 @@ pub struct StructureTemplate { pub size: Vector3, pub author: String, - + - // Backward-compatible fields - pub palette: Vec, - pub blocks: Vec, @@ -354,11 +1148,11 @@ index 211c79e90..bd0406775 100644 + pub blocks: Arc<[TemplateBlock]>, pub entities: Vec, } - + @@ -332,6 +333,20 @@ pub struct StructureBlockInfo { pub nbt: Option, } - + +/// A borrowed view over an indexed template block. Only callers producing a +/// transformed/mutable block need to materialize owned strings and NBT. +pub struct StructureBlockInfoRef<'a> { @@ -385,7 +1179,7 @@ index 211c79e90..bd0406775 100644 + entries: Arc<[PaletteEntry]>, cached_jigsaws: Option>, } - + impl Palette { #[must_use] - pub const fn new(blocks: Vec) -> Self { @@ -405,7 +1199,7 @@ index 211c79e90..bd0406775 100644 cached_jigsaws: None, } } - + #[must_use] - pub fn blocks(&self) -> &[StructureBlockInfo] { - &self.blocks @@ -420,7 +1214,7 @@ index 211c79e90..bd0406775 100644 + nbt: block.nbt.as_deref(), + } } - + #[must_use] pub fn blocks_by_name(&self, filter: &str) -> Vec { - self.blocks @@ -431,7 +1225,7 @@ index 211c79e90..bd0406775 100644 + .map(StructureBlockInfoRef::into_owned) .collect() } - + pub fn jigsaws(&mut self) -> &[JigsawBlockInfo] { - self.cached_jigsaws.get_or_insert_with(|| { - self.blocks @@ -451,10 +1245,10 @@ index 211c79e90..bd0406775 100644 + self.cached_jigsaws.as_deref().unwrap() } } - + @@ -507,11 +542,11 @@ impl SimplePalette { } - + /// A single block placement in the template (legacy format). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] @@ -464,7 +1258,7 @@ index 211c79e90..bd0406775 100644 - pub nbt: Option, + pub nbt: Option>, } - + /// An entity to spawn when placing the template (legacy format). @@ -842,7 +877,7 @@ impl StructureTemplate { result.push(StructureBlockInfo::new( @@ -582,7 +1376,7 @@ index 211c79e90..bd0406775 100644 + }); Ok(()) } - + - fn add_to_lists( - info: StructureBlockInfo, - full_block_list: &mut Vec, @@ -604,7 +1398,7 @@ index 211c79e90..bd0406775 100644 @@ -1066,48 +1091,13 @@ impl StructureTemplate { || stripped.contains("scaffolding")) } - + - fn build_info_list( - mut full_block_list: Vec, - mut block_entities_list: Vec, @@ -652,12 +1446,12 @@ index 211c79e90..bd0406775 100644 + self.palette = Arc::default(); + self.blocks = Arc::default(); } - + self.entities = self @@ -1140,7 +1130,7 @@ impl StructureTemplate { let mut block_list = Vec::new(); let main_palette_blocks = self.palettes[0].blocks(); - + - for (i, block_info) in main_palette_blocks.iter().enumerate() { + for (i, block_info) in main_palette_blocks.enumerate() { let mut block_tag = NbtCompound::new(); @@ -666,14 +1460,14 @@ index 211c79e90..bd0406775 100644 @@ -1152,14 +1142,15 @@ impl StructureTemplate { let id = palettes[0].id_for(block_info.state.clone()); block_tag.put_int(Self::BLOCK_TAG_STATE, id as i32); - + - if let Some(nbt) = &block_info.nbt { + if let Some(nbt) = block_info.nbt { block_tag.put_compound(Self::BLOCK_TAG_NBT, nbt.clone()); } - + block_list.push(NbtTag::Compound(block_tag)); - + for p in 1..self.palettes.len() { - let state = self.palettes[p].blocks()[i].state.clone(); + let state = @@ -683,17 +1477,17 @@ index 211c79e90..bd0406775 100644 } @@ -1263,7 +1254,7 @@ impl StructureTemplate { } - + #[must_use] - pub const fn block_count(&self) -> usize { + pub fn block_count(&self) -> usize { self.blocks.len() } - + @@ -1282,6 +1273,102 @@ impl StructureTemplate { mod tests { use super::*; - + + fn assert_same_blocks(before: &Palette, after: &Palette) { + assert_eq!(before.blocks.len(), after.blocks.len()); + for (index, (a, b)) in before.blocks().zip(after.blocks()).enumerate() { @@ -794,24 +1588,87 @@ index 211c79e90..bd0406775 100644 fn palette_entry_creation() { let entry = PaletteEntry::new("minecraft:stone".to_string()); diff --git a/crates/pumpkin-world/src/generation/structure/template/template_piece.rs b/crates/pumpkin-world/src/generation/structure/template/template_piece.rs -index e3f9357b6..68c33f67a 100644 +index e3f9357b..68c33f67 100644 --- a/crates/pumpkin-world/src/generation/structure/template/template_piece.rs +++ b/crates/pumpkin-world/src/generation/structure/template/template_piece.rs @@ -176,7 +176,7 @@ impl TemplatePiece { .get_bounding_box(&self.place_settings, self.template_position); let box_limit = self.piece.bounding_box; - + - for block in &self.template.blocks { + for block in self.template.blocks.iter() { let palette_entry = &self.template.palette[block.state as usize]; - + // Structure blocks are data markers @@ -185,7 +185,7 @@ impl TemplatePiece { } - + let mut placed_entry = palette_entry.clone(); - let mut block_entity_nbt = block.nbt.clone(); + let mut block_entity_nbt = block.nbt.as_deref().cloned(); - + // Jigsaw blocks are replaced with final_state if palette_entry.name == "minecraft:jigsaw" { +diff --git a/crates/pumpkin-world/src/lighting/engine.rs b/crates/pumpkin-world/src/lighting/engine.rs +index 47318e43..d28e77f3 100644 +--- a/crates/pumpkin-world/src/lighting/engine.rs ++++ b/crates/pumpkin-world/src/lighting/engine.rs +@@ -786,6 +786,10 @@ impl LightEngine { + + self.block_light.clear(); + self.sky_light.clear(); ++ let light = &mut cache.get_center_chunk_mut().light; ++ for section in light.sky_light.iter_mut().chain(&mut light.block_light) { ++ section.compact(); ++ } + } + + pub fn update_block_light( +diff --git a/crates/pumpkin/src/entity/ai/pathfinder/binary_heap.rs b/crates/pumpkin/src/entity/ai/pathfinder/binary_heap.rs +index 5bf1caa5..e6990a67 100644 +--- a/crates/pumpkin/src/entity/ai/pathfinder/binary_heap.rs ++++ b/crates/pumpkin/src/entity/ai/pathfinder/binary_heap.rs +@@ -19,8 +19,8 @@ pub struct BinaryHeap { + impl BinaryHeap { + #[must_use] + pub fn new() -> Self { +- let mut heap = Vec::with_capacity(1024); +- heap.push(None); ++ // Most navigators are idle. Reserve nodes when a search actually needs them. ++ let heap = vec![None]; + + Self { + heap, +@@ -238,3 +238,32 @@ impl Default for BinaryHeap { + Self::new() + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::{BinaryHeap, Node}; ++ use pumpkin_util::math::position::BlockPos; ++ ++ #[test] ++ fn idle_heaps_grow_without_changing_pathfinding_order() { ++ let mut heap = BinaryHeap::new(); ++ assert!(heap.heap.capacity() < 1024); ++ assert!(heap.is_empty()); ++ for x in (0..1500).rev() { ++ let mut node = Node::new(BlockPos::new(x, 0, 0)); ++ node.f = x as f32; ++ heap.insert(node); ++ } ++ for x in 0..1500 { ++ assert_eq!(heap.pop().unwrap().pos, BlockPos::new(x, 0, 0)); ++ } ++ assert!(heap.pop().is_none()); ++ heap.clear(); ++ assert!(heap.get_heap().is_empty()); ++ let mut node = Node::new(BlockPos::new(42, 0, 0)); ++ node.f = 1.0; ++ heap.insert(node); ++ assert_eq!(heap.drain().len(), 1); ++ assert!(heap.is_empty()); ++ } ++} diff --git a/patches/worker-fs-mount-open-mode.patch b/patches/worker-fs-mount-open-mode.patch new file mode 100644 index 0000000..3fd1d4b --- /dev/null +++ b/patches/worker-fs-mount-open-mode.patch @@ -0,0 +1,30 @@ +Subject: [PATCH] Mask numeric open/fchmod modes to their permission bits + +Node's fs accepts a numeric mode with type bits set (parseFileMode keeps +mode & 0o7777); worker-fs-mount's fd-sync rejected anything above 0o7777 +with ERR_OUT_OF_RANGE. Emscripten's FS.open passes S_IFREG | perms to its +backend, so file creation through NODERAWFS on a mounted path failed. + +Applied to the installed package by `npm install` (postinstall) until +danlapid/worker-fs-mount ships the fix (guybedford/worker-fs-mount +fix/open-mode-mask). + +Base-package: worker-fs-mount@0.2.0 + +--- +--- a/node_modules/worker-fs-mount/dist/fd-sync.js ++++ b/node_modules/worker-fs-mount/dist/fd-sync.js +@@ -36,10 +36,12 @@ + } + return value; + } ++// Like Node's parseFileMode: a numeric mode keeps only its permission bits, so ++// callers passing a full st_mode (e.g. S_IFREG | 0o644) are accepted. + function permissionMode(mode) { + if (typeof mode === 'string' && !/^[0-7]+$/.test(mode)) + fail('ERR_INVALID_ARG_VALUE', 'mode'); +- return integer(typeof mode === 'string' ? Number.parseInt(mode, 8) : mode, 'mode', 0o7777); ++ return integer(typeof mode === 'string' ? Number.parseInt(mode, 8) : mode, 'mode') & 0o7777; + } + function options(flags, mode) { + const c = realFs.constants; diff --git a/scripts/patch-packages.mjs b/scripts/patch-packages.mjs new file mode 100644 index 0000000..d202c15 --- /dev/null +++ b/scripts/patch-packages.mjs @@ -0,0 +1,25 @@ +// Applies patches/*.patch that target node_modules to the installed packages. +// Runs from npm's postinstall; idempotent, so reinstalls and repeated runs are safe. +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = fileURLToPath(new URL('../', import.meta.url)); +const patches = readdirSync(path.join(repo, 'patches')) + .filter(name => name.endsWith('.patch')) + .map(name => path.join(repo, 'patches', name)) + .filter(file => /^\+\+\+ b\/node_modules\//m.test(readFileSync(file, 'utf8'))); + +for (const patch of patches) { + const apply = (...flags) => + execFileSync('git', ['apply', ...flags, patch], { cwd: repo, stdio: ['ignore', 'ignore', 'pipe'] }); + try { + apply('--reverse', '--check'); + console.log(` already applied: ${path.basename(patch)}`); + continue; + } catch {} + apply('--check'); + apply(); + console.log(` applied: ${path.basename(patch)}`); +} diff --git a/scripts/test-scheduler.sh b/scripts/test-scheduler.sh index ddbe660..03b74bb 100644 --- a/scripts/test-scheduler.sh +++ b/scripts/test-scheduler.sh @@ -6,3 +6,6 @@ TEST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" CARGO_TARGET_DIR="$REPO/target/native-tests" cargo test \ --manifest-path .work/pumpkin/Cargo.toml --target "$TEST_TARGET" \ -p pumpkin-world --no-default-features --features single-threaded --lib storage_tests +CARGO_TARGET_DIR="$REPO/target/native-tests" cargo test \ + --manifest-path .work/pumpkin/Cargo.toml --target "$TEST_TARGET" \ + -p pumpkin --no-default-features --features single-threaded --lib -- chunk_sender chunk_queue_tests diff --git a/scripts/test.sh b/scripts/test.sh index 5ef4a42..9d2eb06 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,7 +4,8 @@ source "$(dirname "${BASH_SOURCE[0]}")/common.sh" require_node require_packages require_toolchain -"$NODE" "$REPO/scripts/check-ports.mjs" 25565 8787 +"$NODE" "$REPO/scripts/check-ports.mjs" "${TEST_TCP_PORT:-25565}" "${TEST_HTTP_PORT:-8787}" bash "$REPO/scripts/build.sh" cd "$REPO" +"$NODE" --test tests/*.test.mjs exec "$NODE" tests/integration.mjs diff --git a/src/config.rs b/src/config.rs index e9106a7..4ddb6c4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,14 @@ +use crate::settings::Settings; use pumpkin_config::{AdvancedConfiguration, BasicConfiguration}; use std::num::NonZero; -pub fn configuration() -> (BasicConfiguration, AdvancedConfiguration) { +pub fn configuration(settings: &Settings) -> (BasicConfiguration, AdvancedConfiguration) { let mut basic = BasicConfiguration { default_level_name: "world".to_string(), ..Default::default() }; - // Workers exposes configured string variables through the Emscripten - // environment. Existing worlds retain the seed stored in level.dat. - if let Ok(seed) = std::env::var("WORLD_SEED") { + // Existing worlds retain the seed stored in level.dat. + if let Some(seed) = &settings.seed { basic.seed = seed.as_str().into(); } @@ -18,9 +18,10 @@ pub fn configuration() -> (BasicConfiguration, AdvancedConfiguration) { // inbound sockets to it by port. adv.networking.java.online_mode = false; adv.networking.java.encryption = false; - adv.networking.java.view_distance = NonZero::new(3).unwrap(); - adv.networking.java.simulation_distance = NonZero::new(3).unwrap(); - adv.networking.java.motd = "Minecraft on Cloudflare Workers".to_string(); + adv.networking.java.view_distance = NonZero::new(settings.view_distance).unwrap(); + adv.networking.java.simulation_distance = NonZero::new(settings.simulation_distance).unwrap(); + adv.networking.java.max_players = settings.max_players; + adv.networking.java.motd = settings.motd.clone(); // Disable native transports, plugins, and console input. adv.networking.bedrock.enabled = false; // The Bedrock OIDC key fetch spawns on online_mode && auth.enabled regardless of @@ -32,8 +33,10 @@ pub fn configuration() -> (BasicConfiguration, AdvancedConfiguration) { adv.networking.lan_broadcast.enabled = false; adv.plugins.enabled = false; adv.commands.use_console = false; - // Packet compression is disabled for this configuration. - adv.networking.java.compression.enabled = false; + // A negative threshold disables packet compression. + adv.networking.java.compression.enabled = settings.compression_threshold >= 0; + adv.networking.java.compression.info.threshold = settings.compression_threshold.max(0) as u32; + adv.networking.java.compression.info.level = settings.compression_level; adv.logging.enabled = true; adv.logging.file.clear(); diff --git a/src/handshake.rs b/src/handshake.rs new file mode 100644 index 0000000..11ed827 --- /dev/null +++ b/src/handshake.rs @@ -0,0 +1,180 @@ +//! The Minecraft handshake prefix of an inbound connection, read before the +//! connection is forwarded. Server-list requests are answered here from the +//! object's metadata so a ping never starts a server; login connections are +//! forwarded with the consumed bytes replayed. + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const MAX_PACKET: usize = 1024; +const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +pub enum Handshake { + /// A server-list exchange was answered; the connection is done. + Status, + /// A login handshake; `consumed` holds the bytes read so far. + Login { consumed: Vec }, +} + +fn varint(mut value: u32) -> Vec { + let mut bytes = Vec::new(); + loop { + let byte = (value & 127) as u8; + value >>= 7; + if value == 0 { + bytes.push(byte); + return bytes; + } + bytes.push(byte | 128); + } +} + +fn read_varint(bytes: &[u8]) -> Result, String> { + let mut value = 0u32; + for i in 0..5 { + let Some(&byte) = bytes.get(i) else { + return Ok(None); + }; + if i == 4 && byte & 0xf0 != 0 { + return Err("Invalid VarInt".into()); + } + value |= u32::from(byte & 127) << (7 * i); + if byte & 128 == 0 { + return Ok(Some((value, i + 1))); + } + } + Err("Invalid VarInt".into()) +} + +fn required_varint(bytes: &[u8]) -> Result<(u32, usize), String> { + read_varint(bytes)?.ok_or_else(|| "Truncated handshake".into()) +} + +fn frame(id: u32, body: &[u8]) -> Vec { + let mut content = varint(id); + content.extend_from_slice(body); + let mut out = varint(content.len() as u32); + out.extend(content); + out +} + +/// `(protocol, next state)` from a handshake packet body. +pub fn handshake_state(packet: &[u8]) -> Result<(u32, u32), String> { + let (id, a) = required_varint(packet)?; + let (protocol, b) = required_varint(&packet[a..])?; + let (host_length, c) = required_varint(&packet[a + b..])?; + let host_end = a + b + c + host_length as usize; + if id != 0 || host_length as usize > MAX_PACKET || host_end + 2 >= packet.len() { + return Err("Invalid handshake".into()); + } + let (state, end) = required_varint(&packet[host_end + 2..])?; + if host_end + 2 + end != packet.len() { + return Err("Invalid handshake length".into()); + } + if !(1..=3).contains(&state) { + return Err("Invalid handshake state".into()); + } + Ok((protocol, state)) +} + +/// Reads whole handshake/status packets from a byte stream, keeping every +/// byte consumed so a login connection can be replayed. +struct Packets { + reader: R, + buffer: Vec, + consumed: Vec, + deadline: tokio::time::Instant, +} + +impl Packets { + fn new(reader: R) -> Self { + Packets { + reader, + buffer: Vec::new(), + consumed: Vec::new(), + deadline: tokio::time::Instant::now() + TIMEOUT, + } + } + + /// The next packet body, or `None` at a clean end of stream. + async fn read(&mut self) -> Result>, String> { + loop { + if let Some((size, start)) = read_varint(&self.buffer)? { + let size = size as usize; + if size < 1 || size > MAX_PACKET { + return Err("Invalid handshake/status packet size".into()); + } + if self.buffer.len() >= start + size { + let rest = self.buffer.split_off(start + size); + let whole = std::mem::replace(&mut self.buffer, rest); + self.consumed.extend_from_slice(&whole); + return Ok(Some(whole[start..].to_vec())); + } + } + let mut chunk = [0u8; 512]; + let read = tokio::time::timeout_at(self.deadline, self.reader.read(&mut chunk)) + .await + .map_err(|_| "Handshake/status timed out".to_string())? + .map_err(|e| e.to_string())?; + if read == 0 { + if !self.buffer.is_empty() { + return Err("Truncated handshake/status packet".into()); + } + return Ok(None); + } + self.buffer.extend_from_slice(&chunk[..read]); + } + } + + /// Everything read from the stream so far, including unparsed bytes. + fn into_consumed(mut self) -> Vec { + self.consumed.append(&mut self.buffer); + self.consumed + } +} + +/// Classifies a connection by its handshake. `server_list(protocol)` supplies +/// the status JSON. +pub async fn accept(socket: &mut S, server_list: F) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, + F: FnOnce(u32) -> Fut, + Fut: std::future::Future>, +{ + let mut packets = Packets::new(&mut *socket); + let Some(handshake) = packets.read().await? else { + return Ok(Handshake::Status); + }; + let (protocol, state) = handshake_state(&handshake)?; + if state != 1 { + return Ok(Handshake::Login { + consumed: packets.into_consumed(), + }); + } + let request = packets.read().await?; + if request.as_deref() != Some(&[0][..]) { + return Err("Invalid status request".into()); + } + let json = server_list(protocol).await?; + let mut body = varint(json.len() as u32); + body.extend_from_slice(json.as_bytes()); + let response = frame(0, &body); + packets + .reader + .write_all(&response) + .await + .map_err(|e| e.to_string())?; + packets.reader.flush().await.map_err(|e| e.to_string())?; + match packets.read().await? { + None => {} + Some(ping) if ping.len() == 9 && ping[0] == 1 => { + packets + .reader + .write_all(&frame(1, &ping[1..])) + .await + .map_err(|e| e.to_string())?; + packets.reader.flush().await.map_err(|e| e.to_string())?; + } + Some(_) => return Err("Invalid status ping".into()), + } + Ok(Handshake::Status) +} diff --git a/src/host.rs b/src/host.rs index cbb7de3..3096a60 100644 --- a/src/host.rs +++ b/src/host.rs @@ -7,20 +7,11 @@ extern "C" { /// Mounts the object's SQLite storage as the world's filesystem and routes /// NODERAWFS through it; `ROOT` is the mount path. #[wasm_bindgen(js_name = mountStorage)] - pub fn mount_storage(storage: &JsValue); + pub fn mount_storage(storage: &worker::worker_sys::DurableObjectStorage); #[wasm_bindgen(js_name = ROOT, thread_local_v2)] pub static MOUNT_ROOT: JsValue; } -#[wasm_bindgen(module = "cloudflare:node")] -extern "C" { - /// Routes an inbound socket to the `net.Server` listening on its local - /// port within the current Durable Object's port table; resolves when the - /// connection closes. - #[wasm_bindgen(js_name = handleAsNodeConnection, catch)] - pub fn handle_as_node_connection(socket: &JsValue) -> Result; -} - pub fn property(target: &JsValue, name: &str) -> Result { js_sys::Reflect::get(target, &name.into()) } @@ -36,27 +27,6 @@ pub fn method(target: &JsValue, name: &str, args: &[&JsValue]) -> Result, - on_rejected: Option<&JsValue>, -) -> Result { - let then: js_sys::Function = property(promise, "then")?.unchecked_into(); - then.call2( - promise, - on_fulfilled.unwrap_or(&JsValue::UNDEFINED), - on_rejected.unwrap_or(&JsValue::UNDEFINED), - ) -} - -/// `stub.connect(authority, { allowHalfOpen: true })`. -pub fn stub_connect(stub: &JsValue, authority: &str) -> Result { - let options = js_sys::Object::new(); - js_sys::Reflect::set(&options, &"allowHalfOpen".into(), &JsValue::TRUE)?; - method(stub, "connect", &[&authority.into(), &options]) -} - pub fn js_error(error: impl std::fmt::Display) -> JsValue { js_sys::Error::new(&error.to_string()).into() } - diff --git a/src/main.rs b/src/main.rs index 9245c60..542c2bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,67 +1,65 @@ mod config; +mod handshake; mod host; mod memory; +mod settings; mod world; -use host::{method, property, then}; +use host::method; use wasm_bindgen::prelude::*; -use worker::{event, Context, Env, Request, Response}; +use worker::{event, Context, Env, Request, Response, Socket, Stub}; fn main() {} -/// The world's Durable Object stub. -fn stub(env: &Env) -> worker::Result { +/// The world's Durable Object. +fn world(env: &Env) -> worker::Result { let name = env.var("WORLD_NAME")?.to_string(); - Ok(env.durable_object("WORLD")?.get_by_name(&name)?.into_rpc()) + env.durable_object("WORLD")?.get_by_name(&name) } -/// Pipes an inbound TCP connection to the world's object until either side -/// closes. worker-build exports it as the entrypoint's `connect` handler. -#[wasm_bindgen] -pub fn connect(socket: JsValue, env: Env, _ctx: JsValue) -> Result { - let target = host::stub_connect(&stub(&env)?, &world::authority())?; - // Read/write failures are observed by the pumps; a normal peer disconnect - // must not surface as an unhandled rejection of the lifetime promise. - let swallow = Closure::::new(|_| ()).into_js_value(); - for side in [&socket, &target] { - then(&property(side, "closed")?, None, Some(&swallow))?; - } - let abort = web_sys::AbortController::new()?; - let pipe = |from: &JsValue, to: &JsValue| -> Result { - let readable: web_sys::ReadableStream = property(from, "readable")?.unchecked_into(); - let writable: web_sys::WritableStream = property(to, "writable")?.unchecked_into(); - let options = web_sys::StreamPipeOptions::new(); - options.set_signal(&abort.signal()); - let abort = abort.clone(); - let on_error = Closure::once_into_js(move |error: JsValue| -> Result { - abort.abort(); - Err(error) - }); - then(&readable.pipe_to_with_options(&writable, &options), None, Some(&on_error)) +/// Serves an inbound TCP connection. Server-list pings are answered from the +/// object's metadata without starting a server; a login handshake is piped to +/// the world's object until either side closes. +#[event(connect)] +async fn connect(mut socket: Socket, env: Env, _ctx: Context) -> worker::Result<()> { + let world = world(&env)?; + let rpc: JsValue = self::world(&env)?.into_rpc(); + let server_list = |protocol: u32| async move { + let promise = + method(&rpc, "serverList", &[&JsValue::from(protocol)]).map_err(js_message)?; + let json = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise)) + .await + .map_err(js_message)?; + json.as_string() + .ok_or_else(|| "serverList returned no text".to_string()) }; - let pumps = js_sys::Array::of2(&pipe(&socket, &target)?, &pipe(&target, &socket)?); - let close_both = Closure::once_into_js(move |results: JsValue| -> Result { - for result in js_sys::Array::from(&results).iter() { - if property(&result, "status")? != "rejected" { - continue; - } - let reason = property(&result, "reason")?; - let text = reason - .as_string() - .unwrap_or_else(|| js_sys::Error::from(reason.clone()).message().into()) - .to_lowercase(); - let expected = ["closed", "closing", "abort", "cancel", "reset", "network connection lost"]; - if !expected.iter().any(|e| text.contains(e)) { - web_sys::console::error_2(&"TCP forwarding failed".into(), &reason); - } - } - let closes = js_sys::Array::new(); - for side in [&socket, &target] { - closes.push(&method(side, "close", &[])?); + let consumed = match handshake::accept(&mut socket, server_list).await { + Ok(handshake::Handshake::Login { consumed }) => consumed, + // Invalid or interrupted pre-login traffic never starts a game runtime. + Ok(handshake::Handshake::Status) => return Ok(()), + Err(error) => { + worker::console_debug!("Minecraft connection ended: {error}"); + return Ok(()); } - Ok(js_sys::Promise::all_settled(&closes).into()) - }); - then(&js_sys::Promise::all_settled(&pumps).into(), Some(&close_both), None) + }; + let mut upstream = world.connect(&world::authority())?; + // Either side closing ends the connection; a disconnect is not an error + // of the handler (which the `connect` event treats as fatal). + if let Err(error) = tokio::io::AsyncWriteExt::write_all(&mut upstream, &consumed).await { + worker::console_debug!("Minecraft connection ended: {error}"); + return Ok(()); + } + let (mut client_read, mut client_write) = tokio::io::split(socket); + let (mut server_read, mut server_write) = tokio::io::split(upstream); + let _ = tokio::select! { + r = tokio::io::copy(&mut client_read, &mut server_write) => r, + r = tokio::io::copy(&mut server_read, &mut client_write) => r, + }; + Ok(()) +} + +fn js_message(error: JsValue) -> String { + js_sys::Error::from(error).message().into() } #[event(fetch)] @@ -69,8 +67,10 @@ async fn fetch(request: Request, env: Env, _ctx: Context) -> worker::Result Response::ok(r#"{"ready":true}"#).map(json), "/" if request.method() == worker::Method::Get => { - let status = method(&stub(&env)?, "status", &[])?; - let status = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(status)).await?; + let rpc: JsValue = world(&env)?.into_rpc(); + let status = method(&rpc, "status", &[])?; + let status = + wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(status)).await?; let body = js_sys::JSON::stringify(&status)?; Response::ok(String::from(body)).map(json) } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..2438ce4 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,88 @@ +//! Operator settings, read from the Worker's environment variables per world +//! before its server starts. + +use crate::host::property; +use wasm_bindgen::JsValue; + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + pub view_distance: u8, + pub simulation_distance: u8, + pub max_players: u32, + pub compression_threshold: i32, + pub compression_level: u32, + pub idle_timeout_seconds: u32, + pub motd: String, + pub seed: Option, +} + +impl Settings { + pub fn from_env(env: &JsValue) -> Result { + let var = |name: &str| -> Result, String> { + let value = property(env, name).map_err(|_| format!("{name} is unreadable"))?; + if value.is_undefined() || value.is_null() { + return Ok(None); + } + value + .as_string() + .map(Some) + .ok_or_else(|| format!("{name} must be a string")) + }; + let integer = |name: &str, fallback: i64, min: i64, max: i64| -> Result { + let Some(raw) = var(name)? else { + return Ok(fallback); + }; + let value = raw + .parse::() + .ok() + .filter(|v| (min..=max).contains(v)) + .ok_or_else(|| format!("{name} must be an integer between {min} and {max}"))?; + Ok(value) + }; + let view_distance = integer("VIEW_DISTANCE", 4, 2, 32)? as u8; + let simulation_distance = integer("SIMULATION_DISTANCE", 3, 2, 32)? as u8; + if simulation_distance > view_distance { + return Err("SIMULATION_DISTANCE must not exceed VIEW_DISTANCE".into()); + } + let motd = var("MOTD")?.unwrap_or_else(|| "Minecraft on Cloudflare Workers".into()); + if motd.trim().is_empty() || motd.len() > 512 { + return Err("MOTD must contain between 1 and 512 characters".into()); + } + let seed = var("WORLD_SEED")?; + if let Some(seed) = &seed { + if seed.trim().is_empty() || seed.len() > 256 { + return Err( + "WORLD_SEED must be a nonempty string of at most 256 characters".into(), + ); + } + } + Ok(Settings { + view_distance, + simulation_distance, + max_players: integer("MAX_PLAYERS", 20, 1, 1000)? as u32, + compression_threshold: integer("COMPRESSION_THRESHOLD", 512, -1, 2 * 1024 * 1024)? + as i32, + compression_level: integer("COMPRESSION_LEVEL", 1, 0, 9)? as u32, + idle_timeout_seconds: integer("IDLE_TIMEOUT_SECONDS", 10, 0, 30)? as u32, + motd, + seed, + }) + } + + pub fn to_js(&self) -> Result { + let object = js_sys::Object::new(); + let set = |key: &str, value: JsValue| js_sys::Reflect::set(&object, &key.into(), &value); + set("view_distance", self.view_distance.into())?; + set("simulation_distance", self.simulation_distance.into())?; + set("max_players", self.max_players.into())?; + set("compression_threshold", self.compression_threshold.into())?; + set("compression_level", self.compression_level.into())?; + set("idle_timeout_seconds", self.idle_timeout_seconds.into())?; + set("motd", self.motd.as_str().into())?; + set( + "seed", + self.seed.as_deref().map_or(JsValue::NULL, JsValue::from), + )?; + Ok(object.into()) + } +} diff --git a/src/world.rs b/src/world.rs index b7f20af..aee98c7 100644 --- a/src/world.rs +++ b/src/world.rs @@ -7,8 +7,8 @@ //! from the object's storage, and it completes after the final save is synced. //! Later connections are routed to the running listener. -use crate::{config, host, memory}; -use host::{js_error, method, property, then}; +use crate::{config, host, memory, settings::Settings}; +use host::{js_error, method, property}; use pumpkin::{data::VanillaData, server::Server, PumpkinServer}; use std::{ cell::{Cell, RefCell}, @@ -17,6 +17,7 @@ use std::{ }; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; +use worker::{durable_object, DurableObject, Env, Socket, State}; pub const PORT: u16 = 25565; @@ -50,13 +51,17 @@ thread_local! { /// State shared between the exports and the server future. struct Shared { - /// `ctx.storage` - storage: JsValue, + storage: worker::durable::Storage, phase: Cell, failure: RefCell>, connections: Cell, startup_ms: Cell>, saved_at: RefCell>, + /// Servers started in this object's lifetime. + starts: Cell, + /// While running with no players: the time the server will stop. + idle_deadline: Cell>, + settings: Result, /// Signals the server root that the last connection has closed. idle: Arc, /// Resolves at the next phase change; connections arriving mid-transition @@ -68,24 +73,22 @@ struct Shared { // thread, so a poisoned borrow cannot be observed. impl std::panic::RefUnwindSafe for Shared {} -#[wasm_bindgen] +#[durable_object(connect)] pub struct MinecraftWorld { shared: Rc, } -#[wasm_bindgen] -impl MinecraftWorld { - #[wasm_bindgen(constructor)] - pub fn new(state: JsValue, _env: JsValue) -> Result { +impl DurableObject for MinecraftWorld { + fn new(state: State, env: Env) -> Self { std::panic::set_hook(Box::new(|info| { PANIC.with(|slot| { slot.borrow_mut().get_or_insert_with(|| info.to_string()); }); eprintln!("RUST PANIC: {info}"); })); - let storage = property(&state, "storage")?; - host::mount_storage(&storage); - Ok(MinecraftWorld { + let storage = state.storage(); + host::mount_storage(storage.as_raw()); + MinecraftWorld { shared: Rc::new(Shared { storage, phase: Cell::new(Phase::Idle), @@ -93,20 +96,36 @@ impl MinecraftWorld { connections: Cell::new(0), startup_ms: Cell::new(None), saved_at: RefCell::new(None), + starts: Cell::new(0), + idle_deadline: Cell::new(None), + settings: Settings::from_env(&env), idle: Arc::new(tokio::sync::Notify::new()), transition: RefCell::new(None), }), - }) + } + } + + async fn fetch(&self, _req: worker::Request) -> worker::Result { + worker::Response::error("tcp only", 404) } /// Serves one inbound connection; resolves when it closes. The first call /// while idle also runs the server, resolving once the final save is synced. - #[wasm_bindgen(experimental_tokio)] - pub async fn connect(&self, socket: JsValue) -> Result { + async fn connect(&self, socket: Socket) -> worker::Result<()> { let shared = self.shared.clone(); - shared.connect(socket).await + // On a running server `connect` resolves to the promise of the routed + // connection; this handler must outlive it, or the host closes the socket. + let served = shared.connect(socket).await.map_err(worker::Error::from)?; + if let Ok(promise) = served.dyn_into::() { + JsFuture::from(promise).await.map_err(worker::Error::from)?; + } + Ok(()) } +} +/// RPC methods for the Worker's status and server-list responders. +#[wasm_bindgen] +impl MinecraftWorld { pub fn status(&self) -> Result { let shared = &self.shared; let result = js_sys::Object::new(); @@ -115,12 +134,38 @@ impl MinecraftWorld { set("connections", (shared.connections.get() as f64).into())?; set( "failure", - shared.failure.borrow().as_deref().map_or(JsValue::NULL, JsValue::from), + shared + .failure + .borrow() + .as_deref() + .map_or(JsValue::NULL, JsValue::from), + )?; + set( + "startup_ms", + shared.startup_ms.get().map_or(JsValue::NULL, JsValue::from), )?; - set("startup_ms", shared.startup_ms.get().map_or(JsValue::NULL, JsValue::from))?; set( "saved_at", - shared.saved_at.borrow().as_deref().map_or(JsValue::NULL, JsValue::from), + shared + .saved_at + .borrow() + .as_deref() + .map_or(JsValue::NULL, JsValue::from), + )?; + set("runtime_starts", shared.starts.get().into())?; + set( + "idle_deadline", + shared + .idle_deadline + .get() + .map_or(JsValue::NULL, JsValue::from), + )?; + set( + "settings", + match &shared.settings { + Ok(settings) => settings.to_js()?, + Err(_) => JsValue::NULL, + }, )?; let server = SERVER.with(|slot| slot.borrow().clone()); set( @@ -132,15 +177,51 @@ impl MinecraftWorld { )?; Ok(result.into()) } + + /// Server-list metadata for the Worker's status responder, without + /// starting a server. + #[wasm_bindgen(js_name = serverList)] + pub fn server_list(&self, protocol: f64) -> Result { + let shared = &self.shared; + let settings = shared.settings.as_ref().map_err(|e| js_error(e.clone()))?; + let online = SERVER.with(|slot| match (shared.phase.get(), slot.borrow().as_ref()) { + (Phase::Running, Some(server)) => server.get_all_players().len(), + _ => 0, + }); + let text = if shared.phase.get() == Phase::Failed { + "Server unavailable" + } else { + &settings.motd + }; + let protocol = if (MIN_PROTOCOL..=MAX_PROTOCOL).contains(&protocol) { + protocol + } else { + MIN_PROTOCOL + }; + let json = serde_json::json!({ + "version": { "name": VERSION_NAME, "protocol": protocol }, + "players": { "max": settings.max_players, "online": online }, + "description": { "text": text }, + "enforcesSecureChat": false, + }); + Ok(JsValue::from(json.to_string())) + } } +/// The Java versions the pinned Pumpkin accepts. +const MIN_PROTOCOL: f64 = 4.0; +const MAX_PROTOCOL: f64 = 776.0; +const VERSION_NAME: &str = "1.7.2-26.2"; + impl Shared { - async fn connect(self: Rc, socket: JsValue) -> Result { + async fn connect(self: Rc, socket: worker::Socket) -> Result { loop { match self.phase.get() { Phase::Running => return self.route(socket), Phase::Failed => { - return Err(js_error(self.failure.borrow().as_deref().unwrap_or("failed"))) + return Err(js_error( + self.failure.borrow().as_deref().unwrap_or("failed"), + )) } Phase::Starting | Phase::Stopping => { JsFuture::from(self.transition()?).await?; @@ -190,25 +271,32 @@ impl Shared { } /// Routes a socket to Pumpkin's listener and tracks it until it closes. - fn route(self: &Rc, socket: JsValue) -> Result { + fn route(self: &Rc, socket: worker::Socket) -> Result { self.connections.set(self.connections.get() + 1); - let promise = host::handle_as_node_connection(&socket)?; + self.idle_deadline.set(None); let shared = self.clone(); - let done = Closure::once_into_js(move |_: JsValue| { + let served = async move { + let outcome = socket.handle_as_node_connection().await; shared.connections.set(shared.connections.get() - 1); if shared.connections.get() == 0 { shared.idle.notify_one(); } - }); - then(&promise, Some(&done), Some(&done)) + outcome + .map(|()| JsValue::UNDEFINED) + .map_err(|e| JsValue::from(e)) + }; + Ok(wasm_bindgen_futures::future_to_promise(served).into()) } - async fn run(self: Rc, first: JsValue, started: f64) -> Result { - let root = host::MOUNT_ROOT.with(|root| root.as_string()).unwrap_or_default(); + async fn run(self: Rc, first: worker::Socket, started: f64) -> Result { + let root = host::MOUNT_ROOT + .with(|root| root.as_string()) + .unwrap_or_default(); std::fs::create_dir_all(&root).map_err(js_error)?; std::env::set_current_dir(&root).map_err(js_error)?; pumpkin::reset_stop(); - let (basic, advanced) = config::configuration(); + let settings = self.settings.clone().map_err(js_error)?; + let (basic, advanced) = config::configuration(&settings); if !LOGGER.replace(true) { pumpkin::init_logger(&advanced); } @@ -217,16 +305,36 @@ impl Shared { SERVER.with(|slot| *slot.borrow_mut() = Some(server.server.clone())); self.set_phase(Phase::Running); self.startup_ms.set(Some(js_sys::Date::now() - started)); + self.starts.set(self.starts.get() + 1); // The first connection's promise belongs to the platform; the server // root tracks it only through `connections`. drop(self.route(first)?); let idle = self.idle.clone(); let shared = self.clone(); + let timeout = std::time::Duration::from_secs(settings.idle_timeout_seconds.into()); tokio::task::spawn_local(async move { loop { idle.notified().await; // A connection may have been routed since the notification. + if shared.connections.get() != 0 { + continue; + } + // Players are saved on disconnect; save the world too, so the + // mount holds everything before the idle period rather than + // only at stop. + if let Some(server) = SERVER.with(|slot| slot.borrow().clone()) { + save_world(&server).await; + } + if shared.connections.get() != 0 { + continue; + } + // Keep the world up briefly so a rejoining player finds it running. + shared + .idle_deadline + .set(Some(js_sys::Date::now() + timeout.as_millis() as f64)); + tokio::time::sleep(timeout).await; + shared.idle_deadline.set(None); if shared.connections.get() == 0 { break; } @@ -244,20 +352,40 @@ impl Shared { } // Writes issued through the mount become durable before the connection // is reported closed. - let synced = method(&self.storage, "sync", &[])?; - let shared = self.clone(); - let done = Closure::once_into_js(move |_: JsValue| { - shared - .saved_at - .replace(Some(String::from(js_sys::Date::new_0().to_iso_string()))); - shared.set_phase(Phase::Idle); - }); - then(&synced, Some(&done), None) + self.storage.sync().await.map_err(JsValue::from)?; + self.saved_at + .replace(Some(String::from(js_sys::Date::new_0().to_iso_string()))); + self.set_phase(Phase::Idle); + Ok(JsValue::UNDEFINED) + } +} + +/// Saves players, world data and dirty chunks, returning once the chunk writer +/// has handed them to the filesystem. +async fn save_world(server: &Server) { + use pumpkin_world::chunk::io::FileIO; + if let Err(error) = server.save_all().await { + eprintln!("World save after last disconnect failed: {error}"); + } + for world in server.worlds.load().iter() { + let level = &world.level; + // `save_all` flags the scheduler; the flag clears once it has queued the + // dirty chunks for writing. + while level.should_save.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + level.chunk_saver.block_and_await_ongoing_tasks().await; } } fn with_resolvers() -> Result { - method(&js_sys::Promise::resolve(&JsValue::UNDEFINED).constructor().into(), "withResolvers", &[]) + method( + &js_sys::Promise::resolve(&JsValue::UNDEFINED) + .constructor() + .into(), + "withResolvers", + &[], + ) } fn server_status(server: &Server) -> Result { diff --git a/tests/client.mjs b/tests/client.mjs index 2399e40..360b3c5 100644 --- a/tests/client.mjs +++ b/tests/client.mjs @@ -14,6 +14,15 @@ export async function waitFor(check, description, milliseconds = 60_000) { throw new Error(`Timed out waiting for ${description}`); } +/** Expected initial chunk count for the pinned Pumpkin view shape. */ +export function viewChunkCount(view) { + let count = 0; + for (let x = -view - 1; x <= view + 1; x++) for (let z = -view - 1; z <= view + 1; z++) { + if (Math.max(0, Math.abs(x) - 2) ** 2 + Math.max(0, Math.abs(z) - 2) ** 2 < view ** 2) count++; + } + return count; +} + async function socket() { const stream = net.connect({ host: HOST, port: PORT }); stream.setNoDelay(true); @@ -25,25 +34,38 @@ async function socket() { return stream; } -export async function status() { +export async function status({ fragmented = false } = {}) { const stream = await socket(); - stream.write(handshake(1)); + const hello = handshake(1); + if (fragmented) { + for (const byte of hello) { stream.write(Buffer.from([byte])); await delay(1); } + } else stream.write(hello); stream.write(frame(0)); + const nonce = Buffer.from('pingtest'); + let response; try { for await (const { id, payload } of packets(stream)) { - assert.equal(id, 0); - const [length, offset] = readVarint(payload); - const status = JSON.parse(payload.subarray(offset, offset + length).toString()); - assert.equal(status.version.protocol, PROTOCOL); - return status; + if (id === 0 && !response) { + const [length, offset] = readVarint(payload); + response = JSON.parse(payload.subarray(offset, offset + length).toString()); + assert.equal(response.version.protocol, PROTOCOL); + stream.write(frame(1, nonce)); + } else { + assert.equal(id, 1); + assert.deepEqual(payload, nonce); + assert.ok(response); + return response; + } } - throw new Error('No status response'); + throw new Error('Incomplete status response'); } finally { stream.destroy(); } } /** A small vanilla-protocol client that remains connected after receiving chunks. */ -export async function play(name) { +export async function play(name, { viewDistance = 32 } = {}) { const stream = await socket(); + const compression = { threshold: null, decodedBytes: 0 }; + const send = (id, payload) => stream.write(frame(id, payload, compression.threshold)); const closed = new Promise(resolve => stream.once('close', resolve)); const uuid = createHash('md5').update(`OfflinePlayer:${name}`).digest(); uuid[6] = (uuid[6] & 0x0f) | 0x30; @@ -60,23 +82,28 @@ export async function play(name) { let readyResolve, readyReject; const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; }); stream.write(handshake(2)); - stream.write(frame(0, Buffer.concat([mcString(name), uuid]))); + send(0, Buffer.concat([mcString(name), uuid])); const reading = (async () => { - for await (const { id, payload } of packets(stream)) { + for await (const { id, payload } of packets(stream, compression)) { if (state === 'login') { if (id === 0) throw new Error(`${name}: login rejected`); - if (id === 1 || id === 3) throw new Error('Probe requires offline mode with compression disabled'); + if (id === 1) throw new Error('Probe requires offline mode'); + if (id === 3) { + const value = readVarint(payload); + if (!value || value[0] > 2 * 1024 * 1024) throw new Error('Invalid compression threshold'); + compression.threshold = value[0]; + } if (id === 2) { - stream.write(frame(3)); + send(3); // Vanilla clients opt into the server listing through Client Information. - stream.write(frame(0, Buffer.concat([mcString('en_us'), Buffer.from([3, 0, 1, 0x7f, 1, 0, 1])]))); + send(0, Buffer.concat([mcString('en_us'), Buffer.from([viewDistance, 0, 1, 0x7f, 1, 0, 1])])); state = 'configuration'; } } else if (state === 'configuration') { if (id === 2) throw new Error(`${name}: disconnected during configuration`); - if (id === 0x0e) stream.write(frame(7, varint(0))); - if (id === 4 || id === 5) stream.write(frame(id, payload)); - if (id === 3) { stream.write(frame(3)); state = 'play'; } + if (id === 0x0e) send(7, varint(0)); + if (id === 4 || id === 5) send(id, payload); + if (id === 3) { send(3); state = 'play'; } } else { if (id === 0x20) throw new Error(`${name}: disconnected during Play`); if (id === 0x54) { @@ -88,18 +115,18 @@ export async function play(name) { const position = readBlockPosition(payload); blockUpdates.set(`${position.x},${position.y},${position.z}`, readVarint(payload, 8)[0]); } - if (id === 0x2c) stream.write(frame(0x1c, payload)); + if (id === 0x2c) send(0x1c, payload); if (id === 0x48) { const [teleport, offset] = readVarint(payload); position = { x: payload.readDoubleBE(offset), y: payload.readDoubleBE(offset + 8), z: payload.readDoubleBE(offset + 16) }; - stream.write(frame(0, varint(teleport))); + send(0, varint(teleport)); teleported = true; } if (id === 0x2d) { assert.ok(payload.length > 8); const x = payload.readInt32BE(0), z = payload.readInt32BE(4); chunks.set(`${x},${z}`, payload); - if (!loaded) { stream.write(frame(0x2c)); loaded = true; } + if (!loaded) { send(0x2c); loaded = true; } } // Player-info packets carry profile names. Preserve observations for // the two-client test without requiring an entity/render implementation. @@ -111,7 +138,7 @@ export async function play(name) { if (id === 0x0b) { const rate = Buffer.alloc(4); rate.writeFloatBE(8); - stream.write(frame(0x0b, rate)); + send(0x0b, rate); if (teleported && chunks.size > 0) readyResolve(); } } @@ -130,6 +157,7 @@ export async function play(name) { finally { clearTimeout(timer); } return { chunks, seenNames, + get traffic() { return { wireBytes: stream.bytesRead, decodedBytes: compression.decodedBytes, compressionThreshold: compression.threshold }; }, block(position) { const update = blockUpdates.get(`${position.x},${position.y},${position.z}`); if (update !== undefined) return update; @@ -137,9 +165,9 @@ export async function play(name) { return chunk && chunkBlock(chunk, position); }, async breakBlock(position) { - const action = (status, sequence) => stream.write(frame(0x29, Buffer.concat([ + const action = (status, sequence) => send(0x29, Buffer.concat([ varint(status), blockPosition(position), Buffer.from([1]), varint(sequence), - ]))); + ])); action(0, 1); await delay(1000); action(2, 2); @@ -155,7 +183,7 @@ export async function play(name) { payload.writeDoubleBE(next.y, 8); payload.writeDoubleBE(next.z, 16); payload[24] = 1; // On ground - stream.write(frame(0x1e, payload)); + send(0x1e, payload); }, assertHealthy() { if (failure) throw failure; }, async close() { diff --git a/tests/experience.mjs b/tests/experience.mjs new file mode 100644 index 0000000..20d5c8b --- /dev/null +++ b/tests/experience.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { parseArgs } from 'node:util'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { play, status, waitFor, viewChunkCount } from './client.mjs'; +import { testServer } from './server.mjs'; + +const repo = fileURLToPath(new URL('../', import.meta.url)); +const { values } = parseArgs({ options: { + seed: { type: 'string', default: '1789195700909824175' }, + view: { type: 'string', default: '4' }, + simulation: { type: 'string', default: '3' }, + players: { type: 'string', default: '2' }, + 'max-players': { type: 'string' }, + compression: { type: 'string', default: '512' }, + level: { type: 'string', default: '1' }, + label: { type: 'string', default: 'experience' }, + steps: { type: 'string', default: '32' }, +} }); +const playerCount = Number(values.players); +const steps = Number(values.steps); +assert.ok(Number.isInteger(playerCount) && playerCount >= 1 && playerCount <= 20); +assert.ok(Number.isInteger(steps) && steps >= 0 && steps <= 128); +const vars = { + VIEW_DISTANCE: values.view, SIMULATION_DISTANCE: values.simulation, + MAX_PLAYERS: values['max-players'] ?? values.players, COMPRESSION_THRESHOLD: values.compression, + COMPRESSION_LEVEL: values.level, IDLE_TIMEOUT_SECONDS: '0', +}; +const { probe, startWorker, request, checkpoint, seed } = await testServer({ seed: values.seed, vars }); +const worker = await startWorker(); +const clients = [], samples = []; +let phase = 'login', sampling = true, sampleFailure, failure, joinedMs, starting; +const began = performance.now(); +const elapsed = () => performance.now() - began; +const healthy = () => { + worker.healthy(); + clients.forEach(c => c.assertHealthy()); + if (sampleFailure) throw sampleFailure; +}; +const sampler = (async () => { + while (sampling) { + const beganRequest = performance.now(); + const state = await request(); + if (state.failure) throw new Error(state.failure); + if (state.server) samples.push({ elapsed_ms: elapsed(), phase, status_ms: performance.now() - beganRequest, ...state.server }); + await delay(100); + } +})().catch(error => { sampleFailure = error; }); +const expectedChunks = viewChunkCount(Number(values.view)); +const destinations = []; +let pingMs; +try { + const pingStart = performance.now(); + await status(); + pingMs = performance.now() - pingStart; + assert.equal((await request()).runtime_starts, 0); + for (let i = 0; i < playerCount; i++) clients.push(await play(`Probe${String.fromCharCode(65 + i)}`)); + await waitFor(() => { healthy(); return clients.every(c => c.chunks.size >= expectedChunks); }, 'complete initial views'); + joinedMs = elapsed(); + starting = await request(); + phase = 'stationary'; + await delay(5000); + healthy(); + const origins = clients.map(c => ({ ...c.position })); + const directions = playerCount <= 4 ? [[1, 0], [-1, 0], [0, 1], [0, -1]] + : Array.from({ length: playerCount }, (_, i) => [Math.cos(i * 2 * Math.PI / playerCount), Math.sin(i * 2 * Math.PI / playerCount)]); + phase = 'exploration'; + for (let step = 1; step <= steps; step++) { + for (let i = 0; i < clients.length; i++) { + destinations[i] = { ...origins[i], x: origins[i].x + directions[i][0] * step * 4, z: origins[i].z + directions[i][1] * step * 4 }; + clients[i].move(destinations[i]); + } + await delay(500); + healthy(); + } + phase = 'settling'; + await delay(5000); + healthy(); +} catch (error) { failure = String(error); } +finally { + sampling = false; + await sampler; + failure ??= sampleFailure && String(sampleFailure); + const percentile = (numbers, q) => { + if (!numbers.length) return null; + const ordered = [...numbers].sort((a, b) => a - b); + return ordered[Math.ceil(q * ordered.length) - 1]; + }; + const phases = {}; + for (const phase of new Set(samples.map(s => s.phase))) { + const selected = samples.filter(s => s.phase === phase); + const first = selected[0], last = selected.at(-1); + phases[phase] = { + samples: selected.length, + status_p95_ms: percentile(selected.map(s => s.status_ms), 0.95), + status_max_ms: Math.max(...selected.map(s => s.status_ms)), + observed_tps: selected.length > 1 ? (last.ticks - first.ticks) * 1000 / (last.elapsed_ms - first.elapsed_ms) : null, + mean_tick_ms: last.mean_tick_ms, + }; + } + const result = { + label: values.label, seed, vars, players: playerCount, steps, + wasm_sha256: createHash('sha256').update(await readFile(path.join(repo, 'target/workers/wasm32-unknown-emscripten/release/pumpkin_do.wasm'))).digest('hex'), + status_ping_ms: pingMs, startup_ms: starting?.startup_ms, joined_ms: joinedMs, + peak_wasm_bytes: samples.length ? Math.max(...samples.map(s => s.wasm_memory_bytes)) : null, + peak_heap_bytes: samples.length ? Math.max(...samples.map(s => s.heap_allocated_bytes)) : null, + final_sample: samples.at(-1), phases, + clients: clients.map((c, i) => ({ chunks_received: c.chunks.size, ...c.traffic, commanded_destination: destinations[i] })), + failure: failure ?? null, samples, + }; + await writeFile(path.join(probe, 'experience.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify({ ...result, samples: `${samples.length} samples in ${probe}/experience.json` }, null, 2)); + await Promise.allSettled(clients.map(c => c.close())); + try { if (!failure) await checkpoint(worker); } + finally { await worker.stop(); } +} +if (failure) throw new Error(failure); +console.log('PUMPKIN-EXPERIENCE-PROBE-OK'); diff --git a/tests/integration.mjs b/tests/integration.mjs index 0b29590..1e8a270 100644 --- a/tests/integration.mjs +++ b/tests/integration.mjs @@ -4,7 +4,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { status, play, waitFor } from './client.mjs'; import { testServer } from './server.mjs'; -const { startWorker, request, checkpoint } = await testServer(); +const { startWorker, request, checkpoint } = await testServer({ vars: { IDLE_TIMEOUT_SECONDS: '5', MAX_PLAYERS: '2' } }); const { blocks } = JSON.parse(await readFile(new URL('../.work/pumpkin/assets/blocks.json', import.meta.url))); const states = new Map(blocks.flatMap(block => block.states.map(state => [state.id, { ...state, name: block.name }]))); @@ -33,7 +33,10 @@ let savedChunk, savedPosition, changedBlock; let worker = await startWorker(); const clients = []; try { - assert.equal((await status()).version.protocol, 776); + assert.equal((await request('/')).runtime_starts, 0); + assert.equal((await status({ fragmented: true })).version.protocol, 776); + assert.equal((await status()).players.max, 2); + assert.equal((await request('/')).runtime_starts, 0, 'Status ping started Wasm'); const a = await play('ProbeA'); clients.push(a); const b = await play('ProbeB'); clients.push(b); await waitFor(() => { @@ -41,6 +44,9 @@ try { return a.seenNames.has('ProbeB') && b.seenNames.has('ProbeA'); }, 'both players to see each other'); assert.equal((await status()).players.online, 2); + assert.equal(a.traffic.compressionThreshold, 512); + assert.ok(a.traffic.decodedBytes > a.traffic.wireBytes, 'Compression did not reduce traffic'); + await assert.rejects(play('ProbeExtra'), /login rejected/); changedBlock = editableBlock(a); savedChunk = `${Math.floor(changedBlock.x / 16)},${Math.floor(changedBlock.z / 16)}`; assert.ok(a.chunks.has(savedChunk) && b.chunks.has(savedChunk), 'Clients did not receive the spawn chunk'); @@ -58,18 +64,29 @@ try { assert.equal(a.block(changedBlock), 0, 'Edited block changed before checkpoint'); assert.equal(b.block(changedBlock), 0, 'Second client lost the edit before checkpoint'); await a.close(); await b.close(); clients.length = 0; - const saved = await checkpoint(worker); - console.log(`World saved at ${saved.saved_at}`); + const warm = await checkpoint(worker, { stopped: false }); + const reconnected = await play('ProbeA'); clients.push(reconnected); + const resumed = await request('/'); + assert.equal(resumed.runtime_starts, warm.runtime_starts, 'Reconnect restarted Wasm'); + await delay(5500); // Outlive the first disconnect's timer while connected. + reconnected.assertHealthy(); worker.healthy(); + assert.ok((await request('/')).server.ticks > resumed.server.ticks, 'Reconnected world stopped ticking'); + await reconnected.close(); clients.length = 0; + await checkpoint(worker, { stopped: false }); + // Stop this test worker before the idle timer fires: writes through the + // mounted filesystem alone must be sufficient for player and block restoration. + console.log('World writes committed; stopping before the idle timer'); } finally { await Promise.all(clients.map(client => client.close())); await worker.stop(); } -worker = await startWorker(); +worker = await startWorker({ COMPRESSION_THRESHOLD: '-1' }); try { assert.equal((await request('/')).phase, 'idle'); const a = await play('ProbeA'); try { + assert.equal(a.traffic.compressionThreshold, null); await waitFor(() => { a.assertHealthy(); worker.healthy(); return a.chunks.has(savedChunk); }, 'saved chunk after restart'); assert.equal(a.block(changedBlock), 0, 'Edited block was regenerated instead of restored'); assert.equal(a.position.x, savedPosition.x, 'Player X position was not restored'); diff --git a/tests/memory.mjs b/tests/memory.mjs index d0cca9d..7a5e0d0 100644 --- a/tests/memory.mjs +++ b/tests/memory.mjs @@ -6,7 +6,7 @@ import { createHash } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; -import { play, waitFor } from './client.mjs'; +import { play, waitFor, viewChunkCount } from './client.mjs'; import { testServer } from './server.mjs'; const repo = fileURLToPath(new URL('../', import.meta.url)); @@ -35,13 +35,14 @@ const sampler = (async () => { } })().catch(error => { sampleFailure = error; }); try { + const expectedChunks = viewChunkCount((await request()).settings.viewDistance); clients.push(await play('ProbeA')); clients.push(await play('ProbeB')); await waitFor(() => { worker.healthy(); clients.forEach(client => client.assertHealthy()); if (sampleFailure) throw sampleFailure; - return clients.every(client => client.chunks.size >= 81); + return clients.every(client => client.chunks.size >= expectedChunks); }, 'both clients to receive their view distance'); const joinedMs = Date.now() - started; phase = 'stationary'; diff --git a/tests/protocol.mjs b/tests/protocol.mjs index 64208f0..7964a56 100644 --- a/tests/protocol.mjs +++ b/tests/protocol.mjs @@ -1,7 +1,9 @@ -// Minimal, uncompressed Java 26.2 framing for this demo's smoke probe. +// Java 26.2 framing, including negotiated packet compression. +import { deflateSync, inflateSync } from 'node:zlib'; export const PROTOCOL = 776; export const HOST = '127.0.0.1'; -export const PORT = 25565; +export const PORT = Number(process.env.TEST_TCP_PORT ?? 25565); +export const HTTP_PORT = Number(process.env.TEST_HTTP_PORT ?? 8787); export function varint(value) { const bytes = []; @@ -29,8 +31,13 @@ export function mcString(value) { return Buffer.concat([varint(bytes.length), bytes]); } -export function frame(id, payload = Buffer.alloc(0)) { - const body = Buffer.concat([varint(id), payload]); +export function frame(id, payload = Buffer.alloc(0), threshold = null) { + let body = Buffer.concat([varint(id), payload]); + if (threshold !== null) { + body = body.length >= threshold + ? Buffer.concat([varint(body.length), deflateSync(body, { level: 1 })]) + : Buffer.concat([varint(0), body]); + } return Buffer.concat([varint(body.length), body]); } @@ -40,7 +47,7 @@ export function handshake(state) { return frame(0, Buffer.concat([varint(PROTOCOL), mcString(HOST), port, varint(state)])); } -export async function* packets(socket) { +export async function* packets(socket, compression = { threshold: null }) { let buffer = Buffer.alloc(0); for await (const data of socket) { buffer = Buffer.concat([buffer, data]); @@ -50,8 +57,21 @@ export async function* packets(socket) { const [size, start] = length; if (size === 0 || size > 8 * 1024 * 1024) throw new Error('Invalid packet size'); if (buffer.length < start + size) break; - const body = buffer.subarray(start, start + size); + let body = buffer.subarray(start, start + size); buffer = buffer.subarray(start + size); + if (compression.threshold !== null) { + const length = readVarint(body); + if (!length) throw new Error('Missing uncompressed packet length'); + const [expected, offset] = length; + if (expected > 8 * 1024 * 1024) throw new Error('Uncompressed packet exceeds limit'); + body = body.subarray(offset); + if (expected !== 0) { + if (expected < compression.threshold) throw new Error('Compressed packet below threshold'); + body = inflateSync(body, { maxOutputLength: 8 * 1024 * 1024 }); + if (body.length !== expected) throw new Error('Wrong uncompressed packet length'); + } + } + compression.decodedBytes = (compression.decodedBytes ?? 0) + body.length; const id = readVarint(body); if (!id) throw new Error('Missing packet ID'); yield { id: id[0], payload: body.subarray(id[1]) }; diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs new file mode 100644 index 0000000..323129b --- /dev/null +++ b/tests/protocol.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { frame, packets, varint } from './protocol.mjs'; + +test('compression starts immediately after negotiation, including coalesced packets', async () => { + const payload = Buffer.alloc(4096, 97); + const incoming = Buffer.concat([frame(3, varint(512)), frame(42, payload, 512), frame(43, Buffer.from('small'), 512)]); + const compression = { threshold: null }; + const decoded = []; + for await (const packet of packets([incoming], compression)) { + if (packet.id === 3) compression.threshold = 512; + else decoded.push(packet); + } + assert.deepEqual(decoded, [{ id: 42, payload }, { id: 43, payload: Buffer.from('small') }]); + assert.ok(incoming.length < payload.length); +}); + +test('compressed framing rejects inflated lengths above the packet limit', async () => { + const body = varint(8 * 1024 * 1024 + 1); + await assert.rejects(async () => { + for await (const _ of packets([Buffer.concat([varint(body.length), body])], { threshold: 512 })) {} + }, /exceeds limit/); +}); diff --git a/tests/server.mjs b/tests/server.mjs index 7fd5d8b..491b404 100644 --- a/tests/server.mjs +++ b/tests/server.mjs @@ -2,18 +2,24 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import path from 'node:path'; import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, appendFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, appendFile, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { waitFor } from './client.mjs'; +import { PORT, HTTP_PORT } from './protocol.mjs'; const repo = fileURLToPath(new URL('../', import.meta.url)); -export async function testServer({ seed = '1789195700909824175' } = {}) { +export async function testServer({ seed = '1789195700909824175', vars = {} } = {}) { + const inspectorPort = process.env.TEST_INSPECTOR_PORT; + if (inspectorPort !== undefined) { + assert.ok(/^\d+$/.test(inspectorPort) && Number(inspectorPort) >= 1 && Number(inspectorPort) <= 65535, + 'TEST_INSPECTOR_PORT must be a port between 1 and 65535'); + } await mkdir(path.join(repo, '.data/probes'), { recursive: true }); const probe = await mkdtemp(path.join(repo, '.data/probes/workers.')); const persistence = path.join(probe, 'state'); console.log(`Workers probe state: ${probe}`); - for (const port of [25565, 8787]) { + for (const port of [PORT, HTTP_PORT, ...(inspectorPort ? [Number(inspectorPort)] : [])]) { const listener = net.createServer(); await new Promise((resolve, reject) => { listener.once('error', reject); @@ -23,11 +29,29 @@ export async function testServer({ seed = '1789195700909824175' } = {}) { } let phase = 0; - async function startWorker() { + async function startWorker(overrides = {}) { const log = path.join(probe, `${++phase}-minecraft.log`); + const sourceConfig = path.join(repo, 'wrangler.jsonc'); + const config = JSON.parse(await readFile(sourceConfig, 'utf8')); + config.name += '-' + path.basename(probe).split('.').at(-1).toLowerCase(); + // The probe config lives outside the repository root. Keep the real build + // hook and resolve its paths so tests exercise the same startup as dev. + if (config.build) { + config.build.cwd = repo; + const watch = config.build.watch_dir ?? 'src'; + config.build.watch_dir = (Array.isArray(watch) ? watch : [watch]) + .map(directory => path.relative(probe, path.resolve(repo, directory))); + } + config.main = path.resolve(path.dirname(sourceConfig), config.main); + config.dev.port = HTTP_PORT; + config.connect[0].port = PORT; + config.vars = { ...config.vars, ...vars, ...overrides }; + const configPath = path.join(probe, 'wrangler.json'); + await writeFile(configPath, JSON.stringify(config)); const child = spawn(process.execPath, [ path.join(repo, 'node_modules/wrangler/bin/wrangler.js'), 'dev', '--local', - '--config', path.join(repo, 'wrangler.jsonc'), + '--config', configPath, + ...(inspectorPort ? ['--inspector-port', inspectorPort] : []), '--persist-to', persistence, '--var', 'WORLD_NAME:integration', // Known terrain with a stable editable spawn block; random water spawns // cannot exercise the integration test's shared block-edit assertions. @@ -67,7 +91,7 @@ export async function testServer({ seed = '1789195700909824175' } = {}) { await waitFor(async () => { if (failure) throw failure; if (exited) throw new Error(`Wrangler exited; see ${log}`); - try { return (await fetch('http://127.0.0.1:8787/health', { signal: AbortSignal.timeout(2000) })).ok; } + try { return (await fetch(`http://127.0.0.1:${HTTP_PORT}/health`, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } }, `Wrangler (${log})`); } catch (error) { await stop(); throw error; } @@ -78,13 +102,13 @@ export async function testServer({ seed = '1789195700909824175' } = {}) { } async function request(route = '/', options) { - const response = await fetch(`http://127.0.0.1:8787${route}`, { ...options, signal: AbortSignal.timeout(60_000) }); + const response = await fetch(`http://127.0.0.1:${HTTP_PORT}${route}`, { ...options, signal: AbortSignal.timeout(60_000) }); const text = await response.text(); assert.ok(response.ok, `HTTP ${response.status}: ${text.slice(0,2000)}`); return JSON.parse(text); } - async function checkpoint(worker) { + async function checkpoint(worker, { stopped = true, after } = {}) { return waitFor(async () => { worker.healthy(); let state; @@ -95,7 +119,8 @@ export async function testServer({ seed = '1789195700909824175' } = {}) { throw error; } if (state.failure) throw new Error(state.failure); - return state.phase === 'idle' && state.saved_at && state.connections === 0 && state; + return (stopped ? state.phase === 'idle' && state.saved_at && state.saved_at !== after : state.phase === 'running' && state.idle_deadline) + && state.connections === 0 && state; }, 'world checkpoint'); } diff --git a/wrangler.jsonc b/wrangler.jsonc index 507a959..49a530d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,13 +2,25 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "rust-workers-minecraft", "main": "build/index.js", - "build": { "command": "bash scripts/build.sh" }, + "build_": { + "command": "bash scripts/build.sh", + "watch_dir": ["src", "scripts", ".cargo", "Cargo.toml", "Cargo.lock", "build.rs", "rust-toolchain.toml"] + }, "compatibility_date": "2026-09-05", "compatibility_flags": ["nodejs_compat", "new_module_registry", "no_handle_cross_request_promise_resolution"], "dev": { "ip": "127.0.0.1", "port": 8787 }, "connect": [{ "protocol": "tcp", "address": "127.0.0.1", "port": 25565 }], "durable_objects": { "bindings": [{ "name": "WORLD", "class_name": "MinecraftWorld" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MinecraftWorld"] }], - "vars": { "WORLD_NAME": "world" }, + "vars": { + "WORLD_NAME": "world", + "VIEW_DISTANCE": "4", + "SIMULATION_DISTANCE": "3", + "MAX_PLAYERS": "20", + "COMPRESSION_THRESHOLD": "512", + "COMPRESSION_LEVEL": "1", + "IDLE_TIMEOUT_SECONDS": "10", + "MOTD": "Minecraft on Cloudflare Workers" + }, "observability": { "enabled": true, "head_sampling_rate": 1 } } From ada9b00fff1a8c151caae1a73db33cedfd531ba5 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 25 Sep 2026 16:02:24 -0700 Subject: [PATCH 08/12] Declare the Durable Object under exports and move to Wrangler 4.141.0 The deployed Worker was last uploaded with the declarative `exports` configuration, which the API does not allow reverting to `durable_objects` bindings and `migrations`, so wrangler.jsonc declares `MinecraftWorld` as a SQLite durable-object export and the Worker reaches its namespace through `cloudflare:workers`' `exports` rather than an environment binding. Wrangler 4.141.0 with workerd 1.20260925.1, and setup runs `npm ci` so one command provisions everything. --- .github/workflows/ci.yml | 4 +- README.md | 1 - docs/architecture.md | 5 +- docs/dependencies.md | 6 +- package-lock.json | 127 +++++++++++++++++++++++++++------------ package.json | 4 +- scripts/setup.sh | 3 + src/host.rs | 8 +++ src/main.rs | 17 +++--- wrangler.jsonc | 3 +- 10 files changed, 119 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7b775..0f3dc68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,10 +32,8 @@ jobs: ~/.cargo/git ~/.cache/worker-build key: minecraft-toolchain-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', 'scripts/setup.sh') }} - - name: Setup pinned sources and toolchain + - name: Setup pinned sources, toolchain and Worker dependencies run: bash scripts/setup.sh - - name: Install Worker dependencies - run: npm ci - name: Worker TCP, shared world, and SQLite restart run: | set -o pipefail diff --git a/README.md b/README.md index 4a8e35c..7f2ab8d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ Linux and macOS build hosts are supported. Install Git, [rustup](https://rustup.rs/), Python 3.11+, and Node 24+ (26 recommended). From a checkout of this repository: ```sh -npm ci bash scripts/setup.sh npm run dev ``` diff --git a/docs/architecture.md b/docs/architecture.md index d169b03..5df972b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,8 +8,9 @@ through emcc and emits the module Wrangler serves. The entrypoint's `#[event(connect)]` handler reads a bounded Minecraft handshake over the `worker::Socket` (`src/handshake.rs`): server-list requests are answered from the object's metadata (`serverList`) without starting a server, and gameplay -connections are forwarded to the named `MinecraftWorld` object with -`Stub::connect`, writing the consumed bytes ahead and copying both directions +connections are forwarded to the named `MinecraftWorld` object (declared under +`exports` in `wrangler.jsonc` and reached through `cloudflare:workers`' +`exports`) with its `connect`, writing the consumed bytes ahead and copying both directions with Tokio. `fetch` (`#[event(fetch)]`) serves status. `MinecraftWorld` is a `#[durable_object(connect)]`; its constructor mounts diff --git a/docs/dependencies.md b/docs/dependencies.md index 5ecbf73..6343155 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -50,8 +50,8 @@ graph, including the branch commits. `build/`. `EMSCRIPTEN`/`EMSDK` select a local toolchain instead. - Node: 24+; 26 recommended and selected in CI. Used for build tools and tests. - Python: 3.11+ (Emscripten scripts, emsdk and TOML parsing). -- workerd 1.20260918.1 (Wrangler's bundled version, also pinned directly in - `package.json`): the first release with `net.Server` inbound routing into +- workerd 1.20260925.1 (Wrangler 4.141.0's bundled version, also pinned directly + in `package.json`); 1.20260918.1 was the first release with `net.Server` inbound routing into Durable Objects (`handleAsNodeConnection`, cloudflare/workerd#7306, #7313) and the `node:fs` fixes for positional buffer I/O (#7368), `O_TRUNC` (#7369), `O_CREAT` (#7393), and rename over an existing path (#7394). @@ -94,7 +94,7 @@ bash scripts/setup.sh --sources-only ## JavaScript dependencies -`package-lock.json` pins Wrangler 4.135.0, workerd 1.20260918.1, `worker-fs-mount` 0.2.0 and +`package-lock.json` pins Wrangler 4.141.0, workerd 1.20260925.1, `worker-fs-mount` 0.2.0 and `durable-object-fs` 1.0.0 (the SQLite filesystem mount, imported by `src/js/mount.js` and bundled by worker-build). `npm install` applies `patches/worker-fs-mount-open-mode.patch` to the installed `worker-fs-mount` diff --git a/package-lock.json b/package-lock.json index cb12ab0..22ee585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,13 +5,14 @@ "packages": { "": { "name": "rust-workers-minecraft", + "hasInstallScript": true, "dependencies": { "durable-object-fs": "1.0.0", "worker-fs-mount": "0.2.0" }, "devDependencies": { - "workerd": "1.20260918.1", - "wrangler": "4.135.0" + "workerd": "1.20260925.1", + "wrangler": "4.141.0" }, "engines": { "node": ">=24" @@ -28,9 +29,9 @@ } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { @@ -44,9 +45,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260918.1.tgz", - "integrity": "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260925.1.tgz", + "integrity": "sha512-GFDjFo5jfmg98iiy73DOZjHRV+lPWaeGeJ6MbtUIVyostkh9L69KliaAWVytBHCSp36o3NNZOQ0CN8VrqO3+8A==", "cpu": [ "x64" ], @@ -61,9 +62,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260918.1.tgz", - "integrity": "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260925.1.tgz", + "integrity": "sha512-b3BLoP9NkF6b2QOQFs8wFfOt9XSH3jVtacdGn9lcvkUCdPq7F6CfDlleHqyQEnDfZ32lH5jgMRTdaMHUPf/XbA==", "cpu": [ "arm64" ], @@ -78,9 +79,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260918.1.tgz", - "integrity": "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260925.1.tgz", + "integrity": "sha512-LczXd6uEMqEmBxBJPFwplRIwKmUHqj5t8hilIWOtbX5i0TFp7/iMYBXbAiIhvZnev+8yiTyA7uWBBjDvfPh5Ug==", "cpu": [ "x64" ], @@ -95,9 +96,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260918.1.tgz", - "integrity": "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260925.1.tgz", + "integrity": "sha512-hziOugQbLuva85w8z9bGJbEtABKzSip653x0NgvxgP/Q3n1TpRqRyIJRBufbSUZRkJlfH2se2nQaTx2OU0TT3g==", "cpu": [ "arm64" ], @@ -112,9 +113,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260918.1.tgz", - "integrity": "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260925.1.tgz", + "integrity": "sha512-mG0JYuF6HVCgXSsNeB5os8Vtdprk5jHPysviKNNMHJSLA3Q1djFjSmK4+pnQR/zOAgzNo0HJ+Tpcz+tZj1Q7fQ==", "cpu": [ "x64" ], @@ -712,6 +713,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -729,6 +733,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -746,6 +753,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -763,6 +773,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -780,6 +793,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -797,6 +813,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -814,6 +833,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -831,6 +853,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -848,6 +873,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -871,6 +899,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -894,6 +925,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -917,6 +951,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -940,6 +977,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -963,6 +1003,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -986,6 +1029,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1009,6 +1055,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1319,16 +1368,16 @@ } }, "node_modules/miniflare": { - "version": "5.20260918.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260918.0-alpha.tgz", - "integrity": "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg==", + "version": "5.20260925.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260925.0-alpha.tgz", + "integrity": "sha512-ly2ygNOfuouMiPcyTNEUW6f3+zgndfC45e1nQZ2LSciOSFfrih8V7ciaZ01I/64Ccp3F6fWraxh5VuHPsP3AAw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", - "workerd": "1.20260918.1", + "workerd": "1.20260925.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -1464,9 +1513,9 @@ } }, "node_modules/workerd": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260918.1.tgz", - "integrity": "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA==", + "version": "1.20260925.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260925.1.tgz", + "integrity": "sha512-cl7Mrvhm+xEC9brMBIWIHyfYl7y5dGH3CjQog6CglWhgRwAq+/b+2gHBXNQb4hsjLXQVCuCYExJRYCjEaZmAlA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1477,28 +1526,28 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260918.1", - "@cloudflare/workerd-darwin-arm64": "1.20260918.1", - "@cloudflare/workerd-linux-64": "1.20260918.1", - "@cloudflare/workerd-linux-arm64": "1.20260918.1", - "@cloudflare/workerd-windows-64": "1.20260918.1" + "@cloudflare/workerd-darwin-64": "1.20260925.1", + "@cloudflare/workerd-darwin-arm64": "1.20260925.1", + "@cloudflare/workerd-linux-64": "1.20260925.1", + "@cloudflare/workerd-linux-arm64": "1.20260925.1", + "@cloudflare/workerd-windows-64": "1.20260925.1" } }, "node_modules/wrangler": { - "version": "4.135.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.135.0.tgz", - "integrity": "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw==", + "version": "4.141.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.141.0.tgz", + "integrity": "sha512-8+LRZEcynBMfVYgO4N378z5XyuajaILt2IzsdWRLCHrRWz7mmGWv4igdVjqbRQMJtE0S5bSmyz0W2XpA6UM/ow==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", + "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260918.0-alpha", + "miniflare": "5.20260925.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260918.1" + "workerd": "1.20260925.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -1512,7 +1561,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260918.1" + "@cloudflare/workers-types": "^5.20260925.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index 070f311..d45b133 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "worker-fs-mount": "0.2.0" }, "devDependencies": { - "workerd": "1.20260918.1", - "wrangler": "4.135.0" + "workerd": "1.20260925.1", + "wrangler": "4.141.0" }, "scripts": { "postinstall": "node scripts/patch-packages.mjs", diff --git a/scripts/setup.sh b/scripts/setup.sh index edd8a3a..32c7563 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -88,4 +88,7 @@ if [ "$(cat "$BIN/.worker-build-rev" 2>/dev/null)" != "$WORKERS_RS_REV" ]; then echo "$WORKERS_RS_REV" > "$BIN/.worker-build-rev" fi +echo "==> Worker dependencies" +(cd "$REPO" && "$NODE" "$(dirname "$("$NODE" -p 'process.execPath')")/npm" ci --no-audit --no-fund) + echo "Setup complete. Run: bash scripts/test.sh or bash scripts/serve.sh" diff --git a/src/host.rs b/src/host.rs index 3096a60..27b80c0 100644 --- a/src/host.rs +++ b/src/host.rs @@ -12,6 +12,14 @@ extern "C" { pub static MOUNT_ROOT: JsValue; } +#[wasm_bindgen(module = "cloudflare:workers")] +extern "C" { + /// The entrypoint's declared exports (`exports` in wrangler.jsonc), keyed + /// by class name; Durable Object namespaces among them. + #[wasm_bindgen(js_name = exports, thread_local_v2)] + pub static EXPORTS: JsValue; +} + pub fn property(target: &JsValue, name: &str) -> Result { js_sys::Reflect::get(target, &name.into()) } diff --git a/src/main.rs b/src/main.rs index 542c2bf..01c5144 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,14 +7,15 @@ mod world; use host::method; use wasm_bindgen::prelude::*; -use worker::{event, Context, Env, Request, Response, Socket, Stub}; +use worker::{event, Context, Env, Request, Response, Socket}; fn main() {} -/// The world's Durable Object. -fn world(env: &Env) -> worker::Result { +/// The world's Durable Object stub, from the declared `MinecraftWorld` export. +fn world(env: &Env) -> worker::Result { let name = env.var("WORLD_NAME")?.to_string(); - env.durable_object("WORLD")?.get_by_name(&name) + let namespace = host::EXPORTS.with(|exports| host::property(exports, "MinecraftWorld"))?; + Ok(method(&namespace, "getByName", &[&JsValue::from(name)])?.unchecked_into()) } /// Serves an inbound TCP connection. Server-list pings are answered from the @@ -23,7 +24,7 @@ fn world(env: &Env) -> worker::Result { #[event(connect)] async fn connect(mut socket: Socket, env: Env, _ctx: Context) -> worker::Result<()> { let world = world(&env)?; - let rpc: JsValue = self::world(&env)?.into_rpc(); + let rpc: JsValue = world.clone().into(); let server_list = |protocol: u32| async move { let promise = method(&rpc, "serverList", &[&JsValue::from(protocol)]).map_err(js_message)?; @@ -42,7 +43,9 @@ async fn connect(mut socket: Socket, env: Env, _ctx: Context) -> worker::Result< return Ok(()); } }; - let mut upstream = world.connect(&world::authority())?; + let options = js_sys::Object::new(); + js_sys::Reflect::set(&options, &"allowHalfOpen".into(), &JsValue::TRUE)?; + let mut upstream = Socket::from(world.connect(&world::authority(), options.into())?); // Either side closing ends the connection; a disconnect is not an error // of the handler (which the `connect` event treats as fatal). if let Err(error) = tokio::io::AsyncWriteExt::write_all(&mut upstream, &consumed).await { @@ -67,7 +70,7 @@ async fn fetch(request: Request, env: Env, _ctx: Context) -> worker::Result Response::ok(r#"{"ready":true}"#).map(json), "/" if request.method() == worker::Method::Get => { - let rpc: JsValue = world(&env)?.into_rpc(); + let rpc: JsValue = world(&env)?.into(); let status = method(&rpc, "status", &[])?; let status = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(status)).await?; diff --git a/wrangler.jsonc b/wrangler.jsonc index 49a530d..71950c6 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,8 +10,7 @@ "compatibility_flags": ["nodejs_compat", "new_module_registry", "no_handle_cross_request_promise_resolution"], "dev": { "ip": "127.0.0.1", "port": 8787 }, "connect": [{ "protocol": "tcp", "address": "127.0.0.1", "port": 25565 }], - "durable_objects": { "bindings": [{ "name": "WORLD", "class_name": "MinecraftWorld" }] }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MinecraftWorld"] }], + "exports": { "MinecraftWorld": { "type": "durable-object", "storage": "sqlite" } }, "vars": { "WORLD_NAME": "world", "VIEW_DISTANCE": "4", From 69e4c876193eaa3f9b902638080b885b5f82de7a Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 25 Sep 2026 16:12:58 -0700 Subject: [PATCH 09/12] Restore Wrangler's build hook and take worker-build's macOS wasm-opt fix wrangler.jsonc had its build hook renamed away by a probe run, so `wrangler dev` and `deploy` were not rebuilding Rust; the hook is back. worker-build (cloudflare/workers-rs#1061) now runs wasm-opt single-threaded on macOS itself, so the build script no longer does. --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- docs/dependencies.md | 2 +- docs/development.md | 4 ---- scripts/build.sh | 6 ------ wrangler.jsonc | 2 +- 6 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d446ebe..d052c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3035,7 +3035,7 @@ dependencies = [ [[package]] name = "worker" version = "0.8.6" -source = "git+https://github.com/cloudflare/workers-rs?rev=490b6baa6e45d98a4c6f40565e9eb7d214ee2a93#490b6baa6e45d98a4c6f40565e9eb7d214ee2a93" +source = "git+https://github.com/cloudflare/workers-rs?rev=b81387da2476a28805c98086676c7edf8eec77fc#b81387da2476a28805c98086676c7edf8eec77fc" dependencies = [ "async-trait", "bytes", @@ -3065,7 +3065,7 @@ dependencies = [ [[package]] name = "worker-macros" version = "0.8.6" -source = "git+https://github.com/cloudflare/workers-rs?rev=490b6baa6e45d98a4c6f40565e9eb7d214ee2a93#490b6baa6e45d98a4c6f40565e9eb7d214ee2a93" +source = "git+https://github.com/cloudflare/workers-rs?rev=b81387da2476a28805c98086676c7edf8eec77fc#b81387da2476a28805c98086676c7edf8eec77fc" dependencies = [ "async-trait", "proc-macro2", @@ -3080,7 +3080,7 @@ dependencies = [ [[package]] name = "worker-sys" version = "0.8.6" -source = "git+https://github.com/cloudflare/workers-rs?rev=490b6baa6e45d98a4c6f40565e9eb7d214ee2a93#490b6baa6e45d98a4c6f40565e9eb7d214ee2a93" +source = "git+https://github.com/cloudflare/workers-rs?rev=b81387da2476a28805c98086676c7edf8eec77fc#b81387da2476a28805c98086676c7edf8eec77fc" dependencies = [ "cfg-if", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index e203aad..375e94a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ name = "pumpkin-do" path = "src/main.rs" [dependencies] -worker = { git = "https://github.com/cloudflare/workers-rs", rev = "490b6baa6e45d98a4c6f40565e9eb7d214ee2a93", features = ["experimental_tokio"] } +worker = { git = "https://github.com/cloudflare/workers-rs", rev = "b81387da2476a28805c98086676c7edf8eec77fc", features = ["experimental_tokio"] } wasm-bindgen = "0.2.129" wasm-bindgen-futures = "0.4.79" js-sys = "0.3.106" diff --git a/docs/dependencies.md b/docs/dependencies.md index 6343155..7b38215 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -10,7 +10,7 @@ worker-build provisions its own Emscripten and wasm-bindgen CLI under | Component | Source / ref | Commit or tag | Local changes or purpose | | --- | --- | --- | --- | | pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Checkout under `.work/pumpkin`: headless embedding, shared async scheduler, restartable stop signal, compact templates/generation chunks, and build-script fixes | -| workers-rs | [gbedford/worker-build-emscripten](https://github.com/cloudflare/workers-rs/pull/1061) | `490b6baa6e45d98a4c6f40565e9eb7d214ee2a93` | cloudflare/workers-rs#1061: the `worker` crate (`experimental_tokio` feature, a git dependency in Cargo.toml) and `worker-build --emscripten` (installed by setup from the same revision); unmodified | +| workers-rs | [gbedford/worker-build-emscripten](https://github.com/cloudflare/workers-rs/pull/1061) | `b81387da2476a28805c98086676c7edf8eec77fc` | cloudflare/workers-rs#1061: the `worker` crate (`experimental_tokio` feature, a git dependency in Cargo.toml) and `worker-build --emscripten` (installed by setup from the same revision); unmodified | | tokio | [guybedford/tokio](https://github.com/guybedford/tokio) | tag `1.53.1-cf.emscripten` | `LocalEventLoop` (tokio-rs/tokio#8484) and `net` over epoll on Emscripten; a `[patch.crates-io]` git dependency; unmodified | | emscripten | [guybedford/emscripten](https://github.com/guybedford/emscripten) | tag `6.0.10-cf.emscripten` | The 6.0.10 release plus emscripten-core/emscripten#27547 (epoll listeners on the host loop) and #27742 (async DNS lookup). worker-build installs emsdk 6.0.10 and applies these as its bundled patches | diff --git a/docs/development.md b/docs/development.md index 1c081c8..a500ea0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,10 +2,6 @@ Follow the [README setup instructions](../README.md#try-it) to install the pinned Rust toolchain, provision the patched Pumpkin checkout and install worker-build. -On macOS the build runs wasm-opt single-threaded (`BINARYEN_CORES=1`, main -thread at the hard stack limit): Binaryen's worker threads overflow their stack -on this module and crash with SIGBUS. - The initial Pumpkin compilation takes several minutes. Later builds use Cargo's cache. worker-build owns the common codegen and link settings; `build.rs` adds the application's own and the JS library (`src/workerd.js`), tracking changes to diff --git a/scripts/build.sh b/scripts/build.sh index 57b716e..be1e37e 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -4,10 +4,4 @@ source "$(dirname "${BASH_SOURCE[0]}")/common.sh" require_node require_toolchain cd "$REPO" -if [ "$(uname -s)" = Darwin ]; then - # Binaryen's worker threads overflow their stack optimizing this module on - # macOS; run wasm-opt on the main thread with the largest stack allowed. - ulimit -s hard - export BINARYEN_CORES=1 -fi worker-build --emscripten --release -- --locked diff --git a/wrangler.jsonc b/wrangler.jsonc index 71950c6..6f2f67a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "rust-workers-minecraft", "main": "build/index.js", - "build_": { + "build": { "command": "bash scripts/build.sh", "watch_dir": ["src", "scripts", ".cargo", "Cargo.toml", "Cargo.lock", "build.rs", "rust-toolchain.toml"] }, From 45f16bb310a51f8623460d6d9c77f66b0ef20e3e Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 25 Sep 2026 16:28:28 -0700 Subject: [PATCH 10/12] Bring the docs and object comments up to date with the current runtime --- AGENTS.md | 8 +++++--- README.md | 11 ++++++----- docs/architecture.md | 2 +- docs/development.md | 4 ++-- src/world.rs | 12 ++++++------ 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21bbc74..7ae1c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ Read [README.md](README.md), [architecture](docs/architecture.md), and mount (`js/mount.js`), Pumpkin configuration, and the Emscripten JS library (`workerd.js`). - `tests/`: protocol clients and the integration test. -- `scripts/setup.sh`: provision pinned sources and build the toolchain. +- `scripts/setup.sh`: provision the patched Pumpkin checkout, worker-build (which + provisions Emscripten and wasm-bindgen) and npm dependencies. - `scripts/{build,serve,test}.sh`: build, run, and validate the Workers server. Use Node 24+ (26 recommended) and the pinned Rust toolchain. Run `npm test` after @@ -20,8 +21,9 @@ process to free a port. ## Reproducibility and data -Changes to the patched Pumpkin checkout must be reflected in `patches/`. Keep unpatched checkouts unmodified; update pins for -upstream changes. Preserve the unified ticker and cooperative scheduler unless +Changes to the patched Pumpkin checkout must be reflected in `patches/` +(`node_modules` patches are applied by `npm install`). Update pins for +upstream changes; workers-rs, tokio and mio are git dependencies in Cargo.toml. Preserve the unified ticker and cooperative scheduler unless the task requires a runtime change. Keep the event-loop model: exports return promises and nothing blocks or suspends; do not reintroduce JSPI, old Tokio/libc networking patches, or a JS-side driver. diff --git a/README.md b/README.md index 7f2ab8d..30a8ea2 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,18 @@ See [development](docs/development.md), [architecture](docs/architecture.md), an ## Deploy -After setup, build the runtime and deploy the Worker: +After setup, deploy the Worker; Wrangler builds the Rust runtime first: ```sh -npm run build npx wrangler deploy ``` -Deployment uses standard SQLite-backed Durable Objects with Workers TCP ingress. +The Durable Object is declared under `exports` in `wrangler.jsonc` as a +SQLite-backed class. Workers TCP ingress maps a public IP and port to the +Worker's `connect` handler; that mapping is provisioned separately and is tied +to the Worker's name, so deploy under the mapped name. Players connect to the +mapped `ip:port`. The listener in `wrangler.jsonc` is for local development. See [memory usage](docs/memory-reduction.md) for the optimizations and measurements. -Provision the public TCP endpoint separately and route it to this Worker's -`connect` handler. The listener in `wrangler.jsonc` is for local development. ## Credits and license diff --git a/docs/architecture.md b/docs/architecture.md index 5df972b..0103beb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ and settles the connection's promise. Later connections and `status` calls enter the same instance as ordinary calls while that future is parked. A connection arriving while the server is starting or stopping waits for the next phase change and then retries, so a client that connects as the previous server -checkpoints starts the next one. The runtime's epoll and timer waits register +saves and stops starts the next one. The runtime's epoll and timer waits register with the host through Emscripten's Node backend (emscripten-core/emscripten#27547), so a readiness or timer callback resumes the scheduler on the host loop. diff --git a/docs/development.md b/docs/development.md index a500ea0..8ccc8f4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,7 @@ runtime, the player limit, a real block edit, a reconnect during the idle window and player/block restoration after a restart from the SQLite-mounted world. The memory probe waits for both clients to receive their configured view, observes 30 seconds -of stationary play, then checkpoints. Each run creates a fresh isolated world +of stationary play, then stops the server. Each run creates a fresh isolated world with a fixed seed; use the same seed when comparing builds. See [configuration](configuration.md) for the exploration probe: @@ -39,7 +39,7 @@ npm run test:memory -- --seed 1789200079352125165 The probe writes `memory.json` under `.data/probes/`, recording the seed, Wasm and host-glue hashes, observed capacity, and sampled allocator usage. Sampling ends -before checkpointing and can miss short-lived allocations. These numbers exclude +before the stop and can miss short-lived allocations. These numbers exclude JavaScript memory; use an inspector snapshot to check the combined footprint. The test harness sets `WORLD_SEED` to known terrain with a stable spawn block. diff --git a/src/world.rs b/src/world.rs index aee98c7..0e8740f 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,11 +1,11 @@ //! The Durable Object hosting one Pumpkin server. //! -//! `connect` is a `#[wasm_bindgen(experimental_tokio)]` export: its future runs on the -//! thread's Tokio event loop, whose wait is the host event loop, so nothing -//! blocks or suspends and the export returns a Promise. The server lifetime is -//! one such future: the first `connect` after idle runs it on the world mounted -//! from the object's storage, and it completes after the final save is synced. -//! Later connections are routed to the running listener. +//! A `#[durable_object(connect)]`: its handlers run on the thread's ambient +//! Tokio event loop, whose wait is the host event loop, so nothing blocks or +//! suspends. The server lifetime is one such future: the first `connect` after +//! idle runs it on the world mounted from the object's storage, and it +//! completes after the final save is synced. Later connections are routed to +//! the running listener, and the handler outlives each routed connection. use crate::{config, host, memory, settings::Settings}; use host::{js_error, method, property}; From 25887251b4a3df6a79815d5e27e9aaea3eb35414 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 25 Sep 2026 16:33:10 -0700 Subject: [PATCH 11/12] Take over the /data mount in each object constructor worker-fs-mount's mount table is per isolate, and in production an isolate outlives an object instance: constructing the object again (after eviction, or another world landing in the same isolate) threw "Path already mounted". The constructor now unmounts first; one wasm instance serves one object at a time, so the current object owns the mount. --- src/js/mount.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/js/mount.js b/src/js/mount.js index 60f6c21..596a24c 100644 --- a/src/js/mount.js +++ b/src/js/mount.js @@ -2,12 +2,17 @@ // under a mount to the Durable Object's SQLite storage and everything else to // workerd's node:fs. Emscripten's NODERAWFS is rebound to it (src/workerd.js). import { LocalDOFilesystem } from 'durable-object-fs/local'; -import { mount } from 'worker-fs-mount'; +import { mount, unmount } from 'worker-fs-mount'; import * as fs from 'worker-fs-mount/fs-sync'; export const ROOT = '/data'; +// The mount table is per isolate and an isolate outlives an object instance +// (and may host several), so each constructor takes the mount over: one wasm +// instance serves one object at a time, and the server runs only while the +// constructing object holds it. export function mountStorage(storage) { + unmount(ROOT); mount(ROOT, new LocalDOFilesystem(storage)); globalThis.__pumpkin_fs = fs; } From 483506b62921195f0bd759cc84c08257153138e9 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 25 Sep 2026 16:45:35 -0700 Subject: [PATCH 12/12] Move to the worker and worker-build 0.8.7 releases cloudflare/workers-rs#1061 has landed and shipped: the `worker` crate comes from crates.io and setup installs the matching worker-build release. The Worker's CPU limit is raised to 3,000,000 ms for world generation. --- Cargo.lock | 15 +++++++++------ Cargo.toml | 2 +- docs/dependencies.md | 6 +++--- scripts/setup.sh | 12 +++++------- wrangler.jsonc | 1 + 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d052c23..7e9344b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3034,8 +3034,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.8.6" -source = "git+https://github.com/cloudflare/workers-rs?rev=b81387da2476a28805c98086676c7edf8eec77fc#b81387da2476a28805c98086676c7edf8eec77fc" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057bce8ec66c80b7e270d884c312e2a4ff60eb997fd02936656106c06a964e5" dependencies = [ "async-trait", "bytes", @@ -3064,8 +3065,9 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.6" -source = "git+https://github.com/cloudflare/workers-rs?rev=b81387da2476a28805c98086676c7edf8eec77fc#b81387da2476a28805c98086676c7edf8eec77fc" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304" dependencies = [ "async-trait", "proc-macro2", @@ -3079,8 +3081,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.8.6" -source = "git+https://github.com/cloudflare/workers-rs?rev=b81387da2476a28805c98086676c7edf8eec77fc#b81387da2476a28805c98086676c7edf8eec77fc" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82" dependencies = [ "cfg-if", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 375e94a..37719e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ name = "pumpkin-do" path = "src/main.rs" [dependencies] -worker = { git = "https://github.com/cloudflare/workers-rs", rev = "b81387da2476a28805c98086676c7edf8eec77fc", features = ["experimental_tokio"] } +worker = { version = "0.8.7", features = ["experimental_tokio"] } wasm-bindgen = "0.2.129" wasm-bindgen-futures = "0.4.79" js-sys = "0.3.106" diff --git a/docs/dependencies.md b/docs/dependencies.md index 7b38215..25d88d8 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -10,7 +10,6 @@ worker-build provisions its own Emscripten and wasm-bindgen CLI under | Component | Source / ref | Commit or tag | Local changes or purpose | | --- | --- | --- | --- | | pumpkin | [master](https://github.com/Pumpkin-MC/Pumpkin) | `b5b9b9d7010e793806a83c495af223c67e1d35ee` | Checkout under `.work/pumpkin`: headless embedding, shared async scheduler, restartable stop signal, compact templates/generation chunks, and build-script fixes | -| workers-rs | [gbedford/worker-build-emscripten](https://github.com/cloudflare/workers-rs/pull/1061) | `b81387da2476a28805c98086676c7edf8eec77fc` | cloudflare/workers-rs#1061: the `worker` crate (`experimental_tokio` feature, a git dependency in Cargo.toml) and `worker-build --emscripten` (installed by setup from the same revision); unmodified | | tokio | [guybedford/tokio](https://github.com/guybedford/tokio) | tag `1.53.1-cf.emscripten` | `LocalEventLoop` (tokio-rs/tokio#8484) and `net` over epoll on Emscripten; a `[patch.crates-io]` git dependency; unmodified | | emscripten | [guybedford/emscripten](https://github.com/guybedford/emscripten) | tag `6.0.10-cf.emscripten` | The 6.0.10 release plus emscripten-core/emscripten#27547 (epoll listeners on the host loop) and #27742 (async DNS lookup). worker-build installs emsdk 6.0.10 and applies these as its bundled patches | @@ -42,8 +41,9 @@ graph, including the branch commits. `wasm32-unknown-emscripten`; setup installs both through rustup. rustc needs a larger compile-thread stack for pumpkin-data's generated tables; the scripts set `RUST_MIN_STACK`. -- worker-build: installed by setup with `cargo install --git` at the workers-rs - revision Cargo.toml pins, into `.work/bin/`. It provisions emsdk 6.0.10 with +- worker-build 0.8.7 (cloudflare/workers-rs#1061 released): installed by setup + with `cargo install`, matching the `worker` crate version in Cargo.toml, into + `.work/bin/`. It provisions emsdk 6.0.10 with its bundled Emscripten patches and the matching wasm-bindgen CLI under `~/.cache/worker-build`, drives cargo and emcc with the common link settings, wraps the exports into the entrypoint and Durable Object classes, and emits diff --git a/scripts/setup.sh b/scripts/setup.sh index 32c7563..ae1901f 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -78,14 +78,12 @@ RUST_CHANNEL="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv rustup toolchain install "$RUST_CHANNEL" --profile minimal --target wasm32-unknown-emscripten --no-self-update echo "==> worker-build" -# cloudflare/workers-rs#1061, the same revision Cargo.toml takes the `worker` -# crate from. Built for the host, overriding the wasm target .cargo/config.toml sets. -WORKERS_RS_REV="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["dependencies"]["worker"]["rev"])' "$REPO/Cargo.toml")" -if [ "$(cat "$BIN/.worker-build-rev" 2>/dev/null)" != "$WORKERS_RS_REV" ]; then +# The release matching the `worker` crate version in Cargo.toml. Built for the +# host, overriding the wasm target .cargo/config.toml sets. +WORKER_VERSION="$(python3 -c 'import tomllib,sys; print(tomllib.load(open(sys.argv[1],"rb"))["dependencies"]["worker"]["version"])' "$REPO/Cargo.toml")" +if [ "$("$BIN/worker-build" --version 2>/dev/null || true)" != "worker-build $WORKER_VERSION" ]; then HOST="$(rustc "+$RUST_CHANNEL" -vV | sed -n 's/^host: //p')" - cargo "+$RUST_CHANNEL" install --force --locked --target "$HOST" \ - --git https://github.com/cloudflare/workers-rs --rev "$WORKERS_RS_REV" worker-build --root "$WORK" - echo "$WORKERS_RS_REV" > "$BIN/.worker-build-rev" + cargo "+$RUST_CHANNEL" install --force --locked --target "$HOST" worker-build --version "$WORKER_VERSION" --root "$WORK" fi echo "==> Worker dependencies" diff --git a/wrangler.jsonc b/wrangler.jsonc index 6f2f67a..2a86dec 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -11,6 +11,7 @@ "dev": { "ip": "127.0.0.1", "port": 8787 }, "connect": [{ "protocol": "tcp", "address": "127.0.0.1", "port": 25565 }], "exports": { "MinecraftWorld": { "type": "durable-object", "storage": "sqlite" } }, + "limits": { "cpu_ms": 3000000 }, "vars": { "WORLD_NAME": "world", "VIEW_DISTANCE": "4",