File
Blob: src/workerd/io/tracked-wasm-instance-test.c++
| 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 | |
| 11 | namespace workerd { |
| 12 | namespace { |
| 13 | |
| 14 | // --------------------------------------------------------------------------- |
| 15 | // SignalSafeList tests |
| 16 | // --------------------------------------------------------------------------- |
| 17 | |
| 18 | KJ_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 | |
| 38 | KJ_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 | |
| 52 | KJ_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 | |
| 73 | KJ_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 | |
| 85 | KJ_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 | |
| 99 | KJ_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 | |
| 109 | KJ_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 | |
| 130 | KJ_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 | |
| 138 | KJ_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 | |
| 156 | KJ_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. |
| 180 | struct 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 | |
| 199 | void 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 | |
| 208 | void 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 | |
| 217 | void 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 | |
| 226 | KJ_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. |
| 315 | struct 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. |
| 336 | void 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 | |
| 350 | KJ_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 | |
| 394 | KJ_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. |
| 454 | void 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. |
| 469 | uint32_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. |
| 478 | KJ_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 | |
| 492 | KJ_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 | |
| 505 | KJ_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). |
| 517 | KJ_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 | |
| 534 | KJ_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 | |
| 550 | KJ_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). |
| 561 | KJ_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 | |
| 571 | KJ_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 | |
| 583 | KJ_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). |
| 603 | KJ_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&. |
| 695 | void 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. |
| 711 | uint32_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 | |
| 720 | KJ_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 | |
| 731 | KJ_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 | |
| 745 | KJ_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 | |
| 756 | KJ_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 | |
| 775 | KJ_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 | |
| 794 | KJ_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 | |
| 863 | KJ_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 |