castrum 0.9.8 → 0.9.10
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/Cargo.lock +11 -1
- package/Cargo.toml +17 -3
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -4
- package/dist/index.js +434 -70
- package/docs/ENVIRONMENT.md +7 -0
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/bun-builtins-decision-matrix.md +17 -7
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- package/rust/crypto/base64.rs +472 -57
- package/rust/crypto/hashing.rs +68 -0
- package/rust/ffi/task.rs +19 -18
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +4 -2
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/payload/sse.rs +78 -5
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/rust/util/text.rs +112 -10
- package/rust/util/validation.rs +34 -0
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/handlers.ts +1 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/router.ts +36 -1
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/echo.ts +10 -0
- package/src/ingress/routes/json-write.ts +11 -0
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +25 -5
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +102 -23
- package/src/ingress/server.ts +140 -32
- package/src/integration/pipeline.ts +6 -0
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/types.ts +6 -1
- package/src/native/types.ts +6 -0
- package/src/runtime/detect.ts +14 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/scalar/factories.ts +6 -0
- package/src/rust-ffi/scalar/interface.ts +14 -0
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +10 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +52 -9
package/src/rust-ffi/text.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/shared/metrics.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
272
|
+
add(labels, by)
|
|
198
273
|
},
|
|
199
274
|
dec(labels, by = 1) {
|
|
200
|
-
|
|
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)
|
package/src/shared/runtime.ts
CHANGED
|
@@ -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
|
package/src/task/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
395
|
+
const rejected = taskSubmitError(ffi.taskSubmit(op, args, id))
|
|
396
|
+
if (rejected) {
|
|
365
397
|
inflight.delete(id)
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
432
|
+
const rejected = taskSubmitError(ffi.taskSubmitOut(op, args, id, output))
|
|
433
|
+
if (rejected) {
|
|
398
434
|
inflight.delete(id)
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
470
|
+
const rejected = taskSubmitError(ffi.taskSubmitSlice(op, hdr, data, id))
|
|
471
|
+
if (rejected) {
|
|
432
472
|
inflight.delete(id)
|
|
433
|
-
|
|
434
|
-
|
|
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
|