diff --git a/build/deps/gen/build_deps.MODULE.bazel b/build/deps/gen/build_deps.MODULE.bazel index b231c644133..3be93049962 100644 --- a/build/deps/gen/build_deps.MODULE.bazel +++ b/build/deps/gen/build_deps.MODULE.bazel @@ -121,3 +121,58 @@ bazel_dep(name = "rules_python", version = "1.8.5") # rules_shell bazel_dep(name = "rules_shell", version = "0.6.1") + +# wasm_tools_linux_x64 +http.archive( + name = "wasm_tools_linux_x64", + build_file_content = 'exports_files(["wasm-tools"])', + sha256 = "b171e20fd107e63e89ef6c936b5581597666a086af677d7818de92b7cdd5a86d", + strip_prefix = "wasm-tools-1.245.1-x86_64-linux", + type = "tgz", + url = "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.245.1/wasm-tools-1.245.1-x86_64-linux.tar.gz", +) +use_repo(http, "wasm_tools_linux_x64") + +# wasm_tools_linux_arm64 +http.archive( + name = "wasm_tools_linux_arm64", + build_file_content = 'exports_files(["wasm-tools"])', + sha256 = "e01ef74b8e7b4a819d91122fdd87084fb25a938e4bfa4179cc5524b961468c85", + strip_prefix = "wasm-tools-1.245.1-aarch64-linux", + type = "tgz", + url = "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.245.1/wasm-tools-1.245.1-aarch64-linux.tar.gz", +) +use_repo(http, "wasm_tools_linux_arm64") + +# wasm_tools_macos_x64 +http.archive( + name = "wasm_tools_macos_x64", + build_file_content = 'exports_files(["wasm-tools"])', + sha256 = "dd718c5c9c6044f97e2d6ee076e91f6e448c8a3b31d3c5397b16f03c461857b7", + strip_prefix = "wasm-tools-1.245.1-x86_64-macos", + type = "tgz", + url = "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.245.1/wasm-tools-1.245.1-x86_64-macos.tar.gz", +) +use_repo(http, "wasm_tools_macos_x64") + +# wasm_tools_macos_arm64 +http.archive( + name = "wasm_tools_macos_arm64", + build_file_content = 'exports_files(["wasm-tools"])', + sha256 = "d69043b13f8ad4bc07c993e9630e795a7f2c2af488e5688d15044a1448dfa139", + strip_prefix = "wasm-tools-1.245.1-aarch64-macos", + type = "tgz", + url = "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.245.1/wasm-tools-1.245.1-aarch64-macos.tar.gz", +) +use_repo(http, "wasm_tools_macos_arm64") + +# wasm_tools_windows_x64 +http.archive( + name = "wasm_tools_windows_x64", + build_file_content = 'exports_files(["wasm-tools.exe"])', + sha256 = "3d7896b11b419b64a5f1cf81cef92a8da3371babc620eaf75fba4bca4670a75b", + strip_prefix = "wasm-tools-1.245.1-x86_64-windows", + type = "zip", + url = "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.245.1/wasm-tools-1.245.1-x86_64-windows.zip", +) +use_repo(http, "wasm_tools_windows_x64") diff --git a/build/wasm_tools_parse.bzl b/build/wasm_tools_parse.bzl new file mode 100644 index 00000000000..ce30e94a293 --- /dev/null +++ b/build/wasm_tools_parse.bzl @@ -0,0 +1,32 @@ +load("@bazel_skylib//lib:paths.bzl", "paths") + +def _impl(ctx): + in_file = ctx.file.src + out_file = ctx.actions.declare_file(paths.replace_extension(in_file.basename, ".wasm")) + ctx.actions.run_shell( + tools = [ctx.executable._wasm_tools], + inputs = [in_file], + outputs = [out_file], + arguments = [ + ctx.executable._wasm_tools.path, + in_file.path, + out_file.path, + ], + progress_message = "Running wasm-tools parse on %s" % in_file.short_path, + command = "$1 parse $2 -o $3", + ) + + return [DefaultInfo(files = depset([out_file]))] + +wasm_tools_parse = rule( + implementation = _impl, + attrs = { + "src": attr.label(mandatory = True, allow_single_file = True), + "_wasm_tools": attr.label( + executable = True, + allow_files = True, + cfg = "exec", + default = Label("//tools:wasm-tools"), + ), + }, +) diff --git a/src/workerd/io/BUILD.bazel b/src/workerd/io/BUILD.bazel index 65e42e28dfc..a11be03ee17 100644 --- a/src/workerd/io/BUILD.bazel +++ b/src/workerd/io/BUILD.bazel @@ -90,6 +90,7 @@ wd_cc_library( ":observer", ":release-version", ":trace", + ":wasm-instantiate-shim", ":worker-interface", ":worker-source", "//src/rust/cxx-integration", @@ -272,7 +273,11 @@ wd_cc_library( wd_cc_library( name = "limit-enforcer", - hdrs = ["limit-enforcer.h"], + srcs = ["tracked-wasm-instance.c++"], + hdrs = [ + "limit-enforcer.h", + "tracked-wasm-instance.h", + ], visibility = ["//visibility:public"], deps = [ ":outcome_capnp", @@ -356,6 +361,12 @@ wd_cc_embed( base_name = "maximum-compatibility-date", ) +wd_cc_embed( + name = "wasm-instantiate-shim", + src = "wasm-instantiate-shim.js", + base_name = "wasm-instantiate-shim", +) + wd_capnp_library(src = "cdp.capnp") wd_capnp_library( @@ -475,6 +486,37 @@ wd_test( data = ["io-context-test.js"], ) +kj_test( + src = "tracked-wasm-instance-test.c++", + deps = [ + ":limit-enforcer", + ], +) + +wd_test( + src = "tracked-wasm-instance-js-test.wd-test", + args = ["--experimental"], + data = [ + "tracked-wasm-instance-test.js", + "//src/workerd/io/wasm:signal-basic.wasm", + "//src/workerd/io/wasm:signal-bounds-check-edge.wasm", + "//src/workerd/io/wasm:signal-bounds-check-overflow.wasm", + "//src/workerd/io/wasm:signal-bounds-check-valid.wasm", + "//src/workerd/io/wasm:signal-decoy-memory.wasm", + "//src/workerd/io/wasm:signal-externref-memory.wasm", + "//src/workerd/io/wasm:signal-imported-memory.wasm", + "//src/workerd/io/wasm:signal-memory-reclaim.wasm", + "//src/workerd/io/wasm:signal-no-globals.wasm", + "//src/workerd/io/wasm:signal-partial-exports.wasm", + "//src/workerd/io/wasm:signal-preinit.wasm", + "//src/workerd/io/wasm:signal-terminated-only.wasm", + ], + # The WebAssembly.instantiate shim is behind the WASM_SHUTDOWN_SIGNAL_SHIM autogate, + # so this test only works when all autogates are enabled. + generate_all_compat_flags_variant = False, + generate_default_variant = False, +) + kj_test( src = "bundle-fs-test.c++", deps = [ diff --git a/src/workerd/io/limit-enforcer.h b/src/workerd/io/limit-enforcer.h index 2f8897b10eb..36f2060952f 100644 --- a/src/workerd/io/limit-enforcer.h +++ b/src/workerd/io/limit-enforcer.h @@ -5,10 +5,12 @@ #pragma once #include +#include #include #include // For Promise +#include // For KJ_REQUIRE #include // for Own #include // for OneOf #include // for Duration @@ -98,6 +100,11 @@ class IsolateLimitEnforcer: public kj::Refcounted { virtual bool hasExcessivelyExceededHeapLimit() const = 0; + // Returns the TrackedWasmInstanceList for this isolate. Subclasses own the list and provide + // it here. The returned object provides lock-guarded mutation methods and a read-only accessor + // for signal-handler use. + virtual const TrackedWasmInstanceList& getTrackedWasmInstances() const = 0; + // Inserts a custom mark event named `name` into this isolate's perf event data stream. At // present, this is only implemented internally. Call this function from various APIs to be able // to correlate perf event data with usage of those APIs. diff --git a/src/workerd/io/tracked-wasm-instance-js-test.wd-test b/src/workerd/io/tracked-wasm-instance-js-test.wd-test new file mode 100644 index 00000000000..14389ee2caf --- /dev/null +++ b/src/workerd/io/tracked-wasm-instance-js-test.wd-test @@ -0,0 +1,26 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "tracked-wasm-instance-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "tracked-wasm-instance-test.js"), + (name = "signal-basic.wasm", wasm = embed "wasm/signal-basic.wasm"), + (name = "signal-partial-exports.wasm", wasm = embed "wasm/signal-partial-exports.wasm"), + (name = "signal-terminated-only.wasm", wasm = embed "wasm/signal-terminated-only.wasm"), + (name = "signal-no-globals.wasm", wasm = embed "wasm/signal-no-globals.wasm"), + (name = "signal-bounds-check-overflow.wasm", wasm = embed "wasm/signal-bounds-check-overflow.wasm"), + (name = "signal-bounds-check-edge.wasm", wasm = embed "wasm/signal-bounds-check-edge.wasm"), + (name = "signal-bounds-check-valid.wasm", wasm = embed "wasm/signal-bounds-check-valid.wasm"), + (name = "signal-decoy-memory.wasm", wasm = embed "wasm/signal-decoy-memory.wasm"), + (name = "signal-externref-memory.wasm", wasm = embed "wasm/signal-externref-memory.wasm"), + (name = "signal-imported-memory.wasm", wasm = embed "wasm/signal-imported-memory.wasm"), + (name = "signal-memory-reclaim.wasm", wasm = embed "wasm/signal-memory-reclaim.wasm"), + (name = "signal-preinit.wasm", wasm = embed "wasm/signal-preinit.wasm"), + ], + compatibilityFlags = ["experimental"], + ) + ), + ], +); diff --git a/src/workerd/io/tracked-wasm-instance-test.c++ b/src/workerd/io/tracked-wasm-instance-test.c++ new file mode 100644 index 00000000000..7de404a8dcc --- /dev/null +++ b/src/workerd/io/tracked-wasm-instance-test.c++ @@ -0,0 +1,825 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +#include "tracked-wasm-instance.h" + +#include + +#include + +namespace workerd { +namespace { + +// --------------------------------------------------------------------------- +// SignalSafeList tests +// --------------------------------------------------------------------------- + +KJ_TEST("SignalSafeList pushFront and iterate") { + SignalSafeList list; + + KJ_EXPECT(list.isEmpty()); + + list.pushFront(3); + list.pushFront(2); + list.pushFront(1); + + KJ_EXPECT(!list.isEmpty()); + + // Iterate should visit 1, 2, 3 (pushFront prepends). + int expected = 1; + list.iterate([&](int value) { + KJ_EXPECT(value == expected, value, expected); + ++expected; + }); + KJ_EXPECT(expected == 4); +} + +KJ_TEST("SignalSafeList filter removes matching nodes") { + SignalSafeList list; + + list.pushFront(5); + list.pushFront(4); + list.pushFront(3); + list.pushFront(2); + list.pushFront(1); + + // Keep only odd numbers. + list.filter([](int value) { return value % 2 != 0; }); + + kj::Vector remaining; + list.iterate([&](int value) { remaining.add(value); }); + + KJ_EXPECT(remaining.size() == 3); + KJ_EXPECT(remaining[0] == 1); + KJ_EXPECT(remaining[1] == 3); + KJ_EXPECT(remaining[2] == 5); +} + +KJ_TEST("SignalSafeList filter removes all nodes") { + SignalSafeList list; + + list.pushFront(2); + list.pushFront(4); + list.pushFront(6); + + list.filter([](int) { return false; }); + + KJ_EXPECT(list.isEmpty()); +} + +KJ_TEST("SignalSafeList filter keeps all nodes") { + SignalSafeList list; + + list.pushFront(1); + list.pushFront(2); + list.pushFront(3); + + list.filter([](int) { return true; }); + + int count = 0; + list.iterate([&](int) { ++count; }); + KJ_EXPECT(count == 3); +} + +KJ_TEST("SignalSafeList single element filter remove") { + SignalSafeList list; + + list.pushFront(42); + + list.filter([](int) { return false; }); + + KJ_EXPECT(list.isEmpty()); +} + +KJ_TEST("SignalSafeList clear removes all nodes") { + SignalSafeList list; + + list.pushFront(3); + list.pushFront(2); + list.pushFront(1); + + KJ_EXPECT(!list.isEmpty()); + + list.clear(); + + KJ_EXPECT(list.isEmpty()); + + // List should be reusable after clear. + list.pushFront(42); + KJ_EXPECT(!list.isEmpty()); + int value = 0; + list.iterate([&](int v) { value = v; }); + KJ_EXPECT(value == 42); +} + +KJ_TEST("SignalSafeList clear on empty list is a no-op") { + SignalSafeList list; + + KJ_EXPECT(list.isEmpty()); + list.clear(); + KJ_EXPECT(list.isEmpty()); +} + +KJ_TEST("SignalSafeList filter removes head only") { + SignalSafeList list; + + list.pushFront(3); + list.pushFront(2); + list.pushFront(1); + + // Remove head (value 1). + list.filter([](int value) { return value != 1; }); + + kj::Vector remaining; + list.iterate([&](int value) { remaining.add(value); }); + + KJ_EXPECT(remaining.size() == 2); + KJ_EXPECT(remaining[0] == 2); + KJ_EXPECT(remaining[1] == 3); +} + +KJ_TEST("SignalSafeList filter removes tail only") { + SignalSafeList list; + + list.pushFront(3); + list.pushFront(2); + list.pushFront(1); + + // Remove tail (value 3). + list.filter([](int value) { return value != 3; }); + + kj::Vector remaining; + list.iterate([&](int value) { remaining.add(value); }); + + KJ_EXPECT(remaining.size() == 2); + KJ_EXPECT(remaining[0] == 1); + KJ_EXPECT(remaining[1] == 2); +} + +// --------------------------------------------------------------------------- +// TrackedWasmInstance memory-lifetime tests +// --------------------------------------------------------------------------- + +// Simulates a WASM module's backing store (e.g. a v8::BackingStore). Sets a flag on destruction so +// tests can observe exactly when the memory is reclaimed. +struct FakeBackingStore { + FakeBackingStore(bool& destroyed, size_t size) + : destroyed(destroyed), + data(kj::heapArray(size)) { + memset(data.begin(), 0, data.size()); + } + ~FakeBackingStore() noexcept(false) { + destroyed = true; + } + + bool& destroyed; + kj::Array data; +}; + +// Local test helpers that replicate the signal-writing logic formerly provided by the inline free +// functions writeShutdownSignals, clearShutdownSignals, and writeTerminatedFlags. +// The production code now lives in TrackedWasmInstanceList methods, but these unit tests exercise +// the raw SignalSafeList directly without requiring a jsg::Lock. + +void writeShutdownSignals(SignalSafeList& signals) { + signals.iterate([](TrackedWasmInstance& signal) { + KJ_IF_SOME(offset, signal.signalByteOffset) { + uint32_t value = WASM_SIGNAL_SIGXCPU; + signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1)); + } + }); +} + +void clearShutdownSignals(SignalSafeList& signals) { + signals.iterate([](TrackedWasmInstance& signal) { + KJ_IF_SOME(offset, signal.signalByteOffset) { + uint32_t value = 0; + signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1)); + } + }); +} + +void writeTerminatedFlags(SignalSafeList& signals) { + signals.iterate([](TrackedWasmInstance& signal) { + uint32_t value = 1; + signal.memory.asPtr() + .slice(signal.terminatedByteOffset, signal.terminatedByteOffset + sizeof(value)) + .copyFrom(kj::asBytes(&value, 1)); + }); +} + +KJ_TEST("kj::Array attach keeps memory alive after module instance is dropped") { + // This test proves that the kj::Array in TrackedWasmInstance, created via attach(), + // keeps the underlying linear memory alive even after the original owner (simulating a WASM + // module instance) is dropped. + + bool backingStoreDestroyed = false; + SignalSafeList signals; + + // Allocate enough room for both signal fields (signalByteOffset=0, terminatedByteOffset=4). + constexpr size_t kMemorySize = 64; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + { + // Create the backing store — this simulates the WASM module instance owning linear memory. + auto backingStore = kj::heap(backingStoreDestroyed, kMemorySize); + + // Build a kj::Array that points into the backing store's data and keeps it alive via attach(). + // This mirrors what the runtime does: take an ArrayPtr into the v8::BackingStore, then attach + // the BackingStore so the array owns a reference. + kj::Array memory = backingStore->data.asPtr().attach(kj::mv(backingStore)); + + // Register in the signal list. + signals.pushFront(TrackedWasmInstance{ + .memory = kj::mv(memory), + .signalByteOffset = kSignalOffset, + .terminatedByteOffset = kTerminatedOffset, + }); + + // `backingStore` has been moved away — the only reference keeping the memory alive is the + // kj::Array inside the SignalSafeList. + } + + // The backing store must still be alive — the SignalSafeList's kj::Array owns it. + KJ_EXPECT(!backingStoreDestroyed); + + // Prove the memory is accessible: write the shutdown signal through the list. + writeShutdownSignals(signals); + + // Read back the signal value to confirm the write landed in live memory. + signals.iterate([&](TrackedWasmInstance& signal) { + uint32_t value = 0; + memcpy(&value, signal.memory.begin() + kSignalOffset, sizeof(value)); + KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value); + }); + + // Clear the signals (writes zero), then verify the clear also works on the still-live memory. + clearShutdownSignals(signals); + signals.iterate([&](TrackedWasmInstance& signal) { + uint32_t value = 0xff; + memcpy(&value, signal.memory.begin() + kSignalOffset, sizeof(value)); + KJ_EXPECT(value == 0, value); + }); + + // Memory is still alive after all those read/write operations. + KJ_EXPECT(!backingStoreDestroyed); + + // Now remove the entry from the list — this destroys the kj::Array, which in turn destroys + // the attached FakeBackingStore. + signals.filter([](TrackedWasmInstance&) { return false; }); + + KJ_EXPECT(backingStoreDestroyed); + KJ_EXPECT(signals.isEmpty()); +} + +// --------------------------------------------------------------------------- +// Teardown-order test — models the real Worker::Isolate destruction sequence. +// +// In production, the member destruction order in Worker::Isolate is: +// +// 1. `api` destroyed → V8 isolate disposed (v8Alive becomes false) +// 2. `limitEnforcer` destroyed → ~SignalSafeList() frees remaining entries +// +// Each TrackedWasmInstance entry holds a shared_ptr whose +// destructor lives in libv8.so and may touch V8 isolate-internal state. +// If those shared_ptrs are destroyed in step 2 (after V8 is gone), the +// BackingStore destructor reads freed memory → use-after-free. +// +// The fix: call clear() in ~Isolate()'s destructor body (before member +// destruction begins), while V8 is still alive. +// +// This test models that sequence with a mock that records whether "V8" was +// still alive when each backing store was freed. Without the clear() call, +// the test fails because the backing stores are freed after "V8 disposal". +// --------------------------------------------------------------------------- + +// Mock backing store that records whether V8 was alive at destruction time. +struct V8LifetimeAwareStore { + V8LifetimeAwareStore(const bool& v8Alive, bool& freedAfterV8, int& dtorCount, size_t size) + : v8Alive(v8Alive), + freedAfterV8(freedAfterV8), + dtorCount(dtorCount), + data(kj::heapArray(size)) { + memset(data.begin(), 0, data.size()); + } + ~V8LifetimeAwareStore() noexcept(false) { + ++dtorCount; + if (!v8Alive) { + freedAfterV8 = true; + } + } + const bool& v8Alive; + bool& freedAfterV8; + int& dtorCount; + kj::Array data; +}; + +// Helper: push a TrackedWasmInstance backed by a V8LifetimeAwareStore. +void pushV8Signal(SignalSafeList& list, + const bool& v8Alive, + bool& freedAfterV8, + int& dtorCount) { + constexpr size_t kSize = 64; + auto store = kj::heap(v8Alive, freedAfterV8, dtorCount, kSize); + auto memory = store->data.asPtr().attach(kj::mv(store)); + list.pushFront(TrackedWasmInstance{ + .memory = kj::mv(memory), + .signalByteOffset = static_cast(0), + .terminatedByteOffset = sizeof(uint32_t), + }); +} + +KJ_TEST("backing stores freed before V8 disposal when clear() is called") { + // Models the FIXED teardown sequence: + // 1. clear() called while V8 is alive (the fix in ~Isolate) + // 2. V8 disposed + // 3. ~SignalSafeList runs on the now-empty list + // + // This must pass: no backing store is freed after V8 disposal. + constexpr int kEntries = 5; + bool v8Alive = true; + bool freedAfterV8[kEntries] = {}; + int dtorCounts[kEntries] = {}; + + { + SignalSafeList list; + for (int i = 0; i < kEntries; ++i) { + pushV8Signal(list, v8Alive, freedAfterV8[i], dtorCounts[i]); + } + + // ---- Simulates ~Isolate() destructor body (V8 still alive) ---- + list.clear(); + + // All stores freed while V8 was alive. + for (auto count: dtorCounts) { + KJ_EXPECT(count == 1, "entry not freed by clear()", count); + } + for (auto bad: freedAfterV8) { + KJ_EXPECT(!bad, "backing store freed after V8 disposal during clear()"); + } + + // ---- Simulates `api` member destruction (V8 disposed) ---- + v8Alive = false; + + // ---- ~SignalSafeList runs here (simulates `limitEnforcer` destruction) ---- + } + + // No store was freed after V8 disposal, and each was freed exactly once. + for (auto bad: freedAfterV8) { + KJ_EXPECT(!bad, "backing store freed after V8 disposal"); + } + for (auto count: dtorCounts) { + KJ_EXPECT(count == 1, "double-freed or leaked", count); + } +} + +KJ_TEST("backing stores freed after V8 disposal WITHOUT clear() — the bug") { + // Models the BUGGY teardown (no clear() call): + // 1. V8 disposed + // 2. ~SignalSafeList frees entries → BackingStore destructors run after V8 is gone + // + // This test ASSERTS THAT THE BUG EXISTS without the fix: at least one + // backing store is freed after V8 disposal. If this test ever fails, it + // means the destruction order changed and the fix may need revisiting. + constexpr int kEntries = 3; + bool v8Alive = true; + bool freedAfterV8[kEntries] = {}; + int dtorCounts[kEntries] = {}; + + { + SignalSafeList list; + for (int i = 0; i < kEntries; ++i) { + pushV8Signal(list, v8Alive, freedAfterV8[i], dtorCounts[i]); + } + + // NO clear() — this is the bug. + + // ---- Simulates `api` member destruction (V8 disposed) ---- + v8Alive = false; + + // ---- ~SignalSafeList runs here ---- + } + + // Prove the bug: all entries were freed AFTER V8 disposal. + for (auto bad: freedAfterV8) { + KJ_EXPECT(bad, "expected backing store to be freed after V8 disposal (bug scenario)"); + } + // But each was still freed exactly once (no double-free in the buggy path either). + for (auto count: dtorCounts) { + KJ_EXPECT(count == 1, "double-freed or leaked in buggy path", count); + } +} + +// --------------------------------------------------------------------------- +// Signal offset permutation tests +// +// __instance_terminated is REQUIRED for registration; __instance_signal is OPTIONAL. +// The two permutations that reach the C++ signal list are: +// 1. Both signal + terminated offsets present +// 2. Only terminated offset (signal = kj::none) +// +// (Permutations 3 and 4 — signal-only or neither — are rejected by the JS +// shim before reaching C++, so they are only tested in the JS test file.) +// +// For each permutation we verify the full operation set: +// - writeShutdownSignals (writes SIGXCPU to signal address) +// - clearShutdownSignals (zeros the signal address) +// - writeTerminatedFlags (writes 1 to terminated address) +// - isModuleListening (returns true when terminated == 0) +// - filter via isModuleListening (removes entries where terminated != 0) +// --------------------------------------------------------------------------- + +// Helper: construct a TrackedWasmInstance backed by a FakeBackingStore. +void pushSignal(SignalSafeList& list, + bool& destroyed, + kj::Maybe signalOffset, + uint32_t terminatedOffset, + size_t memorySize = 64) { + auto store = kj::heap(destroyed, memorySize); + auto memory = store->data.asPtr().attach(kj::mv(store)); + list.pushFront(TrackedWasmInstance{ + .memory = kj::mv(memory), + .signalByteOffset = signalOffset, + .terminatedByteOffset = terminatedOffset, + }); +} + +// Helper: read a uint32 at `offset` from the first entry in the list. +uint32_t readU32(SignalSafeList& list, uint32_t offset) { + uint32_t value = 0xDEADBEEF; + list.iterate([&](TrackedWasmInstance& signal) { + memcpy(&value, signal.memory.begin() + offset, sizeof(value)); + }); + return value; +} + +// Permutation 1: both signal and terminated offsets present. +KJ_TEST("permutation: both offsets — writeShutdownSignals writes SIGXCPU") { + // `destroyed` must be declared before `signals` so that `signals` is destroyed first + // (reverse declaration order), preventing a stack-use-after-scope when FakeBackingStore's + // destructor writes to `destroyed`. + bool destroyed = false; + SignalSafeList signals; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset); + writeShutdownSignals(signals); + KJ_EXPECT(readU32(signals, kSignalOffset) == WASM_SIGNAL_SIGXCPU); +} + +KJ_TEST("permutation: both offsets — clearShutdownSignals zeros signal") { + bool destroyed = false; + SignalSafeList signals; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset); + writeShutdownSignals(signals); + KJ_EXPECT(readU32(signals, kSignalOffset) == WASM_SIGNAL_SIGXCPU); + clearShutdownSignals(signals); + KJ_EXPECT(readU32(signals, kSignalOffset) == 0); +} + +KJ_TEST("permutation: both offsets �� writeTerminatedFlags writes 1") { + bool destroyed = false; + SignalSafeList signals; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset); + writeTerminatedFlags(signals); + KJ_EXPECT(readU32(signals, kTerminatedOffset) == 1); +} + +KJ_TEST("permutation: both offsets — isModuleListening and filter") { + bool destroyed = false; + SignalSafeList signals; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset); + + signals.iterate([](TrackedWasmInstance& s) { KJ_EXPECT(s.isModuleListening()); }); + + writeTerminatedFlags(signals); + signals.iterate([](TrackedWasmInstance& s) { KJ_EXPECT(!s.isModuleListening()); }); + + signals.filter([](const TrackedWasmInstance& s) { return s.isModuleListening(); }); + KJ_EXPECT(signals.isEmpty()); + KJ_EXPECT(destroyed); +} + +// Permutation 2: only terminated offset (signal = kj::none). +KJ_TEST("permutation: terminated only — writeShutdownSignals is a no-op") { + bool destroyed = false; + SignalSafeList signals; + constexpr size_t kMemorySize = 64; + constexpr uint32_t kTerminatedOffset = 0; + + pushSignal(signals, destroyed, kj::none, kTerminatedOffset, kMemorySize); + writeShutdownSignals(signals); + + // Entire memory should still be zeroed — nothing was written. + signals.iterate([&](TrackedWasmInstance& signal) { + for (size_t i = 0; i < kMemorySize; ++i) { + KJ_EXPECT(signal.memory[i] == 0, "unexpected non-zero byte at offset", i); + } + }); +} + +KJ_TEST("permutation: terminated only — clearShutdownSignals is a no-op") { + bool destroyed = false; + SignalSafeList signals; + constexpr size_t kMemorySize = 64; + constexpr uint32_t kTerminatedOffset = 0; + + pushSignal(signals, destroyed, kj::none, kTerminatedOffset, kMemorySize); + clearShutdownSignals(signals); + + signals.iterate([&](TrackedWasmInstance& signal) { + for (size_t i = 0; i < kMemorySize; ++i) { + KJ_EXPECT(signal.memory[i] == 0, "unexpected non-zero byte at offset", i); + } + }); +} + +KJ_TEST("permutation: terminated only — writeTerminatedFlags writes 1") { + bool destroyed = false; + SignalSafeList signals; + constexpr uint32_t kTerminatedOffset = 0; + + pushSignal(signals, destroyed, kj::none, kTerminatedOffset); + writeTerminatedFlags(signals); + KJ_EXPECT(readU32(signals, kTerminatedOffset) == 1); +} + +KJ_TEST("permutation: terminated only — isModuleListening and filter") { + bool destroyed = false; + SignalSafeList signals; + constexpr uint32_t kTerminatedOffset = 0; + + pushSignal(signals, destroyed, kj::none, kTerminatedOffset); + + signals.iterate([](TrackedWasmInstance& s) { KJ_EXPECT(s.isModuleListening()); }); + + writeTerminatedFlags(signals); + signals.iterate([](TrackedWasmInstance& s) { KJ_EXPECT(!s.isModuleListening()); }); + + signals.filter([](const TrackedWasmInstance& s) { return s.isModuleListening(); }); + KJ_EXPECT(signals.isEmpty()); + KJ_EXPECT(destroyed); +} + +// Mixed list: both permutations in a single list. +// Verifies that operations correctly target each entry based on its signal offset. +KJ_TEST("permutation: mixed list — both-offsets and terminated-only entries coexist") { + bool destroyedBoth = false; + bool destroyedTermOnly = false; + SignalSafeList signals; + constexpr size_t kMemorySize = 64; + + // Push the both-offsets entry first (signal=0, terminated=4). + pushSignal(signals, destroyedBoth, static_cast(0), sizeof(uint32_t), kMemorySize); + // Push the terminated-only entry second (it becomes the head). + pushSignal(signals, destroyedTermOnly, kj::none, 0, kMemorySize); + + // --- writeShutdownSignals: only the both-offsets entry gets SIGXCPU --- + writeShutdownSignals(signals); + + int index = 0; + signals.iterate([&](TrackedWasmInstance& signal) { + if (index == 0) { + // Head = terminated-only entry. Entire memory should be untouched. + for (size_t i = 0; i < kMemorySize; ++i) { + KJ_EXPECT(signal.memory[i] == 0, "terminated-only entry modified at offset", i); + } + } else { + // Both-offsets entry. Signal at offset 0 should be SIGXCPU. + uint32_t value = 0; + memcpy(&value, signal.memory.begin(), sizeof(value)); + KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value); + } + ++index; + }); + + // --- clearShutdownSignals: zeros the both-offsets entry's signal --- + clearShutdownSignals(signals); + + index = 0; + signals.iterate([&](TrackedWasmInstance& signal) { + if (index == 1) { + uint32_t value = 0xff; + memcpy(&value, signal.memory.begin(), sizeof(value)); + KJ_EXPECT(value == 0, "clear did not zero signal on both-offsets entry"); + } + ++index; + }); + + // --- writeTerminatedFlags: both entries get terminated=1 --- + writeTerminatedFlags(signals); + + index = 0; + signals.iterate([&](TrackedWasmInstance& signal) { + uint32_t terminated = 0; + memcpy(&terminated, signal.memory.begin() + signal.terminatedByteOffset, sizeof(terminated)); + KJ_EXPECT(terminated == 1, "entry", index, "terminated", terminated); + ++index; + }); + + // --- isModuleListening: both should report not listening --- + signals.iterate([](TrackedWasmInstance& s) { KJ_EXPECT(!s.isModuleListening()); }); + + // --- filter: both entries removed, memory reclaimed --- + signals.filter([](const TrackedWasmInstance& s) { return s.isModuleListening(); }); + KJ_EXPECT(signals.isEmpty()); + KJ_EXPECT(destroyedBoth); + KJ_EXPECT(destroyedTermOnly); +} + +// --------------------------------------------------------------------------- +// TrackedWasmInstanceList method tests +// +// The methods writeShutdownSignal(), clearShutdownSignal(), and writeTerminatedSignal() on +// TrackedWasmInstanceList are the production entry points called from signal handlers and the +// CPU time limiter. The tests above exercise the same underlying logic through test-local +// helpers on a raw SignalSafeList; these tests verify the methods themselves work correctly +// through the TrackedWasmInstanceList wrapper. +// +// Since registerSignal() requires a jsg::Lock& (not available in plain KJ tests), we +// populate the internal list directly via const_cast on signals(). This is acceptable in +// tests — it mirrors what registerSignal() does internally. +// --------------------------------------------------------------------------- + +// Helper: push a TrackedWasmInstance into a TrackedWasmInstanceList's internal list, bypassing +// registerSignal() which requires jsg::Lock&. +void pushEntry(const TrackedWasmInstanceList& list, + bool& destroyed, + kj::Maybe signalOffset, + uint32_t terminatedOffset, + size_t memorySize = 64) { + auto store = kj::heap(destroyed, memorySize); + auto memory = store->data.asPtr().attach(kj::mv(store)); + const_cast&>(list.signals()) + .pushFront(TrackedWasmInstance{ + .memory = kj::mv(memory), + .signalByteOffset = signalOffset, + .terminatedByteOffset = terminatedOffset, + }); +} + +// Helper: read a uint32 at `offset` from the first entry via the TrackedWasmInstanceList. +uint32_t readU32FromList(const TrackedWasmInstanceList& list, uint32_t offset) { + uint32_t value = 0xDEADBEEF; + const_cast&>(list.signals()) + .iterate([&](TrackedWasmInstance& entry) { + memcpy(&value, entry.memory.begin() + offset, sizeof(value)); + }); + return value; +} + +KJ_TEST("TrackedWasmInstanceList::writeShutdownSignal writes SIGXCPU") { + bool destroyed = false; + TrackedWasmInstanceList list; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushEntry(list, destroyed, kSignalOffset, kTerminatedOffset); + list.writeShutdownSignal(); + KJ_EXPECT(readU32FromList(list, kSignalOffset) == WASM_SIGNAL_SIGXCPU); +} + +KJ_TEST("TrackedWasmInstanceList::clearShutdownSignal zeros the signal") { + bool destroyed = false; + TrackedWasmInstanceList list; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushEntry(list, destroyed, kSignalOffset, kTerminatedOffset); + list.writeShutdownSignal(); + KJ_EXPECT(readU32FromList(list, kSignalOffset) == WASM_SIGNAL_SIGXCPU); + + list.clearShutdownSignal(); + KJ_EXPECT(readU32FromList(list, kSignalOffset) == 0); +} + +KJ_TEST("TrackedWasmInstanceList::writeTerminatedSignal writes 1") { + bool destroyed = false; + TrackedWasmInstanceList list; + constexpr uint32_t kSignalOffset = 0; + constexpr uint32_t kTerminatedOffset = sizeof(uint32_t); + + pushEntry(list, destroyed, kSignalOffset, kTerminatedOffset); + list.writeTerminatedSignal(); + KJ_EXPECT(readU32FromList(list, kTerminatedOffset) == 1); +} + +KJ_TEST("TrackedWasmInstanceList::writeShutdownSignal skips entries without signal offset") { + bool destroyed = false; + TrackedWasmInstanceList list; + constexpr size_t kMemorySize = 64; + constexpr uint32_t kTerminatedOffset = 0; + + // Entry with no signal offset (kj::none). + pushEntry(list, destroyed, kj::none, kTerminatedOffset, kMemorySize); + list.writeShutdownSignal(); + + // Entire memory should still be zeroed — writeShutdownSignal is a no-op for this entry. + const_cast&>(list.signals()) + .iterate([&](TrackedWasmInstance& entry) { + for (size_t i = 0; i < kMemorySize; ++i) { + KJ_EXPECT(entry.memory[i] == 0, "unexpected non-zero byte at offset", i); + } + }); +} + +KJ_TEST("TrackedWasmInstanceList::writeTerminatedSignal works with signal-only entry") { + bool destroyed = false; + TrackedWasmInstanceList list; + constexpr uint32_t kTerminatedOffset = 0; + + pushEntry(list, destroyed, kj::none, kTerminatedOffset); + list.writeTerminatedSignal(); + KJ_EXPECT(readU32FromList(list, kTerminatedOffset) == 1); +} + +KJ_TEST("TrackedWasmInstanceList methods work on a mixed list") { + bool destroyedBoth = false; + bool destroyedTermOnly = false; + TrackedWasmInstanceList list; + constexpr size_t kMemorySize = 64; + + // Push a both-offsets entry (signal=0, terminated=4). + pushEntry(list, destroyedBoth, static_cast(0), sizeof(uint32_t), kMemorySize); + // Push a terminated-only entry (it becomes the head). + pushEntry(list, destroyedTermOnly, kj::none, 0, kMemorySize); + + // writeShutdownSignal: only the both-offsets entry gets SIGXCPU. + list.writeShutdownSignal(); + + int index = 0; + const_cast&>(list.signals()) + .iterate([&](TrackedWasmInstance& entry) { + if (index == 0) { + // Head = terminated-only entry. Entire memory should be untouched. + for (size_t i = 0; i < kMemorySize; ++i) { + KJ_EXPECT(entry.memory[i] == 0, "terminated-only entry modified at offset", i); + } + } else { + // Both-offsets entry. Signal at offset 0 should be SIGXCPU. + uint32_t value = 0; + memcpy(&value, entry.memory.begin(), sizeof(value)); + KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value); + } + ++index; + }); + + // clearShutdownSignal: zeros the both-offsets entry's signal. + list.clearShutdownSignal(); + + index = 0; + const_cast&>(list.signals()) + .iterate([&](TrackedWasmInstance& entry) { + if (index == 1) { + uint32_t value = 0xff; + memcpy(&value, entry.memory.begin(), sizeof(value)); + KJ_EXPECT(value == 0, "clear did not zero signal on both-offsets entry"); + } + ++index; + }); + + // writeTerminatedSignal: both entries get terminated=1. + list.writeTerminatedSignal(); + + index = 0; + const_cast&>(list.signals()) + .iterate([&](TrackedWasmInstance& entry) { + uint32_t terminated = 0; + memcpy(&terminated, entry.memory.begin() + entry.terminatedByteOffset, sizeof(terminated)); + KJ_EXPECT(terminated == 1, "entry", index, "terminated", terminated); + ++index; + }); +} + +KJ_TEST("TrackedWasmInstanceList methods are no-ops on an empty list") { + TrackedWasmInstanceList list; + + // These should not crash or have any observable effect. + list.writeShutdownSignal(); + list.clearShutdownSignal(); + list.writeTerminatedSignal(); + + KJ_EXPECT(list.signals().isEmpty()); +} + +// --------------------------------------------------------------------------- +// TrackedWasmInstance tests are also covered by the JS-level +// tracked-wasm-instance-js-test.wd-test, which runs inside a real workerd +// instance with V8 initialized. Registration requires the WebAssembly.instantiate +// shim, so we test via JS rather than a plain kj_test. +// --------------------------------------------------------------------------- + +} // namespace +} // namespace workerd diff --git a/src/workerd/io/tracked-wasm-instance-test.js b/src/workerd/io/tracked-wasm-instance-test.js new file mode 100644 index 00000000000..092f1549877 --- /dev/null +++ b/src/workerd/io/tracked-wasm-instance-test.js @@ -0,0 +1,256 @@ +// Tests for the WASM shutdown signal registration shim. +// +// These tests verify that the shimWebAssemblyInstantiate() code in worker.c++ correctly +// detects __instance_signal / __instance_terminated exports, handles various memory +// configurations, and rejects out-of-bounds addresses. + +import basicModule from 'signal-basic.wasm'; +import partialModule from 'signal-partial-exports.wasm'; +import terminatedOnlyModule from 'signal-terminated-only.wasm'; +import noGlobalsModule from 'signal-no-globals.wasm'; +import overflowModule from 'signal-bounds-check-overflow.wasm'; +import edgeModule from 'signal-bounds-check-edge.wasm'; +import validModule from 'signal-bounds-check-valid.wasm'; +import decoyModule from 'signal-decoy-memory.wasm'; +import externrefMemoryModule from 'signal-externref-memory.wasm'; +import importedMemoryModule from 'signal-imported-memory.wasm'; +import reclaimModule from 'signal-memory-reclaim.wasm'; +import preinitModule from 'signal-preinit.wasm'; + +// --------------------------------------------------------------------------- +// Export permutation tests +// +// __instance_terminated is REQUIRED for registration; __instance_signal is OPTIONAL. +// The four permutations: +// 1. Both present → registers (signal + terminated) +// 2. Only terminated → registers (terminated only) +// 3. Only signal → NOT registered +// 4. Neither → NOT registered +// --------------------------------------------------------------------------- + +// Permutation 1: both __instance_signal and __instance_terminated present. +// The module should be registered and both addresses are functional. +export let bothGlobalsRegisters = { + async test() { + const instance = await WebAssembly.instantiate(basicModule); + // Registration should zero the signal field. + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// Permutation 1 (sync): same test via the sync WebAssembly.Instance constructor. +export let syncBothGlobalsRegisters = { + test() { + const instance = new WebAssembly.Instance(basicModule); + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// Permutation 2: only __instance_terminated present (no __instance_signal). +// The module should be registered — __instance_signal is optional. +export let terminatedOnlyRegisters = { + async test() { + const instance = await WebAssembly.instantiate(terminatedOnlyModule); + if (instance.exports.get_terminated() !== 0) { + throw new Error('Expected terminated to be 0 initially'); + } + }, +}; + +// Permutation 2 (sync): same test via the sync WebAssembly.Instance constructor. +export let syncTerminatedOnlyRegisters = { + test() { + const instance = new WebAssembly.Instance(terminatedOnlyModule); + if (instance.exports.get_terminated() !== 0) { + throw new Error('Expected terminated to be 0 initially'); + } + }, +}; + +// Permutation 3: only __instance_signal present (no __instance_terminated). +// The module should NOT be registered — __instance_terminated is required. +export let signalOnlySkipped = { + async test() { + const instance = await WebAssembly.instantiate(partialModule); + // Should succeed — the shim just doesn't register it. + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// Permutation 3 (sync): same test via the sync WebAssembly.Instance constructor. +export let syncSignalOnlySkipped = { + test() { + const instance = new WebAssembly.Instance(partialModule); + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// Permutation 4: neither __instance_signal nor __instance_terminated present. +// The module should NOT be registered and should instantiate without error. +export let noGlobalsSkipped = { + async test() { + const instance = await WebAssembly.instantiate(noGlobalsModule); + // Module has a simple add function — verify it works. + if (instance.exports.add(2, 3) !== 5) { + throw new Error('Expected add(2, 3) to return 5'); + } + }, +}; + +// Permutation 4 (sync): same test via the sync WebAssembly.Instance constructor. +export let syncNoGlobalsSkipped = { + test() { + const instance = new WebAssembly.Instance(noGlobalsModule); + if (instance.exports.add(2, 3) !== 5) { + throw new Error('Expected add(2, 3) to return 5'); + } + }, +}; + +// Memory at the signal address is pre-initialized to 0xDEADBEEF via a data segment. +// Registration should zero the signal field. +export let registrationZerosPreinitMemory = { + async test() { + const instance = await WebAssembly.instantiate(preinitModule); + if (instance.exports.get_signal() !== 0) { + throw new Error( + 'Expected signal to be zeroed, got ' + instance.exports.get_signal() + ); + } + }, +}; + +// Same test via the sync WebAssembly.Instance constructor. +export let syncRegistrationZerosPreinitMemory = { + test() { + const instance = new WebAssembly.Instance(preinitModule); + if (instance.exports.get_signal() !== 0) { + throw new Error( + 'Expected signal to be zeroed, got ' + instance.exports.get_signal() + ); + } + }, +}; + +// --------------------------------------------------------------------------- +// Bounds checking tests +// --------------------------------------------------------------------------- + +// __instance_signal beyond memory bounds — registration is silently skipped. +export let boundsCheckOverflow = { + async test() { + // Should instantiate without error; the module simply won't receive shutdown signals. + await WebAssembly.instantiate(overflowModule); + }, +}; + +// __instance_signal at 65533 leaves only 3 bytes but needs 4 — silently skipped. +export let boundsCheckEdge = { + async test() { + await WebAssembly.instantiate(edgeModule); + }, +}; + +// Both addresses exactly at the boundary — should succeed. +export let boundsCheckValid = { + async test() { + const instance = await WebAssembly.instantiate(validModule); + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// --------------------------------------------------------------------------- +// Memory detection tests +// --------------------------------------------------------------------------- + +// A module that imports memory (not exports it) should still register. +export let importedMemoryDetected = { + async test() { + const memory = new WebAssembly.Memory({ initial: 1 }); + const instance = await WebAssembly.instantiate(importedMemoryModule, { + env: { memory }, + }); + // If registration threw, we wouldn't get here. + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// A WebAssembly.Memory passed as a non-memory import must not be used as the +// module's linear memory. The module has internal memory but doesn't export it. +export let decoyMemoryIgnored = { + async test() { + const decoyMemory = new WebAssembly.Memory({ initial: 1 }); + // The module imports (func "env" "log") only — decoy_memory is extra. + const instance = await WebAssembly.instantiate(decoyModule, { + env: { + log: () => {}, + decoy_memory: decoyMemory, + }, + }); + // The shim should have found no memory (internal memory is inaccessible). + // Verify decoy memory is untouched (we can't trigger writeShutdownSignal + // in workerd, but we can verify instantiation didn't blow up). + const view = new Uint32Array(decoyMemory.buffer); + if (view[0] !== 0) { + throw new Error('Decoy memory was modified during instantiation'); + } + }, +}; + +// A module that imports a global named "memory" as externref must not be +// confused for a linear memory import. The shim checks Module.imports() kind +// and should skip registration because the import's kind is 'global', not 'memory'. +export let externrefMemoryIgnored = { + async test() { + const instance = await WebAssembly.instantiate(externrefMemoryModule, { + env: { memory: null }, + }); + // Should instantiate fine — shim just doesn't register it. + if (instance.exports.get_value() !== 42) { + throw new Error('Expected get_value() to return 42'); + } + }, +}; + +// The sync Instance constructor should also detect imported memory. +export let syncInstanceImportedMemory = { + test() { + const memory = new WebAssembly.Memory({ initial: 1 }); + const instance = new WebAssembly.Instance(importedMemoryModule, { + env: { memory }, + }); + if (instance.exports.get_signal() !== 0) { + throw new Error('Expected signal to be 0 initially'); + } + }, +}; + +// --------------------------------------------------------------------------- +// GC reclamation test +// --------------------------------------------------------------------------- + +// Instantiate many large (16MB) WASM modules, mark each as "exited" via +// mark_exited(), then let GC reclaim them. If the GC prologue filter doesn't +// clean up terminated entries, this will OOM. +export let gcReclaimsTerminatedModules = { + async test() { + for (let i = 0; i < 20; i++) { + const instance = await WebAssembly.instantiate(reclaimModule); + // Mark the module as exited so the GC prologue filter removes it. + instance.exports.mark_exited(); + } + // If we get here without OOM, reclamation is working. + }, +}; diff --git a/src/workerd/io/tracked-wasm-instance.c++ b/src/workerd/io/tracked-wasm-instance.c++ new file mode 100644 index 00000000000..691c98012a2 --- /dev/null +++ b/src/workerd/io/tracked-wasm-instance.c++ @@ -0,0 +1,78 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +#include "tracked-wasm-instance.h" + +namespace workerd { + +void TrackedWasmInstanceList::registerSignal(jsg::Lock&, + kj::Array memory, + kj::Maybe signalOffset, + uint32_t terminatedOffset) const { + // Silently skip registration if the terminated address would fall outside the module's linear + // memory. The terminated field is always required. + if (static_cast(terminatedOffset) + WASM_SIGNAL_FIELD_BYTES > memory.size()) { + return; + } + // If a signal offset was provided, validate it fits in memory too. + KJ_IF_SOME(offset, signalOffset) { + if (static_cast(offset) + WASM_SIGNAL_FIELD_BYTES > memory.size()) { + return; + } + // Zero the signal address to clear any stale signals. + uint32_t value = 0; + memory.asPtr().slice(offset, offset + WASM_SIGNAL_FIELD_BYTES).copyFrom(kj::asBytes(&value, 1)); + } + + // Safe to const_cast: the jsg::Lock& parameter proves we hold the isolate lock, which is the + // synchronization required by the signal-safe list for mutations. + const_cast&>(list).pushFront( + TrackedWasmInstance{.memory = kj::mv(memory), + .signalByteOffset = kj::mv(signalOffset), + .terminatedByteOffset = terminatedOffset}); +} + +void TrackedWasmInstanceList::filter(jsg::Lock&) const { + // Safe to const_cast: the jsg::Lock& parameter proves we hold the isolate lock. + const_cast&>(list).filter( + [](const TrackedWasmInstance& signal) { return signal.isModuleListening(); }); +} + +void TrackedWasmInstanceList::clear(jsg::Lock&) const { + // Safe to const_cast: the jsg::Lock& parameter proves we hold the isolate lock. + const_cast&>(list).clear(); +} + +void TrackedWasmInstanceList::writeShutdownSignal() const { + // Safe to const_cast: this is called from a signal handler on the same thread that holds the + // isolate lock, so there is no concurrent mutation of the list structure. + const_cast&>(list).iterate([](TrackedWasmInstance& signal) { + KJ_IF_SOME(offset, signal.signalByteOffset) { + uint32_t value = WASM_SIGNAL_SIGXCPU; + signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1)); + } + }); +} + +void TrackedWasmInstanceList::clearShutdownSignal() const { + // Safe to const_cast: same-thread signal-handler context, no concurrent list mutation. + const_cast&>(list).iterate([](TrackedWasmInstance& signal) { + KJ_IF_SOME(offset, signal.signalByteOffset) { + uint32_t value = 0; + signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1)); + } + }); +} + +void TrackedWasmInstanceList::writeTerminatedSignal() const { + // Safe to const_cast: same-thread signal-handler context, no concurrent list mutation. + const_cast&>(list).iterate([](TrackedWasmInstance& signal) { + uint32_t value = 1; + signal.memory.asPtr() + .slice(signal.terminatedByteOffset, signal.terminatedByteOffset + sizeof(value)) + .copyFrom(kj::asBytes(&value, 1)); + }); +} + +} // namespace workerd diff --git a/src/workerd/io/tracked-wasm-instance.h b/src/workerd/io/tracked-wasm-instance.h new file mode 100644 index 00000000000..9d8f9bf62c6 --- /dev/null +++ b/src/workerd/io/tracked-wasm-instance.h @@ -0,0 +1,204 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +#pragma once + +#include +#include + +#include + +namespace workerd { + +namespace jsg { +class Lock; +} // namespace jsg + +// Byte size of each signal field in WASM linear memory (a single uint32). +constexpr size_t WASM_SIGNAL_FIELD_BYTES = sizeof(uint32_t); + +// Represents a single WASM module that has opted into receiving the "shut down" signal when CPU +// time is nearly exhausted. The module must export at least "__instance_terminated"; the +// "__instance_signal" export is optional: +// +// "__instance_signal" — (optional) address of a uint32 in linear memory. When present, +// the runtime writes SIGXCPU (24) here when CPU time is nearly +// exhausted. +// "__instance_terminated" - address of a uint32 in linear memory. The WASM module writes a +// non-zero value here when it has exited and is no longer listening. +// The runtime checks this in a GC prologue hook and removes entries +// where terminated is non-zero, allowing the linear memory to be +// reclaimed. The runtime also writes 1 here when the isolate is +// killed after exceeding its CPU limit. +struct TrackedWasmInstance { + // Owns a reference to the WASM module's linear memory. The underlying v8::BackingStore is kept + // alive via kj::Array's attach() mechanism, preventing V8 from garbage-collecting the memory + // while we still need to read/write signal addresses. This gets cleaned up in a V8 GC prologue + // hook where we atomically remove the entry from the signal list before releasing the memory. + // + // TODO: If a user were to grow a 64 bit linear memory >16GB, relocation will happen and this + // array will point to stale (but not free'd) memory. The impact is that the user will see a + // spike in memory usage and no longer receive the signal in that module instance. In practice this + // should almost never happen since they would hit a memory limit well before 16GB, + // and 64 bit WASM is currently used very infrequently anyways. Regardless, we should address this + // soon. + kj::Array memory; + + // Offset into `memory` of the uint32 the runtime writes SIGXCPU (24) to (__instance_signal). + // When kj::none, the module did not export __instance_signal and will not receive the + // SIGXCPU shutdown warning, but will still receive the terminated flag. + kj::Maybe signalByteOffset; + + // Offset into `memory` of the uint32 the module writes to (__instance_terminated). + uint32_t terminatedByteOffset; + + // Returns true if the module is still listening for signals (terminated == 0). + // Returns false if the module has exited and this entry should be removed. + bool isModuleListening() const { + uint32_t terminated = 0; + for (auto& b: + memory.slice(terminatedByteOffset, terminatedByteOffset + WASM_SIGNAL_FIELD_BYTES)) { + terminated |= b; + } + return terminated == 0; + } +}; + +// A linked list type which is signal-safe (for reading), but not thread safe - it can handle +// same-thread concurrency and pre-emptive reads ONLY. +// SAFETY: All mutations are must happen with the isolate lock held! +template +class SignalSafeList { + public: + struct Node { + T value; + Node* next; + template + explicit Node(Args&&... args): value(kj::fwd(args)...), + next(nullptr) {} + }; + + SignalSafeList() {} + + ~SignalSafeList() noexcept(false) { + Node* node = __atomic_load_n(&head, __ATOMIC_RELAXED); + while (node != nullptr) { + Node* doomed = node; + node = __atomic_load_n(&doomed->next, __ATOMIC_RELAXED); + delete doomed; + } + } + + // Prepends a new node constructed from `args` at the front of the list + template + void pushFront(Args&&... args) { + Node* node = new Node(kj::fwd(args)...); + __atomic_store_n(&node->next, __atomic_load_n(&head, __ATOMIC_RELAXED), __ATOMIC_RELAXED); + __atomic_store_n(&head, node, __ATOMIC_RELEASE); + } + + // Removes all nodes for which `predicate(node.value)` returns false + template + void filter(Predicate&& predicate) noexcept { + Node** prev = &head; + Node* current = __atomic_load_n(prev, __ATOMIC_RELAXED); + + while (current != nullptr) { + Node* next = __atomic_load_n(¤t->next, __ATOMIC_RELAXED); + + if (predicate(current->value)) { + prev = ¤t->next; + } else { + // Splice out `current` by pointing its predecessor at `next`. Release ordering ensures a + // signal handler that loads *prev with acquire sees a fully consistent successor chain. + __atomic_store_n(prev, next, __ATOMIC_RELEASE); + delete current; + } + + current = next; + } + } + + // Removes all nodes from the list, destroying each one. + void clear() noexcept { + Node* current = __atomic_load_n(&head, __ATOMIC_RELAXED); + __atomic_store_n(&head, static_cast(nullptr), __ATOMIC_RELEASE); + while (current != nullptr) { + Node* next = __atomic_load_n(¤t->next, __ATOMIC_RELAXED); + delete current; + current = next; + } + } + + // Returns true if the list is empty. Signal safe. + bool isEmpty() const { + return __atomic_load_n(&head, __ATOMIC_ACQUIRE) == nullptr; + } + + // Traverses the list, calling `func(node.value)` for each node. Signal-safe (same-thread + // only), but not thread-safe — callers from a signal handler context should const_cast. + template + void iterate(Func&& func) { + Node* current = __atomic_load_n(&head, __ATOMIC_ACQUIRE); + while (current != nullptr) { + func(current->value); + current = __atomic_load_n(¤t->next, __ATOMIC_ACQUIRE); + } + } + + private: + Node* head = nullptr; + + KJ_DISALLOW_COPY_AND_MOVE(SignalSafeList); +}; + +// Encapsulates a SignalSafeList with operations that require the isolate +// lock for mutation, and a read-only accessor for signal-handler use. +// +// The mutation methods are const and accept a jsg::Lock& to prove the caller holds the isolate +// lock. Internally they const_cast the list, which is safe because the lock provides the +// required synchronization. This design allows IsolateLimitEnforcer (whose methods are const +// per KJ convention) to return a const reference without exposing mutable access to code that +// does not hold the lock. +class TrackedWasmInstanceList { + public: + // Registers a WASM module for receiving the "shut down" signal. The signal offset is optional: + // when kj::none, the module will still receive the terminated flag but will not get SIGXCPU. + // Silently skips registration if any provided offset falls outside the module's linear memory. + void registerSignal(jsg::Lock&, + kj::Array memory, + kj::Maybe signalOffset, + uint32_t terminatedOffset) const; + + // Filters out entries where the module has exited (terminated != 0). Call from a GC prologue + // hook to allow linear memory to be reclaimed. + void filter(jsg::Lock&) const; + + // Removes all entries unconditionally. Call before the V8 isolate is disposed, since each + // entry holds a shared_ptr whose destructor may access V8 state. + void clear(jsg::Lock&) const; + + void writeShutdownSignal() const; + + void clearShutdownSignal() const; + + void writeTerminatedSignal() const; + + // Returns the underlying signal-safe list for use by signal handlers and the CPU time limiter. + // The returned reference is const; signal-handler free functions use const_cast internally. + const SignalSafeList& signals() const { + return list; + } + + private: + SignalSafeList list; +}; + +// The value written to the signal address when CPU time is nearly exhausted. +// This is the UNIX signal number for SIGXCPU (24). Technically the number itself +// is not standardized, but for most architectures it is 24 so that is what we're going with. +// We're inventing WASM signals from scratch so we can do whatever we want. +constexpr uint32_t WASM_SIGNAL_SIGXCPU = 24; + +} // namespace workerd diff --git a/src/workerd/io/wasm-instantiate-shim.js b/src/workerd/io/wasm-instantiate-shim.js new file mode 100644 index 00000000000..e680990c44c --- /dev/null +++ b/src/workerd/io/wasm-instantiate-shim.js @@ -0,0 +1,82 @@ +// This file contains a shim for WebAssembly.Instance and WebAssembly.instantiate. Currently, the +// runtime does not support instantiateStreaming, but if this ever changes, we will need to add a +// shim for that too. V8's `SetWasmInstanceCallback` was considered as an alternative, but does +// not quite work since it runs BEFORE instantiation, when the operations we want to do must happen +// after. + +(function (originalInstantiate, originalInstance, registerShutdown, wa) { + // Find memory from exports or imports. Returns Memory instance or undefined. + // When searching imports, only considers entries whose declared import kind is + // 'memory' (via WebAssembly.Module.imports), so that a Memory passed as an + // externref is not mistaken for the module's linear memory. + function findMemory(instance, imports, module) { + // First, check if memory is exported + const importedMemory = wa.Module.imports(module).find( + ({ kind }) => kind === 'memory' + ); + if (importedMemory) { + const value = imports[importedMemory.module][importedMemory.name]; + return value instanceof wa.Memory && value; + } + const exportedMemory = wa.Module.exports(module).find( + ({ kind }) => kind === 'memory' + ); + if (exportedMemory) return instance.exports[exportedMemory.name]; + return undefined; + } + + function checkAndRegisterShutdown(instance, imports, module) { + const exports = instance.exports; + if (!exports) return; + const terminatedGlobal = exports['__instance_terminated']; + // __instance_terminated is required; __instance_signal is optional. + if (!(terminatedGlobal instanceof wa.Global)) return; + const signalGlobal = exports['__instance_signal']; + const hasSignal = signalGlobal instanceof wa.Global; + const memory = findMemory(instance, imports, module); + if (memory) { + // Pass -1 as the signal offset when __instance_signal is not exported. + // The C++ side interprets -1 as "no signal address". + registerShutdown( + memory, + hasSignal ? signalGlobal.value : -1, + terminatedGlobal.value + ); + } + } + + // WebAssembly.instantiate has two overloads: + // instantiate(bytes, imports?, compileOptions?) -> Promise<{module, instance}> + // instantiate(module, imports?, compileOptions?) -> Promise + // Use apply to forward all arguments so compileOptions (and any future args) are not dropped. + wa.instantiate = function instantiate() { + var args = arguments; + return originalInstantiate.apply(wa, args).then(function (result) { + // Called with bytes: result is {module, instance}. + // Called with a Module: result is just the Instance. + var instance = result.instance || result; + var module = result.module || args[0]; + checkAndRegisterShutdown(instance, args[1], module); + return result; + }); + }; + + // new WebAssembly.Instance(module, imports?) + // Forward all arguments and new.target so subclassing works correctly. + wa.Instance = function Instance() { + var instance = Reflect.construct( + originalInstance, + arguments, + new.target || originalInstance + ); + checkAndRegisterShutdown(instance, arguments[1], arguments[0]); + return instance; + }; + // Point the shim's prototype at the original so instanceof checks continue to work. + wa.Instance.prototype = originalInstance.prototype; + Object.defineProperty(wa.Instance.prototype, 'constructor', { + value: wa.Instance, + writable: true, + configurable: true, + }); +}); diff --git a/src/workerd/io/wasm/.gitignore b/src/workerd/io/wasm/.gitignore new file mode 100644 index 00000000000..19e1bced9ad --- /dev/null +++ b/src/workerd/io/wasm/.gitignore @@ -0,0 +1 @@ +*.wasm diff --git a/src/workerd/io/wasm/BUILD.bazel b/src/workerd/io/wasm/BUILD.bazel new file mode 100644 index 00000000000..d2e19ed59c6 --- /dev/null +++ b/src/workerd/io/wasm/BUILD.bazel @@ -0,0 +1,10 @@ +load("//:build/wasm_tools_parse.bzl", "wasm_tools_parse") + +[ + wasm_tools_parse( + name = name.removesuffix(".wat") + ".wasm", + src = name, + visibility = ["//visibility:public"], + ) + for name in glob(["*.wat"]) +] diff --git a/src/workerd/io/wasm/signal-basic.wat b/src/workerd/io/wasm/signal-basic.wat new file mode 100644 index 00000000000..e51bd1adb63 --- /dev/null +++ b/src/workerd/io/wasm/signal-basic.wat @@ -0,0 +1,14 @@ +;; Basic WASM module exporting __instance_signal, __instance_terminated, and memory. +;; Used to verify that the registration shim detects both globals and registers +;; the module, and that the sync WebAssembly.Instance constructor also registers. + +(module + (memory (export "memory") 1) + + (global (export "__instance_signal") i32 (i32.const 0)) + (global (export "__instance_terminated") i32 (i32.const 4)) + + (func (export "get_signal") (result i32) + (i32.load (global.get 0)) + ) +) diff --git a/src/workerd/io/wasm/signal-bounds-check-edge.wat b/src/workerd/io/wasm/signal-bounds-check-edge.wat new file mode 100644 index 00000000000..942fea9c59a --- /dev/null +++ b/src/workerd/io/wasm/signal-bounds-check-edge.wat @@ -0,0 +1,7 @@ +;; __instance_signal at 65533 leaves only 3 bytes, but we need 4. + +(module + (memory (export "memory") 1) + (global (export "__instance_signal") i32 (i32.const 65533)) + (global (export "__instance_terminated") i32 (i32.const 0)) +) diff --git a/src/workerd/io/wasm/signal-bounds-check-overflow.wat b/src/workerd/io/wasm/signal-bounds-check-overflow.wat new file mode 100644 index 00000000000..220f1fb9cfa --- /dev/null +++ b/src/workerd/io/wasm/signal-bounds-check-overflow.wat @@ -0,0 +1,7 @@ +;; __instance_signal points beyond memory bounds (70000 > 65536). + +(module + (memory (export "memory") 1) + (global (export "__instance_signal") i32 (i32.const 70000)) + (global (export "__instance_terminated") i32 (i32.const 70004)) +) diff --git a/src/workerd/io/wasm/signal-bounds-check-valid.wat b/src/workerd/io/wasm/signal-bounds-check-valid.wat new file mode 100644 index 00000000000..6f6cb1dcfaa --- /dev/null +++ b/src/workerd/io/wasm/signal-bounds-check-valid.wat @@ -0,0 +1,13 @@ +;; Both addresses fit exactly at the boundary of a 1-page (65536-byte) memory. +;; __instance_signal = 65528 (4 bytes from 65528..65531) +;; __instance_terminated = 65532 (4 bytes from 65532..65535) + +(module + (memory (export "memory") 1) + (global (export "__instance_signal") i32 (i32.const 65528)) + (global (export "__instance_terminated") i32 (i32.const 65532)) + + (func (export "get_signal") (result i32) + (i32.load (global.get 0)) + ) +) diff --git a/src/workerd/io/wasm/signal-decoy-memory.wat b/src/workerd/io/wasm/signal-decoy-memory.wat new file mode 100644 index 00000000000..4a58e2b2346 --- /dev/null +++ b/src/workerd/io/wasm/signal-decoy-memory.wat @@ -0,0 +1,17 @@ +;; Module with internal memory (not exported) and signal globals. +;; Imports a function so the imports object exists, but does NOT import memory. +;; Used to verify that a WebAssembly.Memory in the imports object is not mistaken +;; for the module's linear memory. + +(module + (import "env" "log" (func $log (param i32))) + + (memory 1) + + (global (export "__instance_signal") i32 (i32.const 0)) + (global (export "__instance_terminated") i32 (i32.const 4)) + + (func (export "get_signal") (result i32) + (i32.load (i32.const 0)) + ) +) diff --git a/src/workerd/io/wasm/signal-externref-memory.wat b/src/workerd/io/wasm/signal-externref-memory.wat new file mode 100644 index 00000000000..9b37ab7d957 --- /dev/null +++ b/src/workerd/io/wasm/signal-externref-memory.wat @@ -0,0 +1,20 @@ +;; Module that imports a global named "memory" typed as externref, NOT as a +;; linear memory import. The shim's findMemory() checks WebAssembly.Module.imports() +;; and only considers descriptors whose kind is 'memory'. An externref global +;; named "memory" will have kind 'global', so the shim must NOT register this +;; module for shutdown signal handling. + +(module + ;; Import a global named "memory" — but as externref, not as linear memory. + (import "env" "memory" (global $mem externref)) + + ;; The two globals that would normally trigger signal registration. + (global (export "__instance_signal") i32 (i32.const 0)) + (global (export "__instance_terminated") i32 (i32.const 4)) + + ;; No linear memory at all — the module cannot be registered. + ;; Export a simple function so the test can verify instantiation succeeded. + (func (export "get_value") (result i32) + (i32.const 42) + ) +) diff --git a/src/workerd/io/wasm/signal-imported-memory.wat b/src/workerd/io/wasm/signal-imported-memory.wat new file mode 100644 index 00000000000..fec60d3ab94 --- /dev/null +++ b/src/workerd/io/wasm/signal-imported-memory.wat @@ -0,0 +1,13 @@ +;; Module that imports memory rather than defining its own. +;; The shim must find the memory in the imports object via Module.imports(). + +(module + (import "env" "memory" (memory 1)) + + (global (export "__instance_signal") i32 (i32.const 0)) + (global (export "__instance_terminated") i32 (i32.const 4)) + + (func (export "get_signal") (result i32) + (i32.load (global.get 0)) + ) +) diff --git a/src/workerd/io/wasm/signal-memory-reclaim.wat b/src/workerd/io/wasm/signal-memory-reclaim.wat new file mode 100644 index 00000000000..1bf2e0bc7bb --- /dev/null +++ b/src/workerd/io/wasm/signal-memory-reclaim.wat @@ -0,0 +1,18 @@ +;; Large-memory module (16MB) for GC reclamation testing. +;; Instantiating many of these without reclaiming terminated ones would OOM. + +(module + (memory (export "memory") 256) + + (global (export "__instance_signal") i32 (i32.const 0)) + (global (export "__instance_terminated") i32 (i32.const 4)) + + ;; Write non-zero to terminated address, signaling the module has exited. + (func (export "mark_exited") + (i32.store (global.get 1) (i32.const 1)) + ) + + (func (export "get_signal") (result i32) + (i32.load (global.get 1)) + ) +) diff --git a/src/workerd/io/wasm/signal-no-globals.wat b/src/workerd/io/wasm/signal-no-globals.wat new file mode 100644 index 00000000000..4b62520affb --- /dev/null +++ b/src/workerd/io/wasm/signal-no-globals.wat @@ -0,0 +1,10 @@ +;; Module that exports memory but neither __instance_signal nor __instance_terminated. +;; The shim should NOT register this module — it should instantiate without error. + +(module + (memory (export "memory") 1) + + (func (export "add") (param i32 i32) (result i32) + (i32.add (local.get 0) (local.get 1)) + ) +) diff --git a/src/workerd/io/wasm/signal-partial-exports.wat b/src/workerd/io/wasm/signal-partial-exports.wat new file mode 100644 index 00000000000..660da438759 --- /dev/null +++ b/src/workerd/io/wasm/signal-partial-exports.wat @@ -0,0 +1,12 @@ +;; Module that exports only __instance_signal but NOT __instance_terminated. +;; The shim should NOT register this module because __instance_terminated is required. + +(module + (memory (export "memory") 1) + + (global (export "__instance_signal") i32 (i32.const 0)) + + (func (export "get_signal") (result i32) + (i32.load (global.get 0)) + ) +) diff --git a/src/workerd/io/wasm/signal-preinit.wat b/src/workerd/io/wasm/signal-preinit.wat new file mode 100644 index 00000000000..af22081e804 --- /dev/null +++ b/src/workerd/io/wasm/signal-preinit.wat @@ -0,0 +1,18 @@ +;; Module whose signal address is pre-initialized to a non-zero value (0xDEADBEEF) +;; via a data segment. The runtime should zero the signal field during registration, +;; so get_signal() should return 0 after instantiation. + +(module + (memory (export "memory") 1) + + ;; Signal at byte 0, terminated at byte 4. + (global (export "__instance_signal") i32 (i32.const 0)) + (global (export "__instance_terminated") i32 (i32.const 4)) + + ;; Pre-fill signal (bytes 0-3) with 0xDEADBEEF. + (data (i32.const 0) "\EF\BE\AD\DE") + + (func (export "get_signal") (result i32) + (i32.load (i32.const 0)) + ) +) diff --git a/src/workerd/io/wasm/signal-terminated-only.wat b/src/workerd/io/wasm/signal-terminated-only.wat new file mode 100644 index 00000000000..b11f4a78e18 --- /dev/null +++ b/src/workerd/io/wasm/signal-terminated-only.wat @@ -0,0 +1,21 @@ +;; Module that exports only __instance_terminated but NOT __instance_signal. +;; The shim should register this module — __instance_signal is optional. The module +;; will receive the terminated flag when the isolate is killed, but will not receive +;; the SIGXCPU warning signal. + +(module + (memory (export "memory") 1) + + ;; terminated at byte 0. + (global (export "__instance_terminated") i32 (i32.const 0)) + + (func (export "get_terminated") (result i32) + (i32.load (global.get 0)) + ) + + ;; Write a non-zero value to the terminated field, signalling that this instance + ;; has exited and the runtime may reclaim the linear memory. + (func (export "mark_exited") + (i32.store (global.get 0) (i32.const 1)) + ) +) diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index a69e75bac9c..919c8cbd634 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +36,7 @@ #include #include #include +#include #include #include @@ -664,6 +667,9 @@ struct Worker::Isolate::Impl { void gcPrologue() { metrics.gcPrologue(); + // Filter out tracked WASM instance entries where the module has exited, allowing + // the linear memory to be reclaimed. + limitEnforcer.getTrackedWasmInstances().filter(*lock); } void gcEpilogue() { metrics.gcEpilogue(); @@ -1559,6 +1565,11 @@ Worker::Isolate::~Isolate() noexcept(false) { // The Rust Realm must be dropped under lock since Realm::drop() accesses V8 globals // and calls drop functions that may interact with V8. auto dropRealm = kj::mv(impl->realm); + + // Release all tracked WASM instance entries while V8 is still alive. Each entry holds a + // shared_ptr whose destructor who needs the isolate to still be alive. + // This is analogous to the cpuTimeLimitNearlyExceededCallback detaching above ^^^ + limitEnforcer->getTrackedWasmInstances().clear(*recordedLock.lock); }); } @@ -1608,25 +1619,98 @@ void Worker::Isolate::setCpuLimitNearlyExceededCallback(kj::Function FAILED, "Python Workers Internal Error: CpuLimitNearlyExceededCallback already set")); } +void Worker::Isolate::registerTrackedWasmInstance(jsg::Lock& js, + kj::Array memory, + kj::Maybe signalOffset, + uint32_t terminatedOffset) const { + // Register the WASM module for receiving shutdown signals. The signal handler will + // iterate the list unconditionally when CPU time is nearly exhausted. + limitEnforcer->getTrackedWasmInstances().registerSignal( + js, kj::mv(memory), kj::mv(signalOffset), terminatedOffset); +} + // EW-1319: Set WebAssembly.Module @@HasInstance // // The instanceof operator can be changed by setting the @@HasInstance method // on the object, https://tc39.es/ecma262/#sec-instanceofoperator. void setWebAssemblyModuleHasInstance(jsg::Lock& lock, v8::Local context) { - auto instanceof = [](const v8::FunctionCallbackInfo& info) { - jsg::Lock::from(info.GetIsolate()).withinHandleScope([&] { - info.GetReturnValue().Set(info[0]->IsWasmModuleObject()); + JSG_WITHIN_CONTEXT_SCOPE(lock, context, [&](jsg::Lock& lock) { + auto instanceof = [](const v8::FunctionCallbackInfo& info) { + jsg::Lock::from(info.GetIsolate()).withinHandleScope([&] { + info.GetReturnValue().Set(info[0]->IsWasmModuleObject()); + }); + }; + v8::Local function = jsg::check(v8::Function::New(context, instanceof)); + + auto webAssembly = + KJ_ASSERT_NONNULL(lock.global().get(lock, "WebAssembly").tryCast()); + auto module = KJ_ASSERT_NONNULL(webAssembly.get(lock, "Module").tryCast()); + + jsg::check(v8::Local(module)->DefineOwnProperty( + context, v8::Symbol::GetHasInstance(lock.v8Isolate), function)); + }); +} + +// Installs a shim around WebAssembly.instantiate and WebAssembly.Instance that hooks into the +// shutdown signal if it exists +void shimWebAssemblyInstantiate(jsg::Lock& lock, v8::Local context) { + // We need to enter the context because this function compiles and executes JavaScript via + // v8::Script::Compile/Run. setupContext() is called before JSG_WITHIN_CONTEXT_SCOPE, so the + // context is not yet entered at this point. + v8::Context::Scope contextScope(context); + + auto webAssembly = + KJ_ASSERT_NONNULL(lock.global().get(lock, "WebAssembly").tryCast()); + + // Create a C++ callback that the JS shims call to register a {memory, signalOffset, + // terminatedOffset} tuple. + // __registerTrackedWasmInstance(memory: WebAssembly.Memory, signalOffset: number, + // terminatedOffset: number) + // signalOffset may be -1, indicating the module did not export __instance_signal. + auto registerCb = [](const v8::FunctionCallbackInfo& info) { + auto& js = jsg::Lock::from(info.GetIsolate()); + js.withinHandleScope([&] { + if (info.Length() < 3 || !info[0]->IsWasmMemoryObject() || !info[1]->IsNumber() || + !info[2]->IsUint32()) { + js.v8Isolate->ThrowException(js.str( + "registerTrackedWasmInstance: expected (WebAssembly.Memory, number, uint32)"_kj)); + return; + } + auto memory = info[0].As(); + // signalOffset is -1 when __instance_signal was not exported. + auto signalRaw = info[1].As()->Value(); + kj::Maybe signalOffset; + if (signalRaw >= 0) { + signalOffset = static_cast(signalRaw); + } + auto terminatedOffset = info[2].As()->Value(); + auto backingStore = memory->Buffer()->GetBackingStore(); + auto wasmMemory = + kj::arrayPtr(static_cast(backingStore->Data()), backingStore->ByteLength()) + .attach(kj::mv(backingStore)); + KJ_IF_SOME(e, kj::runCatchingExceptions([&] { + Worker::Isolate::from(js).registerTrackedWasmInstance( + js, kj::mv(wasmMemory), signalOffset, terminatedOffset); + })) { + js.v8Isolate->ThrowException(js.exceptionToJs(kj::mv(e)).getHandle(js)); + } }); }; - v8::Local function = jsg::check(v8::Function::New(context, instanceof)); + auto registerFn = jsg::check(v8::Function::New(context, registerCb)); + + // Build the shim in JavaScript. It wraps both WebAssembly.instantiate (async) and + // WebAssembly.Instance (sync constructor). + auto shimScript = + jsg::NonModuleScript::compile(lock, WASM_INSTANTIATE_SHIM, "wasm-instantiate-shim.js"_kj); + auto shimFn = KJ_ASSERT_NONNULL(shimScript.runAndReturn(lock).tryCast()); - v8::Object* webAssembly = v8::Object::Cast(*jsg::check( - context->Global()->Get(context, jsg::v8StrIntern(lock.v8Isolate, "WebAssembly")))); - v8::Object* module = v8::Object::Cast( - *jsg::check(webAssembly->Get(context, jsg::v8StrIntern(lock.v8Isolate, "Module")))); + // Grab the originals before they are replaced. + auto originalInstantiate = webAssembly.get(lock, "instantiate"); + auto originalInstance = webAssembly.get(lock, "Instance"); - jsg::check( - module->DefineOwnProperty(context, v8::Symbol::GetHasInstance(lock.v8Isolate), function)); + // Call the factory — it mutates `wa` in place. + shimFn.call(lock, lock.global(), originalInstantiate, originalInstance, + jsg::JsFunction(registerFn), jsg::JsObject(webAssembly)); } void Worker::setupContext( @@ -1634,6 +1718,11 @@ void Worker::setupContext( // Set WebAssembly.Module @@HasInstance setWebAssemblyModuleHasInstance(lock, context); + // Shim WebAssembly.instantiate to detect modules exporting "__instance_signal". + if (util::Autogate::isEnabled(util::AutogateKey::WASM_SHUTDOWN_SIGNAL_SHIM)) { + shimWebAssemblyInstantiate(lock, context); + } + // We replace the default V8 console.log(), etc. methods, to give the worker access to // logged content, and log formatted values to stdout/stderr locally. auto global = context->Global(); diff --git a/src/workerd/io/worker.h b/src/workerd/io/worker.h index 5294458ec55..fac5362a67f 100644 --- a/src/workerd/io/worker.h +++ b/src/workerd/io/worker.h @@ -28,8 +28,9 @@ #include namespace v8 { +class BackingStore; class Isolate; -} +} // namespace v8 namespace workerd { @@ -373,6 +374,14 @@ class Worker::Isolate: public kj::AtomicRefcounted { // Returns a reference to cpuLimitNearlyExceededCallback. Can't outlive the Isolate. kj::Maybe> getCpuLimitNearlyExceededCallback() const; + // Registers a WASM module's linear memory and offsets for receiving the "shut down" signal. + // The signal offset is optional: when kj::none, the module will only receive the terminated + // flag but will not get the SIGXCPU warning. See TrackedWasmInstanceList::registerSignal(). + void registerTrackedWasmInstance(jsg::Lock& js, + kj::Array memory, + kj::Maybe signalOffset, + uint32_t terminatedOffset) const; + inline IsolateObserver& getMetrics() { return *metrics; } diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index af0b66ff1d0..e4d13c96403 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -1789,6 +1789,13 @@ class NullIsolateLimitEnforcer final: public IsolateLimitEnforcer { bool hasExcessivelyExceededHeapLimit() const override { return false; } + + const TrackedWasmInstanceList& getTrackedWasmInstances() const override { + return trackedWasmInstances; + } + + private: + TrackedWasmInstanceList trackedWasmInstances; }; } // namespace diff --git a/src/workerd/tests/test-fixture.c++ b/src/workerd/tests/test-fixture.c++ index 9466aefff87..7e7fafe4cb0 100644 --- a/src/workerd/tests/test-fixture.c++ +++ b/src/workerd/tests/test-fixture.c++ @@ -253,6 +253,12 @@ struct MockIsolateLimitEnforcer final: public IsolateLimitEnforcer { bool hasExcessivelyExceededHeapLimit() const override { return false; } + const TrackedWasmInstanceList& getTrackedWasmInstances() const override { + return trackedWasmInstances; + } + + private: + TrackedWasmInstanceList trackedWasmInstances; }; struct MockErrorReporter final: public Worker::ValidationErrorReporter { diff --git a/src/workerd/util/autogate.c++ b/src/workerd/util/autogate.c++ index 211e0689374..1f7affe9c0e 100644 --- a/src/workerd/util/autogate.c++ +++ b/src/workerd/util/autogate.c++ @@ -33,6 +33,8 @@ kj::StringPtr KJ_STRINGIFY(AutogateKey key) { return "rpc-use-external-pusher"_kj; case AutogateKey::BLOB_USE_STREAMS_NEW_MEMORY_SOURCE: return "blob-use-streams-new-memory-source"_kj; + case AutogateKey::WASM_SHUTDOWN_SIGNAL_SHIM: + return "wasm-shutdown-signal-shim"_kj; case AutogateKey::ENABLE_FAST_TEXTENCODER: return "enable-fast-textencoder"_kj; case AutogateKey::NumOfKeys: diff --git a/src/workerd/util/autogate.h b/src/workerd/util/autogate.h index 37b86ccfd19..b54a9204e29 100644 --- a/src/workerd/util/autogate.h +++ b/src/workerd/util/autogate.h @@ -29,6 +29,9 @@ enum class AutogateKey { RPC_USE_EXTERNAL_PUSHER, // Switch Blob stream() to use streams::newMemorySource instead of Blob::BlobInputStream BLOB_USE_STREAMS_NEW_MEMORY_SOURCE, + // Enable the WebAssembly.instantiate shim that detects modules exporting __instance_signal / + // __instance_terminated and registers them for receiving the CPU-limit shutdown signal. + WASM_SHUTDOWN_SIGNAL_SHIM, // Enable fast TextEncoder implementation using simdutf ENABLE_FAST_TEXTENCODER, NumOfKeys // Reserved for iteration. diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index bbe2750ed27..221c01066b0 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -34,6 +34,21 @@ ts_config( visibility = ["//visibility:public"], ) +native_binary( + name = "wasm-tools", + src = select( + { + "@bazel_tools//src/conditions:linux_x86_64": "@wasm_tools_linux_x64//:wasm-tools", + "@bazel_tools//src/conditions:linux_aarch64": "@wasm_tools_linux_arm64//:wasm-tools", + "@bazel_tools//src/conditions:darwin_arm64": "@wasm_tools_macos_arm64//:wasm-tools", + "@bazel_tools//src/conditions:darwin_x86_64": "@wasm_tools_macos_x64//:wasm-tools", + "@bazel_tools//src/conditions:windows_x64": "@wasm_tools_windows_x64//:wasm-tools.exe", + }, + ), + out = "wasm-tools", + visibility = ["//visibility:public"], +) + native_binary( name = "clang-tidy", src = select(