@danypops/vehicle-core 0.18.2 → 0.18.4

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.
@@ -0,0 +1,427 @@
1
+ import { ResourceAdmissionQueueFull, ResourceAdmissionQueueTimedOut, ResourceCapacityExceeded, ResourceInUse } from "./errors.js";
2
+ export { ResourceAdmissionQueueFull, ResourceAdmissionQueueTimedOut, ResourceCapacityExceeded, ResourceInUse };
3
+ const DEFAULT_MAX_ACTIVE = 3;
4
+ /** A genuine structural ceiling on resource count -- independent of memory, protecting against pathological OS-level exhaustion (file descriptors, threads, scheduler overhead) that no amount of available headroom makes safe to exceed. maxActive itself can never be raised past this by a resource policy's own soft ceiling. */
5
+ const DEFAULT_ABSOLUTE_MAX_ACTIVE = 32;
6
+ const DEFAULT_BACKGROUND_ADMISSION_QUEUE_TIMEOUT_MS = 10_000;
7
+ const DEFAULT_MAX_QUEUED_BACKGROUND_ADMISSIONS = 8;
8
+ /**
9
+ * Internal signal only: admit() throws this to tell acquire() "release the serialized lock and
10
+ * wait outside it" -- never surfaced to a caller. Waiting for a background admission's turn can
11
+ * legitimately take seconds; holding admissionTail (the single global admission mutex) for that
12
+ * whole span would block every OTHER admission request, including foreground's, which is exactly
13
+ * the starvation this exists to prevent.
14
+ */
15
+ class NeedsBackgroundAdmissionWait extends Error {
16
+ }
17
+ /**
18
+ * Owns the bounded lifecycle of pooled, expensive, stateful resources partitioned by an
19
+ * (ownerKey, partitionKey) pair -- e.g. one warm language-server process per (workspace,
20
+ * language), one warm browser context per (tenant, profile). Never knows how to build a resource:
21
+ * `acquire()` takes a lazy factory invoked only on an actual cache miss, so every caller-specific
22
+ * concept (what a resource even is, how to construct one) lives entirely in the caller's own
23
+ * closure at each call site.
24
+ */
25
+ export class BoundedResourcePool {
26
+ options;
27
+ entries = new Map();
28
+ now;
29
+ maxActive;
30
+ absoluteMaxActive;
31
+ lastActiveCeilingSource = "configured";
32
+ lastEffectiveMaxActive;
33
+ partitionLimits;
34
+ admissionTail = Promise.resolve();
35
+ nextSequence = 0;
36
+ reservedForegroundSlots;
37
+ backgroundAdmissionQueueTimeoutMs;
38
+ maxQueuedBackgroundAdmissions;
39
+ admissionWaiters = new Set();
40
+ queuedBackgroundAdmissions = 0;
41
+ waitingCounts = new Map();
42
+ constructor(options = {}) {
43
+ this.options = options;
44
+ this.now = options.now ?? Date.now;
45
+ this.maxActive = options.maxActive ?? DEFAULT_MAX_ACTIVE;
46
+ this.partitionLimits = options.partitionLimits ?? {};
47
+ if (!Number.isSafeInteger(this.maxActive) || this.maxActive < 1)
48
+ throw new TypeError("maxActive must be a positive safe integer");
49
+ this.absoluteMaxActive = options.absoluteMaxActive ?? Math.max(DEFAULT_ABSOLUTE_MAX_ACTIVE, this.maxActive);
50
+ if (!Number.isSafeInteger(this.absoluteMaxActive) || this.absoluteMaxActive < this.maxActive) {
51
+ throw new TypeError("absoluteMaxActive must be a safe integer no smaller than maxActive");
52
+ }
53
+ this.lastEffectiveMaxActive = this.maxActive;
54
+ for (const [partitionKey, limit] of Object.entries(this.partitionLimits)) {
55
+ if (!partitionKey || !Number.isSafeInteger(limit) || limit < 1)
56
+ throw new TypeError("partition limits must be positive safe integers keyed by partition key");
57
+ }
58
+ this.reservedForegroundSlots = options.reservedForegroundSlots ?? 0;
59
+ if (!Number.isSafeInteger(this.reservedForegroundSlots) || this.reservedForegroundSlots < 0) {
60
+ throw new TypeError("reservedForegroundSlots must be a non-negative safe integer");
61
+ }
62
+ this.backgroundAdmissionQueueTimeoutMs = options.backgroundAdmissionQueueTimeoutMs ?? DEFAULT_BACKGROUND_ADMISSION_QUEUE_TIMEOUT_MS;
63
+ if (!Number.isSafeInteger(this.backgroundAdmissionQueueTimeoutMs) || this.backgroundAdmissionQueueTimeoutMs < 0) {
64
+ throw new TypeError("backgroundAdmissionQueueTimeoutMs must be a non-negative safe integer");
65
+ }
66
+ this.maxQueuedBackgroundAdmissions = options.maxQueuedBackgroundAdmissions ?? DEFAULT_MAX_QUEUED_BACKGROUND_ADMISSIONS;
67
+ if (!Number.isSafeInteger(this.maxQueuedBackgroundAdmissions) || this.maxQueuedBackgroundAdmissions < 1) {
68
+ throw new TypeError("maxQueuedBackgroundAdmissions must be a positive safe integer");
69
+ }
70
+ }
71
+ key(ownerKey, partitionKey) {
72
+ return `${ownerKey}:${partitionKey}`;
73
+ }
74
+ partitionLimit(partitionKey) {
75
+ return this.partitionLimits[partitionKey] ?? this.maxActive;
76
+ }
77
+ countPartition(partitionKey) {
78
+ let count = 0;
79
+ for (const entry of this.entries.values())
80
+ if (entry.partitionKey === partitionKey)
81
+ count++;
82
+ return count;
83
+ }
84
+ activePartitions() {
85
+ return Array.from(this.entries.values(), (entry) => entry.partitionKey);
86
+ }
87
+ leastRecentlyUsedIdle(partitionKey) {
88
+ let selected;
89
+ for (const candidate of this.entries) {
90
+ const entry = candidate[1];
91
+ if (entry.activeLeases > 0 || (partitionKey !== undefined && entry.partitionKey !== partitionKey))
92
+ continue;
93
+ const current = selected?.[1];
94
+ if (!current ||
95
+ entry.lastUsedAt < current.lastUsedAt ||
96
+ (entry.lastUsedAt === current.lastUsedAt && entry.recencySequence < current.recencySequence))
97
+ selected = candidate;
98
+ }
99
+ return selected;
100
+ }
101
+ async evict(entry, reason = "admission") {
102
+ try {
103
+ await entry[1].resource.close();
104
+ }
105
+ catch (error) {
106
+ this.options.observe?.({
107
+ kind: "close-failed",
108
+ reason,
109
+ partitionKey: entry[1].partitionKey,
110
+ errorName: error instanceof Error ? error.name : "UnknownError",
111
+ });
112
+ throw error;
113
+ }
114
+ this.entries.delete(entry[0]);
115
+ const kind = reason === "admission" ? "admission-evicted" : reason === "dead-replacement" ? "dead-replaced" : "resource-pressure-evicted";
116
+ this.options.observe?.({ kind, partitionKey: entry[1].partitionKey });
117
+ this.notifyAdmissionWaiters();
118
+ }
119
+ /** Wakes every queued background admission to re-check the real state -- called whenever an entry is removed OR a lease completes (an idle candidate an admit() retry might now be able to evict). A false wake just re-checks and re-waits; never a correctness issue, only a wasted retry. */
120
+ notifyAdmissionWaiters() {
121
+ if (this.admissionWaiters.size === 0)
122
+ return;
123
+ const waiters = Array.from(this.admissionWaiters);
124
+ this.admissionWaiters.clear();
125
+ for (const waiter of waiters)
126
+ waiter();
127
+ }
128
+ /** True while at least one background admission for this owner is currently waiting for a reserved-slot conflict to clear. */
129
+ waitingForAdmission(ownerKey) {
130
+ return (this.waitingCounts.get(ownerKey) ?? 0) > 0;
131
+ }
132
+ /**
133
+ * Runs entirely outside the serialized admission lock -- admissionTail is the single global
134
+ * admission mutex, and this wait can legitimately take up to backgroundAdmissionQueueTimeoutMs.
135
+ * Holding that lock for the whole wait would block every other admission request, foreground
136
+ * included, which is the exact starvation this exists to prevent.
137
+ */
138
+ async waitForAdmissionRoom(partitionKey, ownerKey) {
139
+ if (this.queuedBackgroundAdmissions >= this.maxQueuedBackgroundAdmissions) {
140
+ throw new ResourceAdmissionQueueFull(partitionKey, this.maxQueuedBackgroundAdmissions);
141
+ }
142
+ this.queuedBackgroundAdmissions++;
143
+ this.waitingCounts.set(ownerKey, (this.waitingCounts.get(ownerKey) ?? 0) + 1);
144
+ try {
145
+ const gotSignal = await new Promise((resolve) => {
146
+ let settled = false;
147
+ const finish = (ready) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ this.admissionWaiters.delete(onSignal);
153
+ resolve(ready);
154
+ };
155
+ const onSignal = () => finish(true);
156
+ const timer = setTimeout(() => finish(false), this.backgroundAdmissionQueueTimeoutMs);
157
+ this.admissionWaiters.add(onSignal);
158
+ });
159
+ if (!gotSignal)
160
+ throw new ResourceAdmissionQueueTimedOut(partitionKey, this.backgroundAdmissionQueueTimeoutMs);
161
+ }
162
+ finally {
163
+ this.queuedBackgroundAdmissions--;
164
+ const remaining = (this.waitingCounts.get(ownerKey) ?? 1) - 1;
165
+ if (remaining <= 0)
166
+ this.waitingCounts.delete(ownerKey);
167
+ else
168
+ this.waitingCounts.set(ownerKey, remaining);
169
+ }
170
+ }
171
+ async admit(ownerKey, partitionKey, create, workKind) {
172
+ const partitionLimit = this.partitionLimit(partitionKey);
173
+ while (this.countPartition(partitionKey) >= partitionLimit) {
174
+ const victim = this.leastRecentlyUsedIdle(partitionKey);
175
+ if (!victim)
176
+ throw new ResourceCapacityExceeded(partitionKey, this.maxActive, partitionLimit);
177
+ await this.evict(victim);
178
+ }
179
+ const { ceiling: baseCeiling, source: ceilingSource } = this.baseActiveCeiling();
180
+ this.lastEffectiveMaxActive = baseCeiling;
181
+ this.lastActiveCeilingSource = ceilingSource;
182
+ // "Borrowable": background's own effective ceiling is reduced, but only background is ever
183
+ // held to it -- it constrains what background alone can grow the pool into, not a hard
184
+ // set-aside nothing else can reach. Foreground keeps using the full (possibly resource-
185
+ // budget-raised) baseCeiling unchanged.
186
+ const effectiveMaxActive = workKind === "background" ? Math.max(baseCeiling - this.reservedForegroundSlots, 0) : baseCeiling;
187
+ while (this.entries.size >= effectiveMaxActive) {
188
+ const victim = this.leastRecentlyUsedIdle();
189
+ if (victim) {
190
+ await this.evict(victim);
191
+ continue;
192
+ }
193
+ if (workKind === "background")
194
+ throw new NeedsBackgroundAdmissionWait();
195
+ throw new ResourceCapacityExceeded(partitionKey, baseCeiling, partitionLimit);
196
+ }
197
+ while (this.options.resourcePolicy && !this.options.resourcePolicy.canAdmit(this.activePartitions(), partitionKey)) {
198
+ const victim = this.leastRecentlyUsedIdle();
199
+ if (!victim)
200
+ throw new ResourceCapacityExceeded(partitionKey, baseCeiling, partitionLimit);
201
+ await this.evict(victim, "resource-pressure");
202
+ }
203
+ return {
204
+ resource: create(),
205
+ ownerKey,
206
+ partitionKey,
207
+ recencySequence: this.nextSequence++,
208
+ activeLeases: 0,
209
+ lastUsedAt: this.now(),
210
+ };
211
+ }
212
+ async serialized(operation) {
213
+ const previous = this.admissionTail;
214
+ let release = () => { };
215
+ this.admissionTail = new Promise((resolve) => {
216
+ release = resolve;
217
+ });
218
+ await previous;
219
+ try {
220
+ return await operation();
221
+ }
222
+ finally {
223
+ release();
224
+ }
225
+ }
226
+ /** Acquires a lease for (ownerKey, partitionKey), reusing an already-admitted resource if one is warm, or admitting a fresh one via `create()` -- called only on an actual cache miss, never speculatively. workKind defaults to "foreground". */
227
+ async acquire(ownerKey, partitionKey, create, workKind = "foreground") {
228
+ for (;;) {
229
+ try {
230
+ const entry = await this.serialized(async () => {
231
+ const key = this.key(ownerKey, partitionKey);
232
+ let entry = this.entries.get(key);
233
+ if (entry?.resource.isAlive?.() === false) {
234
+ if (entry.activeLeases > 0) {
235
+ throw new ResourceCapacityExceeded(partitionKey, this.maxActive, this.partitionLimit(partitionKey));
236
+ }
237
+ await this.evict([key, entry], "dead-replacement");
238
+ entry = undefined;
239
+ }
240
+ if (!entry) {
241
+ entry = await this.admit(ownerKey, partitionKey, create, workKind);
242
+ this.entries.set(key, entry);
243
+ }
244
+ entry.activeLeases++;
245
+ return entry;
246
+ });
247
+ return this.lease(entry.resource, [entry]);
248
+ }
249
+ catch (error) {
250
+ if (!(error instanceof NeedsBackgroundAdmissionWait))
251
+ throw error;
252
+ // Outside the lock deliberately -- see waitForAdmissionRoom's own comment. Throws
253
+ // ResourceAdmissionQueueFull/TimedOut instead of looping back if it can't wait.
254
+ await this.waitForAdmissionRoom(partitionKey, ownerKey);
255
+ }
256
+ }
257
+ }
258
+ lease(value, entries) {
259
+ let released = false;
260
+ return {
261
+ value,
262
+ [Symbol.asyncDispose]: async () => {
263
+ if (released)
264
+ return;
265
+ released = true;
266
+ const completedAt = this.now();
267
+ for (const entry of entries) {
268
+ entry.activeLeases--;
269
+ entry.lastUsedAt = completedAt;
270
+ entry.recencySequence = this.nextSequence++;
271
+ }
272
+ // A lease completing makes its entry newly idle -- exactly the condition a queued
273
+ // background admission's retry is waiting to find, whether or not resource pressure
274
+ // itself ends up evicting anything below.
275
+ this.notifyAdmissionWaiters();
276
+ await this.reconcileResources();
277
+ },
278
+ };
279
+ }
280
+ has(ownerKey, partitionKey) {
281
+ return this.entries.has(this.key(ownerKey, partitionKey));
282
+ }
283
+ hasAny(ownerKey) {
284
+ for (const entry of this.entries.values())
285
+ if (entry.ownerKey === ownerKey)
286
+ return true;
287
+ return false;
288
+ }
289
+ /** Every currently active resource belonging to `ownerKey` -- lets a caller's own fan-out (file-watch notifications, etc) stay pool-backed instead of duplicating the entry map. */
290
+ activeResourcesForOwner(ownerKey) {
291
+ const resources = [];
292
+ for (const entry of this.entries.values())
293
+ if (entry.ownerKey === ownerKey)
294
+ resources.push(entry.resource);
295
+ return resources;
296
+ }
297
+ /**
298
+ * Derives the count ceiling actually in effect right now, independent of any particular
299
+ * admission attempt -- the resource policy's own soft ceiling raises maxActive when it reports
300
+ * more room, clamped to absoluteMaxActive, and falls back to maxActive alone (source
301
+ * "configured") on any metric loss -- fails closed, never treated as "unlimited room."
302
+ */
303
+ baseActiveCeiling() {
304
+ const soft = this.options.resourcePolicy?.softActiveCeiling(this.activePartitions());
305
+ if (soft === undefined || !Number.isFinite(soft) || soft <= this.maxActive)
306
+ return { ceiling: this.maxActive, source: "configured" };
307
+ const clamped = Math.min(Math.floor(soft), this.absoluteMaxActive);
308
+ return { ceiling: clamped, source: clamped >= this.absoluteMaxActive ? "absolute-cap" : "resource-budget" };
309
+ }
310
+ status() {
311
+ const byPartition = {};
312
+ let leased = 0;
313
+ for (const entry of this.entries.values()) {
314
+ byPartition[entry.partitionKey] = (byPartition[entry.partitionKey] ?? 0) + 1;
315
+ if (entry.activeLeases > 0)
316
+ leased++;
317
+ }
318
+ const resources = this.options.resourcePolicy?.status(this.activePartitions());
319
+ return {
320
+ active: this.entries.size,
321
+ leased,
322
+ maxActive: this.maxActive,
323
+ effectiveMaxActive: this.lastEffectiveMaxActive,
324
+ activeCeilingSource: this.lastActiveCeilingSource,
325
+ absoluteMaxActive: this.absoluteMaxActive,
326
+ byPartition,
327
+ waitingBackgroundAdmissions: this.queuedBackgroundAdmissions,
328
+ ...(resources !== undefined ? { resources } : {}),
329
+ };
330
+ }
331
+ /** Samples every currently active entry with a real costHandle and folds it into the configured recorder, if any -- a no-op without one. Read-only over the entry map, so it deliberately does not run inside serialized(). */
332
+ calibrateCosts() {
333
+ const recorder = this.options.costRecorder;
334
+ if (!recorder)
335
+ return;
336
+ for (const entry of this.entries.values()) {
337
+ if (entry.resource.costHandle === undefined)
338
+ continue;
339
+ recorder.recordSample(entry.partitionKey, entry.resource.costHandle);
340
+ }
341
+ }
342
+ /** Unconditional force-close of every one of `ownerKey`'s resources, regardless of any active lease -- for the case of a remote resource swapped out from under an already-warm one, where correctness requires closing regardless of who still holds it. */
343
+ async closeOwner(ownerKey) {
344
+ const stale = Array.from(this.entries.entries()).filter(([, entry]) => entry.ownerKey === ownerKey);
345
+ for (const [key] of stale)
346
+ this.entries.delete(key);
347
+ await Promise.all(stale.map(([, entry]) => entry.resource.close()));
348
+ }
349
+ /** Unconditional force-close of one (ownerKey, partitionKey) resource, if any -- the single-partition sibling of closeOwner, for a caller that has already identified exactly which partition needs invalidating. */
350
+ async closePartition(ownerKey, partitionKey) {
351
+ const key = this.key(ownerKey, partitionKey);
352
+ const entry = this.entries.get(key);
353
+ if (!entry)
354
+ return;
355
+ this.entries.delete(key);
356
+ await entry.resource.close();
357
+ }
358
+ /**
359
+ * The safe sibling of closeOwner: refuses (does not evict anything) while any of this owner's
360
+ * resources has an active lease. Serialized against concurrent admission so a lease can't be
361
+ * granted between the check and the close.
362
+ */
363
+ async releaseOwnerIfIdle(ownerKey) {
364
+ return this.serialized(async () => {
365
+ const matching = Array.from(this.entries.entries()).filter(([, entry]) => entry.ownerKey === ownerKey);
366
+ if (matching.some(([, entry]) => entry.activeLeases > 0))
367
+ throw new ResourceInUse(ownerKey);
368
+ for (const pair of matching)
369
+ await this.evict(pair, "admission");
370
+ return { closed: matching.length };
371
+ });
372
+ }
373
+ async closeAll() {
374
+ const entries = Array.from(this.entries.values());
375
+ this.entries.clear();
376
+ await Promise.all(entries.map((entry) => entry.resource.close()));
377
+ }
378
+ async reconcileResourcesUnsafe() {
379
+ const policy = this.options.resourcePolicy;
380
+ if (!policy)
381
+ return 0;
382
+ let reaped = 0;
383
+ while (policy.isOverBudget(this.activePartitions())) {
384
+ const victim = this.leastRecentlyUsedIdle();
385
+ if (!victim)
386
+ break;
387
+ try {
388
+ await this.evict(victim, "resource-pressure");
389
+ reaped++;
390
+ }
391
+ catch {
392
+ break;
393
+ }
394
+ }
395
+ return reaped;
396
+ }
397
+ async reconcileResources() {
398
+ return this.serialized(() => this.reconcileResourcesUnsafe());
399
+ }
400
+ async reapIdle(maxIdleMs) {
401
+ return this.serialized(async () => {
402
+ let reaped = await this.reconcileResourcesUnsafe();
403
+ const now = this.now();
404
+ const effectiveMaxIdleMs = this.options.resourcePolicy?.maxIdleMs(maxIdleMs, this.activePartitions()) ?? maxIdleMs;
405
+ const idle = Array.from(this.entries.entries()).filter(([, entry]) => entry.activeLeases === 0 && now - entry.lastUsedAt > effectiveMaxIdleMs);
406
+ for (const [key, entry] of idle) {
407
+ try {
408
+ await entry.resource.close();
409
+ if (this.entries.get(key) === entry)
410
+ this.entries.delete(key);
411
+ reaped++;
412
+ }
413
+ catch (error) {
414
+ this.options.observe?.({
415
+ kind: "close-failed",
416
+ reason: "idle-reap",
417
+ partitionKey: entry.partitionKey,
418
+ errorName: error instanceof Error ? error.name : "UnknownError",
419
+ });
420
+ }
421
+ }
422
+ if (idle.length > 0)
423
+ this.notifyAdmissionWaiters();
424
+ return reaped;
425
+ });
426
+ }
427
+ }
@@ -0,0 +1,24 @@
1
+ /** Raised when no idle resource can be evicted to admit a new one within the current ceiling. */
2
+ export declare class ResourceCapacityExceeded extends Error {
3
+ readonly partitionKey: string;
4
+ readonly maxActive: number;
5
+ readonly partitionLimit: number;
6
+ constructor(partitionKey: string, maxActive: number, partitionLimit: number);
7
+ }
8
+ /** Raised by releaseOwnerIfIdle when at least one of the owner's own resources still has an active lease. */
9
+ export declare class ResourceInUse extends Error {
10
+ readonly ownerKey: string;
11
+ constructor(ownerKey: string);
12
+ }
13
+ /** Raised when background admission is already waiting at maxQueuedBackgroundAdmissions -- fails fast rather than growing the wait queue without bound. */
14
+ export declare class ResourceAdmissionQueueFull extends Error {
15
+ readonly partitionKey: string;
16
+ readonly maxQueued: number;
17
+ constructor(partitionKey: string, maxQueued: number);
18
+ }
19
+ /** Raised when a queued background admission waits past backgroundAdmissionQueueTimeoutMs without a slot freeing. */
20
+ export declare class ResourceAdmissionQueueTimedOut extends Error {
21
+ readonly partitionKey: string;
22
+ readonly timeoutMs: number;
23
+ constructor(partitionKey: string, timeoutMs: number);
24
+ }
@@ -0,0 +1,44 @@
1
+ /** Raised when no idle resource can be evicted to admit a new one within the current ceiling. */
2
+ export class ResourceCapacityExceeded extends Error {
3
+ partitionKey;
4
+ maxActive;
5
+ partitionLimit;
6
+ constructor(partitionKey, maxActive, partitionLimit) {
7
+ super(`no idle resource can be evicted to admit partition "${partitionKey}" within global capacity ${maxActive} and partition capacity ${partitionLimit}`);
8
+ this.partitionKey = partitionKey;
9
+ this.maxActive = maxActive;
10
+ this.partitionLimit = partitionLimit;
11
+ this.name = "ResourceCapacityExceeded";
12
+ }
13
+ }
14
+ /** Raised by releaseOwnerIfIdle when at least one of the owner's own resources still has an active lease. */
15
+ export class ResourceInUse extends Error {
16
+ ownerKey;
17
+ constructor(ownerKey) {
18
+ super(`cannot release owner "${ownerKey}": a pooled resource for it still has an active lease`);
19
+ this.ownerKey = ownerKey;
20
+ this.name = "ResourceInUse";
21
+ }
22
+ }
23
+ /** Raised when background admission is already waiting at maxQueuedBackgroundAdmissions -- fails fast rather than growing the wait queue without bound. */
24
+ export class ResourceAdmissionQueueFull extends Error {
25
+ partitionKey;
26
+ maxQueued;
27
+ constructor(partitionKey, maxQueued) {
28
+ super(`background admission for partition "${partitionKey}" is already waiting at capacity (${maxQueued} queued); retry later`);
29
+ this.partitionKey = partitionKey;
30
+ this.maxQueued = maxQueued;
31
+ this.name = "ResourceAdmissionQueueFull";
32
+ }
33
+ }
34
+ /** Raised when a queued background admission waits past backgroundAdmissionQueueTimeoutMs without a slot freeing. */
35
+ export class ResourceAdmissionQueueTimedOut extends Error {
36
+ partitionKey;
37
+ timeoutMs;
38
+ constructor(partitionKey, timeoutMs) {
39
+ super(`background admission for partition "${partitionKey}" waited ${timeoutMs}ms for a resource-pool slot and gave up -- foreground demand is holding every admittable slot`);
40
+ this.partitionKey = partitionKey;
41
+ this.timeoutMs = timeoutMs;
42
+ this.name = "ResourceAdmissionQueueTimedOut";
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./bounded-resource-pool.js";
2
+ export * from "./errors.js";
3
+ export * from "./resource-policy.js";
@@ -0,0 +1,3 @@
1
+ export * from "./bounded-resource-pool.js";
2
+ export * from "./errors.js";
3
+ export * from "./resource-policy.js";
@@ -0,0 +1,28 @@
1
+ /** Which ceiling actually constrained the most recent admission decision. */
2
+ export type ResourcePoolActiveCeilingSource = "configured" | "resource-budget" | "absolute-cap";
3
+ /**
4
+ * Optional plug point letting a real resource budget (memory, /proc-sampled process cost, ...)
5
+ * drive admission and retention decisions beyond the pool's own configured counts. A pool without
6
+ * one falls back to its configured maxActive/absoluteMaxActive alone -- this interface exists so a
7
+ * caller CAN plug in something smarter, not because every caller needs to.
8
+ *
9
+ * `Status` is left to the caller (default `unknown`) rather than fixed to one shape: the pool
10
+ * itself never inspects or reshapes what `status()` returns, only forwards it verbatim in its own
11
+ * status() report -- a caller with an existing status shape (its own field names, units, wire
12
+ * contract) plugs it in unchanged, no adapter required.
13
+ */
14
+ export interface ResourcePoolResourcePolicy<Status = unknown> {
15
+ canAdmit(activePartitions: readonly string[], requestedPartition: string): boolean;
16
+ isOverBudget(activePartitions: readonly string[]): boolean;
17
+ /**
18
+ * A conservative, count-shaped ceiling derived from a real budget and worst-case known
19
+ * per-partition cost -- lets a larger real budget actually raise how many resources the pool
20
+ * will try to keep active, instead of a fixed configured count being the permanent bottleneck
21
+ * regardless of how much is genuinely available. Never authoritative on its own: canAdmit's own
22
+ * precise per-attempt check still gates the actual admission. Returns undefined on any metric
23
+ * loss -- fails closed, never treated as "unlimited room."
24
+ */
25
+ softActiveCeiling(activePartitions: readonly string[]): number | undefined;
26
+ maxIdleMs(configuredMaxIdleMs: number, activePartitions: readonly string[]): number;
27
+ status(activePartitions: readonly string[]): Status;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-core",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "Vehicle's runtime-neutral wire contract: operation descriptors, schema codecs, failure shapes. Zero runtime dependencies, zero Bun-specific code -- the one thing every Vehicle client and server package depends on.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Guards against the argv-injection class behind a well-known family of CLI-wrapper CVEs
3
+ * (simple-git's own history includes several): a caller-influenced string that starts with `-`
4
+ * can be parsed by the target CLI as a *flag* (`--upload-pack`, `--exec`, `--template`, `-c`
5
+ * config override) instead of the literal ref/path/pattern value the caller intended. Any Vehicle
6
+ * operation that hands a caller-supplied string to a shelled-out CLI's argv should run it through
7
+ * this check first, at the exact position it reaches that argv.
8
+ *
9
+ * This is a hard rejection with no exceptions -- it has no opinion about *why* a value starting
10
+ * with `-` might be needed and does not attempt to allow-list specific flags. A caller whose CLI
11
+ * genuinely accepts caller-influenced flag-shaped arguments needs a purpose-built allow-list of
12
+ * its own; this primitive only ever covers the common case of a value that should always be a
13
+ * literal.
14
+ */
15
+ export class UnsafeCliArgument extends Error {
16
+ constructor(
17
+ readonly value: string,
18
+ readonly fieldName?: string,
19
+ ) {
20
+ super(
21
+ fieldName
22
+ ? `"${value}" cannot be used as ${fieldName} -- it would be interpreted as a CLI flag, not a literal value`
23
+ : `"${value}" cannot be used as a CLI argument -- it would be interpreted as a flag, not a literal value`,
24
+ );
25
+ this.name = "UnsafeCliArgument";
26
+ }
27
+ }
28
+
29
+ /** Throws UnsafeCliArgument if `value` starts with `-`. `fieldName`, when given, names the field in the thrown error's own message for a caller with several distinct argv positions to check. */
30
+ export function assertNoLeadingFlagChar(value: string, fieldName?: string): void {
31
+ if (value.startsWith("-")) throw new UnsafeCliArgument(value, fieldName);
32
+ }
@@ -0,0 +1 @@
1
+ export * from "./assert-no-leading-flag-char.js";
package/src/index.ts CHANGED
@@ -6,13 +6,16 @@
6
6
  * classification, invocation context), events, manifest, client (the port a
7
7
  * caller programs against), approvals (the Approval Gate's wire shapes), jobs
8
8
  * (Vehicle Jobs' pure pieces), schedules, watches, persistence (atomic
9
- * JSON), and concurrency (timer-based scheduling primitives) -- the latter
10
- * two are technical utilities, not Vehicle protocol capabilities, kept
11
- * distinct for that reason. Every symbol below is re-exported unchanged
9
+ * JSON), concurrency (timer-based scheduling primitives), cli-safety
10
+ * (argv-injection guards for any operation shelling out to a CLI), and
11
+ * resource-pool (bounded admission/eviction/leasing for a pooled expensive
12
+ * stateful resource) -- the latter four are technical utilities, not
13
+ * Vehicle protocol capabilities, kept distinct for that reason. Every symbol below is re-exported unchanged
12
14
  * from its historical flat-file home, so root-level `import { X } from
13
15
  * "@danypops/vehicle-core"` usage is completely unaffected by this layout.
14
16
  */
15
17
  export * from "./approvals/index.js";
18
+ export * from "./cli-safety/index.js";
16
19
  export * from "./client/index.js";
17
20
  export * from "./concurrency/index.js";
18
21
  export * from "./content/index.js";
@@ -23,6 +26,7 @@ export * from "./jobs/index.js";
23
26
  export * from "./manifest/index.js";
24
27
  export * from "./operations/index.js";
25
28
  export * from "./persistence/index.js";
29
+ export * from "./resource-pool/index.js";
26
30
  export * from "./schedules/index.js";
27
31
  export * from "./schemas/index.js";
28
32
  export * from "./watches/index.js";