@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.
- package/dist/cli-safety/assert-no-leading-flag-char.d.ts +21 -0
- package/dist/cli-safety/assert-no-leading-flag-char.js +31 -0
- package/dist/cli-safety/index.d.ts +1 -0
- package/dist/cli-safety/index.js +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +7 -3
- package/dist/resource-pool/bounded-resource-pool.d.ts +139 -0
- package/dist/resource-pool/bounded-resource-pool.js +427 -0
- package/dist/resource-pool/errors.d.ts +24 -0
- package/dist/resource-pool/errors.js +44 -0
- package/dist/resource-pool/index.d.ts +3 -0
- package/dist/resource-pool/index.js +3 -0
- package/dist/resource-pool/resource-policy.d.ts +28 -0
- package/dist/resource-pool/resource-policy.js +1 -0
- package/package.json +1 -1
- package/src/cli-safety/assert-no-leading-flag-char.ts +32 -0
- package/src/cli-safety/index.ts +1 -0
- package/src/index.ts +7 -3
- package/src/resource-pool/bounded-resource-pool.ts +506 -0
- package/src/resource-pool/errors.ts +45 -0
- package/src/resource-pool/index.ts +3 -0
- package/src/resource-pool/resource-policy.ts +29 -0
|
@@ -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,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.
|
|
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),
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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";
|