knitting 0.1.63 → 0.1.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +525 -335
- package/knitting.browser.js +1 -1
- package/map.md +0 -6
- package/package.json +3 -3
- package/prebuilds/darwin-arm64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-arm64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/darwin-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-x64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/darwin-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-x64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/linux-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/linux-x64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/linux-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/linux-x64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
- package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_doorbell.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_doorbell.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
- package/scripts/build-native-addons.ts +5 -0
- package/shared-memory.d.ts +3 -0
- package/shared-memory.js +3 -0
- package/src/api.js +105 -42
- package/src/common/with-resolvers.js +2 -5
- package/src/common/worker-runtime.d.ts +7 -0
- package/src/common/worker-runtime.js +7 -0
- package/src/connections/buffer-reference.d.ts +10 -36
- package/src/connections/buffer-reference.js +15 -170
- package/src/connections/node-addons.d.ts +1 -1
- package/src/connections/shared-array-buffer-payload.d.ts +7 -0
- package/src/connections/shared-array-buffer-payload.js +27 -11
- package/src/knitting_buffer_pointer.cc +57 -2
- package/src/knitting_doorbell.cc +220 -0
- package/src/memory/knitting-body.d.ts +44 -0
- package/src/memory/knitting-body.js +51 -0
- package/src/memory/knitting-buffer-http.d.ts +116 -0
- package/src/memory/knitting-buffer-http.js +255 -0
- package/src/memory/knitting-buffer.d.ts +250 -0
- package/src/memory/knitting-buffer.js +695 -0
- package/src/memory/lazy-region-registry.d.ts +83 -0
- package/src/memory/lazy-region-registry.js +355 -0
- package/src/memory/lock.d.ts +38 -15
- package/src/memory/lock.js +205 -79
- package/src/memory/payloadCodec.d.ts +18 -2
- package/src/memory/payloadCodec.js +309 -65
- package/src/memory/regionRegistry.d.ts +6 -0
- package/src/memory/regionRegistry.js +125 -240
- package/src/memory/shared-buffer-io.d.ts +7 -0
- package/src/memory/shared-buffer-io.js +34 -8
- package/src/runtime/deno-doorbell.d.ts +26 -0
- package/src/runtime/deno-doorbell.js +117 -0
- package/src/runtime/dispatcher.d.ts +8 -6
- package/src/runtime/dispatcher.js +80 -58
- package/src/runtime/host-arg-arena.d.ts +3 -0
- package/src/runtime/host-arg-arena.js +16 -0
- package/src/runtime/node-doorbell.d.ts +14 -0
- package/src/runtime/node-doorbell.js +84 -0
- package/src/runtime/pool.d.ts +21 -15
- package/src/runtime/pool.js +104 -116
- package/src/runtime/process-worker.d.ts +9 -0
- package/src/runtime/process-worker.js +22 -2
- package/src/runtime/tx-queue.d.ts +2 -5
- package/src/runtime/tx-queue.js +52 -48
- package/src/types.d.ts +35 -71
- package/src/worker/loop.js +79 -57
- package/src/worker/rx-queue.d.ts +2 -3
- package/src/worker/rx-queue.js +34 -40
- package/src/worker/shared-return.d.ts +9 -0
- package/src/worker/shared-return.js +22 -0
- package/src/worker/task-loader.js +1 -2
- package/src/worker/timers.d.ts +2 -6
- package/src/worker/timers.js +14 -19
- package/unsafe.d.ts +2 -1
- package/unsafe.js +2 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#include <node.h>
|
|
2
|
+
#include <uv.h>
|
|
3
|
+
#include <v8.h>
|
|
4
|
+
|
|
5
|
+
#include <atomic>
|
|
6
|
+
#include <cstdint>
|
|
7
|
+
#include <mutex>
|
|
8
|
+
#include <unordered_set>
|
|
9
|
+
|
|
10
|
+
namespace knitting_doorbell {
|
|
11
|
+
|
|
12
|
+
struct CompletionDoorbell {
|
|
13
|
+
uv_async_t async;
|
|
14
|
+
v8::Isolate* isolate;
|
|
15
|
+
v8::Global<v8::Context> context;
|
|
16
|
+
v8::Global<v8::Function> callback;
|
|
17
|
+
std::atomic<bool> closed{false};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
std::mutex doorbells_mutex;
|
|
21
|
+
std::unordered_set<CompletionDoorbell*> doorbells;
|
|
22
|
+
|
|
23
|
+
void ThrowType(v8::Isolate* isolate, const char* message) {
|
|
24
|
+
isolate->ThrowException(v8::Exception::TypeError(
|
|
25
|
+
v8::String::NewFromUtf8(isolate, message).ToLocalChecked()
|
|
26
|
+
));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
void ThrowUv(v8::Isolate* isolate, const char* message, int status) {
|
|
30
|
+
const char* uv_message = uv_strerror(status);
|
|
31
|
+
v8::Local<v8::String> detail = v8::String::NewFromUtf8(
|
|
32
|
+
isolate,
|
|
33
|
+
uv_message == nullptr ? message : uv_message
|
|
34
|
+
).ToLocalChecked();
|
|
35
|
+
isolate->ThrowException(v8::Exception::Error(detail));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
bool ReadDoorbellPointer(
|
|
39
|
+
const v8::FunctionCallbackInfo<v8::Value>& args,
|
|
40
|
+
CompletionDoorbell** out
|
|
41
|
+
) {
|
|
42
|
+
v8::Isolate* isolate = args.GetIsolate();
|
|
43
|
+
if (args.Length() < 1 || !args[0]->IsBigInt()) {
|
|
44
|
+
ThrowType(isolate, "completion doorbell pointer must be a bigint");
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
bool lossless = false;
|
|
49
|
+
uint64_t raw = args[0].As<v8::BigInt>()->Uint64Value(&lossless);
|
|
50
|
+
if (!lossless || raw == 0) {
|
|
51
|
+
ThrowType(isolate, "completion doorbell pointer must be non-zero");
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
*out = reinterpret_cast<CompletionDoorbell*>(
|
|
56
|
+
static_cast<uintptr_t>(raw)
|
|
57
|
+
);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
void DisposeDoorbell(uv_handle_t* handle) {
|
|
62
|
+
auto* doorbell = static_cast<CompletionDoorbell*>(handle->data);
|
|
63
|
+
if (doorbell == nullptr) return;
|
|
64
|
+
doorbell->callback.Reset();
|
|
65
|
+
doorbell->context.Reset();
|
|
66
|
+
delete doorbell;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
void InvokeDoorbell(uv_async_t* async) {
|
|
70
|
+
auto* doorbell = static_cast<CompletionDoorbell*>(async->data);
|
|
71
|
+
if (doorbell == nullptr || doorbell->closed.load(std::memory_order_acquire)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
v8::Isolate* isolate = doorbell->isolate;
|
|
76
|
+
v8::HandleScope scope(isolate);
|
|
77
|
+
v8::Local<v8::Context> context = doorbell->context.Get(isolate);
|
|
78
|
+
v8::Local<v8::Function> callback = doorbell->callback.Get(isolate);
|
|
79
|
+
if (context.IsEmpty() || callback.IsEmpty()) return;
|
|
80
|
+
|
|
81
|
+
v8::Context::Scope context_scope(context);
|
|
82
|
+
v8::TryCatch try_catch(isolate);
|
|
83
|
+
// This is called by Node's event-loop thread, not the worker that rang the
|
|
84
|
+
// handle. Do not let a user callback exception escape into libuv.
|
|
85
|
+
v8::MaybeLocal<v8::Value> call_result = callback->Call(
|
|
86
|
+
context,
|
|
87
|
+
v8::Undefined(isolate),
|
|
88
|
+
0,
|
|
89
|
+
nullptr
|
|
90
|
+
);
|
|
91
|
+
(void)call_result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
void CleanupDoorbell(void* data);
|
|
95
|
+
|
|
96
|
+
void CloseDoorbell(CompletionDoorbell* doorbell, bool remove_cleanup_hook) {
|
|
97
|
+
bool should_close = false;
|
|
98
|
+
{
|
|
99
|
+
std::lock_guard<std::mutex> lock(doorbells_mutex);
|
|
100
|
+
auto found = doorbells.find(doorbell);
|
|
101
|
+
if (found == doorbells.end()) return;
|
|
102
|
+
doorbell->closed.store(true, std::memory_order_release);
|
|
103
|
+
doorbells.erase(found);
|
|
104
|
+
should_close = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (remove_cleanup_hook) {
|
|
108
|
+
node::RemoveEnvironmentCleanupHook(
|
|
109
|
+
doorbell->isolate,
|
|
110
|
+
CleanupDoorbell,
|
|
111
|
+
doorbell
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (should_close && !uv_is_closing(reinterpret_cast<uv_handle_t*>(&doorbell->async))) {
|
|
115
|
+
uv_close(reinterpret_cast<uv_handle_t*>(&doorbell->async), DisposeDoorbell);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
void CleanupDoorbell(void* data) {
|
|
120
|
+
CloseDoorbell(static_cast<CompletionDoorbell*>(data), false);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
void CreateCompletionDoorbell(const v8::FunctionCallbackInfo<v8::Value>& args) {
|
|
124
|
+
v8::Isolate* isolate = args.GetIsolate();
|
|
125
|
+
if (args.Length() < 1 || !args[0]->IsFunction()) {
|
|
126
|
+
ThrowType(isolate, "createCompletionDoorbell(callback) requires a function");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
uv_loop_t* loop = node::GetCurrentEventLoop(isolate);
|
|
131
|
+
if (loop == nullptr) {
|
|
132
|
+
ThrowUv(isolate, "Node event loop is unavailable", UV_EINVAL);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
auto* doorbell = new CompletionDoorbell();
|
|
137
|
+
doorbell->isolate = isolate;
|
|
138
|
+
doorbell->async.data = doorbell;
|
|
139
|
+
const int status = uv_async_init(loop, &doorbell->async, InvokeDoorbell);
|
|
140
|
+
if (status != 0) {
|
|
141
|
+
delete doorbell;
|
|
142
|
+
ThrowUv(isolate, "uv_async_init failed", status);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
doorbell->context.Reset(isolate, isolate->GetCurrentContext());
|
|
147
|
+
doorbell->callback.Reset(isolate, args[0].As<v8::Function>());
|
|
148
|
+
{
|
|
149
|
+
std::lock_guard<std::mutex> lock(doorbells_mutex);
|
|
150
|
+
doorbells.insert(doorbell);
|
|
151
|
+
}
|
|
152
|
+
node::AddEnvironmentCleanupHook(isolate, CleanupDoorbell, doorbell);
|
|
153
|
+
|
|
154
|
+
args.GetReturnValue().Set(v8::BigInt::NewFromUnsigned(
|
|
155
|
+
isolate,
|
|
156
|
+
static_cast<uint64_t>(reinterpret_cast<uintptr_t>(doorbell))
|
|
157
|
+
));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
void RingCompletionDoorbell(const v8::FunctionCallbackInfo<v8::Value>& args) {
|
|
161
|
+
CompletionDoorbell* doorbell = nullptr;
|
|
162
|
+
if (!ReadDoorbellPointer(args, &doorbell)) return;
|
|
163
|
+
|
|
164
|
+
int status = UV_EINVAL;
|
|
165
|
+
{
|
|
166
|
+
// The registry guards a host close racing a worker-thread ring. Holding it
|
|
167
|
+
// through uv_async_send is safe: the send is non-blocking and coalesced.
|
|
168
|
+
std::lock_guard<std::mutex> lock(doorbells_mutex);
|
|
169
|
+
if (
|
|
170
|
+
doorbells.contains(doorbell) &&
|
|
171
|
+
!doorbell->closed.load(std::memory_order_acquire)
|
|
172
|
+
) {
|
|
173
|
+
status = uv_async_send(&doorbell->async);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
args.GetReturnValue().Set(status == 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
void UnrefCompletionDoorbell(const v8::FunctionCallbackInfo<v8::Value>& args) {
|
|
180
|
+
CompletionDoorbell* doorbell = nullptr;
|
|
181
|
+
if (!ReadDoorbellPointer(args, &doorbell)) return;
|
|
182
|
+
|
|
183
|
+
bool found = false;
|
|
184
|
+
{
|
|
185
|
+
std::lock_guard<std::mutex> lock(doorbells_mutex);
|
|
186
|
+
if (doorbells.contains(doorbell)) {
|
|
187
|
+
uv_unref(reinterpret_cast<uv_handle_t*>(&doorbell->async));
|
|
188
|
+
found = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
args.GetReturnValue().Set(found);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
void CloseCompletionDoorbell(const v8::FunctionCallbackInfo<v8::Value>& args) {
|
|
195
|
+
CompletionDoorbell* doorbell = nullptr;
|
|
196
|
+
if (!ReadDoorbellPointer(args, &doorbell)) return;
|
|
197
|
+
|
|
198
|
+
bool found = false;
|
|
199
|
+
{
|
|
200
|
+
std::lock_guard<std::mutex> lock(doorbells_mutex);
|
|
201
|
+
found = doorbells.contains(doorbell);
|
|
202
|
+
}
|
|
203
|
+
if (found) CloseDoorbell(doorbell, true);
|
|
204
|
+
args.GetReturnValue().Set(found);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
void Initialize(
|
|
208
|
+
v8::Local<v8::Object> exports,
|
|
209
|
+
v8::Local<v8::Value>,
|
|
210
|
+
v8::Local<v8::Context>
|
|
211
|
+
) {
|
|
212
|
+
NODE_SET_METHOD(exports, "createCompletionDoorbell", CreateCompletionDoorbell);
|
|
213
|
+
NODE_SET_METHOD(exports, "ringCompletionDoorbell", RingCompletionDoorbell);
|
|
214
|
+
NODE_SET_METHOD(exports, "unrefCompletionDoorbell", UnrefCompletionDoorbell);
|
|
215
|
+
NODE_SET_METHOD(exports, "closeCompletionDoorbell", CloseCompletionDoorbell);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
NODE_MODULE_CONTEXT_AWARE(NODE_GYP_MODULE_NAME, Initialize)
|
|
219
|
+
|
|
220
|
+
} // namespace knitting_doorbell
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One handle for a request body, whichever way it travels.
|
|
3
|
+
*
|
|
4
|
+
* A body uses either pooled arena bytes or a moved `BufferReference`;
|
|
5
|
+
* `allocOrRefer()` hides that choice behind one disposable handle.
|
|
6
|
+
*
|
|
7
|
+
* The host owns the body, while each send holds it until the call settles, so
|
|
8
|
+
* early disposal cannot recycle bytes still in use by a worker. The worker gets
|
|
9
|
+
* a `Uint8Array` and never releases the body.
|
|
10
|
+
*
|
|
11
|
+
* The bytes are valid only until the host releases. A worker that wants to
|
|
12
|
+
* keep them past the call must copy.
|
|
13
|
+
*/
|
|
14
|
+
import { type KnittingAllocator, type KnittingBufferDescriptor } from "./knitting-buffer.js";
|
|
15
|
+
import { BufferReference } from "../connections/buffer-reference.js";
|
|
16
|
+
/** What `allocator.transport()` hands a worker so it can attach once. */
|
|
17
|
+
export type KnittingTransport = ReturnType<KnittingAllocator["transport"]>;
|
|
18
|
+
/**
|
|
19
|
+
* The value a body travels as. Three shapes, because a body has three homes:
|
|
20
|
+
* the arena (a descriptor), the heap (a moved reference), or a standalone
|
|
21
|
+
* buffer when the body did not fit the arena. `openBody` resolves all three.
|
|
22
|
+
*/
|
|
23
|
+
export type KnittingBodyWire = KnittingBufferDescriptor | BufferReference | SharedArrayBuffer;
|
|
24
|
+
/** The host's handle: send `wire`, read `u8()`, and dispose when done. */
|
|
25
|
+
export type KnittingBody = {
|
|
26
|
+
/** Pass this as the task argument. */
|
|
27
|
+
readonly wire: KnittingBodyWire;
|
|
28
|
+
readonly byteLength: number;
|
|
29
|
+
/** The bytes on the host side, whichever transport was chosen. */
|
|
30
|
+
u8(): Uint8Array;
|
|
31
|
+
release(): void;
|
|
32
|
+
[Symbol.dispose](): void;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Attach this worker to the host's arena and return the opener.
|
|
36
|
+
*
|
|
37
|
+
* Call it once per worker from a bootstrap module -- `transport()` nests
|
|
38
|
+
* SharedArrayBuffers in an object, which survives the bootstrap's structured
|
|
39
|
+
* clone but not a task payload's encoding.
|
|
40
|
+
*
|
|
41
|
+
* The returned function is total over `KnittingBodyWire`: whatever the host
|
|
42
|
+
* chose, the task sees bytes.
|
|
43
|
+
*/
|
|
44
|
+
export declare const createBodyReader: (transport: KnittingTransport) => ((wire: KnittingBodyWire) => Uint8Array);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One handle for a request body, whichever way it travels.
|
|
3
|
+
*
|
|
4
|
+
* A body uses either pooled arena bytes or a moved `BufferReference`;
|
|
5
|
+
* `allocOrRefer()` hides that choice behind one disposable handle.
|
|
6
|
+
*
|
|
7
|
+
* The host owns the body, while each send holds it until the call settles, so
|
|
8
|
+
* early disposal cannot recycle bytes still in use by a worker. The worker gets
|
|
9
|
+
* a `Uint8Array` and never releases the body.
|
|
10
|
+
*
|
|
11
|
+
* The bytes are valid only until the host releases. A worker that wants to
|
|
12
|
+
* keep them past the call must copy.
|
|
13
|
+
*/
|
|
14
|
+
import { attachKnittingAllocator, } from "./knitting-buffer.js";
|
|
15
|
+
import { isBufferReferenceValue, } from "../connections/buffer-reference.js";
|
|
16
|
+
const hasSharedArrayBuffer = typeof SharedArrayBuffer === "function";
|
|
17
|
+
/**
|
|
18
|
+
* True for a buffer that arrived over a transport.
|
|
19
|
+
*
|
|
20
|
+
* knitting ships a SharedArrayBuffer by pointer and rebuilds it branded as an
|
|
21
|
+
* ArrayBuffer, so `instanceof SharedArrayBuffer` is false on the far side even
|
|
22
|
+
* though the memory is shared.
|
|
23
|
+
*/
|
|
24
|
+
const isTransportedBuffer = (value) => (hasSharedArrayBuffer && value instanceof SharedArrayBuffer) ||
|
|
25
|
+
value instanceof ArrayBuffer;
|
|
26
|
+
/**
|
|
27
|
+
* Attach this worker to the host's arena and return the opener.
|
|
28
|
+
*
|
|
29
|
+
* Call it once per worker from a bootstrap module -- `transport()` nests
|
|
30
|
+
* SharedArrayBuffers in an object, which survives the bootstrap's structured
|
|
31
|
+
* clone but not a task payload's encoding.
|
|
32
|
+
*
|
|
33
|
+
* The returned function is total over `KnittingBodyWire`: whatever the host
|
|
34
|
+
* chose, the task sees bytes.
|
|
35
|
+
*/
|
|
36
|
+
export const createBodyReader = (transport) => {
|
|
37
|
+
const lane = attachKnittingAllocator(transport);
|
|
38
|
+
return (wire) => {
|
|
39
|
+
// Moved: this worker holds the only live alias, and the host releases the
|
|
40
|
+
// reference once the call settles.
|
|
41
|
+
if (isBufferReferenceValue(wire))
|
|
42
|
+
return wire.toUint8Array();
|
|
43
|
+
// A body too large for the arena travels as its own buffer. It carries no
|
|
44
|
+
// identity, so there is nothing to borrow and nothing to release.
|
|
45
|
+
if (isTransportedBuffer(wire))
|
|
46
|
+
return lane.adopt(wire).u8();
|
|
47
|
+
// Pooled: borrowed for the call. `borrow` is what makes it impossible for
|
|
48
|
+
// this worker to release an identity the host still owns.
|
|
49
|
+
return lane.adopt(wire, { borrow: true }).u8();
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an HTTP request body into a pooled shared-memory region.
|
|
3
|
+
*
|
|
4
|
+
* Two strategies, because neither wins everywhere:
|
|
5
|
+
*
|
|
6
|
+
* - **Materialize.** Let the runtime assemble the body on the heap, then
|
|
7
|
+
* copy it into a region. One copy, no per-chunk bookkeeping.
|
|
8
|
+
* - **Stream.** Preallocate a region of the declared length and write each
|
|
9
|
+
* chunk straight into it. No heap body at all, but a reader per request.
|
|
10
|
+
*
|
|
11
|
+
* A small body arrives as a single chunk, so streaming saves no copy and still
|
|
12
|
+
* pays for the reader; a large one is split across many chunks, and there
|
|
13
|
+
* writing straight into the region wins on p99 as much as on throughput. The
|
|
14
|
+
* crossover is the body size at which chunking starts, so it depends on the
|
|
15
|
+
* runtime and the network in front of it -- `HTTP_BODY_STREAM_THRESHOLD_BYTES`
|
|
16
|
+
* is a starting point, not a constant of nature. `bench/http-body-oha.ts`
|
|
17
|
+
* sweeps it.
|
|
18
|
+
*
|
|
19
|
+
* Streaming needs a length up front, so a body with no `Content-Length` is
|
|
20
|
+
* always materialized. Reserving an upper bound instead is possible with
|
|
21
|
+
* `allocator.allocUpTo()` but is a worse default: the reservation has to fit
|
|
22
|
+
* the bump window, and a bound that does not fit takes the standalone-SAB
|
|
23
|
+
* valve.
|
|
24
|
+
*/
|
|
25
|
+
import type { KnittingAllocator, KnittingSharedBuffer } from "./knitting-buffer.js";
|
|
26
|
+
import { BufferReference } from "../connections/buffer-reference.js";
|
|
27
|
+
/**
|
|
28
|
+
* Bodies at least this large stream, if their length is known in advance.
|
|
29
|
+
*
|
|
30
|
+
* The crossover between the two strategies is runtime-dependent; re-measure
|
|
31
|
+
* with `bench/http-body-oha.ts` if body sizes cluster near it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const HTTP_BODY_STREAM_THRESHOLD_BYTES: number;
|
|
34
|
+
/**
|
|
35
|
+
* Bodies at or above this size are moved with `BufferReference` by
|
|
36
|
+
* `readBodyOrRefer`.
|
|
37
|
+
*
|
|
38
|
+
* Intentionally higher than `SHARED_RETURN_MIN_BYTES`, the crossover for an
|
|
39
|
+
* already materialized buffer: request handling has to consume the stream
|
|
40
|
+
* first, so the move only pays once the body is large enough to dominate that.
|
|
41
|
+
* Tune it for the application's body sizes and in-flight request count.
|
|
42
|
+
*/
|
|
43
|
+
export declare const HTTP_BODY_REFERENCE_THRESHOLD_BYTES: number;
|
|
44
|
+
export type ReadBodyOptions = {
|
|
45
|
+
/**
|
|
46
|
+
* Bodies of at least this many bytes stream into a preallocated region
|
|
47
|
+
* instead of being materialized first. Below the crossover, streaming is
|
|
48
|
+
* slower; see `HTTP_BODY_STREAM_THRESHOLD_BYTES`.
|
|
49
|
+
*/
|
|
50
|
+
streamThresholdBytes?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Reject a body larger than this.
|
|
53
|
+
*
|
|
54
|
+
* A declared length is a claim by the client, and the memory is committed
|
|
55
|
+
* on the strength of that claim before a single byte arrives -- so an
|
|
56
|
+
* unbounded default is a request-sized allocation primitive for anyone who
|
|
57
|
+
* can send a header. A body that declares nothing is read against the same
|
|
58
|
+
* cap rather than buffered whole and measured afterwards.
|
|
59
|
+
*
|
|
60
|
+
* `readBodyIntoRegion` defaults it to the allocator's `arenaByteLength`,
|
|
61
|
+
* which is the most a pooled region can hold anyway. The helpers that
|
|
62
|
+
* allocate somewhere else cannot infer a bound and require one.
|
|
63
|
+
*/
|
|
64
|
+
maxByteLength?: number;
|
|
65
|
+
};
|
|
66
|
+
/** `readBodyIntoBytes` allocates through a caller-supplied function, so only
|
|
67
|
+
* the caller knows what that allocation can afford. */
|
|
68
|
+
export type ReadBodyIntoBytesOptions = ReadBodyOptions & {
|
|
69
|
+
maxByteLength: number;
|
|
70
|
+
};
|
|
71
|
+
/** `readBodyOrRefer` exists to handle bodies too large for the arena, so
|
|
72
|
+
* neither the pool nor the crossover implies a bound. */
|
|
73
|
+
export type ReadBodyOrReferOptions = ReadBodyOptions & {
|
|
74
|
+
maxByteLength: number;
|
|
75
|
+
/** Move bodies at or above this size with `BufferReference`. */
|
|
76
|
+
referenceAboveBytes?: number;
|
|
77
|
+
};
|
|
78
|
+
/** What these helpers need of an allocator: a region, and its ceiling. */
|
|
79
|
+
export type RegionAllocator = Pick<KnittingAllocator, "alloc" | "arenaByteLength">;
|
|
80
|
+
export type ReadBodyPayload = KnittingSharedBuffer | BufferReference;
|
|
81
|
+
/**
|
|
82
|
+
* Read `request`'s body into bytes from `allocate`.
|
|
83
|
+
*
|
|
84
|
+
* The generic form behind `readBodyIntoRegion`. `allocate` may be anything
|
|
85
|
+
* that hands back a writable `Uint8Array` of the requested size -- a
|
|
86
|
+
* `KnittingAllocator` region's view, or `pool.sharedArgBytes`, which borrows
|
|
87
|
+
* from the arena the workers already read from, so the bytes reach a task
|
|
88
|
+
* without a further copy.
|
|
89
|
+
*
|
|
90
|
+
* The returned view is exactly the bytes that arrived, which is not
|
|
91
|
+
* necessarily what `Content-Length` claimed.
|
|
92
|
+
*/
|
|
93
|
+
export declare const readBodyIntoBytes: (request: Request, allocate: (byteLength: number) => Uint8Array, { streamThresholdBytes, maxByteLength, }: ReadBodyIntoBytesOptions) => Promise<Uint8Array>;
|
|
94
|
+
/**
|
|
95
|
+
* Read `request`'s body into a region from `allocator`.
|
|
96
|
+
*
|
|
97
|
+
* The caller owns the region and must `release()` it (or let the
|
|
98
|
+
* allocator's collector backstop reclaim it). The returned region's
|
|
99
|
+
* `byteLength` is what
|
|
100
|
+
* actually arrived, which is not necessarily what `Content-Length` claimed.
|
|
101
|
+
*/
|
|
102
|
+
export declare const readBodyIntoRegion: (request: Request, allocator: RegionAllocator, { streamThresholdBytes, maxByteLength, }?: ReadBodyOptions) => Promise<KnittingSharedBuffer>;
|
|
103
|
+
/**
|
|
104
|
+
* Read a request body into the representation that is cheaper to transport.
|
|
105
|
+
*
|
|
106
|
+
* Below `referenceAboveBytes`, the result is a `KnittingSharedBuffer` owned by
|
|
107
|
+
* the supplied allocator. At or above it, the request is materialized into a
|
|
108
|
+
* heap `ArrayBuffer` and moved into a `BufferReference`; constructing the
|
|
109
|
+
* reference detaches the materialized source. The caller owns the result and
|
|
110
|
+
* must release a `KnittingSharedBuffer` or `BufferReference` when finished.
|
|
111
|
+
*
|
|
112
|
+
* A missing `Content-Length` is materialized before choosing the result, so a
|
|
113
|
+
* genuinely large chunked body still takes the reference path. This helper is
|
|
114
|
+
* for thread workers; `BufferReference` cannot cross a process boundary.
|
|
115
|
+
*/
|
|
116
|
+
export declare const readBodyOrRefer: (request: Request, allocator: RegionAllocator, { referenceAboveBytes, ...bodyOptions }: ReadBodyOrReferOptions) => Promise<ReadBodyPayload>;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an HTTP request body into a pooled shared-memory region.
|
|
3
|
+
*
|
|
4
|
+
* Two strategies, because neither wins everywhere:
|
|
5
|
+
*
|
|
6
|
+
* - **Materialize.** Let the runtime assemble the body on the heap, then
|
|
7
|
+
* copy it into a region. One copy, no per-chunk bookkeeping.
|
|
8
|
+
* - **Stream.** Preallocate a region of the declared length and write each
|
|
9
|
+
* chunk straight into it. No heap body at all, but a reader per request.
|
|
10
|
+
*
|
|
11
|
+
* A small body arrives as a single chunk, so streaming saves no copy and still
|
|
12
|
+
* pays for the reader; a large one is split across many chunks, and there
|
|
13
|
+
* writing straight into the region wins on p99 as much as on throughput. The
|
|
14
|
+
* crossover is the body size at which chunking starts, so it depends on the
|
|
15
|
+
* runtime and the network in front of it -- `HTTP_BODY_STREAM_THRESHOLD_BYTES`
|
|
16
|
+
* is a starting point, not a constant of nature. `bench/http-body-oha.ts`
|
|
17
|
+
* sweeps it.
|
|
18
|
+
*
|
|
19
|
+
* Streaming needs a length up front, so a body with no `Content-Length` is
|
|
20
|
+
* always materialized. Reserving an upper bound instead is possible with
|
|
21
|
+
* `allocator.allocUpTo()` but is a worse default: the reservation has to fit
|
|
22
|
+
* the bump window, and a bound that does not fit takes the standalone-SAB
|
|
23
|
+
* valve.
|
|
24
|
+
*/
|
|
25
|
+
import { BufferReference } from "../connections/buffer-reference.js";
|
|
26
|
+
/**
|
|
27
|
+
* Bodies at least this large stream, if their length is known in advance.
|
|
28
|
+
*
|
|
29
|
+
* The crossover between the two strategies is runtime-dependent; re-measure
|
|
30
|
+
* with `bench/http-body-oha.ts` if body sizes cluster near it.
|
|
31
|
+
*/
|
|
32
|
+
export const HTTP_BODY_STREAM_THRESHOLD_BYTES = 192 * 1024;
|
|
33
|
+
/**
|
|
34
|
+
* Bodies at or above this size are moved with `BufferReference` by
|
|
35
|
+
* `readBodyOrRefer`.
|
|
36
|
+
*
|
|
37
|
+
* Intentionally higher than `SHARED_RETURN_MIN_BYTES`, the crossover for an
|
|
38
|
+
* already materialized buffer: request handling has to consume the stream
|
|
39
|
+
* first, so the move only pays once the body is large enough to dominate that.
|
|
40
|
+
* Tune it for the application's body sizes and in-flight request count.
|
|
41
|
+
*/
|
|
42
|
+
export const HTTP_BODY_REFERENCE_THRESHOLD_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
/** The declared body length, or -1 when it is absent or not a sane integer. */
|
|
44
|
+
const declaredLength = (request) => {
|
|
45
|
+
const header = request.headers.get("content-length");
|
|
46
|
+
if (header === null)
|
|
47
|
+
return -1;
|
|
48
|
+
const value = Number(header);
|
|
49
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : -1;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Read a body whose length was not declared, against `maxByteLength`.
|
|
53
|
+
*
|
|
54
|
+
* Buffering the whole thing and checking afterwards is the same exposure as
|
|
55
|
+
* trusting `Content-Length`: an attacker only has to omit the header. Reading
|
|
56
|
+
* against the cap stops at the first chunk that crosses it, and cancels the
|
|
57
|
+
* stream so the sender stops rather than filling a socket buffer.
|
|
58
|
+
*/
|
|
59
|
+
const materialize = async (request, maxByteLength) => {
|
|
60
|
+
if (!Number.isFinite(maxByteLength) || request.body === null) {
|
|
61
|
+
return request.bytes !== undefined
|
|
62
|
+
? await request.bytes()
|
|
63
|
+
: new Uint8Array(await request.arrayBuffer());
|
|
64
|
+
}
|
|
65
|
+
const chunks = [];
|
|
66
|
+
let total = 0;
|
|
67
|
+
const reader = request.body.getReader();
|
|
68
|
+
try {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const { done, value } = await reader.read();
|
|
71
|
+
if (done)
|
|
72
|
+
break;
|
|
73
|
+
total += value.byteLength;
|
|
74
|
+
if (total > maxByteLength) {
|
|
75
|
+
await reader.cancel();
|
|
76
|
+
throw new RangeError(`body is over the ${maxByteLength} byte limit`);
|
|
77
|
+
}
|
|
78
|
+
chunks.push(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
if (chunks.length === 1)
|
|
85
|
+
return chunks[0];
|
|
86
|
+
const out = new Uint8Array(total);
|
|
87
|
+
let at = 0;
|
|
88
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
89
|
+
out.set(chunks[i], at);
|
|
90
|
+
at += chunks[i].byteLength;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Read `request`'s body into bytes from `allocate`.
|
|
96
|
+
*
|
|
97
|
+
* The generic form behind `readBodyIntoRegion`. `allocate` may be anything
|
|
98
|
+
* that hands back a writable `Uint8Array` of the requested size -- a
|
|
99
|
+
* `KnittingAllocator` region's view, or `pool.sharedArgBytes`, which borrows
|
|
100
|
+
* from the arena the workers already read from, so the bytes reach a task
|
|
101
|
+
* without a further copy.
|
|
102
|
+
*
|
|
103
|
+
* The returned view is exactly the bytes that arrived, which is not
|
|
104
|
+
* necessarily what `Content-Length` claimed.
|
|
105
|
+
*/
|
|
106
|
+
export const readBodyIntoBytes = async (request, allocate, { streamThresholdBytes = HTTP_BODY_STREAM_THRESHOLD_BYTES, maxByteLength, }) => {
|
|
107
|
+
if (!Number.isSafeInteger(maxByteLength) || maxByteLength < 0) {
|
|
108
|
+
throw new RangeError("maxByteLength must be a non-negative safe integer");
|
|
109
|
+
}
|
|
110
|
+
const req = request;
|
|
111
|
+
const declared = declaredLength(req);
|
|
112
|
+
if (declared > maxByteLength) {
|
|
113
|
+
throw new RangeError(`body declares ${declared} bytes, over the ${maxByteLength} limit`);
|
|
114
|
+
}
|
|
115
|
+
if (declared >= streamThresholdBytes && req.body !== null) {
|
|
116
|
+
const out = allocate(declared);
|
|
117
|
+
let at = 0;
|
|
118
|
+
const reader = req.body.getReader();
|
|
119
|
+
try {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done)
|
|
123
|
+
break;
|
|
124
|
+
if (at + value.byteLength > declared) {
|
|
125
|
+
throw new RangeError(`body exceeds its declared ${declared} bytes`);
|
|
126
|
+
}
|
|
127
|
+
out.set(value, at);
|
|
128
|
+
at += value.byteLength;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
reader.releaseLock();
|
|
133
|
+
}
|
|
134
|
+
return at === declared ? out : out.subarray(0, at);
|
|
135
|
+
}
|
|
136
|
+
const bytes = await materialize(req, maxByteLength);
|
|
137
|
+
if (bytes.byteLength > maxByteLength) {
|
|
138
|
+
throw new RangeError(`body is ${bytes.byteLength} bytes, over the ${maxByteLength} limit`);
|
|
139
|
+
}
|
|
140
|
+
const out = allocate(bytes.byteLength);
|
|
141
|
+
out.set(bytes);
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Read `request`'s body into a region from `allocator`.
|
|
146
|
+
*
|
|
147
|
+
* The caller owns the region and must `release()` it (or let the
|
|
148
|
+
* allocator's collector backstop reclaim it). The returned region's
|
|
149
|
+
* `byteLength` is what
|
|
150
|
+
* actually arrived, which is not necessarily what `Content-Length` claimed.
|
|
151
|
+
*/
|
|
152
|
+
export const readBodyIntoRegion = async (request, allocator, { streamThresholdBytes = HTTP_BODY_STREAM_THRESHOLD_BYTES,
|
|
153
|
+
// The arena is the ceiling on a pooled region: a larger body cannot be
|
|
154
|
+
// pooled at all, it can only take the standalone-SAB valve, which is the
|
|
155
|
+
// allocation an attacker would be aiming for.
|
|
156
|
+
maxByteLength = allocator.arenaByteLength, } = {}) => {
|
|
157
|
+
const req = request;
|
|
158
|
+
const declared = declaredLength(req);
|
|
159
|
+
if (declared > maxByteLength) {
|
|
160
|
+
throw new RangeError(`body declares ${declared} bytes, over the ${maxByteLength} limit`);
|
|
161
|
+
}
|
|
162
|
+
// Stream only when the length is known and large enough to pay for it.
|
|
163
|
+
if (declared >= streamThresholdBytes && req.body !== null) {
|
|
164
|
+
const region = allocator.alloc(declared);
|
|
165
|
+
try {
|
|
166
|
+
const out = region.u8();
|
|
167
|
+
let at = 0;
|
|
168
|
+
const reader = req.body.getReader();
|
|
169
|
+
try {
|
|
170
|
+
for (;;) {
|
|
171
|
+
const { done, value } = await reader.read();
|
|
172
|
+
if (done)
|
|
173
|
+
break;
|
|
174
|
+
// Content-Length is a claim, not a guarantee. Writing past the
|
|
175
|
+
// region would corrupt whatever region follows it in the arena.
|
|
176
|
+
if (at + value.byteLength > declared) {
|
|
177
|
+
throw new RangeError(`body exceeds its declared ${declared} bytes`);
|
|
178
|
+
}
|
|
179
|
+
out.set(value, at);
|
|
180
|
+
at += value.byteLength;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
reader.releaseLock();
|
|
185
|
+
}
|
|
186
|
+
// A body shorter than it claimed leaves a tail of stale arena bytes;
|
|
187
|
+
// commit hands that tail back and reports the real length.
|
|
188
|
+
return at === declared ? region : region.commit(at);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
region.release();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const bytes = await materialize(req, maxByteLength);
|
|
196
|
+
if (bytes.byteLength > maxByteLength) {
|
|
197
|
+
throw new RangeError(`body is ${bytes.byteLength} bytes, over the ${maxByteLength} limit`);
|
|
198
|
+
}
|
|
199
|
+
const region = allocator.alloc(bytes.byteLength);
|
|
200
|
+
try {
|
|
201
|
+
region.u8().set(bytes);
|
|
202
|
+
return region;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
region.release();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const copyBytesIntoRegion = (bytes, allocator) => {
|
|
210
|
+
const region = allocator.alloc(bytes.byteLength);
|
|
211
|
+
try {
|
|
212
|
+
region.u8().set(bytes);
|
|
213
|
+
return region;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
region.release();
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Read a request body into the representation that is cheaper to transport.
|
|
222
|
+
*
|
|
223
|
+
* Below `referenceAboveBytes`, the result is a `KnittingSharedBuffer` owned by
|
|
224
|
+
* the supplied allocator. At or above it, the request is materialized into a
|
|
225
|
+
* heap `ArrayBuffer` and moved into a `BufferReference`; constructing the
|
|
226
|
+
* reference detaches the materialized source. The caller owns the result and
|
|
227
|
+
* must release a `KnittingSharedBuffer` or `BufferReference` when finished.
|
|
228
|
+
*
|
|
229
|
+
* A missing `Content-Length` is materialized before choosing the result, so a
|
|
230
|
+
* genuinely large chunked body still takes the reference path. This helper is
|
|
231
|
+
* for thread workers; `BufferReference` cannot cross a process boundary.
|
|
232
|
+
*/
|
|
233
|
+
export const readBodyOrRefer = async (request, allocator, { referenceAboveBytes = HTTP_BODY_REFERENCE_THRESHOLD_BYTES, ...bodyOptions }) => {
|
|
234
|
+
if (!Number.isSafeInteger(referenceAboveBytes) || referenceAboveBytes < 0) {
|
|
235
|
+
throw new RangeError("referenceAboveBytes must be a non-negative safe integer");
|
|
236
|
+
}
|
|
237
|
+
// Reference allocations need an explicit runtime size bound.
|
|
238
|
+
if (!Number.isSafeInteger(bodyOptions.maxByteLength) ||
|
|
239
|
+
bodyOptions.maxByteLength < 0) {
|
|
240
|
+
throw new RangeError("maxByteLength must be a non-negative safe integer");
|
|
241
|
+
}
|
|
242
|
+
const req = request;
|
|
243
|
+
const declared = declaredLength(req);
|
|
244
|
+
// A known-large body can stream directly into the heap buffer that will be
|
|
245
|
+
// moved. A body without a length must also use this path so we can choose on
|
|
246
|
+
// the actual byte count after consuming it.
|
|
247
|
+
if (declared < 0 || declared >= referenceAboveBytes) {
|
|
248
|
+
const bytes = await readBodyIntoBytes(request, (byteLength) => new Uint8Array(byteLength), bodyOptions);
|
|
249
|
+
if (bytes.byteLength >= referenceAboveBytes) {
|
|
250
|
+
return new BufferReference(bytes);
|
|
251
|
+
}
|
|
252
|
+
return copyBytesIntoRegion(bytes, allocator);
|
|
253
|
+
}
|
|
254
|
+
return readBodyIntoRegion(request, allocator, bodyOptions);
|
|
255
|
+
};
|