castrum 0.9.4 → 0.9.6

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.
Files changed (58) hide show
  1. package/Cargo.lock +25 -42
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4432 -3641
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +154 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/ffi/mod.rs +1 -0
  27. package/rust/ffi/task.rs +172 -0
  28. package/rust/http/form.rs +6 -2
  29. package/rust/http/query_parser.rs +53 -22
  30. package/rust/ingress/native_route.rs +131 -140
  31. package/rust/ingress/pipeline.rs +3 -3
  32. package/rust/ingress/tests.rs +5 -4
  33. package/rust/json/json_ser.rs +13 -11
  34. package/rust/lib.rs +1 -0
  35. package/rust/task/completion.rs +119 -0
  36. package/rust/task/mod.rs +65 -0
  37. package/rust/task/ops.rs +503 -0
  38. package/rust/task/runtime.rs +220 -0
  39. package/rust/task/tests.rs +231 -0
  40. package/rust/util/bytes.rs +238 -51
  41. package/src/ingress/health.ts +1 -2
  42. package/src/native/ffi/build/codecs.ts +29 -7
  43. package/src/native/ffi/build/compress.ts +8 -0
  44. package/src/native/ffi/build/instances.ts +7 -2
  45. package/src/native/ffi/build/metrics.ts +23 -0
  46. package/src/native/ffi/build/task.ts +79 -0
  47. package/src/native/ffi/build/util.ts +18 -0
  48. package/src/native/ffi/build.ts +2 -0
  49. package/src/native/ffi/selftest.ts +3 -1
  50. package/src/native/ffi/types.ts +40 -0
  51. package/src/native/ffi.ts +73 -14
  52. package/src/native/loader.ts +53 -3
  53. package/src/rust-ffi/scalar/http.ts +54 -16
  54. package/src/rust-ffi/text.ts +25 -3
  55. package/src/shared/bytes.ts +19 -0
  56. package/src/task/index.ts +24 -0
  57. package/src/task/op.ts +171 -0
  58. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,568 @@
