File
Blob: src/workerd/io/tracked-wasm-instance.h
| 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 | #pragma once |
| 6 | |
| 7 | #include <v8-forward.h> |
| 8 | #include <v8-persistent-handle.h> |
| 9 | |
| 10 | #include <kj/array.h> |
| 11 | #include <kj/common.h> |
| 12 | |
| 13 | #include <cstdint> |
| 14 | |
| 15 | namespace workerd { |
| 16 | |
| 17 | namespace jsg { |
| 18 | class Lock; |
| 19 | } // namespace jsg |
| 20 | |
| 21 | // Byte size of each signal field in WASM linear memory (a single uint32). |
| 22 | constexpr size_t WASM_SIGNAL_FIELD_BYTES = sizeof(uint32_t); |
| 23 | |
| 24 | // Represents a single WASM module that has opted into receiving the "shut down" signal when CPU |
| 25 | // time is nearly exhausted, or that wants its memory reclaimed when the instance is garbage |
| 26 | // collected. The module must export at least one of "__instance_terminated" or "__instance_signal": |
| 27 | // |
| 28 | // "__instance_signal" — (optional) address of a uint32 in linear memory. When present, |
| 29 | // the runtime writes SIGXCPU (24) here when CPU time is nearly |
| 30 | // exhausted. |
| 31 | // "__instance_terminated" — (optional) address of a uint32 in linear memory. The runtime |
| 32 | // writes 1 here when the isolate is killed after exceeding its CPU |
| 33 | // limit, informing the WASM module that it was forcefully terminated. |
| 34 | // |
| 35 | // Cleanup of entries relies on a weak reference to the WASM instance (instanceRef): when V8 |
| 36 | // garbage-collects the instance, the handle becomes empty and the GC prologue filter removes |
| 37 | // the entry, releasing the strong reference to linear memory. |
| 38 | struct TrackedWasmInstance { |
| 39 | // Owns a reference to the WASM module's linear memory. The underlying v8::BackingStore is kept |
| 40 | // alive via kj::Array's attach() mechanism, preventing V8 from garbage-collecting the memory |
| 41 | // while we still need to read/write signal addresses. This gets cleaned up in a V8 GC prologue |
| 42 | // hook where we atomically remove the entry from the signal list before releasing the memory. |
| 43 | // |
| 44 | // TODO: If a user were to grow a 64 bit linear memory >16GB, relocation will happen and this |
| 45 | // array will point to stale (but not free'd) memory. The impact is that the user will see a |
| 46 | // spike in memory usage and no longer receive the signal in that module instance. In practice this |
| 47 | // should almost never happen since they would hit a memory limit well before 16GB, |
| 48 | // and 64 bit WASM is currently used very infrequently anyways. Regardless, we should address this |
| 49 | // soon. |
| 50 | kj::Array<kj::byte> memory; |
| 51 | |
| 52 | // Offset into `memory` of the uint32 the runtime writes SIGXCPU (24) to (__instance_signal). |
| 53 | // When kj::none, the module did not export __instance_signal and will not receive the |
| 54 | // SIGXCPU shutdown warning. |
| 55 | kj::Maybe<uint32_t> signalByteOffset; |
| 56 | |
| 57 | // Offset into `memory` of the uint32 the runtime writes to (__instance_terminated). |
| 58 | // When kj::none, the module did not export __instance_terminated. |
| 59 | kj::Maybe<uint32_t> terminatedByteOffset; |
| 60 | |
| 61 | // Weak handle to the WASM instance. Set to weak via SetWeak() at registration time. When V8 |
| 62 | // collects the instance, the handle becomes empty (IsEmpty() returns true). Checked in the GC |
| 63 | // prologue filter to clean up entries whose instance has been garbage-collected. Always set at |
| 64 | // registration time, so IsEmpty() unambiguously means the instance was collected. |
| 65 | // |
| 66 | // This field is never accessed from signal handlers — signal handlers only touch `memory` and |
| 67 | // `signalByteOffset`. The Global's destructor is safe to call during filter() (under isolate |
| 68 | // lock) or clear() (during isolate teardown with V8 still alive). |
| 69 | v8::Global<v8::Object> instanceRef; |
| 70 | |
| 71 | // Returns true if the entry should be kept in the list, false if it should be removed. |
| 72 | // An entry is removed when V8 garbage-collects the WASM instance, which resets the weak |
| 73 | // handle making IsEmpty() return true. |
| 74 | bool shouldRetain() const { |
| 75 | return !instanceRef.IsEmpty(); |
| 76 | } |
| 77 | }; |
| 78 | |
| 79 | // A linked list type which is signal-safe (for reading), but not thread safe - it can handle |
| 80 | // same-thread concurrency and pre-emptive reads ONLY. |
| 81 | // SAFETY: All mutations are must happen with the isolate lock held! |
| 82 | template <typename T> |
| 83 | class SignalSafeList { |
| 84 | public: |
| 85 | struct Node { |
| 86 | T value; |
| 87 | Node* next; |
| 88 | template <typename... Args> |
| 89 | explicit Node(Args&&... args): value(kj::fwd<Args>(args)...), |
| 90 | next(nullptr) {} |
| 91 | }; |
| 92 | |
| 93 | SignalSafeList() {} |
| 94 | |
| 95 | ~SignalSafeList() noexcept(false) { |
| 96 | Node* node = __atomic_load_n(&head, __ATOMIC_RELAXED); |
| 97 | while (node != nullptr) { |
| 98 | Node* doomed = node; |
| 99 | node = __atomic_load_n(&doomed->next, __ATOMIC_RELAXED); |
| 100 | delete doomed; |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // Prepends a new node constructed from `args` at the front of the list. |
| 105 | // Returns a reference to the inserted value. The reference is stable (heap-allocated node) |
| 106 | // and valid until the node is removed from the list via filter() or clear(). |
| 107 | template <typename... Args> |
| 108 | T& pushFront(Args&&... args) { |
| 109 | Node* node = new Node(kj::fwd<Args>(args)...); |
| 110 | __atomic_store_n(&node->next, __atomic_load_n(&head, __ATOMIC_RELAXED), __ATOMIC_RELAXED); |
| 111 | __atomic_store_n(&head, node, __ATOMIC_RELEASE); |
| 112 | return node->value; |
| 113 | } |
| 114 | |
| 115 | // Removes all nodes for which `predicate(node.value)` returns false |
| 116 | template <typename Predicate> |
| 117 | void filter(Predicate&& predicate) noexcept { |
| 118 | Node** prev = &head; |
| 119 | Node* current = __atomic_load_n(prev, __ATOMIC_RELAXED); |
| 120 | |
| 121 | while (current != nullptr) { |
| 122 | Node* next = __atomic_load_n(¤t->next, __ATOMIC_RELAXED); |
| 123 | |
| 124 | if (predicate(current->value)) { |
| 125 | prev = ¤t->next; |
| 126 | } else { |
| 127 | // Splice out `current` by pointing its predecessor at `next`. Release ordering ensures a |
| 128 | // signal handler that loads *prev with acquire sees a fully consistent successor chain. |
| 129 | __atomic_store_n(prev, next, __ATOMIC_RELEASE); |
| 130 | delete current; |
| 131 | } |
| 132 | |
| 133 | current = next; |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // Removes all nodes from the list, destroying each one. |
| 138 | void clear() noexcept { |
| 139 | Node* current = __atomic_load_n(&head, __ATOMIC_RELAXED); |
| 140 | __atomic_store_n(&head, static_cast<Node*>(nullptr), __ATOMIC_RELEASE); |
| 141 | while (current != nullptr) { |
| 142 | Node* next = __atomic_load_n(¤t->next, __ATOMIC_RELAXED); |
| 143 | delete current; |
| 144 | current = next; |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // Returns true if the list is empty. Signal safe. |
| 149 | bool isEmpty() const { |
| 150 | return __atomic_load_n(&head, __ATOMIC_ACQUIRE) == nullptr; |
| 151 | } |
| 152 | |
| 153 | // Traverses the list, calling `func(node.value)` for each node. Signal-safe (same-thread |
| 154 | // only), but not thread-safe — callers from a signal handler context should const_cast. |
| 155 | template <typename Func> |
| 156 | void iterate(Func&& func) { |
| 157 | Node* current = __atomic_load_n(&head, __ATOMIC_ACQUIRE); |
| 158 | while (current != nullptr) { |
| 159 | func(current->value); |
| 160 | current = __atomic_load_n(¤t->next, __ATOMIC_ACQUIRE); |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | private: |
| 165 | Node* head = nullptr; |
| 166 | |
| 167 | KJ_DISALLOW_COPY_AND_MOVE(SignalSafeList); |
| 168 | }; |
| 169 | |
| 170 | // Encapsulates a SignalSafeList<TrackedWasmInstance> with operations that require the isolate |
| 171 | // lock for mutation, and a read-only accessor for signal-handler use. |
| 172 | // |
| 173 | // The mutation methods are const and accept a jsg::Lock& to prove the caller holds the isolate |
| 174 | // lock. Internally they const_cast the list, which is safe because the lock provides the |
| 175 | // required synchronization. This design allows IsolateLimitEnforcer (whose methods are const |
| 176 | // per KJ convention) to return a const reference without exposing mutable access to code that |
| 177 | // does not hold the lock. |
| 178 | class TrackedWasmInstanceList { |
| 179 | public: |
| 180 | // Registers a WASM module for receiving the "shut down" signal and/or terminated notification. |
| 181 | // At least one of signalOffset or terminatedOffset must be provided. Both are optional |
| 182 | // individually: when signalOffset is kj::none, the module will not receive SIGXCPU; when |
| 183 | // terminatedOffset is kj::none, the module will not receive the terminated notification. |
| 184 | // Cleanup always relies on the weak instanceRef becoming empty when V8 GCs the instance. |
| 185 | // |
| 186 | // Silently skips registration if any provided offset falls outside the module's linear memory. |
| 187 | // |
| 188 | // Returns a reference to the registered entry on success, or kj::none if registration was |
| 189 | // skipped. The caller uses the returned reference to set up the weak instance handle. |
| 190 | kj::Maybe<TrackedWasmInstance&> registerSignal(jsg::Lock&, |
| 191 | kj::Array<kj::byte> memory, |
| 192 | kj::Maybe<uint32_t> signalOffset, |
| 193 | kj::Maybe<uint32_t> terminatedOffset) const; |
| 194 | |
| 195 | // Filters out entries where the instance has been garbage-collected (instanceRef is empty). |
| 196 | // Call from a GC prologue hook to allow linear memory to be reclaimed. |
| 197 | void filter(jsg::Lock&) const; |
| 198 | |
| 199 | // Removes all entries unconditionally. Call before the V8 isolate is disposed, since each |
| 200 | // entry holds a shared_ptr<v8::BackingStore> (via the memory array) and a v8::Global whose |
| 201 | // destructors may access V8 state. |
| 202 | void clear(jsg::Lock&) const; |
| 203 | |
| 204 | void writeShutdownSignal() const; |
| 205 | |
| 206 | void clearShutdownSignal() const; |
| 207 | |
| 208 | void writeTerminatedSignal() const; |
| 209 | |
| 210 | // Returns the underlying signal-safe list for use by signal handlers and the CPU time limiter. |
| 211 | // The returned reference is const; signal-handler free functions use const_cast internally. |
| 212 | const SignalSafeList<TrackedWasmInstance>& signals() const { |
| 213 | return list; |
| 214 | } |
| 215 | |
| 216 | private: |
| 217 | SignalSafeList<TrackedWasmInstance> list; |
| 218 | }; |
| 219 | |
| 220 | // The value written to the signal address when CPU time is nearly exhausted. |
| 221 | // This is the UNIX signal number for SIGXCPU (24). Technically the number itself |
| 222 | // is not standardized, but for most architectures it is 24 so that is what we're going with. |
| 223 | // We're inventing WASM signals from scratch so we can do whatever we want. |
| 224 | constexpr uint32_t WASM_SIGNAL_SIGXCPU = 24; |
| 225 | |
| 226 | } // namespace workerd |