@nimbus-sh/platform 0.7.1 → 0.8.1

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,1108 @@
1
+ /**
2
+ * wave-writer.ts — a producer's writes into the session, as W7 waves.
3
+ *
4
+ * The one W7 producer: git's network facet (a clone's files, fetch and
5
+ * pull's objects and refs), npm's install facet (package files), and the
6
+ * session's own bulk writes (npm bin shims, the clang sysroot). Each write
7
+ * is a record: a file, a link, a directory, or a removal. The writer buffers
8
+ * records into a wave and publishes the wave through one writeBatchStream()
9
+ * call. A wave closes before it would pass W7's owned-path bounds (count and
10
+ * bytes: files, removals and the directories above them, up to the root) or
11
+ * its byte budget; a file larger than the budget travels in a wave of its
12
+ * own, and one streamed from a source is never held whole.
13
+ *
14
+ * Pipelining: one wave is in flight while the next one buffers. A wave
15
+ * starts only once its predecessor has published, so a producer waits only
16
+ * when it fills a second wave, and a failed wave is the last this writer
17
+ * sends: the failure names its wave, and every later call rejects with it.
18
+ * Waves publish in order, so a record written after another is durable only
19
+ * if that one is: a completion marker written last proves what came before.
20
+ *
21
+ * Lost transport (lost-call.ts, the one policy for it): a wave whose call
22
+ * failed before the session answered (isLostFencedCall), or that nothing
23
+ * read for LOST_STREAM_STALL_MS before its end, or that stayed unanswered
24
+ * LOST_STREAM_ANSWER_MS after it, is sent again after a backoff, at most
25
+ * LOST_CALL_RESEND_BACKOFF_MS.length times. The abandoned attempt's stream
26
+ * is errored so it reads nothing more, and every attempt carries its fence
27
+ * (writer, wave, attempt): the session refuses an attempt older than one it
28
+ * has seen, so a late original never applies over its re-send.
29
+ * Re-sending is safe: a wave is the same paths and bytes, replacing. A shed
30
+ * wave never ran; the platform's advice is not to retry an overloaded
31
+ * object, but these waves are few and backed off, and npm measured the
32
+ * re-send recover a 119-package install that otherwise lost 31 packages to
33
+ * one shed. A wave the session answered (ok: false) is its verdict and is
34
+ * never retried, nor is a wave with a streamed source (its source is spent).
35
+ *
36
+ * Fault domains: with `failPerOwner`, a record's `meta` is its owner (an
37
+ * npm package), and a failed wave whose records all have owners fails those
38
+ * owners (their later records reject, their buffered ones are not sent)
39
+ * while the writer goes on for the rest. Otherwise, and for a failed wave
40
+ * carrying anything unowned (a directory or removal record, the pin, a
41
+ * record without meta), the failed wave is the last one sent. A write that
42
+ * supersedes a buffered record (the same path, written again before a cut)
43
+ * settles it: the wave carrying the later write publishes or fails the
44
+ * owners of both. An owner is published once every wave that carried its
45
+ * writes has (`published`).
46
+ *
47
+ * Admitting a record costs its own new directories, never a recount of the
48
+ * wave: the owned set grows as records arrive, and a directory chain walk
49
+ * stops at the first directory already owned (whose chain is owned).
50
+ * Records may be written concurrently (an install writes several packages
51
+ * at once): each is admitted and buffered in call order.
52
+ *
53
+ * Several writers may publish into one session at once (a clone's parallel
54
+ * producers); each is its own stream, and the session takes them
55
+ * concurrently.
56
+ */
57
+
58
+ import {
59
+ encodeWriteBatch,
60
+ encodeWriteBatchStream,
61
+ W7_MAX_OWNED_PATH_BYTES,
62
+ W7_MAX_PATHS_PER_BATCH,
63
+ w7ChunkCount,
64
+ w7Chunks,
65
+ type BatchChunkEntry,
66
+ type BatchInodeEntry,
67
+ type BatchStreamEntry,
68
+ type BatchWritePayload,
69
+ } from './w7-frame.js';
70
+ import {
71
+ LOST_CALL_RESEND_BACKOFF_MS,
72
+ LOST_STREAM_ANSWER_MS,
73
+ LOST_STREAM_STALL_MS,
74
+ WAVE_EPOCH_TTL_MS,
75
+ isLostFencedCall,
76
+ lostCallAttributes,
77
+ } from './lost-call.js';
78
+ import { disposeRpcResource } from './rpc-dispose.js';
79
+ import { retryDelayMs } from './retry.js';
80
+ import { utf8Length } from './utf8.js';
81
+
82
+ /** Paths a wave holds back from W7's bound, for its pinned marker and the marker's directories. */
83
+ export const WAVE_PATHS = W7_MAX_PATHS_PER_BATCH - 8;
84
+ export const WAVE_PATH_BYTES = W7_MAX_OWNED_PATH_BYTES - 4 * 1024;
85
+ /** Buffered content bytes that close a wave. */
86
+ export const WAVE_BYTES = 4 * 1024 * 1024;
87
+ /** The pieces a wave encoded whole crosses its transport in. */
88
+ const SEND_SLICE_BYTES = 1024 * 1024;
89
+
90
+
91
+ /**
92
+ * Which attempt of which wave under which writer epoch a stream is. The
93
+ * session issued the epoch (openWaveWriter) and refuses, by default, an
94
+ * attempt under an epoch it does not hold open, or older than one it has
95
+ * admitted under it: an attempt the writer gave up on never applies after
96
+ * its re-send, however late it arrives.
97
+ */
98
+ export interface WaveFence {
99
+ writer: string;
100
+ wave: number;
101
+ attempt: number;
102
+ /**
103
+ * A sequenced writer's wave (a process's filesystem client): the number of
104
+ * its first op, each op after it the next, and the highest cursor the
105
+ * writer has had answered. The session keeps the writer's cursor with its
106
+ * commits, so an op a re-send carries again is answered, not applied twice.
107
+ */
108
+ seq?: number;
109
+ ack?: number;
110
+ }
111
+
112
+ /** The supervisor surface a writer publishes through. */
113
+ export interface WaveSupervisor {
114
+ /**
115
+ * `fence` is absent when the supervisor issued no epoch: the session
116
+ * fences nothing. `owner`: the delegation the wave is written under, when
117
+ * it is not the one the binding was made with.
118
+ */
119
+ writeBatchStream(stream: ReadableStream<Uint8Array>, fence?: WaveFence, owner?: string): Promise<unknown>;
120
+ /**
121
+ * A writer epoch from the session, the only identity it admits fenced
122
+ * waves under, or null when it fences nothing. Absent on a supervisor
123
+ * that is the session itself (nothing between them can lose a call).
124
+ */
125
+ openWaveWriter?(): Promise<string | null>;
126
+ }
127
+
128
+ /** A published file as the session will stat it: what a warm index entry needs. */
129
+ export interface WaveFileReceipt {
130
+ path: string;
131
+ ino: number;
132
+ mode: number;
133
+ size: number;
134
+ mtimeMs: number;
135
+ ctimeMs: number;
136
+ uid: number;
137
+ gid: number;
138
+ dev: number;
139
+ }
140
+
141
+ export interface WaveReport {
142
+ wave: number;
143
+ files: number;
144
+ bytes: number;
145
+ rpcWallMs: number;
146
+ receipts: WaveFileReceipt[];
147
+ }
148
+
149
+ /** What one wave carried, for the caller's own view of the paths it published. */
150
+ export interface WaveCut<Meta> {
151
+ wave: number;
152
+ mtimeMs: number;
153
+ /** Each file and link, with what its caller attached to the record. */
154
+ files: { path: string; meta: Meta | undefined }[];
155
+ directories: string[];
156
+ }
157
+
158
+ export interface WaveWriterOptions<Meta = undefined> {
159
+ supervisor: WaveSupervisor;
160
+ /**
161
+ * The directory a wave publishes up to, inclusive: a record's directories
162
+ * above it are not the writer's. Null publishes every ancestor.
163
+ */
164
+ root: string | null;
165
+ /** An existing worktree: only directories strictly below it are published. */
166
+ worktreeRoot?: string | null;
167
+ /** Record paths are relative to it (joined with '/'); omitted, they are VFS paths. */
168
+ base?: string;
169
+ /** Wall-clock time (ms) after which no new wave starts. */
170
+ deadline?: number | null;
171
+ /** Every inode's mtime; omitted, each wave stamps the time it was cut. */
172
+ mtimeMs?: number;
173
+ /** A directory's mode, as the caller knows it; omitted or undefined, 0o755. */
174
+ directoryMode?: (path: string) => number | undefined;
175
+ /** Called synchronously as a wave is cut, before it is sent. */
176
+ onCut?: (cut: WaveCut<Meta>) => void;
177
+ /** Called once per published wave, in order. */
178
+ onWave?: (report: WaveReport) => void;
179
+ /** A record's `meta` names its owner, and a failed wave fails only the owners it carried. */
180
+ failPerOwner?: boolean;
181
+ /** The lost-call policy's timings (lost-call.ts); tests shorten them. */
182
+ retry?: { backoffMs: readonly number[]; stallMs: number; answerDeadlineMs: number };
183
+ /** Called before each re-send of a lost wave, with its lost-call attributes (lost-call.ts). */
184
+ onResend?: (lost: Record<string, string | number>) => void;
185
+ }
186
+
187
+ export interface WaveStats {
188
+ waves: number;
189
+ files: number;
190
+ bytes: number;
191
+ rpcWallMs: number;
192
+ maxRpcWallMs: number;
193
+ producerWaitMs: number;
194
+ /** Paths probed by ownership accounting: linear in records, never wave × records. */
195
+ ownershipVisits: number;
196
+ maxWavePaths: number;
197
+ maxWaveBytes: number;
198
+ /** Waves sent again after their transport was lost. */
199
+ retries: number;
200
+ /** Waves encoded whole before they were sent (the rest streamed as encoded). */
201
+ wholeWaves: number;
202
+ }
203
+
204
+ type BufferedRecord<Meta> =
205
+ | { kind: 'file' | 'symlink'; mode: number; bytes: Uint8Array; meta: Meta | undefined }
206
+ | { kind: 'stream'; mode: number; size: number; source: AsyncIterable<Uint8Array>; meta: Meta | undefined };
207
+
208
+ interface Pin {
209
+ path: string;
210
+ bytes: Uint8Array;
211
+ durable: boolean;
212
+ }
213
+
214
+ interface Tally {
215
+ pathCount: number;
216
+ pathBytes: number;
217
+ }
218
+
219
+ export class WaveFailure extends Error {
220
+ readonly wave: number;
221
+ constructor(wave: number, cause: unknown) {
222
+ super(`write wave ${wave} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
223
+ this.name = 'WaveFailure';
224
+ this.wave = wave;
225
+ }
226
+ }
227
+
228
+ const encoder = new TextEncoder();
229
+
230
+ function parentOf(path: string): string {
231
+ const slash = path.lastIndexOf('/');
232
+ return slash < 0 ? '' : path.slice(0, slash);
233
+ }
234
+
235
+ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
236
+ return typeof value === 'object' && value !== null;
237
+ }
238
+
239
+ function waveResultError(result: unknown): Error | null {
240
+ if (!isRecord(result)) return new Error('writeBatchStream failed: missing writeBatchStream result');
241
+ if (result.ok === true) return null;
242
+ const error = result.error;
243
+ const detail = isRecord(error) && typeof error.message === 'string'
244
+ ? error.message
245
+ : 'missing writeBatchStream result';
246
+ return new Error(
247
+ 'writeBatchStream failed after group ' + String(result.committedGroupSequence) +
248
+ ' (' + String(result.committedPathCount) + ' committed paths): ' + detail,
249
+ );
250
+ }
251
+
252
+ const RECEIPT_NUMBERS = ['ino', 'mode', 'size', 'mtimeMs', 'ctimeMs', 'uid', 'gid', 'dev'] as const;
253
+
254
+ /** The session's stat of each published file, as it answered the wave. */
255
+ function parseReceipts(result: unknown): WaveFileReceipt[] {
256
+ if (!isRecord(result) || result.receipts === undefined) return [];
257
+ if (!Array.isArray(result.receipts)) throw new Error('writeBatchStream receipts are not a list');
258
+ return result.receipts.map((value: unknown) => {
259
+ if (!isRecord(value) || typeof value.path !== 'string') {
260
+ throw new Error('writeBatchStream receipt is invalid');
261
+ }
262
+ const receipt: WaveFileReceipt = {
263
+ path: value.path, ino: 0, mode: 0, size: 0, mtimeMs: 0, ctimeMs: 0, uid: 0, gid: 0, dev: 0,
264
+ };
265
+ for (const field of RECEIPT_NUMBERS) {
266
+ const number = value[field];
267
+ if (typeof number !== 'number' || !Number.isFinite(number)) {
268
+ throw new Error(`writeBatchStream receipt for ${value.path} has no ${field}`);
269
+ }
270
+ receipt[field] = number;
271
+ }
272
+ return receipt;
273
+ });
274
+ }
275
+
276
+ /**
277
+ * A view of `bytes` that owns its buffer. The writer holds a record until
278
+ * its wave publishes, and may send it again: a view would pin its whole
279
+ * parent buffer for that long, and see whatever the caller wrote to it next.
280
+ */
281
+ function ownedBytes(bytes: Uint8Array): Uint8Array {
282
+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) return bytes;
283
+ // A copy: a Node Buffer's slice() is another view of the same memory.
284
+ return new Uint8Array(bytes);
285
+ }
286
+
287
+ export class WaveWriter<Meta = undefined> {
288
+ private readonly options: WaveWriterOptions<Meta>;
289
+ private readonly records = new Map<string, BufferedRecord<Meta>>();
290
+ private readonly directories = new Set<string>();
291
+ private readonly deletes = new Set<string>();
292
+ private bufferedBytes = 0;
293
+ private symlinks = 0;
294
+ /** Links in the wave in flight: sent, not yet published. */
295
+ private inFlightSymlinks = 0;
296
+ /** Every path the buffered wave publishes; a superset once a buffered record is removed. */
297
+ private readonly owned = new Set<string>();
298
+ /** The upward-closed part of `owned`: each one's chain, to the root, is owned too. */
299
+ private readonly ownedDirectories = new Set<string>();
300
+ private ownedPathBytes = 0;
301
+ private pin: Pin | null = null;
302
+ private inFlight: Promise<void> | null = null;
303
+ private cutQueue: Promise<unknown> = Promise.resolve();
304
+ private sequence = 0;
305
+ private failure: WaveFailure | null = null;
306
+ /** Owners (records' `meta`) whose records a failed wave carried. */
307
+ private readonly failedOwners = new Map<Meta, WaveFailure>();
308
+ /** With `failPerOwner`: owners with a write in the buffered wave, superseded ones included. */
309
+ private readonly bufferedOwners = new Set<Meta>();
310
+ /** With `failPerOwner`: the last wave that carried each owner's writes. */
311
+ private readonly ownerWaves = new Map<Meta, number>();
312
+ /** Waves publish in order: every wave up to this one has settled, this one published. */
313
+ private lastPublishedWave = 0;
314
+ private readonly counters: WaveStats = {
315
+ waves: 0,
316
+ files: 0,
317
+ bytes: 0,
318
+ rpcWallMs: 0,
319
+ maxRpcWallMs: 0,
320
+ producerWaitMs: 0,
321
+ ownershipVisits: 0,
322
+ maxWavePaths: 0,
323
+ maxWaveBytes: 0,
324
+ retries: 0,
325
+ wholeWaves: 0,
326
+ };
327
+ /** The session's epoch for this writer, and when it was opened (null: unfenced). */
328
+ private epoch: Promise<{ writer: string | null; openedAt: number }> | null = null;
329
+ /** Mutations run one at a time, in call order: concurrent writers interleave by record. */
330
+ private mutations: Promise<unknown> = Promise.resolve();
331
+
332
+ constructor(options: WaveWriterOptions<Meta>) {
333
+ this.options = options;
334
+ }
335
+
336
+ // ── Records ──────────────────────────────────────────────────────────
337
+
338
+ /** Run `mutate` once every mutation called before it has finished. */
339
+ private exclusive<T>(mutate: () => Promise<T>): Promise<T> {
340
+ const run = this.mutations.then(mutate);
341
+ this.mutations = run.catch(() => {});
342
+ return run;
343
+ }
344
+
345
+ /**
346
+ * A regular file, with its permission bits as given (`mode & 0o7777`). The
347
+ * writer takes `bytes`; a view sharing its buffer is copied. `meta` rides
348
+ * with the record, back to the caller as it is cut.
349
+ */
350
+ file(path: string, mode: number, bytes: Uint8Array, meta?: Meta): Promise<void> {
351
+ return this.exclusive(async () => {
352
+ const key = this.key(path);
353
+ this.assertOwnerHealthy(meta);
354
+ await this.afterRemoval(key);
355
+ await this.admit(key, bytes.byteLength, true);
356
+ this.buffer(key, { kind: 'file', mode: mode & 0o7777, bytes: ownedBytes(bytes), meta });
357
+ await this.cutIfFull();
358
+ });
359
+ }
360
+
361
+ /** A symbolic link to `target`. */
362
+ symlink(path: string, target: string, meta?: Meta): Promise<void> {
363
+ return this.exclusive(async () => {
364
+ const key = this.key(path);
365
+ const bytes = encoder.encode(target);
366
+ this.assertOwnerHealthy(meta);
367
+ await this.afterRemoval(key);
368
+ await this.admit(key, bytes.byteLength, true);
369
+ this.buffer(key, { kind: 'symlink', mode: 0o777, bytes, meta });
370
+ await this.cutIfFull();
371
+ });
372
+ }
373
+
374
+ /**
375
+ * A regular file of `size` bytes read from `chunks` as its wave drains:
376
+ * the buffered wave is sent first, then this file travels alone, and the
377
+ * call resolves once its source is consumed.
378
+ */
379
+ fileChunks(
380
+ path: string,
381
+ mode: number,
382
+ size: number,
383
+ chunks: AsyncIterable<Uint8Array>,
384
+ meta?: Meta,
385
+ ): Promise<void> {
386
+ return this.exclusive(async () => {
387
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error(`write wave: ${path}: invalid size ${size}`);
388
+ const key = this.key(path);
389
+ this.assertOwnerHealthy(meta);
390
+ if (this.hasBuffered()) await this.cut();
391
+ this.buffer(key, { kind: 'stream', mode: mode & 0o7777, size, source: chunks, meta });
392
+ await this.cut();
393
+ if (this.inFlight) await this.inFlight;
394
+ });
395
+ }
396
+
397
+ /** A directory (an empty one, a gitlink): files' directories need no record. */
398
+ directory(path: string): Promise<void> {
399
+ return this.exclusive(async () => {
400
+ const key = this.key(path);
401
+ await this.afterRemoval(key);
402
+ await this.admit(key, 0, true);
403
+ this.directories.add(key);
404
+ this.deletes.delete(key);
405
+ const tally = this.tally();
406
+ this.ownPath(key, true, tally);
407
+ this.walkChain(key, true, tally);
408
+ this.ownedPathBytes = tally.pathBytes;
409
+ await this.cutIfFull();
410
+ });
411
+ }
412
+
413
+ /**
414
+ * Remove what stands at `path`, its subtree included. A buffered record at
415
+ * the path is dropped; with `directory`, a buffered mkdir of it too.
416
+ */
417
+ remove(path: string, directory = false): Promise<void> {
418
+ return this.exclusive(async () => {
419
+ const key = this.key(path);
420
+ await this.admit(key, 0, false);
421
+ this.drop(key);
422
+ if (directory) this.directories.delete(key);
423
+ this.deletes.add(key);
424
+ const tally = this.tally();
425
+ this.ownPath(key, true, tally);
426
+ this.ownedPathBytes = tally.pathBytes;
427
+ await this.cutIfFull();
428
+ });
429
+ }
430
+
431
+ /**
432
+ * A file every wave re-asserts until a wave carrying it publishes (a
433
+ * clone's ownership marker): parent directories publish independently of
434
+ * files, so each wave leaves the marker's proof in place. `durable` says
435
+ * these bytes are already published.
436
+ */
437
+ setPin(path: string, text: string, durable = false): void {
438
+ this.pin = { path: this.key(path), bytes: encoder.encode(text), durable };
439
+ }
440
+
441
+ clearPin(path: string): void {
442
+ if (this.pin && this.pin.path === this.key(path)) this.pin = null;
443
+ }
444
+
445
+ // ── The buffered wave, for a reader that must see it ─────────────────
446
+
447
+ /** The bytes buffered at `path`, if a file or link is. */
448
+ buffered(path: string): Uint8Array | undefined {
449
+ const record = this.records.get(path);
450
+ return record && record.kind !== 'stream' ? record.bytes : undefined;
451
+ }
452
+
453
+ /** The buffered file or link at `path`: its kind, size and the caller's `meta`. */
454
+ bufferedRecord(path: string): { kind: 'file' | 'symlink'; size: number; meta: Meta | undefined } | undefined {
455
+ const record = this.records.get(path);
456
+ if (record === undefined) return undefined;
457
+ return record.kind === 'stream'
458
+ ? { kind: 'file', size: record.size, meta: record.meta }
459
+ : { kind: record.kind, size: record.bytes.byteLength, meta: record.meta };
460
+ }
461
+
462
+ isBufferedDirectory(path: string): boolean {
463
+ return this.directories.has(path);
464
+ }
465
+
466
+ isBufferedDelete(path: string): boolean {
467
+ return this.deletes.has(path);
468
+ }
469
+
470
+ bufferedPaths(): { files: Iterable<string>; directories: Iterable<string>; deletes: Iterable<string> } {
471
+ return { files: this.records.keys(), directories: this.directories, deletes: this.deletes };
472
+ }
473
+
474
+ /** Whether a link is buffered or in flight: written, not yet published. */
475
+ get hasUnpublishedSymlinks(): boolean {
476
+ return this.symlinks > 0 || this.inFlightSymlinks > 0;
477
+ }
478
+
479
+ // ── Publication ──────────────────────────────────────────────────────
480
+
481
+ /** Every record written before this call is durable; rejects with the first failed wave. */
482
+ flush(): Promise<void> {
483
+ return this.exclusive(async () => {
484
+ await this.cut();
485
+ if (this.inFlight) await this.inFlight;
486
+ this.assertHealthy();
487
+ });
488
+ }
489
+
490
+ /** The wave in flight has settled; nothing new is cut. */
491
+ async settled(): Promise<void> {
492
+ if (this.inFlight) await this.inFlight.catch(() => {});
493
+ this.assertHealthy();
494
+ }
495
+
496
+ assertHealthy(): void {
497
+ if (this.failure) throw this.failure;
498
+ }
499
+
500
+ /** The failure of the wave that carried `owner`'s records, if one failed. */
501
+ failureOf(owner: Meta): WaveFailure | undefined {
502
+ return this.failedOwners.get(owner);
503
+ }
504
+
505
+ /**
506
+ * Whether every record written with `owner` (as `meta`, under
507
+ * `failPerOwner`) is durable: each wave that carried it, or a write
508
+ * superseding it, published.
509
+ */
510
+ published(owner: Meta): boolean {
511
+ if (this.options.failPerOwner !== true) throw new Error('published(owner) needs failPerOwner');
512
+ return !this.failedOwners.has(owner)
513
+ && !this.bufferedOwners.has(owner)
514
+ && (this.ownerWaves.get(owner) ?? 0) <= this.lastPublishedWave;
515
+ }
516
+
517
+ private assertOwnerHealthy(owner: Meta | undefined): void {
518
+ this.assertHealthy();
519
+ if (owner === undefined) return;
520
+ const failure = this.failedOwners.get(owner);
521
+ if (failure) throw failure;
522
+ }
523
+
524
+ get failed(): WaveFailure | null {
525
+ return this.failure;
526
+ }
527
+
528
+ stats(): WaveStats {
529
+ return { ...this.counters };
530
+ }
531
+
532
+ // ── Internals ────────────────────────────────────────────────────────
533
+
534
+ private key(path: string): string {
535
+ return this.options.base ? this.options.base + '/' + path : path;
536
+ }
537
+
538
+ private tally(): Tally {
539
+ return { pathCount: this.owned.size, pathBytes: this.ownedPathBytes };
540
+ }
541
+
542
+ private hasBuffered(): boolean {
543
+ return this.records.size > 0 || this.directories.size > 0 || this.deletes.size > 0;
544
+ }
545
+
546
+ private ownPath(path: string, admit: boolean, tally: Tally): void {
547
+ this.counters.ownershipVisits++;
548
+ if (this.owned.has(path)) return;
549
+ tally.pathCount++;
550
+ tally.pathBytes += utf8Length(path);
551
+ if (admit) this.owned.add(path);
552
+ }
553
+
554
+ /** collectDirectoryPaths' chain from `path` upward, to the first directory already owned. */
555
+ private walkChain(path: string, admit: boolean, tally: Tally): void {
556
+ const { root, worktreeRoot = null } = this.options;
557
+ let current = path;
558
+ while (current) {
559
+ if (root && current !== root && !current.startsWith(root + '/')) break;
560
+ if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) break;
561
+ this.counters.ownershipVisits++;
562
+ if (this.ownedDirectories.has(current)) break;
563
+ if (admit) this.ownedDirectories.add(current);
564
+ if (!this.owned.has(current)) {
565
+ tally.pathCount++;
566
+ tally.pathBytes += utf8Length(current);
567
+ if (admit) this.owned.add(current);
568
+ }
569
+ if (current === root) break;
570
+ current = parentOf(current);
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Send what is buffered when a removal of `path` is pending in it: a wave
576
+ * names a path once, and what is made there must land after the removal,
577
+ * as rm then create does (the directory's permission decides, not the old
578
+ * file's mode).
579
+ */
580
+ private async afterRemoval(path: string): Promise<void> {
581
+ if (this.deletes.has(path)) await this.cut();
582
+ }
583
+
584
+ /** Cut waves until `path` (with its chain) and `bytes` fit beside what is buffered. */
585
+ private async admit(path: string, bytes: number, withParents: boolean): Promise<void> {
586
+ this.assertHealthy();
587
+ while (this.hasBuffered()) {
588
+ const tally = this.tally();
589
+ this.ownPath(path, false, tally);
590
+ if (withParents) this.walkChain(parentOf(path), false, tally);
591
+ const replaced = this.records.get(path);
592
+ const replacedBytes = replaced && replaced.kind !== 'stream' ? replaced.bytes.byteLength : 0;
593
+ if (this.bufferedBytes - replacedBytes + bytes <= WAVE_BYTES &&
594
+ tally.pathCount < WAVE_PATHS &&
595
+ tally.pathBytes < WAVE_PATH_BYTES) return;
596
+ await this.cut();
597
+ }
598
+ const tally = this.tally();
599
+ this.ownPath(path, false, tally);
600
+ if (withParents) this.walkChain(parentOf(path), false, tally);
601
+ if (tally.pathCount > W7_MAX_PATHS_PER_BATCH) {
602
+ throw new Error('write wave exceeds ' + W7_MAX_PATHS_PER_BATCH + ' owned paths');
603
+ }
604
+ if (tally.pathBytes > W7_MAX_OWNED_PATH_BYTES) {
605
+ throw new Error('write wave exceeds ' + W7_MAX_OWNED_PATH_BYTES + ' owned path bytes');
606
+ }
607
+ }
608
+
609
+ private buffer(path: string, record: BufferedRecord<Meta>): void {
610
+ this.drop(path);
611
+ if (this.options.failPerOwner === true && record.meta !== undefined) this.bufferedOwners.add(record.meta);
612
+ this.deletes.delete(path);
613
+ this.records.set(path, record);
614
+ if (record.kind === 'symlink') this.symlinks++;
615
+ if (record.kind !== 'stream') this.bufferedBytes += record.bytes.byteLength;
616
+ const tally = this.tally();
617
+ this.ownPath(path, true, tally);
618
+ this.walkChain(parentOf(path), true, tally);
619
+ this.ownedPathBytes = tally.pathBytes;
620
+ }
621
+
622
+ private drop(path: string): void {
623
+ const previous = this.records.get(path);
624
+ if (!previous) return;
625
+ if (previous.kind === 'symlink') this.symlinks--;
626
+ if (previous.kind !== 'stream') this.bufferedBytes -= previous.bytes.byteLength;
627
+ this.records.delete(path);
628
+ }
629
+
630
+ private async cutIfFull(): Promise<void> {
631
+ if (this.owned.size >= WAVE_PATHS ||
632
+ this.ownedPathBytes >= WAVE_PATH_BYTES ||
633
+ this.bufferedBytes >= WAVE_BYTES) {
634
+ await this.cut();
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Send the buffered wave once the one in flight has published. Cuts are
640
+ * serialised, so at most one wave is in flight and one buffers.
641
+ */
642
+ private cut(): Promise<void> {
643
+ const run = this.cutQueue.then(() => this.cutNow());
644
+ this.cutQueue = run.catch(() => {});
645
+ return run;
646
+ }
647
+
648
+ private async cutNow(): Promise<void> {
649
+ if (this.inFlight) {
650
+ const waitStarted = Date.now();
651
+ await this.inFlight.catch(() => {});
652
+ this.counters.producerWaitMs += Date.now() - waitStarted;
653
+ }
654
+ this.assertHealthy();
655
+ // A wave that failed took its owners with it: their records still
656
+ // buffered are not sent (an owner's later record must not publish
657
+ // without its earlier ones).
658
+ if (this.failedOwners.size > 0) {
659
+ for (const [path, record] of [...this.records]) {
660
+ if (record.meta !== undefined && this.failedOwners.has(record.meta)) this.drop(path);
661
+ }
662
+ for (const owner of [...this.bufferedOwners]) if (this.failedOwners.has(owner)) this.bufferedOwners.delete(owner);
663
+ }
664
+ this.bufferPin();
665
+ if (!this.hasBuffered()) return;
666
+ // A wave already sent can still publish after the deadline; none starts after it.
667
+ const { deadline = null } = this.options;
668
+ if (deadline !== null && Date.now() >= deadline) {
669
+ throw new Error('phase deadline reached before starting a new write wave');
670
+ }
671
+ const wave = ++this.sequence;
672
+ const sentPin = this.pin;
673
+ const owners = [...this.bufferedOwners];
674
+ for (const owner of owners) this.ownerWaves.set(owner, wave);
675
+ const mtime = this.options.mtimeMs ?? Date.now();
676
+ const files: { path: string; meta: Meta | undefined }[] = [];
677
+ const directories = this.publishedDirectories();
678
+ const inodes: BatchInodeEntry[] = [];
679
+ const chunks: BatchChunkEntry[] = [];
680
+ const streams: BatchStreamEntry[] = [];
681
+ let waveBytes = 0;
682
+ for (const dir of directories) {
683
+ inodes.push({
684
+ path: dir, parentPath: parentOf(dir), kind: 'directory', isDir: true,
685
+ size: 0, mtime, mode: this.options.directoryMode?.(dir) ?? 0o755, chunkCount: 0,
686
+ });
687
+ }
688
+ for (const [path, record] of this.records) {
689
+ files.push({ path, meta: record.meta });
690
+ const size = record.kind === 'stream' ? record.size : record.bytes.byteLength;
691
+ const chunkCount = w7ChunkCount(size);
692
+ waveBytes += size;
693
+ inodes.push({
694
+ path, parentPath: parentOf(path),
695
+ kind: record.kind === 'symlink' ? 'symlink' : 'file',
696
+ isDir: false, size, mtime, mode: record.mode, chunkCount,
697
+ });
698
+ if (record.kind === 'stream') {
699
+ streams.push({ path, source: record.source });
700
+ continue;
701
+ }
702
+ // Views: the encoder copies a chunk's bytes into the buffers it
703
+ // enqueues, so the record's bytes stay whole for a re-send.
704
+ chunks.push(...w7Chunks(path, record.bytes));
705
+ }
706
+ const deletePaths = this.deletes.size > 0 ? [...this.deletes] : undefined;
707
+ const ownedOnly = this.options.failPerOwner === true
708
+ && files.length > 0
709
+ && files.every((file) => file.meta !== undefined)
710
+ && this.directories.size === 0
711
+ && this.deletes.size === 0
712
+ && (sentPin === null || !this.records.has(sentPin.path));
713
+ const paths = this.owned.size;
714
+ const symlinks = this.symlinks;
715
+ this.options.onCut?.({ wave, mtimeMs: mtime, files, directories });
716
+ // The payload holds the wave's bytes now; the buffer lets go of them so
717
+ // the facet holds one copy while the stream drains.
718
+ this.records.clear();
719
+ this.bufferedOwners.clear();
720
+ this.directories.clear();
721
+ this.deletes.clear();
722
+ this.owned.clear();
723
+ this.ownedDirectories.clear();
724
+ this.ownedPathBytes = 0;
725
+ this.bufferedBytes = 0;
726
+ this.symlinks = 0;
727
+ this.counters.maxWavePaths = Math.max(this.counters.maxWavePaths, paths);
728
+ this.counters.maxWaveBytes = Math.max(this.counters.maxWaveBytes, waveBytes);
729
+
730
+ this.inFlightSymlinks = symlinks;
731
+ const sentAt = Date.now();
732
+ const opened = this.open({ inodes, chunks, deletePaths, streams }, waveBytes);
733
+ const published = opened.then(({ open, streamed }) => this.sendAttempts(open, streamed, wave)).then((result) => {
734
+ try {
735
+ const error = waveResultError(result);
736
+ if (error) throw error;
737
+ const rpcWallMs = Date.now() - sentAt;
738
+ if (sentPin !== null && this.pin === sentPin) sentPin.durable = true;
739
+ this.inFlightSymlinks = 0;
740
+ this.lastPublishedWave = wave;
741
+ this.counters.waves++;
742
+ this.counters.files += files.length;
743
+ this.counters.bytes += waveBytes;
744
+ this.counters.rpcWallMs += rpcWallMs;
745
+ this.counters.maxRpcWallMs = Math.max(this.counters.maxRpcWallMs, rpcWallMs);
746
+ this.options.onWave?.({ wave, files: files.length, bytes: waveBytes, rpcWallMs, receipts: parseReceipts(result) });
747
+ } finally {
748
+ disposeRpcResource(result);
749
+ }
750
+ }).catch((error: unknown) => {
751
+ const failure = error instanceof WaveFailure ? error : new WaveFailure(wave, error);
752
+ this.inFlightSymlinks = 0;
753
+ // A wave whose every record has an owner fails those owners, and
754
+ // the owners of writes its records superseded; the writer goes on
755
+ // for the rest. Anything unowned in it (a directory, a removal, the
756
+ // pin, a record without meta) fails the writer.
757
+ if (ownedOnly) {
758
+ for (const owner of owners) this.failedOwners.set(owner, failure);
759
+ return;
760
+ }
761
+ if (this.failure === null) this.failure = failure;
762
+ throw this.failure;
763
+ }).finally(() => {
764
+ if (this.inFlight === published) this.inFlight = null;
765
+ });
766
+ // Its failure reaches whoever waits next; it is never unobserved. In
767
+ // flight from here, before its encode is awaited: its records are no
768
+ // longer buffered, so settled() must wait for it.
769
+ published.catch(() => {});
770
+ this.inFlight = published;
771
+ // The next wave buffers only once this one is encoded and its records
772
+ // let go, so a producer holds one wave's bytes beside the next, not two;
773
+ // its answer is still awaited only by the cut after it.
774
+ await opened.catch(() => {});
775
+ }
776
+
777
+ /**
778
+ * How a wave of `bytes` content crosses: an attempt's stream, made anew
779
+ * for each attempt.
780
+ *
781
+ * Across a transport (a supervisor that opens epochs), a wave held in
782
+ * memory is encoded whole before it is sent, and the transport takes it in
783
+ * SEND_SLICE_BYTES pieces, never waiting on the encoder. A re-send sends
784
+ * the same bytes, and the wave's records are let go once it is encoded.
785
+ * Measured, eight producers into one session, Markflow's file sizes: the
786
+ * encoder's stream, pulled through the watchdog as the transport read it,
787
+ * took 1,002 files/s; the wave encoded first, 1,374 (re-chunking that
788
+ * stream to 64 KiB or 1 MiB gained nothing). A wave with a streamed
789
+ * source is never held whole, nor is a lone file larger than a wave, and a
790
+ * session's own writer has no transport to wait on: they stream as the
791
+ * encoder makes them.
792
+ */
793
+ private async open(payload: BatchWritePayload, bytes: number): Promise<{ open: () => ReadableStream<Uint8Array>; streamed: boolean }> {
794
+ const streamed = (payload.streams?.length ?? 0) > 0;
795
+ // A file larger than a wave travels in a wave of its own: never copied whole.
796
+ if (streamed || bytes > WAVE_BYTES || this.options.supervisor.openWaveWriter === undefined) {
797
+ return { open: encoderOf(payload), streamed };
798
+ }
799
+ const whole = await encodeWriteBatch(payload);
800
+ this.counters.wholeWaves++;
801
+ return { open: piecesOf(whole, SEND_SLICE_BYTES), streamed: false };
802
+ }
803
+
804
+ /** Send one wave (sendWaveAttempts) under this writer's epoch. */
805
+ private sendAttempts(open: () => ReadableStream<Uint8Array>, streamed: boolean, wave: number): Promise<unknown> {
806
+ return sendWaveAttempts({
807
+ supervisor: this.options.supervisor,
808
+ writer: () => this.currentEpoch(),
809
+ open,
810
+ streamed,
811
+ wave,
812
+ ...(this.options.retry === undefined ? {} : { retry: this.options.retry }),
813
+ resent: (lost) => {
814
+ this.counters.retries++;
815
+ this.options.onResend?.(lost);
816
+ },
817
+ });
818
+ }
819
+
820
+ /**
821
+ * The epoch this writer's waves are fenced under: opened before its first
822
+ * wave, and again once half of WAVE_EPOCH_TTL_MS has passed, so a wave is
823
+ * never sent under an epoch about to close. Opened under the lost-call
824
+ * policy (openWaveEpoch); an open that failed even so is not the writer's
825
+ * epoch, and the next wave opens again.
826
+ */
827
+ private async currentEpoch(): Promise<string | null> {
828
+ const supervisor = this.options.supervisor;
829
+ if (supervisor.openWaveWriter === undefined) return null;
830
+ const now = Date.now();
831
+ const held = this.epoch === null ? null : await this.epoch;
832
+ if (held === null || (held.writer !== null && now - held.openedAt >= WAVE_EPOCH_TTL_MS / 2)) {
833
+ // Called as a method of the supervisor, never through .call/.apply:
834
+ // on an RPC stub those are remote method names too.
835
+ const opening = openWaveEpoch(() => supervisor.openWaveWriter!(), {
836
+ ...(this.options.retry === undefined ? {} : { retry: this.options.retry }),
837
+ resent: (lost) => {
838
+ this.counters.retries++;
839
+ this.options.onResend?.(lost);
840
+ },
841
+ }).then((writer) => ({ writer, openedAt: now }));
842
+ this.epoch = opening;
843
+ opening.catch(() => { if (this.epoch === opening) this.epoch = null; });
844
+ }
845
+ return (await this.epoch!).writer;
846
+ }
847
+
848
+ /** The directories the buffered records publish, shallowest first. */
849
+ private publishedDirectories(): string[] {
850
+ const dirs = new Set<string>();
851
+ const { root, worktreeRoot = null } = this.options;
852
+ const collect = (path: string): void => {
853
+ let current = path;
854
+ while (current) {
855
+ if (root && current !== root && !current.startsWith(root + '/')) break;
856
+ if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) break;
857
+ if (dirs.has(current)) break;
858
+ dirs.add(current);
859
+ if (current === root) break;
860
+ current = parentOf(current);
861
+ }
862
+ };
863
+ for (const path of this.records.keys()) collect(parentOf(path));
864
+ for (const dir of this.directories) collect(dir);
865
+ return [...dirs].sort((left, right) => {
866
+ const depth = left.split('/').length - right.split('/').length;
867
+ return depth || (left < right ? -1 : left > right ? 1 : 0);
868
+ });
869
+ }
870
+
871
+ private bufferPin(): void {
872
+ const pin = this.pin;
873
+ if (!pin) return;
874
+ // A durable pin asserts presence, not churn: re-writing identical bytes
875
+ // re-arms the receiver's content GC for no change. Re-buffer only when a
876
+ // buffered record claims the path.
877
+ if (pin.durable && !this.records.has(pin.path) && !this.deletes.has(pin.path)) return;
878
+ this.buffer(pin.path, { kind: 'file', mode: 0o644, bytes: pin.bytes.slice(), meta: undefined });
879
+ }
880
+ }
881
+
882
+ /** An attempt the session never read or never answered: its call did not arrive, or its answer was lost. */
883
+ /** One wave's attempts (sendWaveAttempts). */
884
+ export interface WaveAttempts {
885
+ supervisor: WaveSupervisor;
886
+ /** The epoch each attempt is fenced under, asked before each one (null: unfenced). */
887
+ writer: () => Promise<string | null>;
888
+ /** A fresh stream of the wave's bytes for each attempt. */
889
+ open: () => ReadableStream<Uint8Array>;
890
+ /** A wave with a streamed source is sent once: its source is spent. */
891
+ streamed: boolean;
892
+ wave: number;
893
+ /** A sequenced writer's numbering, on every attempt's fence (WaveFence `seq`, `ack`). */
894
+ sequence?: { seq: number; ack: number };
895
+ /** The delegation the wave is written under (WaveSupervisor.writeBatchStream's `owner`). */
896
+ owner?: string;
897
+ /** The lost-call policy's timings (lost-call.ts); tests shorten them. */
898
+ retry?: { backoffMs: readonly number[]; stallMs: number; answerDeadlineMs: number };
899
+ /** Told before each re-send, with its lost-call attributes. */
900
+ resent?: (lost: Record<string, string | number>) => void;
901
+ /** The timers the watch and the backoff run on: a program's own may be its shims'. */
902
+ timers?: WaveTimers;
903
+ }
904
+
905
+ /** setTimeout and clearTimeout, as a caller captured them. */
906
+ export interface WaveTimers {
907
+ setTimeout(callback: () => void, ms: number): unknown;
908
+ clearTimeout(timer: unknown): void;
909
+ }
910
+
911
+ const GLOBAL_TIMERS: WaveTimers = {
912
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
913
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
914
+ };
915
+
916
+ /**
917
+ * Send one wave, again while its transport is lost (see the module's
918
+ * comment), and answer with what the session answered. The one way every
919
+ * W7 producer sends: the wave writer, and a process's filesystem client.
920
+ */
921
+ export async function sendWaveAttempts(options: WaveAttempts): Promise<unknown> {
922
+ const { backoffMs, stallMs, answerDeadlineMs } = options.retry
923
+ ?? { backoffMs: LOST_CALL_RESEND_BACKOFF_MS, stallMs: LOST_STREAM_STALL_MS, answerDeadlineMs: LOST_STREAM_ANSWER_MS };
924
+ const timers = options.timers ?? GLOBAL_TIMERS;
925
+ for (let attempt = 0; ; attempt++) {
926
+ const writer = await options.writer();
927
+ const attemptStream = abortable(options.open(), stallMs, answerDeadlineMs, timers);
928
+ const fence: WaveFence | undefined = writer === null ? undefined : { writer, wave: options.wave, attempt: attempt + 1, ...options.sequence };
929
+ // Called as a method of the supervisor, never through .call/.apply: on an RPC stub those are remote method names too.
930
+ const answer = options.owner === undefined
931
+ ? options.supervisor.writeBatchStream(attemptStream.stream, fence)
932
+ : options.supervisor.writeBatchStream(attemptStream.stream, fence, options.owner);
933
+ try {
934
+ return await Promise.race([answer, attemptStream.lost]);
935
+ } catch (error) {
936
+ const lost = error instanceof WaveLost || isLostFencedCall(error);
937
+ // Given up: the attempt reads nothing more either (its writer retires its epoch).
938
+ if (lost && (options.streamed || attempt >= backoffMs.length)) attemptStream.abort(error);
939
+ if (!lost || options.streamed || attempt >= backoffMs.length) throw error;
940
+ // The abandoned attempt can read nothing more, and its late answer is dropped.
941
+ attemptStream.abort(error);
942
+ answer.then(disposeRpcResource, () => {});
943
+ options.resent?.(lostCallAttributes({
944
+ operation: 'writeBatchStream',
945
+ attempt: attempt + 1,
946
+ of: backoffMs.length,
947
+ reason: error instanceof Error ? error.message : String(error),
948
+ }));
949
+ await new Promise<void>((resolve) => { timers.setTimeout(resolve, retryDelayMs(backoffMs, attempt)); });
950
+ } finally {
951
+ attemptStream.settle();
952
+ }
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Open a writer epoch (`open`: the supervisor's openWaveWriter, or a
958
+ * process's openWriter), again while the call is lost or shed, under a
959
+ * wave's lost-call policy and budget (isLostFencedCall,
960
+ * LOST_CALL_RESEND_BACKOFF_MS). Re-opening is safe: an open the session
961
+ * never ran issued nothing, and one whose answer was lost issued an epoch
962
+ * nothing is sent under, which closes at WAVE_EPOCH_TTL_MS. Anything else
963
+ * it answered is its verdict. A shed open would otherwise fail every write
964
+ * the epoch was for, unsent: measured, create-next-app lost 31 of 42
965
+ * packages to one.
966
+ */
967
+ export async function openWaveEpoch(
968
+ open: () => Promise<string | null>,
969
+ options: { retry?: { backoffMs: readonly number[] }; resent?: (lost: Record<string, string | number>) => void; timers?: WaveTimers } = {},
970
+ ): Promise<string | null> {
971
+ const backoffMs = options.retry?.backoffMs ?? LOST_CALL_RESEND_BACKOFF_MS;
972
+ const timers = options.timers ?? GLOBAL_TIMERS;
973
+ for (let attempt = 0; ; attempt++) {
974
+ try {
975
+ return await open();
976
+ } catch (error) {
977
+ if (!isLostFencedCall(error) || attempt >= backoffMs.length) throw error;
978
+ options.resent?.(lostCallAttributes({
979
+ operation: 'openWaveWriter',
980
+ attempt: attempt + 1,
981
+ of: backoffMs.length,
982
+ reason: error instanceof Error ? error.message : String(error),
983
+ }));
984
+ await new Promise<void>((resolve) => { timers.setTimeout(resolve, retryDelayMs(backoffMs, attempt)); });
985
+ }
986
+ }
987
+ }
988
+
989
+ /** A wave's encoded bytes as each attempt's stream (in SEND_SLICE_BYTES pieces, each a copy). */
990
+ export function waveAttemptsOf(bytes: Uint8Array): () => ReadableStream<Uint8Array> {
991
+ return piecesOf(bytes, SEND_SLICE_BYTES);
992
+ }
993
+
994
+ class WaveLost extends Error {
995
+ constructor(message: string) {
996
+ super(message);
997
+ this.name = 'WaveLost';
998
+ }
999
+ }
1000
+
1001
+ /** An attempt's stream as the encoder makes it, read from `payload`. */
1002
+ function encoderOf(payload: BatchWritePayload): () => ReadableStream<Uint8Array> {
1003
+ return () => encodeWriteBatchStream(payload);
1004
+ }
1005
+
1006
+ /**
1007
+ * An attempt's stream as `bytes` in `size`-byte pieces, each its own copy:
1008
+ * the watched stream they pass through transfers what it enqueues, and the
1009
+ * bytes stay whole for a re-send. (A function of its own, so the closure
1010
+ * holds the bytes and nothing the encoder read them from.)
1011
+ */
1012
+ function piecesOf(bytes: Uint8Array, size: number): () => ReadableStream<Uint8Array> {
1013
+ return () => slices(bytes, size);
1014
+ }
1015
+
1016
+ function slices(bytes: Uint8Array, size: number): ReadableStream<Uint8Array> {
1017
+ let at = 0;
1018
+ return new ReadableStream<Uint8Array>({
1019
+ pull(controller) {
1020
+ if (at >= bytes.byteLength) {
1021
+ controller.close();
1022
+ return;
1023
+ }
1024
+ const end = Math.min(bytes.byteLength, at + size);
1025
+ controller.enqueue(bytes.slice(at, end));
1026
+ at = end;
1027
+ },
1028
+ }, { highWaterMark: 0 });
1029
+ }
1030
+
1031
+ /**
1032
+ * One attempt's stream, watched from the writer's side: `lost` rejects when
1033
+ * nothing has read it for `stallMs` before it ended, or no answer came
1034
+ * `answerDeadlineMs` after it ended; `abort` errors it so the attempt reads
1035
+ * nothing more. Reading is the bytes drained from the stream's queue: a
1036
+ * reader taking a large piece in small reads drains it without a pull.
1037
+ */
1038
+ function abortable(stream: ReadableStream<Uint8Array>, stallMs: number, answerDeadlineMs: number, timers: WaveTimers): {
1039
+ stream: ReadableStream<Uint8Array>;
1040
+ lost: Promise<never>;
1041
+ abort(reason: unknown): void;
1042
+ settle(): void;
1043
+ } {
1044
+ const reader = stream.getReader();
1045
+ let target: ReadableByteStreamController | null = null;
1046
+ let timer: unknown = null;
1047
+ let declareLost: (error: WaveLost) => void = () => {};
1048
+ const lost = new Promise<never>((_, reject) => { declareLost = reject; });
1049
+ lost.catch(() => {});
1050
+ const watch = (ms: number, message: string): void => {
1051
+ if (timer !== null) timers.clearTimeout(timer);
1052
+ timer = timers.setTimeout(() => declareLost(new WaveLost(message)), ms);
1053
+ };
1054
+ /** Bytes enqueued and not yet read (the queue's total, as desiredSize below a mark of 0). */
1055
+ const queued = (): number => Math.max(0, -(target?.desiredSize ?? 0));
1056
+ // Lost when a stall period passes with nothing drained from the queue.
1057
+ const watchReads = (): void => {
1058
+ if (timer !== null) timers.clearTimeout(timer);
1059
+ const unread = queued();
1060
+ timer = timers.setTimeout(() => {
1061
+ if (queued() < unread) watchReads();
1062
+ else declareLost(new WaveLost(`writeBatchStream stalled: nothing read it for ${stallMs} ms`));
1063
+ }, stallMs);
1064
+ };
1065
+ watchReads();
1066
+ let sourceDone = false;
1067
+ const source: UnderlyingByteSource = {
1068
+ type: 'bytes',
1069
+ start(controller) {
1070
+ target = controller;
1071
+ },
1072
+ async pull(controller) {
1073
+ const next = sourceDone ? null : await reader.read();
1074
+ if (next === null || next.done) {
1075
+ sourceDone = true;
1076
+ // The stream has ended once its reader asks for more with nothing
1077
+ // queued; a pull may come while it still drains the last piece.
1078
+ if (queued() > 0) return;
1079
+ watch(answerDeadlineMs, `writeBatchStream unanswered ${answerDeadlineMs} ms after its stream ended`);
1080
+ controller.close();
1081
+ // A reader waiting with its own buffer is answered "done" only so.
1082
+ controller.byobRequest?.respond(0);
1083
+ return;
1084
+ }
1085
+ controller.enqueue(next.value);
1086
+ watchReads();
1087
+ },
1088
+ cancel(reason) {
1089
+ return reader.cancel(reason);
1090
+ },
1091
+ };
1092
+ return {
1093
+ stream: new ReadableStream<Uint8Array>(source as never, { highWaterMark: 0 }),
1094
+ lost,
1095
+ abort(reason) {
1096
+ try { target?.error(reason); } catch { /* already closed */ }
1097
+ reader.cancel(reason).catch(() => {});
1098
+ },
1099
+ settle() {
1100
+ if (timer !== null) timers.clearTimeout(timer);
1101
+ timer = null;
1102
+ },
1103
+ };
1104
+ }
1105
+
1106
+ export function createWaveWriter<Meta = undefined>(options: WaveWriterOptions<Meta>): WaveWriter<Meta> {
1107
+ return new WaveWriter(options);
1108
+ }