Skip to content

Comments

Signal handling for non-Python WASM modules#6154

Draft
logan-gatlin wants to merge 7 commits intomainfrom
logan/wasm-signal-handling
Draft

Signal handling for non-Python WASM modules#6154
logan-gatlin wants to merge 7 commits intomainfrom
logan/wasm-signal-handling

Conversation

@logan-gatlin
Copy link

@logan-gatlin logan-gatlin commented Feb 24, 2026

Continuation of 6098

This adds a shim to WebAssembly.Instantiate and friends which looks for modules that export two i32 globals: __signal_address and __terminated_address. Modules matching one of these signatures will receive a hook, allowing us to read and write to those memory locations, allowing for signal-based communication with modules.

The addition of __terminated_address is required, since we must know when the module is dead so we can stop sending it signals. This is also useful for modules that want to know when they have been terminated, so that they can re-initialize.

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm Bonk, and I've done a quick review of your PR.

Summary: Adds a JS shim around WebAssembly.instantiate/Instance to detect WASM modules exporting __signal_address/__terminated_address globals, registering them for signal-based shutdown notification via a new signal-safe AtomicList data structure.

Issues ranked by severity:

  1. [High] Missing compat flag — behavioral change applied unconditionally to all workers. The shim wraps WebAssembly.instantiate's return promise with an extra .then(), which adds a microtask tick before results are delivered. This subtly changes promise scheduling for every worker that uses WebAssembly.instantiate, not just ones with signal globals. workerd has a strong backward-compat commitment; this kind of observable behavioral change must be gated behind a compatibility flag in compatibility-date.capnp.

  2. [High] Missing autogate for staged rollout. Even with a compat flag, this is a risky change (shimming fundamental WebAssembly APIs, new signal-handler code path). It should additionally be gated behind an autogate flag for safe incremental rollout.

  3. [High] No tests. There are no tests for any part of this feature: AtomicList, the JS shim, registerWasmShutdownSignal, or the GC prologue filtering. AtomicList is a custom concurrent data structure used from signal handlers — this absolutely needs unit tests, including tests that verify signal-safety invariants (e.g., that iterate() sees a consistent list while filter() is interrupted).

  4. [Medium] WebAssembly.Instance shim changes behavior when called without new. The original WebAssembly.Instance throws if called as a regular function (not as a constructor). The shim is a plain JS function that internally does new originalInstance(...), so WebAssembly.Instance(module, imports) (without new) would succeed instead of throwing. This is a spec deviation.

  5. [Medium] AtomicList uses GCC __atomic builtins on non-atomic types. Node* head and Node* next are plain pointers, not std::atomic<Node*>. The GCC builtins work in practice, but this is technically undefined behavior per the C++ memory model. Consider using std::atomic<Node*> for head and Node::next — the generated code is identical, and it makes the concurrency intent explicit and portable.

  6. [Low] <atomic> header is included but std::atomic is not used. Either switch to std::atomic (see issue 5) or remove the unused include.

  7. [Low] signalByteOffset and terminatedByteOffset can overlap. The bounds checks don't verify the two offsets point to non-overlapping memory. Overlapping offsets would cause the runtime's own signal write to be misinterpreted as the module's termination flag. A simple KJ_REQUIRE(signalOffset != terminatedOffset) would catch this.

setWebAssemblyModuleHasInstance(lock, context);

// Shim WebAssembly.instantiate to detect modules exporting "__signal_address".
shimWebAssemblyInstantiate(lock, context);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] This shim is installed unconditionally for all workers. The .then() wrapper in the instantiate shim adds a microtask tick, which is an observable behavioral change to promise scheduling. Per workerd's backward-compat policy, this should be gated behind a compatibility flag.

Additionally, this is a risky change (shimming fundamental WebAssembly APIs + new signal-handler codepath) and should use an autogate for staged rollout.

Copy link
Contributor

@guybedford guybedford Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@logan-gatlin there is no then() wrapper in the instantiate shim right? This is a complete misclassification I believe.

(re AI comment)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job, Bonk! This is exactly the kind of thing we need flagged :-) ... ideally the shim is only installed on workers that actually need it. This will install it on all of them it seems.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new hook is attached on a per module basis, not a per worker basis. The shim is where we determine which modules are actually listening for the signal, and create the hook. Maybe we need to put it behind a gate?


#include <kj/common.h>

#include <atomic>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] <atomic> is included but std::atomic is not used anywhere in this file. Either switch head/next to std::atomic<Node*> (recommended, see other comment) or remove this include.

Copy link
Contributor

@guybedford guybedford Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also seems incorrect, as atomic methods are used?

(re AI comment)

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Feb 24, 2026

I'm Bonk, and I've done a quick review of your PR.

Summary: Adds a JS shim around WebAssembly.instantiate/Instance to detect WASM modules exporting __signal_address/__terminated_address globals, registering them for signal-based shutdown notification via a new signal-safe AtomicList data structure.

