Skip to content
File

Blob: src/workerd/io/tracked-wasm-instance-test.c++

30.8 KB
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5#include "tracked-wasm-instance.h"
6 
7#include <kj/test.h>
8 
9#include <cstring>
10 
11namespace workerd {
12namespace {
13 
14// ---------------------------------------------------------------------------
15// SignalSafeList tests
16// ---------------------------------------------------------------------------
17 
18KJ_TEST("SignalSafeList pushFront and iterate") {
19 SignalSafeList<int> list;
20 
21 KJ_EXPECT(list.isEmpty());
22 
23 list.pushFront(3);
24 list.pushFront(2);
25 list.pushFront(1);
26 
27 KJ_EXPECT(!list.isEmpty());
28 
29 // Iterate should visit 1, 2, 3 (pushFront prepends).
30 int expected = 1;
31 list.iterate([&](int value) {
32 KJ_EXPECT(value == expected, value, expected);
33 ++expected;
34 });
35 KJ_EXPECT(expected == 4);
36}
37 
38KJ_TEST("SignalSafeList pushFront returns reference to inserted value") {
39 SignalSafeList<int> list;
40 
41 int& ref = list.pushFront(42);
42 KJ_EXPECT(ref == 42);
43 
44 // Mutate through the reference.
45 ref = 99;
46 
47 int value = 0;
48 list.iterate([&](int v) { value = v; });
49 KJ_EXPECT(value == 99);
50}
51 
52KJ_TEST("SignalSafeList filter removes matching nodes") {
53 SignalSafeList<int> list;
54 
55 list.pushFront(5);
56 list.pushFront(4);
57 list.pushFront(3);
58 list.pushFront(2);
59 list.pushFront(1);
60 
61 // Keep only odd numbers.
62 list.filter([](int value) { return value % 2 != 0; });
63 
64 kj::Vector<int> remaining;
65 list.iterate([&](int value) { remaining.add(value); });
66 
67 KJ_EXPECT(remaining.size() == 3);
68 KJ_EXPECT(remaining[0] == 1);
69 KJ_EXPECT(remaining[1] == 3);
70 KJ_EXPECT(remaining[2] == 5);
71}
72 
73KJ_TEST("SignalSafeList filter removes all nodes") {
74 SignalSafeList<int> list;
75 
76 list.pushFront(2);
77 list.pushFront(4);
78 list.pushFront(6);
79 
80 list.filter([](int) { return false; });
81 
82 KJ_EXPECT(list.isEmpty());
83}
84 
85KJ_TEST("SignalSafeList filter keeps all nodes") {
86 SignalSafeList<int> list;
87 
88 list.pushFront(1);
89 list.pushFront(2);
90 list.pushFront(3);
91 
92 list.filter([](int) { return true; });
93 
94 int count = 0;
95 list.iterate([&](int) { ++count; });
96 KJ_EXPECT(count == 3);
97}
98 
99KJ_TEST("SignalSafeList single element filter remove") {
100 SignalSafeList<int> list;
101 
102 list.pushFront(42);
103 
104 list.filter([](int) { return false; });
105 
106 KJ_EXPECT(list.isEmpty());
107}
108 
109KJ_TEST("SignalSafeList clear removes all nodes") {
110 SignalSafeList<int> list;
111 
112 list.pushFront(3);
113 list.pushFront(2);
114 list.pushFront(1);
115 
116 KJ_EXPECT(!list.isEmpty());
117 
118 list.clear();
119 
120 KJ_EXPECT(list.isEmpty());
121 
122 // List should be reusable after clear.
123 list.pushFront(42);
124 KJ_EXPECT(!list.isEmpty());
125 int value = 0;
126 list.iterate([&](int v) { value = v; });
127 KJ_EXPECT(value == 42);
128}
129 
130KJ_TEST("SignalSafeList clear on empty list is a no-op") {
131 SignalSafeList<int> list;
132 
133 KJ_EXPECT(list.isEmpty());
134 list.clear();
135 KJ_EXPECT(list.isEmpty());
136}
137 
138KJ_TEST("SignalSafeList filter removes head only") {
139 SignalSafeList<int> list;
140 
141 list.pushFront(3);
142 list.pushFront(2);
143 list.pushFront(1);
144 
145 // Remove head (value 1).
146 list.filter([](int value) { return value != 1; });
147 
148 kj::Vector<int> remaining;
149 list.iterate([&](int value) { remaining.add(value); });
150 
151 KJ_EXPECT(remaining.size() == 2);
152 KJ_EXPECT(remaining[0] == 2);
153 KJ_EXPECT(remaining[1] == 3);
154}
155 
156KJ_TEST("SignalSafeList filter removes tail only") {
157 SignalSafeList<int> list;
158 
159 list.pushFront(3);
160 list.pushFront(2);
161 list.pushFront(1);
162 
163 // Remove tail (value 3).
164 list.filter([](int value) { return value != 3; });
165 
166 kj::Vector<int> remaining;
167 list.iterate([&](int value) { remaining.add(value); });
168 
169 KJ_EXPECT(remaining.size() == 2);
170 KJ_EXPECT(remaining[0] == 1);
171 KJ_EXPECT(remaining[1] == 2);
172}
173 
174// ---------------------------------------------------------------------------
175// TrackedWasmInstance memory-lifetime tests
176// ---------------------------------------------------------------------------
177 
178// Simulates a WASM module's backing store (e.g. a v8::BackingStore). Sets a flag on destruction so
179// tests can observe exactly when the memory is reclaimed.
180struct FakeBackingStore {
181 FakeBackingStore(bool& destroyed, size_t size)
182 : destroyed(destroyed),
183 data(kj::heapArray<kj::byte>(size)) {
184 memset(data.begin(), 0, data.size());
185 }
186 ~FakeBackingStore() noexcept(false) {
187 destroyed = true;
188 }
189 
190 bool& destroyed;
191 kj::Array<kj::byte> data;
192};
193 
194// Local test helpers that replicate the signal-writing logic formerly provided by the inline free
195// functions writeShutdownSignals, clearShutdownSignals, and writeTerminatedFlags.
196// The production code now lives in TrackedWasmInstanceList methods, but these unit tests exercise
197// the raw SignalSafeList directly without requiring a jsg::Lock.
198 
199void writeShutdownSignals(SignalSafeList<TrackedWasmInstance>& signals) {
200 signals.iterate([](TrackedWasmInstance& signal) {
201 KJ_IF_SOME(offset, signal.signalByteOffset) {
202 uint32_t value = WASM_SIGNAL_SIGXCPU;
203 signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1));
204 }
205 });
206}
207 
208void clearShutdownSignals(SignalSafeList<TrackedWasmInstance>& signals) {
209 signals.iterate([](TrackedWasmInstance& signal) {
210 KJ_IF_SOME(offset, signal.signalByteOffset) {
211 uint32_t value = 0;
212 signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1));
213 }
214 });
215}
216 
217void writeTerminatedFlags(SignalSafeList<TrackedWasmInstance>& signals) {
218 signals.iterate([](TrackedWasmInstance& signal) {
219 KJ_IF_SOME(offset, signal.terminatedByteOffset) {
220 uint32_t value = 1;
221 signal.memory.asPtr().slice(offset, offset + sizeof(value)).copyFrom(kj::asBytes(&value, 1));
222 }
223 });
224}
225 
226KJ_TEST("kj::Array attach keeps memory alive after module instance is dropped") {
227 // This test proves that the kj::Array<kj::byte> in TrackedWasmInstance, created via attach(),
228 // keeps the underlying linear memory alive even after the original owner (simulating a WASM
229 // module instance) is dropped.
230 
231 bool backingStoreDestroyed = false;
232 SignalSafeList<TrackedWasmInstance> signals;
233 
234 // Allocate enough room for both signal fields (signalByteOffset=0, terminatedByteOffset=4).
235 constexpr size_t kMemorySize = 64;
236 constexpr uint32_t kSignalOffset = 0;
237 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
238 
239 {
240 // Create the backing store — this simulates the WASM module instance owning linear memory.
241 auto backingStore = kj::heap<FakeBackingStore>(backingStoreDestroyed, kMemorySize);
242 
243 // Build a kj::Array that points into the backing store's data and keeps it alive via attach().
244 // This mirrors what the runtime does: take an ArrayPtr into the v8::BackingStore, then attach
245 // the BackingStore so the array owns a reference.
246 kj::Array<kj::byte> memory = backingStore->data.asPtr().attach(kj::mv(backingStore));
247 
248 // Register in the signal list.
249 signals.pushFront(TrackedWasmInstance{
250 .memory = kj::mv(memory),
251 .signalByteOffset = kSignalOffset,
252 .terminatedByteOffset = kTerminatedOffset,
253 });
254 
255 // `backingStore` has been moved away — the only reference keeping the memory alive is the
256 // kj::Array inside the SignalSafeList.
257 }
258 
259 // The backing store must still be alive — the SignalSafeList's kj::Array owns it.
260 KJ_EXPECT(!backingStoreDestroyed);
261 
262 // Prove the memory is accessible: write the shutdown signal through the list.
263 writeShutdownSignals(signals);
264 
265 // Read back the signal value to confirm the write landed in live memory.
266 signals.iterate([&](TrackedWasmInstance& signal) {
267 uint32_t value = 0;
268 memcpy(&value, signal.memory.begin() + kSignalOffset, sizeof(value));
269 KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value);
270 });
271 
272 // Clear the signals (writes zero), then verify the clear also works on the still-live memory.
273 clearShutdownSignals(signals);
274 signals.iterate([&](TrackedWasmInstance& signal) {
275 uint32_t value = 0xff;
276 memcpy(&value, signal.memory.begin() + kSignalOffset, sizeof(value));
277 KJ_EXPECT(value == 0, value);
278 });
279 
280 // Memory is still alive after all those read/write operations.
281 KJ_EXPECT(!backingStoreDestroyed);
282 
283 // Now remove the entry from the list — this destroys the kj::Array, which in turn destroys
284 // the attached FakeBackingStore.
285 signals.filter([](TrackedWasmInstance&) { return false; });
286 
287 KJ_EXPECT(backingStoreDestroyed);
288 KJ_EXPECT(signals.isEmpty());
289}
290 
291// ---------------------------------------------------------------------------
292// Teardown-order test — models the real Worker::Isolate destruction sequence.
293//
294// In production, the member destruction order in Worker::Isolate is:
295//
296// 1. `api` destroyed → V8 isolate disposed (v8Alive becomes false)
297// 2. `limitEnforcer` destroyed → ~SignalSafeList() frees remaining entries
298//
299// Each TrackedWasmInstance entry holds a shared_ptr<v8::BackingStore> whose
300// destructor lives in libv8.so and may touch V8 isolate-internal state,
301// as well as a v8::Global<v8::Object> weak handle whose destructor calls
302// V8::DisposeGlobal.
303// If those are destroyed in step 2 (after V8 is gone), the destructors read
304// freed memory → use-after-free.
305//
306// The fix: call clear() in ~Isolate()'s destructor body (before member
307// destruction begins), while V8 is still alive.
308//
309// This test models that sequence with a mock that records whether "V8" was
310// still alive when each backing store was freed. Without the clear() call,
311// the test fails because the backing stores are freed after "V8 disposal".
312// ---------------------------------------------------------------------------
313 
314// Mock backing store that records whether V8 was alive at destruction time.
315struct V8LifetimeAwareStore {
316 V8LifetimeAwareStore(const bool& v8Alive, bool& freedAfterV8, int& dtorCount, size_t size)
317 : v8Alive(v8Alive),
318 freedAfterV8(freedAfterV8),
319 dtorCount(dtorCount),
320 data(kj::heapArray<kj::byte>(size)) {
321 memset(data.begin(), 0, data.size());
322 }
323 ~V8LifetimeAwareStore() noexcept(false) {
324 ++dtorCount;
325 if (!v8Alive) {
326 freedAfterV8 = true;
327 }
328 }
329 const bool& v8Alive;
330 bool& freedAfterV8;
331 int& dtorCount;
332 kj::Array<kj::byte> data;
333};
334 
335// Helper: push a TrackedWasmInstance backed by a V8LifetimeAwareStore.
336void pushV8Signal(SignalSafeList<TrackedWasmInstance>& list,
337 const bool& v8Alive,
338 bool& freedAfterV8,
339 int& dtorCount) {
340 constexpr size_t kSize = 64;
341 auto store = kj::heap<V8LifetimeAwareStore>(v8Alive, freedAfterV8, dtorCount, kSize);
342 auto memory = store->data.asPtr().attach(kj::mv(store));
343 list.pushFront(TrackedWasmInstance{
344 .memory = kj::mv(memory),
345 .signalByteOffset = static_cast<uint32_t>(0),
346 .terminatedByteOffset = static_cast<uint32_t>(sizeof(uint32_t)),
347 });
348}
349 
350KJ_TEST("backing stores freed before V8 disposal when clear() is called") {
351 // Models the FIXED teardown sequence:
352 // 1. clear() called while V8 is alive (the fix in ~Isolate)
353 // 2. V8 disposed
354 // 3. ~SignalSafeList runs on the now-empty list
355 //
356 // This must pass: no backing store is freed after V8 disposal.
357 constexpr int kEntries = 5;
358 bool v8Alive = true;
359 bool freedAfterV8[kEntries] = {};
360 int dtorCounts[kEntries] = {};
361 
362 {
363 SignalSafeList<TrackedWasmInstance> list;
364 for (int i = 0; i < kEntries; ++i) {
365 pushV8Signal(list, v8Alive, freedAfterV8[i], dtorCounts[i]);
366 }
367 
368 // ---- Simulates ~Isolate() destructor body (V8 still alive) ----
369 list.clear();
370 
371 // All stores freed while V8 was alive.
372 for (auto count: dtorCounts) {
373 KJ_EXPECT(count == 1, "entry not freed by clear()", count);
374 }
375 for (auto bad: freedAfterV8) {
376 KJ_EXPECT(!bad, "backing store freed after V8 disposal during clear()");
377 }
378 
379 // ---- Simulates `api` member destruction (V8 disposed) ----
380 v8Alive = false;
381 
382 // ---- ~SignalSafeList runs here (simulates `limitEnforcer` destruction) ----
383 }
384 
385 // No store was freed after V8 disposal, and each was freed exactly once.
386 for (auto bad: freedAfterV8) {
387 KJ_EXPECT(!bad, "backing store freed after V8 disposal");
388 }
389 for (auto count: dtorCounts) {
390 KJ_EXPECT(count == 1, "double-freed or leaked", count);
391 }
392}
393 
394KJ_TEST("backing stores freed after V8 disposal WITHOUT clear() — the bug") {
395 // Models the BUGGY teardown (no clear() call):
396 // 1. V8 disposed
397 // 2. ~SignalSafeList frees entries → BackingStore destructors run after V8 is gone
398 //
399 // This test ASSERTS THAT THE BUG EXISTS without the fix: at least one
400 // backing store is freed after V8 disposal. If this test ever fails, it
401 // means the destruction order changed and the fix may need revisiting.
402 constexpr int kEntries = 3;
403 bool v8Alive = true;
404 bool freedAfterV8[kEntries] = {};
405 int dtorCounts[kEntries] = {};
406 
407 {
408 SignalSafeList<TrackedWasmInstance> list;
409 for (int i = 0; i < kEntries; ++i) {
410 pushV8Signal(list, v8Alive, freedAfterV8[i], dtorCounts[i]);
411 }
412 
413 // NO clear() — this is the bug.
414 
415 // ---- Simulates `api` member destruction (V8 disposed) ----
416 v8Alive = false;
417 
418 // ---- ~SignalSafeList runs here ----
419 }
420 
421 // Prove the bug: all entries were freed AFTER V8 disposal.
422 for (auto bad: freedAfterV8) {
423 KJ_EXPECT(bad, "expected backing store to be freed after V8 disposal (bug scenario)");
424 }
425 // But each was still freed exactly once (no double-free in the buggy path either).
426 for (auto count: dtorCounts) {
427 KJ_EXPECT(count == 1, "double-freed or leaked in buggy path", count);
428 }
429}
430 
431// ---------------------------------------------------------------------------
432// Signal offset permutation tests
433//
434// At least one of __instance_terminated or __instance_signal must be present. The three valid
435// permutations that reach the C++ signal list are:
436// 1. Both signal + terminated offsets present
437// 2. Only terminated offset (signal = kj::none)
438// 3. Only signal offset (terminated = kj::none)
439//
440// (The fourth permutation — neither — is rejected by the JS shim before reaching C++, so it
441// is only tested in the JS test file.)
442//
443// For each permutation we verify the signal read/write operations:
444// - writeShutdownSignals (writes SIGXCPU to signal address)
445// - clearShutdownSignals (zeros the signal address)
446// - writeTerminatedFlags (writes 1 to terminated address)
447//
448// shouldRetain + filter coverage is in the mixed list test, which exercises all
449// three permutations in a single pass. Without a real V8 isolate, instanceRef is
450// always empty (default-constructed), so shouldRetain() returns false for all entries.
451// ---------------------------------------------------------------------------
452 
453// Helper: construct a TrackedWasmInstance backed by a FakeBackingStore.
454void pushSignal(SignalSafeList<TrackedWasmInstance>& list,
455 bool& destroyed,
456 kj::Maybe<uint32_t> signalOffset,
457 kj::Maybe<uint32_t> terminatedOffset,
458 size_t memorySize = 64) {
459 auto store = kj::heap<FakeBackingStore>(destroyed, memorySize);
460 auto memory = store->data.asPtr().attach(kj::mv(store));
461 list.pushFront(TrackedWasmInstance{
462 .memory = kj::mv(memory),
463 .signalByteOffset = signalOffset,
464 .terminatedByteOffset = terminatedOffset,
465 });
466}
467 
468// Helper: read a uint32 at `offset` from the first entry in the list.
469uint32_t readU32(SignalSafeList<TrackedWasmInstance>& list, uint32_t offset) {
470 uint32_t value = 0xDEADBEEF;
471 list.iterate([&](TrackedWasmInstance& signal) {
472 memcpy(&value, signal.memory.begin() + offset, sizeof(value));
473 });
474 return value;
475}
476 
477// Permutation 1: both signal and terminated offsets present.
478KJ_TEST("permutation: both offsets — writeShutdownSignals writes SIGXCPU") {
479 // `destroyed` must be declared before `signals` so that `signals` is destroyed first
480 // (reverse declaration order), preventing a stack-use-after-scope when FakeBackingStore's
481 // destructor writes to `destroyed`.
482 bool destroyed = false;
483 SignalSafeList<TrackedWasmInstance> signals;
484 constexpr uint32_t kSignalOffset = 0;
485 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
486 
487 pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset);
488 writeShutdownSignals(signals);
489 KJ_EXPECT(readU32(signals, kSignalOffset) == WASM_SIGNAL_SIGXCPU);
490}
491 
492KJ_TEST("permutation: both offsets — clearShutdownSignals zeros signal") {
493 bool destroyed = false;
494 SignalSafeList<TrackedWasmInstance> signals;
495 constexpr uint32_t kSignalOffset = 0;
496 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
497 
498 pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset);
499 writeShutdownSignals(signals);
500 KJ_EXPECT(readU32(signals, kSignalOffset) == WASM_SIGNAL_SIGXCPU);
501 clearShutdownSignals(signals);
502 KJ_EXPECT(readU32(signals, kSignalOffset) == 0);
503}
504 
505KJ_TEST("permutation: both offsets — writeTerminatedFlags writes 1") {
506 bool destroyed = false;
507 SignalSafeList<TrackedWasmInstance> signals;
508 constexpr uint32_t kSignalOffset = 0;
509 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
510 
511 pushSignal(signals, destroyed, kSignalOffset, kTerminatedOffset);
512 writeTerminatedFlags(signals);
513 KJ_EXPECT(readU32(signals, kTerminatedOffset) == 1);
514}
515 
516// Permutation 2: only terminated offset (signal = kj::none).
517KJ_TEST("permutation: terminated only — writeShutdownSignals is a no-op") {
518 bool destroyed = false;
519 SignalSafeList<TrackedWasmInstance> signals;
520 constexpr size_t kMemorySize = 64;
521 constexpr uint32_t kTerminatedOffset = 0;
522 
523 pushSignal(signals, destroyed, kj::none, kTerminatedOffset, kMemorySize);
524 writeShutdownSignals(signals);
525 
526 // Entire memory should still be zeroed — nothing was written.
527 signals.iterate([&](TrackedWasmInstance& signal) {
528 for (size_t i = 0; i < kMemorySize; ++i) {
529 KJ_EXPECT(signal.memory[i] == 0, "unexpected non-zero byte at offset", i);
530 }
531 });
532}
533 
534KJ_TEST("permutation: terminated only — clearShutdownSignals is a no-op") {
535 bool destroyed = false;
536 SignalSafeList<TrackedWasmInstance> signals;
537 constexpr size_t kMemorySize = 64;
538 constexpr uint32_t kTerminatedOffset = 0;
539 
540 pushSignal(signals, destroyed, kj::none, kTerminatedOffset, kMemorySize);
541 clearShutdownSignals(signals);
542 
543 signals.iterate([&](TrackedWasmInstance& signal) {
544 for (size_t i = 0; i < kMemorySize; ++i) {
545 KJ_EXPECT(signal.memory[i] == 0, "unexpected non-zero byte at offset", i);
546 }
547 });
548}
549 
550KJ_TEST("permutation: terminated only — writeTerminatedFlags writes 1") {
551 bool destroyed = false;
552 SignalSafeList<TrackedWasmInstance> signals;
553 constexpr uint32_t kTerminatedOffset = 0;
554 
555 pushSignal(signals, destroyed, kj::none, kTerminatedOffset);
556 writeTerminatedFlags(signals);
557 KJ_EXPECT(readU32(signals, kTerminatedOffset) == 1);
558}
559 
560// Permutation 3: only signal offset (terminated = kj::none).
561KJ_TEST("permutation: signal only — writeShutdownSignals writes SIGXCPU") {
562 bool destroyed = false;
563 SignalSafeList<TrackedWasmInstance> signals;
564 constexpr uint32_t kSignalOffset = 0;
565 
566 pushSignal(signals, destroyed, kSignalOffset, kj::none);
567 writeShutdownSignals(signals);
568 KJ_EXPECT(readU32(signals, kSignalOffset) == WASM_SIGNAL_SIGXCPU);
569}
570 
571KJ_TEST("permutation: signal only — clearShutdownSignals zeros signal") {
572 bool destroyed = false;
573 SignalSafeList<TrackedWasmInstance> signals;
574 constexpr uint32_t kSignalOffset = 0;
575 
576 pushSignal(signals, destroyed, kSignalOffset, kj::none);
577 writeShutdownSignals(signals);
578 KJ_EXPECT(readU32(signals, kSignalOffset) == WASM_SIGNAL_SIGXCPU);
579 clearShutdownSignals(signals);
580 KJ_EXPECT(readU32(signals, kSignalOffset) == 0);
581}
582 
583KJ_TEST("permutation: signal only — writeTerminatedFlags is a no-op") {
584 bool destroyed = false;
585 SignalSafeList<TrackedWasmInstance> signals;
586 constexpr size_t kMemorySize = 64;
587 constexpr uint32_t kSignalOffset = 0;
588 
589 pushSignal(signals, destroyed, kSignalOffset, kj::none, kMemorySize);
590 writeTerminatedFlags(signals);
591 
592 // Entire memory should still be zeroed — no terminated offset means nothing is written.
593 signals.iterate([&](TrackedWasmInstance& signal) {
594 for (size_t i = 0; i < kMemorySize; ++i) {
595 KJ_EXPECT(signal.memory[i] == 0, "unexpected non-zero byte at offset", i);
596 }
597 });
598}
599 
600// Mixed list: all three permutations in a single list.
601// Verifies that signal read/write operations correctly target each entry based on its offsets,
602// and that shouldRetain + filter removes all entries (instanceRef is empty without real V8).
603KJ_TEST("permutation: mixed list — all three entry types coexist") {
604 bool destroyedBoth = false;
605 bool destroyedTermOnly = false;
606 bool destroyedSignalOnly = false;
607 SignalSafeList<TrackedWasmInstance> signals;
608 constexpr size_t kMemorySize = 64;
609 
610 // Push the both-offsets entry first (signal=0, terminated=4).
611 pushSignal(signals, destroyedBoth, static_cast<uint32_t>(0),
612 static_cast<uint32_t>(sizeof(uint32_t)), kMemorySize);
613 // Push the terminated-only entry second.
614 pushSignal(signals, destroyedTermOnly, kj::none, static_cast<uint32_t>(0), kMemorySize);
615 // Push the signal-only entry third (it becomes the head).
616 pushSignal(signals, destroyedSignalOnly, static_cast<uint32_t>(0), kj::none, kMemorySize);
617 
618 // --- writeShutdownSignals: only entries with signal offset get SIGXCPU ---
619 writeShutdownSignals(signals);
620 
621 int index = 0;
622 signals.iterate([&](TrackedWasmInstance& signal) {
623 if (index == 0) {
624 // Head = signal-only entry. Signal at offset 0 should be SIGXCPU.
625 uint32_t value = 0;
626 memcpy(&value, signal.memory.begin(), sizeof(value));
627 KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value);
628 } else if (index == 1) {
629 // Terminated-only entry. Entire memory should be untouched.
630 for (size_t i = 0; i < kMemorySize; ++i) {
631 KJ_EXPECT(signal.memory[i] == 0, "terminated-only entry modified at offset", i);
632 }
633 } else {
634 // Both-offsets entry. Signal at offset 0 should be SIGXCPU.
635 uint32_t value = 0;
636 memcpy(&value, signal.memory.begin(), sizeof(value));
637 KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value);
638 }
639 ++index;
640 });
641 
642 // --- clearShutdownSignals: zeros signal entries ---
643 clearShutdownSignals(signals);
644 
645 index = 0;
646 signals.iterate([&](TrackedWasmInstance& signal) {
647 if (index == 0 || index == 2) {
648 uint32_t value = 0xff;
649 memcpy(&value, signal.memory.begin(), sizeof(value));
650 KJ_EXPECT(value == 0, "clear did not zero signal on entry", index);
651 }
652 ++index;
653 });
654 
655 // --- writeTerminatedFlags: only entries with terminated offset ---
656 writeTerminatedFlags(signals);
657 
658 index = 0;
659 signals.iterate([&](TrackedWasmInstance& signal) {
660 KJ_IF_SOME(offset, signal.terminatedByteOffset) {
661 uint32_t terminated = 0;
662 memcpy(&terminated, signal.memory.begin() + offset, sizeof(terminated));
663 KJ_EXPECT(terminated == 1, "entry", index, "terminated", terminated);
664 }
665 ++index;
666 });
667 
668 // --- shouldRetain: all should report not retained (instanceRef is empty without real V8) ---
669 signals.iterate([](TrackedWasmInstance& s) { KJ_EXPECT(!s.shouldRetain()); });
670 
671 // --- filter: all entries removed, memory reclaimed ---
672 signals.filter([](const TrackedWasmInstance& s) { return s.shouldRetain(); });
673 KJ_EXPECT(signals.isEmpty());
674 KJ_EXPECT(destroyedBoth);
675 KJ_EXPECT(destroyedTermOnly);
676 KJ_EXPECT(destroyedSignalOnly);
677}
678 
679// ---------------------------------------------------------------------------
680// TrackedWasmInstanceList method tests
681//
682// The methods writeShutdownSignal(), clearShutdownSignal(), and writeTerminatedSignal() on
683// TrackedWasmInstanceList are the production entry points called from signal handlers and the
684// CPU time limiter. The tests above exercise the same underlying logic through test-local
685// helpers on a raw SignalSafeList; these tests verify the methods themselves work correctly
686// through the TrackedWasmInstanceList wrapper.
687//
688// Since registerSignal() requires a jsg::Lock& (not available in plain KJ tests), we
689// populate the internal list directly via const_cast on signals(). This is acceptable in
690// tests — it mirrors what registerSignal() does internally.
691// ---------------------------------------------------------------------------
692 
693// Helper: push a TrackedWasmInstance into a TrackedWasmInstanceList's internal list, bypassing
694// registerSignal() which requires jsg::Lock&.
695void pushEntry(const TrackedWasmInstanceList& list,
696 bool& destroyed,
697 kj::Maybe<uint32_t> signalOffset,
698 kj::Maybe<uint32_t> terminatedOffset,
699 size_t memorySize = 64) {
700 auto store = kj::heap<FakeBackingStore>(destroyed, memorySize);
701 auto memory = store->data.asPtr().attach(kj::mv(store));
702 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
703 .pushFront(TrackedWasmInstance{
704 .memory = kj::mv(memory),
705 .signalByteOffset = signalOffset,
706 .terminatedByteOffset = terminatedOffset,
707 });
708}
709 
710// Helper: read a uint32 at `offset` from the first entry via the TrackedWasmInstanceList.
711uint32_t readU32FromList(const TrackedWasmInstanceList& list, uint32_t offset) {
712 uint32_t value = 0xDEADBEEF;
713 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
714 .iterate([&](TrackedWasmInstance& entry) {
715 memcpy(&value, entry.memory.begin() + offset, sizeof(value));
716 });
717 return value;
718}
719 
720KJ_TEST("TrackedWasmInstanceList::writeShutdownSignal writes SIGXCPU") {
721 bool destroyed = false;
722 TrackedWasmInstanceList list;
723 constexpr uint32_t kSignalOffset = 0;
724 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
725 
726 pushEntry(list, destroyed, kSignalOffset, kTerminatedOffset);
727 list.writeShutdownSignal();
728 KJ_EXPECT(readU32FromList(list, kSignalOffset) == WASM_SIGNAL_SIGXCPU);
729}
730 
731KJ_TEST("TrackedWasmInstanceList::clearShutdownSignal zeros the signal") {
732 bool destroyed = false;
733 TrackedWasmInstanceList list;
734 constexpr uint32_t kSignalOffset = 0;
735 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
736 
737 pushEntry(list, destroyed, kSignalOffset, kTerminatedOffset);
738 list.writeShutdownSignal();
739 KJ_EXPECT(readU32FromList(list, kSignalOffset) == WASM_SIGNAL_SIGXCPU);
740 
741 list.clearShutdownSignal();
742 KJ_EXPECT(readU32FromList(list, kSignalOffset) == 0);
743}
744 
745KJ_TEST("TrackedWasmInstanceList::writeTerminatedSignal writes 1") {
746 bool destroyed = false;
747 TrackedWasmInstanceList list;
748 constexpr uint32_t kSignalOffset = 0;
749 constexpr uint32_t kTerminatedOffset = sizeof(uint32_t);
750 
751 pushEntry(list, destroyed, kSignalOffset, kTerminatedOffset);
752 list.writeTerminatedSignal();
753 KJ_EXPECT(readU32FromList(list, kTerminatedOffset) == 1);
754}
755 
756KJ_TEST("TrackedWasmInstanceList::writeShutdownSignal skips entries without signal offset") {
757 bool destroyed = false;
758 TrackedWasmInstanceList list;
759 constexpr size_t kMemorySize = 64;
760 constexpr uint32_t kTerminatedOffset = 0;
761 
762 // Entry with no signal offset (kj::none).
763 pushEntry(list, destroyed, kj::none, kTerminatedOffset, kMemorySize);
764 list.writeShutdownSignal();
765 
766 // Entire memory should still be zeroed — writeShutdownSignal is a no-op for this entry.
767 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
768 .iterate([&](TrackedWasmInstance& entry) {
769 for (size_t i = 0; i < kMemorySize; ++i) {
770 KJ_EXPECT(entry.memory[i] == 0, "unexpected non-zero byte at offset", i);
771 }
772 });
773}
774 
775KJ_TEST("TrackedWasmInstanceList::writeTerminatedSignal skips entries without terminated offset") {
776 bool destroyed = false;
777 TrackedWasmInstanceList list;
778 constexpr size_t kMemorySize = 64;
779 constexpr uint32_t kSignalOffset = 0;
780 
781 // Signal-only entry (no terminated offset).
782 pushEntry(list, destroyed, kSignalOffset, kj::none, kMemorySize);
783 list.writeTerminatedSignal();
784 
785 // Entire memory should still be zeroed — writeTerminatedSignal is a no-op for this entry.
786 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
787 .iterate([&](TrackedWasmInstance& entry) {
788 for (size_t i = 0; i < kMemorySize; ++i) {
789 KJ_EXPECT(entry.memory[i] == 0, "unexpected non-zero byte at offset", i);
790 }
791 });
792}
793 
794KJ_TEST("TrackedWasmInstanceList methods work on a mixed list") {
795 bool destroyedBoth = false;
796 bool destroyedTermOnly = false;
797 bool destroyedSignalOnly = false;
798 TrackedWasmInstanceList list;
799 constexpr size_t kMemorySize = 64;
800 
801 // Push a both-offsets entry (signal=0, terminated=4).
802 pushEntry(list, destroyedBoth, static_cast<uint32_t>(0), static_cast<uint32_t>(sizeof(uint32_t)),
803 kMemorySize);
804 // Push a terminated-only entry (it becomes the new head).
805 pushEntry(list, destroyedTermOnly, kj::none, static_cast<uint32_t>(0), kMemorySize);
806 // Push a signal-only entry (becomes the head).
807 pushEntry(list, destroyedSignalOnly, static_cast<uint32_t>(0), kj::none, kMemorySize);
808 
809 // writeShutdownSignal: only entries with signal offset get SIGXCPU.
810 list.writeShutdownSignal();
811 
812 int index = 0;
813 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
814 .iterate([&](TrackedWasmInstance& entry) {
815 if (index == 0) {
816 // Head = signal-only entry. Signal at offset 0 should be SIGXCPU.
817 uint32_t value = 0;
818 memcpy(&value, entry.memory.begin(), sizeof(value));
819 KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value);
820 } else if (index == 1) {
821 // Terminated-only entry. Entire memory should be untouched.
822 for (size_t i = 0; i < kMemorySize; ++i) {
823 KJ_EXPECT(entry.memory[i] == 0, "terminated-only entry modified at offset", i);
824 }
825 } else {
826 // Both-offsets entry. Signal at offset 0 should be SIGXCPU.
827 uint32_t value = 0;
828 memcpy(&value, entry.memory.begin(), sizeof(value));
829 KJ_EXPECT(value == WASM_SIGNAL_SIGXCPU, value);
830 }
831 ++index;
832 });
833 
834 // clearShutdownSignal: zeros entries with signal offset.
835 list.clearShutdownSignal();
836 
837 index = 0;
838 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
839 .iterate([&](TrackedWasmInstance& entry) {
840 if (index == 0 || index == 2) {
841 uint32_t value = 0xff;
842 memcpy(&value, entry.memory.begin(), sizeof(value));
843 KJ_EXPECT(value == 0, "clear did not zero signal on entry", index);
844 }
845 ++index;
846 });
847 
848 // writeTerminatedSignal: only entries with terminated offset get terminated=1.
849 list.writeTerminatedSignal();
850 
851 index = 0;
852 const_cast<SignalSafeList<TrackedWasmInstance>&>(list.signals())
853 .iterate([&](TrackedWasmInstance& entry) {
854 KJ_IF_SOME(offset, entry.terminatedByteOffset) {
855 uint32_t terminated = 0;
856 memcpy(&terminated, entry.memory.begin() + offset, sizeof(terminated));
857 KJ_EXPECT(terminated == 1, "entry", index, "terminated", terminated);
858 }
859 ++index;
860 });
861}
862 
863KJ_TEST("TrackedWasmInstanceList methods are no-ops on an empty list") {
864 TrackedWasmInstanceList list;
865 
866 // These should not crash or have any observable effect.
867 list.writeShutdownSignal();
868 list.clearShutdownSignal();
869 list.writeTerminatedSignal();
870 
871 KJ_EXPECT(list.signals().isEmpty());
872}
873 
874// ---------------------------------------------------------------------------
875// TrackedWasmInstance tests are also covered by the JS-level
876// tracked-wasm-instance-js-test.wd-test, which runs inside a real workerd
877// instance with V8 initialized. Registration requires the WebAssembly.instantiate
878// shim, so we test via JS rather than a plain kj_test.
879// ---------------------------------------------------------------------------
880 
881} // namespace
882} // namespace workerd