@mlx-node/server 0.0.12 → 0.0.15
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/host/discover.d.ts +3 -6
- package/dist/host/discover.d.ts.map +1 -1
- package/dist/host/discover.js +9 -42
- package/dist/host/index.d.ts +2 -2
- package/dist/host/index.d.ts.map +1 -1
- package/dist/host/index.js +8 -1
- package/package.json +9 -4
- package/src/auth.ts +111 -0
- package/src/chat-session-warm-reuse.ts +96 -0
- package/src/endpoints/messages-count-tokens.ts +164 -0
- package/src/endpoints/messages.ts +1802 -0
- package/src/endpoints/models.ts +20 -0
- package/src/endpoints/responses.ts +3928 -0
- package/src/errors.ts +120 -0
- package/src/handler.ts +195 -0
- package/src/health.ts +213 -0
- package/src/host/discover.ts +25 -0
- package/src/host/env-policy.ts +81 -0
- package/src/host/index.ts +496 -0
- package/src/host/logger.ts +419 -0
- package/src/host/net.ts +100 -0
- package/src/host/paths.ts +77 -0
- package/src/host/swap.ts +200 -0
- package/src/host/temp-root.ts +110 -0
- package/src/idle-sweeper.ts +555 -0
- package/src/index.ts +114 -0
- package/src/load-model.ts +92 -0
- package/src/mappers/anthropic-request.ts +485 -0
- package/src/mappers/anthropic-response.ts +306 -0
- package/src/mappers/request.ts +456 -0
- package/src/mappers/response.ts +163 -0
- package/src/model-work-coordinator.ts +416 -0
- package/src/pending-writes.ts +481 -0
- package/src/registry.ts +691 -0
- package/src/router.ts +220 -0
- package/src/server.ts +579 -0
- package/src/session-registry.ts +1371 -0
- package/src/stop-sequence-buffer.ts +161 -0
- package/src/streaming.ts +205 -0
- package/src/text-recovery.ts +41 -0
- package/src/timing.ts +236 -0
- package/src/tool-call-buffer.ts +78 -0
- package/src/transport-visibility.ts +185 -0
- package/src/types-anthropic.ts +409 -0
- package/src/types.ts +470 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { ModelLoadRecord } from './health.js';
|
|
2
|
+
import type { PreDispatchAdmission, SessionRegistry } from './session-registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Render a thrown value for {@link ModelLoadRecord.error}.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately avoids `String(unknown)`: a rejection carrying a plain object
|
|
8
|
+
* would render as the useless `[object Object]` in the one field a supervisor
|
|
9
|
+
* reads to find out why the model would not load.
|
|
10
|
+
*/
|
|
11
|
+
function describeLoadFailure(error: unknown): string {
|
|
12
|
+
if (error instanceof Error) return error.message;
|
|
13
|
+
if (typeof error === 'string') return error;
|
|
14
|
+
if (error == null) return 'unknown error';
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(error) ?? 'unknown error';
|
|
17
|
+
} catch {
|
|
18
|
+
// Circular structure, or a `toJSON` that throws.
|
|
19
|
+
return 'unknown error';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result of a `withModelLoad` call that surfaces who actually drove the load
|
|
25
|
+
* vs. who merely parked behind one that was already in flight. Callers use
|
|
26
|
+
* this to split observability between the request that triggered a cold
|
|
27
|
+
* weight-materialize from one that arrived a millisecond later and merely
|
|
28
|
+
* inherited the wait — without the split a 60-second cold-load shows up
|
|
29
|
+
* on every concurrent request as if each one paid for a separate load.
|
|
30
|
+
*
|
|
31
|
+
* `owner` reflects the SYNCHRONOUS state observed at lock acquisition:
|
|
32
|
+
* `true` if the writer lock was free when this caller arrived and the
|
|
33
|
+
* caller itself executed the supplied `fn`; `false` if there was already
|
|
34
|
+
* a writer active (or queued ahead of this caller) when it arrived.
|
|
35
|
+
*
|
|
36
|
+
* `waitMs` and `ownMs` partition the wall-clock interval between when
|
|
37
|
+
* the caller arrived at the coordinator and when its `fn` resolved:
|
|
38
|
+
* - `waitMs` is time spent blocked inside `acquireWrite()` (zero for a
|
|
39
|
+
* no-contention owner; ≈ peer's load duration for a follower).
|
|
40
|
+
* - `ownMs` is time spent inside `fn` once the writer lock was held
|
|
41
|
+
* (≈ load duration for an owner driving a cold load; near-zero for a
|
|
42
|
+
* follower whose `fn` is a no-op cache lookup).
|
|
43
|
+
* Both are measured from `Date.now()` and clamped at zero to absorb
|
|
44
|
+
* monotonic-skew. Their sum equals the total elapsed time in the call,
|
|
45
|
+
* so handlers can plumb them into separate observability fields
|
|
46
|
+
* (`server_load_wait_ms` vs. `server_model_resolve_ms`) without
|
|
47
|
+
* double-counting.
|
|
48
|
+
*/
|
|
49
|
+
export interface ModelLoadOutcome<T> {
|
|
50
|
+
result: T;
|
|
51
|
+
owner: boolean;
|
|
52
|
+
waitMs: number;
|
|
53
|
+
ownMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A bounded request admission transferred into the resident FIFO budget. */
|
|
57
|
+
export interface ModelLoadAdmission {
|
|
58
|
+
/**
|
|
59
|
+
* Atomically move this request from the cold-load budget into the
|
|
60
|
+
* resident model's ordinary pre-dispatch budget. Idempotent for the same
|
|
61
|
+
* registry; transferring after a hot-swap releases the old reservation
|
|
62
|
+
* before charging the new binding.
|
|
63
|
+
*/
|
|
64
|
+
transferToResident(registry: SessionRegistry): PreDispatchAdmission;
|
|
65
|
+
/** Idempotently release this request's current cold or resident unit. */
|
|
66
|
+
release(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ModelLoadAdmissionState {
|
|
70
|
+
released: boolean;
|
|
71
|
+
coldCounted: boolean;
|
|
72
|
+
residentRegistry?: SessionRegistry;
|
|
73
|
+
residentAdmission?: PreDispatchAdmission;
|
|
74
|
+
transferError?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Raised synchronously when unresolved model-load traffic is over capacity. */
|
|
78
|
+
export class ModelLoadQueueFullError extends Error {
|
|
79
|
+
readonly admissionFootprint: number;
|
|
80
|
+
readonly limit: number;
|
|
81
|
+
|
|
82
|
+
constructor(admissionFootprint: number, limit: number) {
|
|
83
|
+
super(`Model load queue full: admission footprint ${admissionFootprint} (waiter limit ${limit})`);
|
|
84
|
+
this.name = 'ModelLoadQueueFullError';
|
|
85
|
+
this.admissionFootprint = admissionFootprint;
|
|
86
|
+
this.limit = limit;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Process-local gate for native MLX work.
|
|
92
|
+
*
|
|
93
|
+
* Individual model instances already have a per-model execution mutex, but a
|
|
94
|
+
* lazy `loadModel()` can still run load-time materialization / warmup Metal
|
|
95
|
+
* work while another model is decoding. MLX's allocator and command queues are
|
|
96
|
+
* process-wide, so model load/swap takes an exclusive writer slot; inference
|
|
97
|
+
* takes shared reader slots.
|
|
98
|
+
*/
|
|
99
|
+
export class ModelWorkCoordinator {
|
|
100
|
+
private activeReaders = 0;
|
|
101
|
+
private writerHeld = false;
|
|
102
|
+
private queuedWriters = 0;
|
|
103
|
+
private readonly readerWaiters: Array<() => void> = [];
|
|
104
|
+
private readonly writerWaiters: Array<() => void> = [];
|
|
105
|
+
private requestLoadAdmissions = 0;
|
|
106
|
+
/** Outstanding permits grouped by the requested model name. */
|
|
107
|
+
private readonly requestLoadAdmissionsByModel = new Map<string, Set<ModelLoadAdmissionState>>();
|
|
108
|
+
/**
|
|
109
|
+
* Resident bindings published synchronously by `ModelRegistry.register`.
|
|
110
|
+
* Publishing transfers every already-admitted cold request before another
|
|
111
|
+
* request can spend the resident budget independently.
|
|
112
|
+
*/
|
|
113
|
+
private readonly residentRegistriesByModel = new Map<string, SessionRegistry>();
|
|
114
|
+
private readonly maxRequestLoadQueueDepth: number | undefined;
|
|
115
|
+
/**
|
|
116
|
+
* Most recent settled load bracket. Retained here because the coordinator
|
|
117
|
+
* is the ONE place that brackets every load: a `resolveModel` failure in
|
|
118
|
+
* `/v1/messages` becomes a 500 and is otherwise dropped on the floor, so a
|
|
119
|
+
* supervisor polling `/health` afterwards had no way to learn what went
|
|
120
|
+
* wrong. See {@link ModelLoadRecord} for the "successful no-op overwrites
|
|
121
|
+
* an earlier failure" caveat.
|
|
122
|
+
*/
|
|
123
|
+
private lastLoadRecord: ModelLoadRecord | null = null;
|
|
124
|
+
|
|
125
|
+
constructor(maxRequestLoadQueueDepth?: number) {
|
|
126
|
+
this.maxRequestLoadQueueDepth = maxRequestLoadQueueDepth;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bound requests that arrive before a model has a resident
|
|
131
|
+
* `SessionRegistry`. The capacity mirrors the resident FIFO: `limit`
|
|
132
|
+
* waiters plus one owner/runner. Callers retain the permit through every
|
|
133
|
+
* pre-lock await; registration synchronously transfers it into the resident
|
|
134
|
+
* `SessionRegistry` budget before any later arrival can spend that capacity.
|
|
135
|
+
*/
|
|
136
|
+
beginRequestLoadAdmission(modelId: string): ModelLoadAdmission {
|
|
137
|
+
const limit = this.maxRequestLoadQueueDepth;
|
|
138
|
+
const admissions = this.requestLoadAdmissionsByModel.get(modelId) ?? new Set<ModelLoadAdmissionState>();
|
|
139
|
+
let coldFootprint = 0;
|
|
140
|
+
for (const admission of admissions) {
|
|
141
|
+
if (!admission.released && admission.coldCounted) coldFootprint += 1;
|
|
142
|
+
}
|
|
143
|
+
if (limit !== undefined && coldFootprint >= limit + 1) {
|
|
144
|
+
throw new ModelLoadQueueFullError(coldFootprint, limit);
|
|
145
|
+
}
|
|
146
|
+
if (!this.requestLoadAdmissionsByModel.has(modelId)) {
|
|
147
|
+
this.requestLoadAdmissionsByModel.set(modelId, admissions);
|
|
148
|
+
}
|
|
149
|
+
const state: ModelLoadAdmissionState = {
|
|
150
|
+
released: false,
|
|
151
|
+
coldCounted: true,
|
|
152
|
+
};
|
|
153
|
+
admissions.add(state);
|
|
154
|
+
this.requestLoadAdmissions += 1;
|
|
155
|
+
const admission: ModelLoadAdmission = {
|
|
156
|
+
transferToResident: (registry): PreDispatchAdmission => this.transferAdmission(state, registry),
|
|
157
|
+
release: (): void => {
|
|
158
|
+
if (state.released) return;
|
|
159
|
+
state.released = true;
|
|
160
|
+
state.residentAdmission?.release();
|
|
161
|
+
state.residentAdmission = undefined;
|
|
162
|
+
state.residentRegistry = undefined;
|
|
163
|
+
if (state.coldCounted) {
|
|
164
|
+
state.coldCounted = false;
|
|
165
|
+
this.requestLoadAdmissions -= 1;
|
|
166
|
+
if (this.requestLoadAdmissions < 0) this.requestLoadAdmissions = 0;
|
|
167
|
+
}
|
|
168
|
+
const active = this.requestLoadAdmissionsByModel.get(modelId);
|
|
169
|
+
active?.delete(state);
|
|
170
|
+
if (active?.size === 0) this.requestLoadAdmissionsByModel.delete(modelId);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const resident = this.residentRegistriesByModel.get(modelId);
|
|
174
|
+
if (resident) {
|
|
175
|
+
try {
|
|
176
|
+
this.transferAdmission(state, resident);
|
|
177
|
+
} catch {
|
|
178
|
+
// Preserve the failure on `state`; the handler observes it from its
|
|
179
|
+
// explicit transfer after resolving the binding and returns 429.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return admission;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Publish a requested model name's resident admission lane. Called
|
|
187
|
+
* synchronously from `ModelRegistry.register`, so every outstanding cold
|
|
188
|
+
* permit moves into the registry before a later arrival can be admitted
|
|
189
|
+
* against an apparently empty resident budget.
|
|
190
|
+
*/
|
|
191
|
+
bindRequestLoadAdmissions(modelId: string, registry: SessionRegistry): void {
|
|
192
|
+
this.residentRegistriesByModel.set(modelId, registry);
|
|
193
|
+
const admissions = this.requestLoadAdmissionsByModel.get(modelId);
|
|
194
|
+
if (!admissions) return;
|
|
195
|
+
for (const admission of admissions) {
|
|
196
|
+
if (admission.released) continue;
|
|
197
|
+
try {
|
|
198
|
+
this.transferAdmission(admission, registry);
|
|
199
|
+
} catch {
|
|
200
|
+
// A cold request that cannot fit after an alias/hot-swap transition is
|
|
201
|
+
// marked fail-closed. Its handler will surface the stored QueueFullError
|
|
202
|
+
// rather than running outside either budget.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Forget a name only when it still points at the supplied binding. */
|
|
208
|
+
unbindRequestLoadAdmissions(modelId: string, registry: SessionRegistry): void {
|
|
209
|
+
if (this.residentRegistriesByModel.get(modelId) === registry) {
|
|
210
|
+
this.residentRegistriesByModel.delete(modelId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private transferAdmission(state: ModelLoadAdmissionState, registry: SessionRegistry): PreDispatchAdmission {
|
|
215
|
+
if (state.released) {
|
|
216
|
+
throw new Error('Model load admission has already been released');
|
|
217
|
+
}
|
|
218
|
+
if (state.residentRegistry === registry && state.transferError !== undefined) {
|
|
219
|
+
throw state.transferError;
|
|
220
|
+
}
|
|
221
|
+
if (state.residentRegistry === registry && state.residentAdmission) {
|
|
222
|
+
return state.residentAdmission;
|
|
223
|
+
}
|
|
224
|
+
if (state.residentAdmission) {
|
|
225
|
+
state.residentAdmission.release();
|
|
226
|
+
state.residentAdmission = undefined;
|
|
227
|
+
state.residentRegistry = undefined;
|
|
228
|
+
}
|
|
229
|
+
state.transferError = undefined;
|
|
230
|
+
try {
|
|
231
|
+
const residentAdmission = registry.beginPreDispatchAdmission();
|
|
232
|
+
state.residentRegistry = registry;
|
|
233
|
+
state.residentAdmission = residentAdmission;
|
|
234
|
+
if (state.coldCounted) {
|
|
235
|
+
state.coldCounted = false;
|
|
236
|
+
this.requestLoadAdmissions -= 1;
|
|
237
|
+
if (this.requestLoadAdmissions < 0) this.requestLoadAdmissions = 0;
|
|
238
|
+
}
|
|
239
|
+
return residentAdmission;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// This permit is now a rejected resident transition, not an invisible
|
|
242
|
+
// cold unit. Remove its cold charge; `transferToResident` rethrows the
|
|
243
|
+
// exact resident error when the owning request resumes.
|
|
244
|
+
if (state.coldCounted) {
|
|
245
|
+
state.coldCounted = false;
|
|
246
|
+
this.requestLoadAdmissions -= 1;
|
|
247
|
+
if (this.requestLoadAdmissions < 0) this.requestLoadAdmissions = 0;
|
|
248
|
+
}
|
|
249
|
+
state.transferError = error;
|
|
250
|
+
state.residentRegistry = registry;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Read-only unresolved/pre-FIFO request footprint for diagnostics/tests. */
|
|
256
|
+
get requestLoadAdmissionCount(): number {
|
|
257
|
+
return this.requestLoadAdmissions;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Read-only: `true` while a load holds the exclusive writer slot. */
|
|
261
|
+
get writerActive(): boolean {
|
|
262
|
+
return this.writerHeld;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read-only: loads parked in `acquireWrite()` waiting for the slot. */
|
|
266
|
+
get waitingWriters(): number {
|
|
267
|
+
return this.queuedWriters;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read-only: outcome of the most recent settled load bracket, or `null`. */
|
|
271
|
+
get lastLoad(): ModelLoadRecord | null {
|
|
272
|
+
return this.lastLoadRecord;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Record a settled bracket. Called from the `finally` of both load
|
|
277
|
+
* wrappers so a throw is captured just as reliably as a success.
|
|
278
|
+
*/
|
|
279
|
+
private recordLoad(label: string | undefined, startedAt: number, error: unknown, ok: boolean): void {
|
|
280
|
+
this.lastLoadRecord = {
|
|
281
|
+
label: label ?? null,
|
|
282
|
+
startedAt,
|
|
283
|
+
finishedAt: Date.now(),
|
|
284
|
+
ok,
|
|
285
|
+
error: ok ? null : describeLoadFailure(error),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param label Optional identifier (normally the model name) stamped into
|
|
291
|
+
* {@link lastLoad} so `/health` can name what was being loaded.
|
|
292
|
+
*/
|
|
293
|
+
async withModelLoad<T>(fn: () => Promise<T> | T, label?: string): Promise<T> {
|
|
294
|
+
await this.acquireWrite();
|
|
295
|
+
// Measured from lock acquisition, not from arrival: `startedAt` is meant
|
|
296
|
+
// to answer "how long has the actual materialization been running",
|
|
297
|
+
// which is what a supervisor deciding whether to wait needs.
|
|
298
|
+
const startedAt = Date.now();
|
|
299
|
+
let ok = false;
|
|
300
|
+
let failure: unknown;
|
|
301
|
+
try {
|
|
302
|
+
const result = await fn();
|
|
303
|
+
ok = true;
|
|
304
|
+
return result;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
failure = err;
|
|
307
|
+
throw err;
|
|
308
|
+
} finally {
|
|
309
|
+
this.recordLoad(label, startedAt, failure, ok);
|
|
310
|
+
this.releaseWrite();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Like {@link withModelLoad} but reports whether THIS caller owned the
|
|
316
|
+
* load (acquired the writer lock with no contention) or merely waited
|
|
317
|
+
* behind a load that was already in flight when it arrived.
|
|
318
|
+
*
|
|
319
|
+
* Decided at sync-time before any await: if neither a writer is active
|
|
320
|
+
* nor any writer is queued ahead, this caller is the owner; otherwise
|
|
321
|
+
* it is parked behind someone else's load and `owner` is `false`. The
|
|
322
|
+
* distinction is used by `/v1/messages` to split `resolve_ms` (own
|
|
323
|
+
* load + lookup) from `load_wait_ms` (waiting on a peer's load) so a
|
|
324
|
+
* 60-second cold-load does not look like 60 seconds of own work for
|
|
325
|
+
* every concurrent request.
|
|
326
|
+
*/
|
|
327
|
+
async withModelLoadInstrumented<T>(fn: () => Promise<T> | T, label?: string): Promise<ModelLoadOutcome<T>> {
|
|
328
|
+
// `owner` MUST be decided synchronously, before any await, so the
|
|
329
|
+
// signal reflects coordinator state at arrival rather than after
|
|
330
|
+
// any peer transition. The wait/own split is measured around the
|
|
331
|
+
// actual phase boundaries (lock acquisition, fn completion) so the
|
|
332
|
+
// two intervals partition cleanly instead of both reporting total
|
|
333
|
+
// elapsed time — see `ModelLoadOutcome` for the contract.
|
|
334
|
+
const owner = !this.writerHeld && this.queuedWriters === 0;
|
|
335
|
+
const arrivedAt = Date.now();
|
|
336
|
+
await this.acquireWrite();
|
|
337
|
+
const lockAcquiredAt = Date.now();
|
|
338
|
+
let ok = false;
|
|
339
|
+
let failure: unknown;
|
|
340
|
+
try {
|
|
341
|
+
const result = await fn();
|
|
342
|
+
ok = true;
|
|
343
|
+
const fnDoneAt = Date.now();
|
|
344
|
+
const waitMs = Math.max(0, lockAcquiredAt - arrivedAt);
|
|
345
|
+
const ownMs = Math.max(0, fnDoneAt - lockAcquiredAt);
|
|
346
|
+
return { result, owner, waitMs, ownMs };
|
|
347
|
+
} catch (err) {
|
|
348
|
+
failure = err;
|
|
349
|
+
throw err;
|
|
350
|
+
} finally {
|
|
351
|
+
this.recordLoad(label, lockAcquiredAt, failure, ok);
|
|
352
|
+
this.releaseWrite();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async withInference<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
357
|
+
await this.acquireRead();
|
|
358
|
+
try {
|
|
359
|
+
return await fn();
|
|
360
|
+
} finally {
|
|
361
|
+
this.releaseRead();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private acquireRead(): Promise<void> {
|
|
366
|
+
if (!this.writerHeld && this.queuedWriters === 0) {
|
|
367
|
+
this.activeReaders += 1;
|
|
368
|
+
return Promise.resolve();
|
|
369
|
+
}
|
|
370
|
+
return new Promise<void>((resolve) => {
|
|
371
|
+
this.readerWaiters.push(() => {
|
|
372
|
+
this.activeReaders += 1;
|
|
373
|
+
resolve();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private acquireWrite(): Promise<void> {
|
|
379
|
+
this.queuedWriters += 1;
|
|
380
|
+
if (!this.writerHeld && this.activeReaders === 0) {
|
|
381
|
+
this.queuedWriters -= 1;
|
|
382
|
+
this.writerHeld = true;
|
|
383
|
+
return Promise.resolve();
|
|
384
|
+
}
|
|
385
|
+
return new Promise<void>((resolve) => {
|
|
386
|
+
this.writerWaiters.push(() => {
|
|
387
|
+
this.queuedWriters -= 1;
|
|
388
|
+
this.writerHeld = true;
|
|
389
|
+
resolve();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private releaseRead(): void {
|
|
395
|
+
this.activeReaders -= 1;
|
|
396
|
+
if (this.activeReaders < 0) this.activeReaders = 0;
|
|
397
|
+
if (this.activeReaders === 0) this.drain();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private releaseWrite(): void {
|
|
401
|
+
this.writerHeld = false;
|
|
402
|
+
this.drain();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private drain(): void {
|
|
406
|
+
if (this.writerHeld) return;
|
|
407
|
+
if (this.activeReaders === 0 && this.writerWaiters.length > 0) {
|
|
408
|
+
this.writerWaiters.shift()?.();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (this.queuedWriters === 0 && this.readerWaiters.length > 0) {
|
|
412
|
+
const readers = this.readerWaiters.splice(0);
|
|
413
|
+
for (const resolve of readers) resolve();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|