castrum 0.9.8 → 0.9.9

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 (55) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +164 -4
  12. package/dist/index.js +434 -70
  13. package/docs/ENVIRONMENT.md +7 -0
  14. package/docs/RND-CONCURRENCY.md +20 -4
  15. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  16. package/index.ts +5 -0
  17. package/package.json +2 -1
  18. package/rust/ffi/task.rs +19 -18
  19. package/rust/ingress/api.rs +21 -5
  20. package/rust/ingress/mod.rs +7 -0
  21. package/rust/ingress/native_route.rs +4 -2
  22. package/rust/ingress/schema_cache.rs +266 -0
  23. package/rust/task/mod.rs +1 -0
  24. package/rust/task/runtime.rs +55 -7
  25. package/rust/task/tests.rs +98 -1
  26. package/src/ingress/abort.ts +48 -0
  27. package/src/ingress/body.ts +104 -8
  28. package/src/ingress/handlers.ts +1 -0
  29. package/src/ingress/health.ts +22 -0
  30. package/src/ingress/index.ts +3 -1
  31. package/src/ingress/router.ts +36 -1
  32. package/src/ingress/routes/common.ts +9 -1
  33. package/src/ingress/routes/echo.ts +10 -0
  34. package/src/ingress/routes/json-write.ts +11 -0
  35. package/src/ingress/routes/native.ts +24 -5
  36. package/src/ingress/routes/options.ts +6 -2
  37. package/src/ingress/routes/responder.ts +25 -5
  38. package/src/ingress/routes/safe-record.ts +57 -0
  39. package/src/ingress/server-error.ts +111 -0
  40. package/src/ingress/server-node.ts +102 -23
  41. package/src/ingress/server.ts +140 -32
  42. package/src/integration/pipeline.ts +6 -0
  43. package/src/native/ffi/build/task.ts +2 -1
  44. package/src/native/ffi/types.ts +6 -1
  45. package/src/native/types.ts +6 -0
  46. package/src/runtime/detect.ts +14 -0
  47. package/src/rust-ffi/context.ts +46 -3
  48. package/src/rust-ffi/scalar/factories.ts +6 -0
  49. package/src/rust-ffi/scalar/interface.ts +14 -0
  50. package/src/rust-ffi/text.ts +5 -4
  51. package/src/shared/buffer-pool.ts +10 -0
  52. package/src/shared/memory.ts +68 -0
  53. package/src/shared/metrics.ts +86 -3
  54. package/src/shared/runtime.ts +7 -0
  55. package/src/task/runtime.ts +52 -9
@@ -344,6 +344,12 @@ export interface NativeAddon {
344
344
 
345
345
  initThreadPool(rayonThreads?: number): void
346
346
  rayonNumThreads(): number
347
+ /**
348
+ * Drop the process-wide compiled request-body schema cache (identical schema
349
+ * bytes are compiled once and shared across ingress instances/routes).
350
+ * Maintenance hook for the public `flushMemory()`; idempotent.
351
+ */
352
+ clearSchemaCache(): void
347
353
 
348
354
  jsonValidBatchPacked(input: Uint8Array): Uint8Array
349
355
  validateEmailBatchPacked(input: Uint8Array): Uint8Array
@@ -27,6 +27,20 @@ export function isNodeRuntime(): boolean {
27
27
  return detectedRuntime === 'node'
28
28
  }
29
29
 
30
+ /**
31
+ * Request a synchronous full garbage collection when the host runtime exposes
32
+ * one. On Bun this is `Bun.gc(true)`; on Node it is `globalThis.gc?.()`, which
33
+ * is only defined when the process runs with `--expose-gc`. A no-op otherwise.
34
+ */
35
+ export function forceGc(): void {
36
+ if (detectedRuntime === 'bun') {
37
+ Bun.gc(true)
38
+ return
39
+ }
40
+ // Node only defines `globalThis.gc` under `--expose-gc`.
41
+ ;(globalThis as { gc?: () => void }).gc?.()
42
+ }
43
+
30
44
  /** The Node.js major version when running under Node, otherwise `null`. */
31
45
  export function nodeMajorVersion(): number | null {
32
46
  if (detectedRuntime !== 'node') return null
@@ -21,6 +21,51 @@ import { normalizeExt, resolveRayonThreads } from './options'
21
21
  // batch.ts uses for its packed entries.
22
22
  const nativeFnCache = new Map<string, unknown>()
23
23
 
24
+ /** Max entries retained per process-wide MIME cache (insertion-order eviction). */
25
+ const MIME_CACHE_MAX = 1024
26
+
27
+ /**
28
+ * Process-wide extension-bytes → MIME-bytes cache shared by every client
29
+ * context: the native addon is a process singleton, so `extension → MIME` is a
30
+ * global pure lookup. Bounded by `MIME_CACHE_MAX`; on overflow the oldest half
31
+ * is evicted via {@link evictOldestHalf}. Exported for the memory-flush path
32
+ * and tests — callers must NOT mutate the returned slices.
33
+ */
34
+ export const mimeByText = new Map<string, Uint8Array>()
35
+
36
+ /**
37
+ * Process-wide extension-string → MIME-string memo (the `text.mimeFromExtension`
38
+ * fast path). Bounded exactly like {@link mimeByText}.
39
+ */
40
+ export const mimeStrCache = new Map<string, string>()
41
+
42
+ /**
43
+ * Evict the oldest half of a cache map (insertion order) and return the keys
44
+ * dropped. Generational eviction — halving at the cap amortizes the cost rather
45
+ * than paying a delete per insertion. Returns an empty array below the cap.
46
+ */
47
+ export function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
48
+ if (map.size < MIME_CACHE_MAX) return []
49
+ const drop = Math.ceil(map.size / 2)
50
+ const dropped: K[] = []
51
+ for (const key of map.keys()) {
52
+ if (dropped.length >= drop) break
53
+ dropped.push(key)
54
+ }
55
+ for (const key of dropped) map.delete(key)
56
+ return dropped
57
+ }
58
+
59
+ /**
60
+ * Clear the process-wide MIME caches (both the bytes and string forms). This is
61
+ * the impure-boundary hook consumed by the public `flushMemory()`; safe to call
62
+ * at any time, including before the addon has ever been loaded.
63
+ */
64
+ export function clearMimeCaches(): void {
65
+ mimeByText.clear()
66
+ mimeStrCache.clear()
67
+ }
68
+
24
69
  /**
25
70
  * Resolve a native op on first use and cache it for all subsequent calls —
26
71
  * FFI-FIRST via the runtime adapter's transport (`bun:ffi` on Bun, napi
@@ -91,7 +136,6 @@ export interface RustClientContext {
91
136
  runtime: NativeRuntimeAdapter
92
137
  /** Per-instance toggles (mime/hmac caching). */
93
138
  state: { mimeCache: boolean; hmacCache: boolean }
94
- mimeByText: Map<string, Uint8Array>
95
139
  hmacSigners: WeakMap<Uint8Array, HmacSignerInstance>
96
140
 
97
141
  /** Initialize the process-wide rayon pool exactly once (no-op after). */
@@ -110,7 +154,6 @@ export interface RustClientContext {
110
154
 
111
155
  /** Build a per-instance context from client options. */
112
156
  export function createContext(options: RustOptions): RustClientContext {
113
- const mimeByText = new Map<string, Uint8Array>()
114
157
  const hmacSigners = new WeakMap<Uint8Array, HmacSignerInstance>()
115
158
  const state = {
116
159
  mimeCache: options.mimeCache !== false,
@@ -152,6 +195,7 @@ export function createContext(options: RustOptions): RustClientContext {
152
195
 
153
196
  let val = mimeByText.get(key)
154
197
  if (!val) {
198
+ evictOldestHalf(mimeByText)
155
199
  val = addon.mimeFromExtension(ext)
156
200
  mimeByText.set(key, val)
157
201
  }
@@ -181,7 +225,6 @@ export function createContext(options: RustOptions): RustClientContext {
181
225
  addon,
182
226
  runtime: runtimeNative,
183
227
  state,
184
- mimeByText,
185
228
  hmacSigners,
186
229
  ensurePool,
187
230
  isPoolInitialized,
@@ -559,5 +559,11 @@ export function buildFactories(ctx: RustClientContext) {
559
559
  rayonNumThreads(): number {
560
560
  return asNumber(addon.rayonNumThreads() as unknown)
561
561
  },
562
+ clearSchemaCache(): void {
563
+ // Defensive: a stale addon built before this symbol, or a failed bind,
564
+ // must make this a no-op rather than a throw (flushMemory calls it).
565
+ const native = addon as unknown as { clearSchemaCache?: () => void }
566
+ native.clearSchemaCache?.()
567
+ },
562
568
  }
563
569
  }
@@ -237,6 +237,20 @@ export interface RustScalar {
237
237
  createMetricsRegistry(): MetricsRegistryInstance
238
238
  initThreadPool(rayonThreads?: number): void
239
239
  rayonNumThreads(): number
240
+ /**
241
+ * Drop the process-wide compiled request-body schema cache. Identical schema
242
+ * bytes are compiled once and shared across `Ingress` / `NativeRoute`
243
+ * instances; this releases those compiled validators (already-constructed
244
+ * instances keep their own shared reference and are unaffected). Idempotent,
245
+ * and a safe no-op when the native addon is absent or predates the symbol
246
+ * (the public `flushMemory()` relies on that).
247
+ *
248
+ * Maintenance-only escape hatch — NOT a benchmarked op, so it is intentionally
249
+ * exempt from `PROVEN_SELECTION` / `selection.json` (there is no native-vs-JS
250
+ * winner to classify). `test/unit/contract/proven.test.ts` only requires
251
+ * registry coverage for selection.json ops + Bun built-ins.
252
+ */
253
+ clearSchemaCache(): void
240
254
 
241
255
  // ── Backend-framework scalar features ──
242
256
  jwtSign(
@@ -14,7 +14,7 @@
14
14
  // napi fallback) — no inline `isBun()` / `getBunFFI()`.
15
15
 
16
16
  import { decoder, encoder, hasNul } from '../shared/bytes'
17
- import { type RustClientContext, resolveNative } from './context'
17
+ import { evictOldestHalf, mimeStrCache, type RustClientContext, resolveNative } from './context'
18
18
 
19
19
  /**
20
20
  * Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
@@ -67,9 +67,9 @@ export function buildText(ctx: RustClientContext): RustText {
67
67
  const { builtins, transport } = ctx.runtime
68
68
  // String memo: the byte path already caches the native bytes; this avoids the
69
69
  // encode → native lookup → slice → decode round-trip for repeated extension
70
- // strings (the common case) — 0 allocations on a cache hit.
71
- const mimeStrCache = new Map<string, string>()
72
-
70
+ // strings (the common case) — 0 allocations on a cache hit. The map is
71
+ // process-wide and bounded by `evictOldestHalf` (MIME_CACHE_MAX) — see
72
+ // ./context.ts.
73
73
  return {
74
74
  mimeFromExtension(ext) {
75
75
  let mime = mimeStrCache.get(ext)
@@ -81,6 +81,7 @@ export function buildText(ctx: RustClientContext): RustText {
81
81
  mime = f
82
82
  ? (f.mimeFromExtension(ext) ?? 'application/octet-stream')
83
83
  : decoder.decode(ctx.cachedMime(encoder.encode(ext)))
84
+ evictOldestHalf(mimeStrCache)
84
85
  mimeStrCache.set(ext, mime)
85
86
  }
86
87
  return mime
@@ -181,6 +181,16 @@ export class BufferPool {
181
181
  return this.created
182
182
  }
183
183
 
184
+ /**
185
+ * Drop every retained free buffer. In-flight (borrowed) buffers are
186
+ * unaffected and return to an empty pool on `release()`, so the next
187
+ * `acquire()` allocates (or grows) as needed. Used by an explicit memory
188
+ * flush; the pool stays fully functional afterwards.
189
+ */
190
+ shrink(): void {
191
+ this.free.length = 0
192
+ }
193
+
184
194
  /**
185
195
  * Acquire a buffer of at least `minSize` bytes. The returned handle must be
186
196
  * released (directly, or via a zero-copy Response) once the caller is done.
@@ -0,0 +1,68 @@
1
+ // src/shared/memory.ts — Public memory-flush escape hatch.
2
+ //
3
+ // Impure boundary module: it owns no state of its own but reaches the cache
4
+ // owners (the global loader LRU and the process-wide MIME caches) to drop their
5
+ // retained entries, then optionally requests a runtime GC. The metrics registry
6
+ // is deliberately NOT flushed — an observability registry is not a cache, and
7
+ // clearing it would erase the counters an operator is watching.
8
+
9
+ import { loader } from '../loader'
10
+ import { rust } from '../rust-ffi'
11
+ import { clearMimeCaches } from '../rust-ffi/context'
12
+ import { forceGc } from './runtime'
13
+
14
+ /** Options for {@link flushMemory}. */
15
+ export interface FlushMemoryOptions {
16
+ /**
17
+ * When true (the default), also request a full garbage collection from the
18
+ * runtime: `Bun.gc(true)` on Bun, `globalThis.gc?.()` on Node when the
19
+ * process runs with `--expose-gc`. Set false to flush caches only.
20
+ */
21
+ gc?: boolean
22
+ }
23
+
24
+ /**
25
+ * Flush process-level caches and (by default) request a full GC. Intended for
26
+ * explicit memory-pressure handling — e.g. after a burst or before a snapshot.
27
+ *
28
+ * Clears:
29
+ * - the global loader LRU cache (`loader.clear()`),
30
+ * - the process-wide MIME caches (`clearMimeCaches()`),
31
+ * - the native compiled-schema cache (`rust.clearSchemaCache()`).
32
+ *
33
+ * The metrics registry is intentionally left untouched.
34
+ *
35
+ * Idempotent, and safe to call before the native addon has ever been loaded
36
+ * (every cache starts empty, the loader cache is a plain Map, and the native
37
+ * schema-cache clear is a defensive no-op when the addon/symbol is absent).
38
+ *
39
+ * @param options - `gc: false` skips the runtime GC request. Defaults to `{}`
40
+ * (GC requested).
41
+ * @example
42
+ * ```ts
43
+ * import { flushMemory } from 'castrum'
44
+ *
45
+ * flushMemory() // drop caches + request GC
46
+ * flushMemory({ gc: false }) // drop caches only
47
+ * ```
48
+ */
49
+ export function flushMemory(options: FlushMemoryOptions = {}): void {
50
+ // Global loader LRU (createLoader() instances own their own caches and are
51
+ // not registered globally — flush the shared singleton only).
52
+ loader.clear()
53
+
54
+ // Process-wide MIME caches (bytes + string forms).
55
+ clearMimeCaches()
56
+
57
+ // Process-wide native compiled-schema cache. Defensive: an absent or stale
58
+ // addon (no `clearSchemaCache` symbol) must be a no-op, never a throw.
59
+ try {
60
+ rust.clearSchemaCache()
61
+ } catch {
62
+ // addon not loaded / stale build — nothing to clear.
63
+ }
64
+
65
+ if (options.gc !== false) {
66
+ forceGc()
67
+ }
68
+ }
@@ -12,6 +12,27 @@ export const DEFAULT_BUCKETS: readonly number[] = [
12
12
  0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
13
13
  ]
14
14
 
15
+ /**
16
+ * Per-family cap on the number of distinct label sets (series) retained by a
17
+ * single counter/gauge/histogram. A metrics registry is keyed by caller-supplied
18
+ * label values, so an unbounded label (e.g. a raw path or user id) would grow a
19
+ * `Map` without limit. At the cap the oldest half of that family is evicted
20
+ * (insertion order) and the count is accumulated into the synthetic counter
21
+ * `castrum_metrics_series_dropped_total`, making the loss observable instead of
22
+ * silent. That name is RESERVED — a caller declaring a counter/gauge/histogram
23
+ * with it suppresses the synthetic emission (see {@link SERIES_DROPPED_METRIC}).
24
+ */
25
+ export const MAX_SERIES_PER_METRIC = 10_000
26
+
27
+ /**
28
+ * Reserved exposition name of the synthetic counter reporting series evicted by
29
+ * {@link MAX_SERIES_PER_METRIC}. Callers must not register a metric family with
30
+ * this name; if one exists, `render()` skips the synthetic emission so the
31
+ * scrape never carries two `# HELP`/`# TYPE` lines for one family (Prometheus
32
+ * rejects that).
33
+ */
34
+ const SERIES_DROPPED_METRIC = 'castrum_metrics_series_dropped_total'
35
+
15
36
  type LabelValues = readonly string[]
16
37
  type Labels = Readonly<Record<string, string>>
17
38
 
@@ -29,6 +50,24 @@ function escapeLabel(v: string): string {
29
50
  return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
30
51
  }
31
52
 
53
+ /**
54
+ * Once a series map is at {@link MAX_SERIES_PER_METRIC}, evict its oldest half
55
+ * (insertion order) and return the keys dropped; returns `[]` below the cap.
56
+ * Mirrors Elysia's `evictOldestHalf` generational policy: halving at the cap
57
+ * amortizes the eviction cost instead of paying a delete per insertion.
58
+ */
59
+ function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
60
+ if (map.size < MAX_SERIES_PER_METRIC) return []
61
+ const drop = Math.ceil(map.size / 2)
62
+ const dropped: K[] = []
63
+ for (const key of map.keys()) {
64
+ if (dropped.length >= drop) break
65
+ dropped.push(key)
66
+ }
67
+ for (const key of dropped) map.delete(key)
68
+ return dropped
69
+ }
70
+
32
71
  /** A single labelled counter value. */
33
72
  export interface Counter {
34
73
  /** Increment by 1 (or `by`). `labels` must match the label names exactly. */
@@ -97,6 +136,8 @@ export function createMetrics(): MetricsRegistry {
97
136
  const counters = new Map<string, CounterEntry>()
98
137
  const gauges = new Map<string, GaugeEntry>()
99
138
  const histograms = new Map<string, HistogramEntry>()
139
+ /** Series evicted by the per-family cardinality cap, rendered as a counter. */
140
+ let seriesDropped = 0
100
141
 
101
142
  const bucketUpper = (buckets: number[]): number[] => [
102
143
  ...buckets.map((b) => b),
@@ -144,6 +185,22 @@ export function createMetrics(): MetricsRegistry {
144
185
  for (const e of counters.values()) out.push(...renderCounter(e))
145
186
  for (const e of gauges.values()) out.push(...renderGauge(e))
146
187
  for (const e of histograms.values()) out.push(...renderHistogram(e))
188
+ // Only emitted once a family has actually overflowed, so the output for a
189
+ // well-behaved registry is byte-identical to before the cap existed. Guard
190
+ // against a caller-registered family of the same (reserved) name — two
191
+ // `# HELP`/`# TYPE` lines for one family make the scrape invalid.
192
+ if (
193
+ seriesDropped > 0 &&
194
+ !counters.has(SERIES_DROPPED_METRIC) &&
195
+ !gauges.has(SERIES_DROPPED_METRIC) &&
196
+ !histograms.has(SERIES_DROPPED_METRIC)
197
+ ) {
198
+ out.push(
199
+ `# HELP ${SERIES_DROPPED_METRIC} Total metric series evicted by the per-family cardinality cap.`,
200
+ )
201
+ out.push(`# TYPE ${SERIES_DROPPED_METRIC} counter`)
202
+ out.push(`${SERIES_DROPPED_METRIC} ${seriesDropped}`)
203
+ }
147
204
  return `${out.join('\n')}\n`
148
205
  }
149
206
 
@@ -151,6 +208,7 @@ export function createMetrics(): MetricsRegistry {
151
208
  counters.clear()
152
209
  gauges.clear()
153
210
  histograms.clear()
211
+ seriesDropped = 0
154
212
  }
155
213
 
156
214
  return {
@@ -172,7 +230,14 @@ export function createMetrics(): MetricsRegistry {
172
230
  names,
173
231
  names.map((n) => labels?.[n] ?? ''),
174
232
  )
175
- entry.values.set(key, (entry.values.get(key) ?? 0) + by)
233
+ const prev = entry.values.get(key)
234
+ if (prev === undefined) {
235
+ // New series: make room first (bounded family) and count evictions.
236
+ seriesDropped += evictOldestHalf(entry.values).length
237
+ entry.values.set(key, by)
238
+ } else {
239
+ entry.values.set(key, prev + by)
240
+ }
176
241
  },
177
242
  }
178
243
  },
@@ -192,14 +257,27 @@ export function createMetrics(): MetricsRegistry {
192
257
  names,
193
258
  names.map((n) => labels?.[n] ?? ''),
194
259
  )
260
+ const add = (labels: Labels | undefined, delta: number): void => {
261
+ const key = keyOf(labels)
262
+ const prev = entry.values.get(key)
263
+ if (prev === undefined) {
264
+ seriesDropped += evictOldestHalf(entry.values).length
265
+ entry.values.set(key, delta)
266
+ } else {
267
+ entry.values.set(key, prev + delta)
268
+ }
269
+ }
195
270
  return {
196
271
  inc(labels, by = 1) {
197
- entry.values.set(keyOf(labels), (entry.values.get(keyOf(labels)) ?? 0) + by)
272
+ add(labels, by)
198
273
  },
199
274
  dec(labels, by = 1) {
200
- entry.values.set(keyOf(labels), (entry.values.get(keyOf(labels)) ?? 0) - by)
275
+ add(labels, -by)
201
276
  },
202
277
  set(labels, value) {
278
+ if (!entry.values.has(keyOf(labels))) {
279
+ seriesDropped += evictOldestHalf(entry.values).length
280
+ }
203
281
  entry.values.set(keyOf(labels), value)
204
282
  },
205
283
  }
@@ -225,6 +303,11 @@ export function createMetrics(): MetricsRegistry {
225
303
  )
226
304
  let counts = entry.counts.get(key)
227
305
  if (!counts) {
306
+ // New series: bound the family and drop the matching sum entries
307
+ // (counts and sums always share the same key set).
308
+ const dropped = evictOldestHalf(entry.counts)
309
+ for (const k of dropped) entry.sums.delete(k)
310
+ seriesDropped += dropped.length
228
311
  // One slot per bucket PLUS the implicit +Inf slot.
229
312
  counts = new Array<number>(entry.buckets.length + 1).fill(0)
230
313
  entry.counts.set(key, counts)
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  detectedRuntime,
10
+ forceGc as forceGcDetected,
10
11
  isBunRuntime,
11
12
  isNodeRuntime,
12
13
  nodeMajorVersion as nodeMajorVersionDetected,
@@ -27,3 +28,9 @@ export const runtimeName: () => RuntimeName = () => detectedRuntime
27
28
 
28
29
  /** The Node.js major version when running under Node, otherwise `null`. */
29
30
  export const nodeMajorVersion: () => number | null = nodeMajorVersionDetected
31
+
32
+ /**
33
+ * Request a synchronous full GC when the runtime exposes one (`Bun.gc(true)` on
34
+ * Bun, `globalThis.gc?.()` under Node `--expose-gc`). A no-op otherwise.
35
+ */
36
+ export const forceGc: () => void = forceGcDetected
@@ -152,6 +152,37 @@ function cancelledError(): Error {
152
152
  return err
153
153
  }
154
154
 
155
+ /** Native `taskSubmit*` return: the task was accepted onto the pool. */
156
+ const TASK_SUBMIT_ACCEPTED = 1
157
+ /** Native `taskSubmit*` return: the bounded work queue is full (overload). */
158
+ const TASK_SUBMIT_OVERLOADED = 2
159
+
160
+ /** Error raised when the bounded native task queue is at its admission bound. */
161
+ interface TaskOverloadedError extends Error {
162
+ /** Stable discriminant so callers can branch on overload. */
163
+ code: 'OVERLOADED'
164
+ }
165
+
166
+ /**
167
+ * Map a native `taskSubmit*` return code to the reason a task promise should
168
+ * reject with, or `null` when the submission was accepted. Pure and exported
169
+ * for tests, so the rejection mapping can be checked without driving the native
170
+ * pool into a real overload.
171
+ *
172
+ * @param code Native submit return: `1` accepted, `2` overloaded, else rejected.
173
+ * @returns The typed overload error, the generic rejection, or `null`.
174
+ */
175
+ export function taskSubmitError(code: number): Error | null {
176
+ if (code === TASK_SUBMIT_ACCEPTED) return null
177
+ if (code === TASK_SUBMIT_OVERLOADED) {
178
+ const err = new Error('task rejected: native work queue is full') as TaskOverloadedError
179
+ err.name = 'TaskOverloadedError'
180
+ err.code = 'OVERLOADED'
181
+ return err
182
+ }
183
+ return new Error('task rejected by the native runtime')
184
+ }
185
+
155
186
  /** Bunny-shaped doorbell handle (structural, so it needs no bun-types dep). */
156
187
  interface Doorbell {
157
188
  ptr: number | null
@@ -361,10 +392,14 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
361
392
  return new Promise<Uint8Array>((resolve, reject) => {
362
393
  inflight.set(id, { out: null, keep: null, resolve, reject })
363
394
  retain()
364
- if (ffi.taskSubmit(op, args, id) === 0) {
395
+ const rejected = taskSubmitError(ffi.taskSubmit(op, args, id))
396
+ if (rejected) {
365
397
  inflight.delete(id)
366
- release()
367
- reject(new Error('task rejected by the native runtime'))
398
+ // Release the keep-alive only when this was the last in-flight task.
399
+ // An overload/invalid rejection must not tear down the interval (and
400
+ // its polling safety net) while other tasks are still pending.
401
+ if (inflight.size === 0) release()
402
+ reject(rejected)
368
403
  return
369
404
  }
370
405
  const signal = runOptions?.signal
@@ -394,10 +429,14 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
394
429
  return new Promise<Uint8Array>((resolve, reject) => {
395
430
  inflight.set(id, { out: output, keep: null, resolve, reject })
396
431
  retain()
397
- if (ffi.taskSubmitOut(op, args, id, output) === 0) {
432
+ const rejected = taskSubmitError(ffi.taskSubmitOut(op, args, id, output))
433
+ if (rejected) {
398
434
  inflight.delete(id)
399
- release()
400
- reject(new Error('task rejected by the native runtime'))
435
+ // Release the keep-alive only when this was the last in-flight task.
436
+ // An overload/invalid rejection must not tear down the interval (and
437
+ // its polling safety net) while other tasks are still pending.
438
+ if (inflight.size === 0) release()
439
+ reject(rejected)
401
440
  return
402
441
  }
403
442
  const signal = runOptions?.signal
@@ -428,10 +467,14 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
428
467
  return new Promise<Uint8Array>((resolve, reject) => {
429
468
  inflight.set(id, { out: null, keep: data, resolve, reject })
430
469
  retain()
431
- if (ffi.taskSubmitSlice(op, hdr, data, id) === 0) {
470
+ const rejected = taskSubmitError(ffi.taskSubmitSlice(op, hdr, data, id))
471
+ if (rejected) {
432
472
  inflight.delete(id)
433
- release()
434
- reject(new Error('task rejected by the native runtime'))
473
+ // Release the keep-alive only when this was the last in-flight task.
474
+ // An overload/invalid rejection must not tear down the interval (and
475
+ // its polling safety net) while other tasks are still pending.
476
+ if (inflight.size === 0) release()
477
+ reject(rejected)
435
478
  return
436
479
  }
437
480
  const signal = runOptions?.signal