I found 7 issues and posted them as a batch review on the PR. The highest severity items are:

  1. [High] Missing compat flag — The .then() wrapper adds a microtask tick to WebAssembly.instantiate for all workers, which is an observable behavioral change that needs a compat flag.
  2. [High] Missing autogate — Shimming fundamental WebAssembly APIs with new signal-handler code is risky and should use an autogate for staged rollout.
  3. [High] No tests — No tests for AtomicList, the JS shim, registration, or GC prologue filtering.
  4. [Medium] Instance shim spec deviationWebAssembly.Instance() without new would succeed instead of throwing (suggestion provided).
  5. [Medium] UB with __atomic builtins on non-atomic typeshead/next should be std::atomic<Node*> (suggestion provided).
  6. [Low] Unused <atomic> include.
  7. [Low] No overlap check for signal/terminated offsets (suggestion provided).

github run

@codecov-commenter
Copy link

codecov-commenter commented Feb 24, 2026

Codecov Report

❌ Patch coverage is 85.23810% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.62%. Comparing base (c0f9d42) to head (334150e).
⚠️ Report is 132 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/io/wasm-shutdown-signal-test.c++ 77.92% 0 Missing and 17 partials ⚠️
src/workerd/io/worker.c++ 87.25% 10 Missing and 3 partials ⚠️
src/workerd/io/wasm-shutdown-signal.h 96.77% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6154      +/-   ##
==========================================
- Coverage   70.70%   70.62%   -0.08%     
==========================================
  Files         409      415       +6     
  Lines      109411   109986     +575     
  Branches    18018    18111      +93     
==========================================
+ Hits        77356    77679     +323     
- Misses      21246    21472     +226     
- Partials    10809    10835      +26     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

auto* base = static_cast<kj::byte*>(backingStore->Data());
uint32_t zero = 0;
// The address may not be aligned, so use memcpy instead of writing directly
memcpy(base + signalOffset, &zero, sizeof(zero));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be using v8::BackingStore and memcpy directly here. Instead, you should wrap the ArrayBuffer with jsg::JsArrayBuffer, use it's toArrayPtr() and copyFrom(...) API. It's just safer overall.


auto webAssembly =
jsg::check(context->Global()->Get(context, jsg::v8StrIntern(isolate, "WebAssembly")))
.As<v8::Object>();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should make use of js.global() and the jsg::JsObject API instead. It'll cut down on boilerplate and make this easier.

// __registerWasmShutdownSignal(memory: WebAssembly.Memory, signalOffset: number,
// terminatedOffset: number)
auto registerCb = [](const v8::FunctionCallbackInfo<v8::Value>& info) {
jsg::Lock::from(info.GetIsolate()).withinHandleScope([&] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: better to separate these...

  auto& js = jsg::Lock::from(info.GetIsolate());
  js.withinHandleScope(...);

Easier to read and grep for.

})) {
// Convert KJ exception to a JavaScript Error object
auto message = jsg::v8Str(isolate, e.getDescription());
isolate->ThrowException(v8::Exception::Error(message));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have js.exceptionToJs(...) for this.

// registerShutdown - the C++ registration callback
// wa - the WebAssembly object
auto shimSource = jsg::v8Str(isolate,
"(function(originalInstantiate, originalInstance, registerShutdown, wa) {\n"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than dropping this large string in line like this, it would be better to have it either be an embed (like how we do the node and cloudflare built-ins) or a static constant, will make it easier to maintain over time.

Copy link
Contributor

@guybedford guybedford left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial feedback, please let me know when this is ready for final review.

// originalInstance - the original WebAssembly.Instance constructor
// registerShutdown - the C++ registration callback
// wa - the WebAssembly object
auto shimSource = jsg::v8Str(isolate,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not include this as a JS source like we do for normal JS builtins, rather than as a string? Would be a lot better if we can properly lint and track this JS code.

setWebAssemblyModuleHasInstance(lock, context);

// Shim WebAssembly.instantiate to detect modules exporting "__signal_address".
shimWebAssemblyInstantiate(lock, context);
Copy link
Contributor

@guybedford guybedford Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@logan-gatlin there is no then() wrapper in the instantiate shim right? This is a complete misclassification I believe.

(re AI comment)


#include <kj/common.h>

#include <atomic>
Copy link
Contributor

@guybedford guybedford Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also seems incorrect, as atomic methods are used?

(re AI comment)

"})\n"_kj);

auto shimFactory = jsg::check(v8::Script::Compile(context, shimSource));
auto shimFactoryResult = jsg::check(shimFactory->Run(context));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have jsg::NonModuleScript for this.


// Call the factory — it mutates `wa` in place.
v8::Local<v8::Value> args[] = {originalInstantiate, originalInstance, registerFn, webAssembly};
jsg::check(shimFactoryFn->Call(context, context->Global(), 4, args));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using jsg::JsFunction is easier here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants