Signal handling for non-Python WASM modules#6154
Conversation
There was a problem hiding this comment.
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:
-
[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 usesWebAssembly.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 incompatibility-date.capnp. -
[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.
-
[High] No tests. There are no tests for any part of this feature:
AtomicList, the JS shim,registerWasmShutdownSignal, or the GC prologue filtering.AtomicListis a custom concurrent data structure used from signal handlers — this absolutely needs unit tests, including tests that verify signal-safety invariants (e.g., thatiterate()sees a consistent list whilefilter()is interrupted). -
[Medium]
WebAssembly.Instanceshim changes behavior when called withoutnew. The originalWebAssembly.Instancethrows if called as a regular function (not as a constructor). The shim is a plain JS function that internally doesnew originalInstance(...), soWebAssembly.Instance(module, imports)(withoutnew) would succeed instead of throwing. This is a spec deviation. -
[Medium]
AtomicListuses GCC__atomicbuiltins on non-atomic types.Node* headandNode* nextare plain pointers, notstd::atomic<Node*>. The GCC builtins work in practice, but this is technically undefined behavior per the C++ memory model. Consider usingstd::atomic<Node*>forheadandNode::next— the generated code is identical, and it makes the concurrency intent explicit and portable. -
[Low]
<atomic>header is included butstd::atomicis not used. Either switch tostd::atomic(see issue 5) or remove the unused include. -
[Low]
signalByteOffsetandterminatedByteOffsetcan 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 simpleKJ_REQUIRE(signalOffset != terminatedOffset)would catch this.
| setWebAssemblyModuleHasInstance(lock, context); | ||
|
|
||
| // Shim WebAssembly.instantiate to detect modules exporting "__signal_address". | ||
| shimWebAssemblyInstantiate(lock, context); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
@logan-gatlin there is no then() wrapper in the instantiate shim right? This is a complete misclassification I believe.
(re AI comment)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
This also seems incorrect, as atomic methods are used?
(re AI comment)
|
I'm Bonk, and I've done a quick review of your PR. Summary: Adds a JS shim around I found 7 issues and posted them as a batch review on the PR. The highest severity items are:
|
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
| 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)); |
There was a problem hiding this comment.
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>(); |
There was a problem hiding this comment.
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([&] { |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
guybedford
left a comment
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
@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> |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
Using jsg::JsFunction is easier here.
Continuation of 6098
This adds a shim to
WebAssembly.Instantiateand friends which looks for modules that export two i32 globals:__signal_addressand__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_addressis 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.