1
+ // src/task/runtime.ts — Bun-first off-thread task runtime (impure boundary).
2
+ //
3
+ // Submits a named native op to the Rust task pool and resolves a JS promise
4
+ // when it finishes. The JS thread NEVER blocks: the pool pushes completions
5
+ // into a native ring and rings ONE thread-safe doorbell per batch, so a burst
6
+ // of tasks costs one crossing instead of one per task.
7
+ //
8
+ // Bun is the primary transport (`bun:ffi` + `JSCallback({threadsafe:true})`).
9
+ // On Node — or when `CASTRUM_FFI_MODE=napi` disables the ffi layer — the op
10
+ // runs synchronously on the caller thread; true Node offload needs the napi
11
+ // `AsyncTask` bridge (see docs/RND-CONCURRENCY.md, milestone M2).
12
+ //
13
+ // Side effects (doorbell registration, pooled drain buffer, in-flight map) are
14
+ // deliberately contained to this module, matching the repo's purity policy.
15
+
16
+ import { decodeUtf8 } from '../shared/codec'
17
+ import { isBun } from '../shared/runtime'
18
+ import { getBunFFI } from '../native/ffi'
19
+ import {
20
+ TASK_OP,
21
+ TASK_STATUS_CANCELLED,
22
+ TASK_STATUS_OK,
23
+ TASK_STATUS_TOO_SMALL,
24
+ TASK_STATUS_UNSUPPORTED,
25
+ brotliCapacityGuess,
26
+ encodeArgon2VerifyArgs,
27
+ encodeBrotliDecompressArgs,
28
+ encodeGzipCompressHeader,
29
+ encodeGzipDecompressArgs,
30
+ encodePbkdf2Args,
31
+ } from './op'
32
+
33
+ /** Per-call options for an offloaded task. */
34
+ export interface TaskRunOptions {
35
+ /** Abort the task; it completes with a cancelled error. */
36
+ signal?: AbortSignal
37
+ /** Decompress ops: output cap in bytes (default native 64 MiB bomb cap). */
38
+ maxDecompressed?: number
39
+ /** `gzipCompress` only: deflate level `0`-`9` (default `6`). */
40
+ level?: number
41
+ }
42
+
43
+ /** Options for {@link TaskRuntime.pbkdf2Sha256}. */
44
+ export interface Pbkdf2RunOptions extends TaskRunOptions {
45
+ /** Iteration count (clamped to >= 1 natively). */
46
+ rounds: number
47
+ /** Derived-key length in bytes (default 32). */
48
+ dkLen?: number
49
+ }
50
+
51
+ /** Runtime introspection snapshot. */
52
+ export interface TaskStats {
53
+ /** Pool worker count (0 when the pool has not started). */
54
+ threads: number
55
+ /** Finished-but-undrained completions in the native ring. */
56
+ pending: number
57
+ /** JS promises currently awaiting a completion. */
58
+ inflight: number
59
+ /** Completions resolved since the runtime was created. */
60
+ completed: number
61
+ /** Drain rounds run on the JS thread (one per doorbell, plus coalesced work). */
62
+ drains: number
63
+ /** Largest number of completions carried by a single drain round. */
64
+ maxBatch: number
65
+ /** Zero-copy attempts that had to retry at the exact needed size. */
66
+ tooSmallRetries: number
67
+ }
68
+
69
+ /** Off-thread task runtime handle. */
70
+ export interface TaskRuntime {
71
+ /** gzip-decompress `data` on a pool thread (keeps the 64 MiB bomb cap). */
72
+ gzipDecompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
73
+ /** brotli-decompress `data` on a pool thread (keeps the 64 MiB bomb cap). */
74
+ brotliDecompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
75
+ /** gzip-compress `data` on a pool thread. */
76
+ gzipCompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
77
+ /** Verify a password against a PHC string on a pool thread (10-200 ms CPU). */
78
+ argon2Verify(
79
+ password: Uint8Array,
80
+ phc: Uint8Array,
81
+ options?: TaskRunOptions,
82
+ ): Promise<boolean>
83
+ /** PBKDF2-HMAC-SHA256 on a pool thread (10-200 ms CPU, 1-64 B output). */
84
+ pbkdf2Sha256(
85
+ password: Uint8Array,
86
+ salt: Uint8Array,
87
+ options: Pbkdf2RunOptions,
88
+ ): Promise<Uint8Array>
89
+ /** Pool / ring / in-flight counters. */
90
+ stats(): TaskStats
91
+ /** Cancel everything in flight, stop the pool, release the doorbell. */
92
+ shutdown(): void
93
+ }
94
+
95
+ /** Options for {@link createTaskRuntime}. */
96
+ export interface TaskRuntimeOptions {
97
+ /** Pool worker count; `0`/omitted → native default (cores − 1). */
98
+ threads?: number
99
+ }
100
+
101
+ interface Deferred {
102
+ /** Set for zero-copy tasks: the JS-owned destination the pool thread writes. */
103
+ out: Uint8Array | null
104
+ /**
105
+ * Set for zero-copy-INPUT tasks: the payload the pool thread reads in place.
106
+ * Holding it here is what keeps it alive (and un-moved) until the completion
107
+ * drains — without it the GC could free it under a running worker.
108
+ */
109
+ keep: Uint8Array | null
110
+ resolve: (value: Uint8Array) => void
111
+ reject: (reason: unknown) => void
112
+ }
113
+
114
+ /**
115
+ * Read a little-endian u64 at `off` as a JS number, without allocating.
116
+ * `getBigUint64` would box a BigInt per call; two u32 reads are exact for any
117
+ * value below 2^53, which covers ids (a JS counter) and byte counts.
118
+ */
119
+ function readU64At(view: DataView, off: number): number {
120
+ return view.getUint32(off, true) + view.getUint32(off + 4, true) * 4294967296
121
+ }
122
+
123
+ /**
124
+ * Materialize a completion body out of the drain buffer. A pooled buffer is
125
+ * reused, so its body must be COPIED; a fresh large buffer is not reused, so a
126
+ * view into it is free.
127
+ */
128
+ function bodyOf(buf: Uint8Array, off: number, len: number, pooled: boolean): Uint8Array {
129
+ return pooled ? buf.slice(off, off + len) : buf.subarray(off, off + len)
130
+ }
131
+
132
+ /** The zero-copy destination was too small; `needed` is the exact size. */
133
+ type TooSmallError = Error & { needed: number }
134
+
135
+ function tooSmallError(needed: number): TooSmallError {
136
+ const err = new Error('task output buffer too small') as TooSmallError
137
+ err.name = 'TaskTooSmallError'
138
+ err.needed = needed
139
+ return err
140
+ }
141
+
142
+ function isTooSmall(err: unknown): err is TooSmallError {
143
+ return err instanceof Error && err.name === 'TaskTooSmallError'
144
+ }
145
+
146
+ /** The op has no zero-copy form (older addon) — use the copy path. */
147
+ function unsupportedError(): Error {
148
+ const err = new Error('task op has no zero-copy form')
149
+ err.name = 'TaskUnsupportedError'
150
+ return err
151
+ }
152
+
153
+ function cancelledError(): Error {
154
+ const err = new Error('task cancelled')
155
+ err.name = 'TaskCancelledError'
156
+ return err
157
+ }
158
+
159
+ /** Bunny-shaped doorbell handle (structural, so it needs no bun-types dep). */
160
+ interface Doorbell {
161
+ ptr: number | null
162
+ close(): void
163
+ }
164
+
165
+ /**
166
+ * Process-wide singleton. The native pool, the completion ring, and the
167
+ * doorbell are ONE per process, so multiple runtimes would each register a
168
+ * doorbell and drain — and drop — each other's completions. `options` applies
169
+ * to the first call only; later calls return the same handle.
170
+ */
171
+ let shared: TaskRuntime | null = null
172
+
173
+ /**
174
+ * Get the off-thread task runtime (created on first use, then shared). Binds
175
+ * the Bun ffi surface lazily.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const tasks = createTaskRuntime()
180
+ * const out = await tasks.gzipDecompress(compressed)
181
+ * ```
182
+ */
183
+ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime {
184
+ if (shared) return shared
185
+ const boundFfi = isBun() ? getBunFFI() : null
186
+
187
+ if (!boundFfi) {
188
+ // Node / napi fallback: synchronous on the caller thread. Imported lazily
189
+ // so the Bun path never pulls the napi client into the hot module graph.
190
+ const fallback: TaskRuntime = {
191
+ async gzipDecompress(data, runOptions) {
192
+ const { rust } = await import('../rust-ffi')
193
+ return rust.gzipDecompress(data, runOptions?.maxDecompressed)
194
+ },
195
+ async pbkdf2Sha256(password, salt, runOptions) {
196
+ const { rust } = await import('../rust-ffi')
197
+ return rust.pbkdf2Sha256(password, salt, runOptions.rounds, runOptions.dkLen ?? 32)
198
+ },
199
+ async brotliDecompress(data, runOptions) {
200
+ const { rust } = await import('../rust-ffi')
201
+ return rust.brotliDecompress(data, runOptions?.maxDecompressed ?? null)
202
+ },
203
+ async gzipCompress(data, runOptions) {
204
+ const { rust } = await import('../rust-ffi')
205
+ return rust.gzipCompress(data, runOptions?.level ?? null)
206
+ },
207
+ async argon2Verify(password, phc) {
208
+ const { rust } = await import('../rust-ffi')
209
+ return rust.passwordVerify(password, phc)
210
+ },
211
+ stats: () => ({
212
+ threads: 0,
213
+ pending: 0,
214
+ inflight: 0,
215
+ completed: 0,
216
+ drains: 0,
217
+ maxBatch: 0,
218
+ tooSmallRetries: 0,
219
+ }),
220
+ shutdown: () => {},
221
+ }
222
+ shared = fallback
223
+ return fallback
224
+ }
225
+
226
+ // Bind to a non-null local so the nested closures keep the narrowing.
227
+ const ffi = boundFfi
228
+ ffi.taskInit(options.threads ?? 0)
229
+
230
+ const inflight = new Map<number, Deferred>()
231
+ let nextId = 1
232
+ let drainBuf = new Uint8Array(64 * 1024)
233
+ // A batch needing more than this grows the pooled buffer; anything larger
234
+ // gets a fresh exact-sized buffer handed out as subarray views (one JS-side
235
+ // copy instead of two + less GC churn).
236
+ const MAX_POOLED_DRAIN = 256 * 1024
237
+ // Outputs at least this big use the zero-copy (write-into-JS-buffer) path —
238
+ // below it the pooled copy path is cheaper than allocating a destination.
239
+ const ZERO_COPY_MIN = 64 * 1024
240
+ let scheduled = false
241
+ // Reusable packed-args destination for the SYNCHRONOUS copy path: native
242
+ // copies args into the job on submit, so one buffer serves every submission
243
+ // and the hot path allocates nothing per task. Deliberately NOT used for
244
+ // secret-bearing ops (pbkdf2/argon2) — a shared buffer would keep plaintext
245
+ // passwords alive far longer than a per-call allocation — nor for zero-copy
246
+ // args, which are re-submitted after an await and must own their memory.
247
+ let argScratch = new Uint8Array(256)
248
+ function scratch(need: number): Uint8Array {
249
+ if (argScratch.length < need) argScratch = new Uint8Array(need)
250
+ return argScratch
251
+ }
252
+ // Drain-side counters, exposed through `stats()`. They are what tells you
253
+ // whether a workload is RTT-bound (many tiny drains) or pool-bound (few big
254
+ // drains): `completed / drains` is the coalescing factor actually achieved.
255
+ let drains = 0
256
+ let completed = 0
257
+ let maxBatch = 0
258
+ let tooSmallRetries = 0
259
+ let doorbell: Doorbell | null = null
260
+ let keepAlive: ReturnType<typeof setInterval> | null = null
261
+
262
+ // Native pool threads are invisible to Bun's event loop, so an `await`ed task
263
+ // would not keep a CLI process alive on its own. Hold a ref'd interval while
264
+ // anything is in flight; it doubles as a polling safety net if a doorbell
265
+ // ever fails to arrive.
266
+ function retain(): void {
267
+ if (keepAlive === null) {
268
+ keepAlive = setInterval(() => {
269
+ if (inflight.size === 0) release()
270
+ }, 1000)
271
+ }
272
+ }
273
+ function release(): void {
274
+ if (keepAlive !== null) {
275
+ clearInterval(keepAlive)
276
+ keepAlive = null
277
+ }
278
+ }
279
+
280
+ function drainNow(): void {
281
+ scheduled = false
282
+ let guard = 0
283
+ // `taskDrain` returns the exact needed size, or 0/1 when nothing is pending,
284
+ // so the loop condition needs no separate `taskPending` probe — one fewer FFI
285
+ // crossing per drain round.
286
+ while (guard++ < 1024) {
287
+ // The ring buffer is only reused for batches that fit; a grow-initiated
288
+ // buffer must NOT persist across iterations, because the previous
289
+ // iteration's `subarray` bodies still reference it.
290
+ let buf: Uint8Array = drainBuf
291
+ let pooled = true
292
+ // `taskDrain` follows the needed-size convention: a buffer that is too
293
+ // small reports the exact required byte count WITHOUT consuming anything.
294
+ // So this single call covers both the common (fits) and grow cases — there
295
+ // is no separate probe round trip, and therefore one fewer FFI crossing
296
+ // per drain.
297
+ let written = ffi.taskDrain(buf)
298
+ if (written < 4) break
299
+ if (written > buf.length) {
300
+ // Too small: re-run into a bigger buffer. A modest batch grows the
301
+ // pooled buffer (so later drains are copy-only); a large one gets a
302
+ // fresh buffer and is handed out as `subarray` views, never reused.
303
+ if (written <= MAX_POOLED_DRAIN) {
304
+ drainBuf = new Uint8Array(written)
305
+ buf = drainBuf
306
+ pooled = true
307
+ } else {
308
+ buf = new Uint8Array(written)
309
+ pooled = false
310
+ }
311
+ written = ffi.taskDrain(buf)
312
+ if (written < 4) break
313
+ if (written > buf.length) continue // grew again — retry
314
+ }
315
+ const view = new DataView(buf.buffer, buf.byteOffset, written)
316
+ const count = view.getUint32(0, true)
317
+ drains++
318
+ if (count > maxBatch) maxBatch = count
319
+ let off = 4
320
+ for (let i = 0; i < count; i++) {
321
+ // Header reads come straight out of the drain view — no BigInt boxing
322
+ // and no per-completion DataView.
323
+ const id = readU64At(view, off)
324
+ const status = view.getUint32(off + 8, true)
325
+ const len = view.getUint32(off + 12, true)
326
+ const bodyOff = off + 16
327
+ off = bodyOff + len
328
+ const deferred = inflight.get(id)
329
+ if (!deferred) continue
330
+ inflight.delete(id)
331
+ if (status === TASK_STATUS_OK) {
332
+ // Zero-copy tasks complete with the written length in the body; the
333
+ // result itself is already in the buffer JS handed us, so no body
334
+ // bytes are touched at all. The copy path hands the payload over.
335
+ deferred.resolve(
336
+ deferred.out
337
+ ? deferred.out.subarray(0, len >= 8 ? readU64At(view, bodyOff) : len)
338
+ : bodyOf(buf, bodyOff, len, pooled),
339
+ )
340
+ } else if (status === TASK_STATUS_TOO_SMALL) {
341
+ tooSmallRetries++
342
+ deferred.reject(tooSmallError(len >= 8 ? readU64At(view, bodyOff) : 0))
343
+ } else if (status === TASK_STATUS_UNSUPPORTED) {
344
+ deferred.reject(unsupportedError())
345
+ } else if (status === TASK_STATUS_CANCELLED) {
346
+ deferred.reject(cancelledError())
347
+ } else {
348
+ deferred.reject(
349
+ new Error(decodeUtf8(bodyOf(buf, bodyOff, len, pooled)) || 'task failed'),
350
+ )
351
+ }
352
+ completed++
353
+ }
354
+ }
355
+ if (inflight.size === 0) release()
356
+ }
357
+
358
+ function schedule(): void {
359
+ if (scheduled) return
360
+ scheduled = true
361
+ queueMicrotask(drainNow)
362
+ }
363
+
364
+ function submit(
365
+ op: number,
366
+ args: Uint8Array,
367
+ runOptions?: TaskRunOptions,
368
+ ): Promise<Uint8Array> {
369
+ const id = nextId++
370
+ return new Promise<Uint8Array>((resolve, reject) => {
371
+ inflight.set(id, { out: null, keep: null, resolve, reject })
372
+ retain()
373
+ if (ffi.taskSubmit(op, args, id) === 0) {
374
+ inflight.delete(id)
375
+ release()
376
+ reject(new Error('task rejected by the native runtime'))
377
+ return
378
+ }
379
+ const signal = runOptions?.signal
380
+ if (signal) {
381
+ if (signal.aborted) {
382
+ ffi.taskCancel(id)
383
+ } else {
384
+ signal.addEventListener('abort', () => ffi.taskCancel(id), { once: true })
385
+ }
386
+ }
387
+ })
388
+ }
389
+
390
+ /**
391
+ * Zero-copy submit: the op writes its result directly into `output` on a pool
392
+ * thread (the buffer is held by this runtime until the completion drains, so
393
+ * it cannot be collected or moved). Resolves a view of `output` — no copy.
394
+ */
395
+ function submitInto(
396
+ op: number,
397
+ args: Uint8Array,
398
+ output: Uint8Array,
399
+ runOptions?: TaskRunOptions,
400
+ ): Promise<Uint8Array> {
401
+ const id = nextId++
402
+ return new Promise<Uint8Array>((resolve, reject) => {
403
+ inflight.set(id, { out: output, keep: null, resolve, reject })
404
+ retain()
405
+ if (ffi.taskSubmitOut(op, args, id, output) === 0) {
406
+ inflight.delete(id)
407
+ release()
408
+ reject(new Error('task rejected by the native runtime'))
409
+ return
410
+ }
411
+ const signal = runOptions?.signal
412
+ if (signal) {
413
+ if (signal.aborted) {
414
+ ffi.taskCancel(id)
415
+ } else {
416
+ signal.addEventListener('abort', () => ffi.taskCancel(id), { once: true })
417
+ }
418
+ }
419
+ })
420
+ }
421
+
422
+ /**
423
+ * Zero-copy-INPUT submit: only the small `hdr` is copied; the pool thread
424
+ * reads `data` in place. The runtime holds `data` until the completion
425
+ * drains, so the caller may drop its own reference immediately — but must
426
+ * NOT mutate it or hand over a view of a recycled pooled buffer.
427
+ */
428
+ function submitSlice(
429
+ op: number,
430
+ hdr: Uint8Array,
431
+ data: Uint8Array,
432
+ runOptions?: TaskRunOptions,
433
+ ): Promise<Uint8Array> {
434
+ const id = nextId++
435
+ return new Promise<Uint8Array>((resolve, reject) => {
436
+ inflight.set(id, { out: null, keep: data, resolve, reject })
437
+ retain()
438
+ if (ffi.taskSubmitSlice(op, hdr, data, id) === 0) {
439
+ inflight.delete(id)
440
+ release()
441
+ reject(new Error('task rejected by the native runtime'))
442
+ return
443
+ }
444
+ const signal = runOptions?.signal
445
+ if (signal) {
446
+ if (signal.aborted) {
447
+ ffi.taskCancel(id)
448
+ } else {
449
+ signal.addEventListener('abort', () => ffi.taskCancel(id), { once: true })
450
+ }
451
+ }
452
+ })
453
+ }
454
+
455
+ // One process-wide trampoline; Bun marshals calls from pool threads onto the
456
+ // JS thread. Rust coalesces so a burst rings it once.
457
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
458
+ const { JSCallback } = require('bun:ffi') as typeof import('bun:ffi')
459
+ const cb = new JSCallback(
460
+ () => {
461
+ drainNow()
462
+ },
463
+ { args: [], returns: 'void', threadsafe: true } as unknown as ConstructorParameters<
464
+ typeof JSCallback
465
+ >[1],
466
+ )
467
+ doorbell = cb
468
+ ffi.taskSetDoorbell(Number(cb.ptr))
469
+
470
+ // A zero-copy result handoff via `toArrayBuffer(ptr, 0, len, dealloc)` was
471
+ // prototyped here and REVERTED: Bun 1.4.2's deallocator hook segfaults
472
+ // (`docs/runtime/ffi.mdx` does not match the implementation). Zero-copy is
473
+ // instead done by pre-allocating the destination in JS and having the pool
474
+ // thread write into it (see docs/RND-CONCURRENCY.md §7a). If a completion
475
+ // landed between bind and registration, drain it now.
476
+ schedule()
477
+
478
+ const runtime: TaskRuntime = {
479
+ gzipDecompress(data, runOptions) {
480
+ // Pre-size from the gzip ISIZE trailer; unknown (0) → copy path.
481
+ const size = ffi.gzipIsize(data)
482
+ if (size < ZERO_COPY_MIN) {
483
+ // Copy path: consumed synchronously, so the scratch is safe to reuse.
484
+ return submit(
485
+ TASK_OP.gzipDecompress,
486
+ encodeGzipDecompressArgs(data, runOptions?.maxDecompressed, scratch(4 + data.length)),
487
+ runOptions,
488
+ )
489
+ }
490
+ // Zero-copy: the pool thread writes straight into a JS-owned buffer. On
491
+ // TOO_SMALL retry once with the exact size; on anything else (including
492
+ // UNSUPPORTED from an older addon) fall back, so behavior can't regress.
493
+ // Args are re-submitted on the retry step, so they own their memory.
494
+ const args = encodeGzipDecompressArgs(data, runOptions?.maxDecompressed)
495
+ const copyPath = (): Promise<Uint8Array> =>
496
+ submit(TASK_OP.gzipDecompress, args, runOptions)
497
+ const zeroCopyOnce = (cap: number): Promise<Uint8Array> =>
498
+ submitInto(TASK_OP.gzipDecompressInto, args, Buffer.allocUnsafe(cap), runOptions)
499
+ return zeroCopyOnce(size)
500
+ .catch((err: unknown) => (isTooSmall(err) ? zeroCopyOnce(err.needed) : copyPath()))
501
+ .catch(() => copyPath())
502
+ },
503
+ pbkdf2Sha256(password, salt, runOptions) {
504
+ return submit(
505
+ TASK_OP.pbkdf2Sha256,
506
+ encodePbkdf2Args(password, salt, runOptions.rounds, runOptions.dkLen),
507
+ runOptions,
508
+ )
509
+ },
510
+ gzipCompress(data, runOptions) {
511
+ // Zero-copy INPUT: the payload is read in place, so compressing a large
512
+ // buffer no longer pays an O(input) copy on the JS thread (which measured
513
+ // 3x the wall time of the synchronous built-in AND still stalled). The
514
+ // 4-byte header is copied by Rust, so the shared scratch is safe.
515
+ return submitSlice(
516
+ TASK_OP.gzipCompress,
517
+ encodeGzipCompressHeader(runOptions?.level ?? 6, scratch(4)),
518
+ data,
519
+ runOptions,
520
+ )
521
+ },
522
+ brotliDecompress(data, runOptions) {
523
+ const args = encodeBrotliDecompressArgs(data, runOptions?.maxDecompressed)
524
+ const copyPath = (): Promise<Uint8Array> =>
525
+ submit(TASK_OP.brotliDecompress, args, runOptions)
526
+ // Brotli has no ISIZE trailer, so there is no exact size hint: start from
527
+ // a ratio guess and let the needed-size retry land it exactly (at most
528
+ // one extra pass; on anything but TOO_SMALL, fall back to the copy op).
529
+ const zeroCopyOnce = (cap: number): Promise<Uint8Array> =>
530
+ submitInto(TASK_OP.brotliDecompressInto, args, Buffer.allocUnsafe(cap), runOptions)
531
+ return zeroCopyOnce(brotliCapacityGuess(data.length))
532
+ .catch((err: unknown) => (isTooSmall(err) ? zeroCopyOnce(err.needed) : copyPath()))
533
+ .catch(() => copyPath())
534
+ },
535
+ async argon2Verify(password, phc, runOptions) {
536
+ const out = await submit(
537
+ TASK_OP.argon2Verify,
538
+ encodeArgon2VerifyArgs(password, phc),
539
+ runOptions,
540
+ )
541
+ // The whole point of offloading argon2: a one-bit answer for tens of ms
542
+ // of CPU. A malformed PHC string is a `false`, never an error.
543
+ return out[0] === 1
544
+ },
545
+ stats: () => ({
546
+ threads: ffi.taskThreads(),
547
+ pending: ffi.taskPending(),
548
+ inflight: inflight.size,
549
+ completed,
550
+ drains,
551
+ maxBatch,
552
+ tooSmallRetries,
553
+ }),
554
+ shutdown() {
555
+ for (const [id, deferred] of inflight) {
556
+ ffi.taskCancel(id)
557
+ deferred.reject(cancelledError())
558
+ }
559
+ inflight.clear()
560
+ ffi.taskShutdown()
561
+ release()
562
+ doorbell?.close()
563
+ doorbell = null
564
+ },
565
+ }
566
+ shared = runtime
567
+ return runtime
568
+ }