@nimbus-sh/platform 0.7.0 → 0.8.0

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,879 @@
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
+ import { encodeWriteBatch, encodeWriteBatchStream, W7_MAX_OWNED_PATH_BYTES, W7_MAX_PATHS_PER_BATCH, w7ChunkCount, w7Chunks, } from './w7-frame.js';
58
+ import { LOST_CALL_RESEND_BACKOFF_MS, LOST_STREAM_ANSWER_MS, LOST_STREAM_STALL_MS, WAVE_EPOCH_TTL_MS, isLostFencedCall, lostCallAttributes, } from './lost-call.js';
59
+ import { disposeRpcResource } from './rpc-dispose.js';
60
+ import { retryDelayMs } from './retry.js';
61
+ import { utf8Length } from './utf8.js';
62
+ /** Paths a wave holds back from W7's bound, for its pinned marker and the marker's directories. */
63
+ export const WAVE_PATHS = W7_MAX_PATHS_PER_BATCH - 8;
64
+ export const WAVE_PATH_BYTES = W7_MAX_OWNED_PATH_BYTES - 4 * 1024;
65
+ /** Buffered content bytes that close a wave. */
66
+ export const WAVE_BYTES = 4 * 1024 * 1024;
67
+ /** The pieces a wave encoded whole crosses its transport in. */
68
+ const SEND_SLICE_BYTES = 1024 * 1024;
69
+ export class WaveFailure extends Error {
70
+ wave;
71
+ constructor(wave, cause) {
72
+ super(`write wave ${wave} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
73
+ this.name = 'WaveFailure';
74
+ this.wave = wave;
75
+ }
76
+ }
77
+ const encoder = new TextEncoder();
78
+ function parentOf(path) {
79
+ const slash = path.lastIndexOf('/');
80
+ return slash < 0 ? '' : path.slice(0, slash);
81
+ }
82
+ function isRecord(value) {
83
+ return typeof value === 'object' && value !== null;
84
+ }
85
+ function waveResultError(result) {
86
+ if (!isRecord(result))
87
+ return new Error('writeBatchStream failed: missing writeBatchStream result');
88
+ if (result.ok === true)
89
+ return null;
90
+ const error = result.error;
91
+ const detail = isRecord(error) && typeof error.message === 'string'
92
+ ? error.message
93
+ : 'missing writeBatchStream result';
94
+ return new Error('writeBatchStream failed after group ' + String(result.committedGroupSequence) +
95
+ ' (' + String(result.committedPathCount) + ' committed paths): ' + detail);
96
+ }
97
+ const RECEIPT_NUMBERS = ['ino', 'mode', 'size', 'mtimeMs', 'ctimeMs', 'uid', 'gid', 'dev'];
98
+ /** The session's stat of each published file, as it answered the wave. */
99
+ function parseReceipts(result) {
100
+ if (!isRecord(result) || result.receipts === undefined)
101
+ return [];
102
+ if (!Array.isArray(result.receipts))
103
+ throw new Error('writeBatchStream receipts are not a list');
104
+ return result.receipts.map((value) => {
105
+ if (!isRecord(value) || typeof value.path !== 'string') {
106
+ throw new Error('writeBatchStream receipt is invalid');
107
+ }
108
+ const receipt = {
109
+ path: value.path, ino: 0, mode: 0, size: 0, mtimeMs: 0, ctimeMs: 0, uid: 0, gid: 0, dev: 0,
110
+ };
111
+ for (const field of RECEIPT_NUMBERS) {
112
+ const number = value[field];
113
+ if (typeof number !== 'number' || !Number.isFinite(number)) {
114
+ throw new Error(`writeBatchStream receipt for ${value.path} has no ${field}`);
115
+ }
116
+ receipt[field] = number;
117
+ }
118
+ return receipt;
119
+ });
120
+ }
121
+ /**
122
+ * A view of `bytes` that owns its buffer. The writer holds a record until
123
+ * its wave publishes, and may send it again: a view would pin its whole
124
+ * parent buffer for that long, and see whatever the caller wrote to it next.
125
+ */
126
+ function ownedBytes(bytes) {
127
+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength)
128
+ return bytes;
129
+ // A copy: a Node Buffer's slice() is another view of the same memory.
130
+ return new Uint8Array(bytes);
131
+ }
132
+ export class WaveWriter {
133
+ options;
134
+ records = new Map();
135
+ directories = new Set();
136
+ deletes = new Set();
137
+ bufferedBytes = 0;
138
+ symlinks = 0;
139
+ /** Links in the wave in flight: sent, not yet published. */
140
+ inFlightSymlinks = 0;
141
+ /** Every path the buffered wave publishes; a superset once a buffered record is removed. */
142
+ owned = new Set();
143
+ /** The upward-closed part of `owned`: each one's chain, to the root, is owned too. */
144
+ ownedDirectories = new Set();
145
+ ownedPathBytes = 0;
146
+ pin = null;
147
+ inFlight = null;
148
+ cutQueue = Promise.resolve();
149
+ sequence = 0;
150
+ failure = null;
151
+ /** Owners (records' `meta`) whose records a failed wave carried. */
152
+ failedOwners = new Map();
153
+ /** With `failPerOwner`: owners with a write in the buffered wave, superseded ones included. */
154
+ bufferedOwners = new Set();
155
+ /** With `failPerOwner`: the last wave that carried each owner's writes. */
156
+ ownerWaves = new Map();
157
+ /** Waves publish in order: every wave up to this one has settled, this one published. */
158
+ lastPublishedWave = 0;
159
+ counters = {
160
+ waves: 0,
161
+ files: 0,
162
+ bytes: 0,
163
+ rpcWallMs: 0,
164
+ maxRpcWallMs: 0,
165
+ producerWaitMs: 0,
166
+ ownershipVisits: 0,
167
+ maxWavePaths: 0,
168
+ maxWaveBytes: 0,
169
+ retries: 0,
170
+ wholeWaves: 0,
171
+ };
172
+ /** The session's epoch for this writer, and when it was opened (null: unfenced). */
173
+ epoch = null;
174
+ /** Mutations run one at a time, in call order: concurrent writers interleave by record. */
175
+ mutations = Promise.resolve();
176
+ constructor(options) {
177
+ this.options = options;
178
+ }
179
+ // ── Records ──────────────────────────────────────────────────────────
180
+ /** Run `mutate` once every mutation called before it has finished. */
181
+ exclusive(mutate) {
182
+ const run = this.mutations.then(mutate);
183
+ this.mutations = run.catch(() => { });
184
+ return run;
185
+ }
186
+ /**
187
+ * A regular file, with its permission bits as given (`mode & 0o7777`). The
188
+ * writer takes `bytes`; a view sharing its buffer is copied. `meta` rides
189
+ * with the record, back to the caller as it is cut.
190
+ */
191
+ file(path, mode, bytes, meta) {
192
+ return this.exclusive(async () => {
193
+ const key = this.key(path);
194
+ this.assertOwnerHealthy(meta);
195
+ await this.afterRemoval(key);
196
+ await this.admit(key, bytes.byteLength, true);
197
+ this.buffer(key, { kind: 'file', mode: mode & 0o7777, bytes: ownedBytes(bytes), meta });
198
+ await this.cutIfFull();
199
+ });
200
+ }
201
+ /** A symbolic link to `target`. */
202
+ symlink(path, target, meta) {
203
+ return this.exclusive(async () => {
204
+ const key = this.key(path);
205
+ const bytes = encoder.encode(target);
206
+ this.assertOwnerHealthy(meta);
207
+ await this.afterRemoval(key);
208
+ await this.admit(key, bytes.byteLength, true);
209
+ this.buffer(key, { kind: 'symlink', mode: 0o777, bytes, meta });
210
+ await this.cutIfFull();
211
+ });
212
+ }
213
+ /**
214
+ * A regular file of `size` bytes read from `chunks` as its wave drains:
215
+ * the buffered wave is sent first, then this file travels alone, and the
216
+ * call resolves once its source is consumed.
217
+ */
218
+ fileChunks(path, mode, size, chunks, meta) {
219
+ return this.exclusive(async () => {
220
+ if (!Number.isSafeInteger(size) || size < 0)
221
+ throw new Error(`write wave: ${path}: invalid size ${size}`);
222
+ const key = this.key(path);
223
+ this.assertOwnerHealthy(meta);
224
+ if (this.hasBuffered())
225
+ await this.cut();
226
+ this.buffer(key, { kind: 'stream', mode: mode & 0o7777, size, source: chunks, meta });
227
+ await this.cut();
228
+ if (this.inFlight)
229
+ await this.inFlight;
230
+ });
231
+ }
232
+ /** A directory (an empty one, a gitlink): files' directories need no record. */
233
+ directory(path) {
234
+ return this.exclusive(async () => {
235
+ const key = this.key(path);
236
+ await this.afterRemoval(key);
237
+ await this.admit(key, 0, true);
238
+ this.directories.add(key);
239
+ this.deletes.delete(key);
240
+ const tally = this.tally();
241
+ this.ownPath(key, true, tally);
242
+ this.walkChain(key, true, tally);
243
+ this.ownedPathBytes = tally.pathBytes;
244
+ await this.cutIfFull();
245
+ });
246
+ }
247
+ /**
248
+ * Remove what stands at `path`, its subtree included. A buffered record at
249
+ * the path is dropped; with `directory`, a buffered mkdir of it too.
250
+ */
251
+ remove(path, directory = false) {
252
+ return this.exclusive(async () => {
253
+ const key = this.key(path);
254
+ await this.admit(key, 0, false);
255
+ this.drop(key);
256
+ if (directory)
257
+ this.directories.delete(key);
258
+ this.deletes.add(key);
259
+ const tally = this.tally();
260
+ this.ownPath(key, true, tally);
261
+ this.ownedPathBytes = tally.pathBytes;
262
+ await this.cutIfFull();
263
+ });
264
+ }
265
+ /**
266
+ * A file every wave re-asserts until a wave carrying it publishes (a
267
+ * clone's ownership marker): parent directories publish independently of
268
+ * files, so each wave leaves the marker's proof in place. `durable` says
269
+ * these bytes are already published.
270
+ */
271
+ setPin(path, text, durable = false) {
272
+ this.pin = { path: this.key(path), bytes: encoder.encode(text), durable };
273
+ }
274
+ clearPin(path) {
275
+ if (this.pin && this.pin.path === this.key(path))
276
+ this.pin = null;
277
+ }
278
+ // ── The buffered wave, for a reader that must see it ─────────────────
279
+ /** The bytes buffered at `path`, if a file or link is. */
280
+ buffered(path) {
281
+ const record = this.records.get(path);
282
+ return record && record.kind !== 'stream' ? record.bytes : undefined;
283
+ }
284
+ /** The buffered file or link at `path`: its kind, size and the caller's `meta`. */
285
+ bufferedRecord(path) {
286
+ const record = this.records.get(path);
287
+ if (record === undefined)
288
+ return undefined;
289
+ return record.kind === 'stream'
290
+ ? { kind: 'file', size: record.size, meta: record.meta }
291
+ : { kind: record.kind, size: record.bytes.byteLength, meta: record.meta };
292
+ }
293
+ isBufferedDirectory(path) {
294
+ return this.directories.has(path);
295
+ }
296
+ isBufferedDelete(path) {
297
+ return this.deletes.has(path);
298
+ }
299
+ bufferedPaths() {
300
+ return { files: this.records.keys(), directories: this.directories, deletes: this.deletes };
301
+ }
302
+ /** Whether a link is buffered or in flight: written, not yet published. */
303
+ get hasUnpublishedSymlinks() {
304
+ return this.symlinks > 0 || this.inFlightSymlinks > 0;
305
+ }
306
+ // ── Publication ──────────────────────────────────────────────────────
307
+ /** Every record written before this call is durable; rejects with the first failed wave. */
308
+ flush() {
309
+ return this.exclusive(async () => {
310
+ await this.cut();
311
+ if (this.inFlight)
312
+ await this.inFlight;
313
+ this.assertHealthy();
314
+ });
315
+ }
316
+ /** The wave in flight has settled; nothing new is cut. */
317
+ async settled() {
318
+ if (this.inFlight)
319
+ await this.inFlight.catch(() => { });
320
+ this.assertHealthy();
321
+ }
322
+ assertHealthy() {
323
+ if (this.failure)
324
+ throw this.failure;
325
+ }
326
+ /** The failure of the wave that carried `owner`'s records, if one failed. */
327
+ failureOf(owner) {
328
+ return this.failedOwners.get(owner);
329
+ }
330
+ /**
331
+ * Whether every record written with `owner` (as `meta`, under
332
+ * `failPerOwner`) is durable: each wave that carried it, or a write
333
+ * superseding it, published.
334
+ */
335
+ published(owner) {
336
+ if (this.options.failPerOwner !== true)
337
+ throw new Error('published(owner) needs failPerOwner');
338
+ return !this.failedOwners.has(owner)
339
+ && !this.bufferedOwners.has(owner)
340
+ && (this.ownerWaves.get(owner) ?? 0) <= this.lastPublishedWave;
341
+ }
342
+ assertOwnerHealthy(owner) {
343
+ this.assertHealthy();
344
+ if (owner === undefined)
345
+ return;
346
+ const failure = this.failedOwners.get(owner);
347
+ if (failure)
348
+ throw failure;
349
+ }
350
+ get failed() {
351
+ return this.failure;
352
+ }
353
+ stats() {
354
+ return { ...this.counters };
355
+ }
356
+ // ── Internals ────────────────────────────────────────────────────────
357
+ key(path) {
358
+ return this.options.base ? this.options.base + '/' + path : path;
359
+ }
360
+ tally() {
361
+ return { pathCount: this.owned.size, pathBytes: this.ownedPathBytes };
362
+ }
363
+ hasBuffered() {
364
+ return this.records.size > 0 || this.directories.size > 0 || this.deletes.size > 0;
365
+ }
366
+ ownPath(path, admit, tally) {
367
+ this.counters.ownershipVisits++;
368
+ if (this.owned.has(path))
369
+ return;
370
+ tally.pathCount++;
371
+ tally.pathBytes += utf8Length(path);
372
+ if (admit)
373
+ this.owned.add(path);
374
+ }
375
+ /** collectDirectoryPaths' chain from `path` upward, to the first directory already owned. */
376
+ walkChain(path, admit, tally) {
377
+ const { root, worktreeRoot = null } = this.options;
378
+ let current = path;
379
+ while (current) {
380
+ if (root && current !== root && !current.startsWith(root + '/'))
381
+ break;
382
+ if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/'))
383
+ break;
384
+ this.counters.ownershipVisits++;
385
+ if (this.ownedDirectories.has(current))
386
+ break;
387
+ if (admit)
388
+ this.ownedDirectories.add(current);
389
+ if (!this.owned.has(current)) {
390
+ tally.pathCount++;
391
+ tally.pathBytes += utf8Length(current);
392
+ if (admit)
393
+ this.owned.add(current);
394
+ }
395
+ if (current === root)
396
+ break;
397
+ current = parentOf(current);
398
+ }
399
+ }
400
+ /**
401
+ * Send what is buffered when a removal of `path` is pending in it: a wave
402
+ * names a path once, and what is made there must land after the removal,
403
+ * as rm then create does (the directory's permission decides, not the old
404
+ * file's mode).
405
+ */
406
+ async afterRemoval(path) {
407
+ if (this.deletes.has(path))
408
+ await this.cut();
409
+ }
410
+ /** Cut waves until `path` (with its chain) and `bytes` fit beside what is buffered. */
411
+ async admit(path, bytes, withParents) {
412
+ this.assertHealthy();
413
+ while (this.hasBuffered()) {
414
+ const tally = this.tally();
415
+ this.ownPath(path, false, tally);
416
+ if (withParents)
417
+ this.walkChain(parentOf(path), false, tally);
418
+ const replaced = this.records.get(path);
419
+ const replacedBytes = replaced && replaced.kind !== 'stream' ? replaced.bytes.byteLength : 0;
420
+ if (this.bufferedBytes - replacedBytes + bytes <= WAVE_BYTES &&
421
+ tally.pathCount < WAVE_PATHS &&
422
+ tally.pathBytes < WAVE_PATH_BYTES)
423
+ return;
424
+ await this.cut();
425
+ }
426
+ const tally = this.tally();
427
+ this.ownPath(path, false, tally);
428
+ if (withParents)
429
+ this.walkChain(parentOf(path), false, tally);
430
+ if (tally.pathCount > W7_MAX_PATHS_PER_BATCH) {
431
+ throw new Error('write wave exceeds ' + W7_MAX_PATHS_PER_BATCH + ' owned paths');
432
+ }
433
+ if (tally.pathBytes > W7_MAX_OWNED_PATH_BYTES) {
434
+ throw new Error('write wave exceeds ' + W7_MAX_OWNED_PATH_BYTES + ' owned path bytes');
435
+ }
436
+ }
437
+ buffer(path, record) {
438
+ this.drop(path);
439
+ if (this.options.failPerOwner === true && record.meta !== undefined)
440
+ this.bufferedOwners.add(record.meta);
441
+ this.deletes.delete(path);
442
+ this.records.set(path, record);
443
+ if (record.kind === 'symlink')
444
+ this.symlinks++;
445
+ if (record.kind !== 'stream')
446
+ this.bufferedBytes += record.bytes.byteLength;
447
+ const tally = this.tally();
448
+ this.ownPath(path, true, tally);
449
+ this.walkChain(parentOf(path), true, tally);
450
+ this.ownedPathBytes = tally.pathBytes;
451
+ }
452
+ drop(path) {
453
+ const previous = this.records.get(path);
454
+ if (!previous)
455
+ return;
456
+ if (previous.kind === 'symlink')
457
+ this.symlinks--;
458
+ if (previous.kind !== 'stream')
459
+ this.bufferedBytes -= previous.bytes.byteLength;
460
+ this.records.delete(path);
461
+ }
462
+ async cutIfFull() {
463
+ if (this.owned.size >= WAVE_PATHS ||
464
+ this.ownedPathBytes >= WAVE_PATH_BYTES ||
465
+ this.bufferedBytes >= WAVE_BYTES) {
466
+ await this.cut();
467
+ }
468
+ }
469
+ /**
470
+ * Send the buffered wave once the one in flight has published. Cuts are
471
+ * serialised, so at most one wave is in flight and one buffers.
472
+ */
473
+ cut() {
474
+ const run = this.cutQueue.then(() => this.cutNow());
475
+ this.cutQueue = run.catch(() => { });
476
+ return run;
477
+ }
478
+ async cutNow() {
479
+ if (this.inFlight) {
480
+ const waitStarted = Date.now();
481
+ await this.inFlight.catch(() => { });
482
+ this.counters.producerWaitMs += Date.now() - waitStarted;
483
+ }
484
+ this.assertHealthy();
485
+ // A wave that failed took its owners with it: their records still
486
+ // buffered are not sent (an owner's later record must not publish
487
+ // without its earlier ones).
488
+ if (this.failedOwners.size > 0) {
489
+ for (const [path, record] of [...this.records]) {
490
+ if (record.meta !== undefined && this.failedOwners.has(record.meta))
491
+ this.drop(path);
492
+ }
493
+ for (const owner of [...this.bufferedOwners])
494
+ if (this.failedOwners.has(owner))
495
+ this.bufferedOwners.delete(owner);
496
+ }
497
+ this.bufferPin();
498
+ if (!this.hasBuffered())
499
+ return;
500
+ // A wave already sent can still publish after the deadline; none starts after it.
501
+ const { deadline = null } = this.options;
502
+ if (deadline !== null && Date.now() >= deadline) {
503
+ throw new Error('phase deadline reached before starting a new write wave');
504
+ }
505
+ const wave = ++this.sequence;
506
+ const sentPin = this.pin;
507
+ const owners = [...this.bufferedOwners];
508
+ for (const owner of owners)
509
+ this.ownerWaves.set(owner, wave);
510
+ const mtime = this.options.mtimeMs ?? Date.now();
511
+ const files = [];
512
+ const directories = this.publishedDirectories();
513
+ const inodes = [];
514
+ const chunks = [];
515
+ const streams = [];
516
+ let waveBytes = 0;
517
+ for (const dir of directories) {
518
+ inodes.push({
519
+ path: dir, parentPath: parentOf(dir), kind: 'directory', isDir: true,
520
+ size: 0, mtime, mode: this.options.directoryMode?.(dir) ?? 0o755, chunkCount: 0,
521
+ });
522
+ }
523
+ for (const [path, record] of this.records) {
524
+ files.push({ path, meta: record.meta });
525
+ const size = record.kind === 'stream' ? record.size : record.bytes.byteLength;
526
+ const chunkCount = w7ChunkCount(size);
527
+ waveBytes += size;
528
+ inodes.push({
529
+ path, parentPath: parentOf(path),
530
+ kind: record.kind === 'symlink' ? 'symlink' : 'file',
531
+ isDir: false, size, mtime, mode: record.mode, chunkCount,
532
+ });
533
+ if (record.kind === 'stream') {
534
+ streams.push({ path, source: record.source });
535
+ continue;
536
+ }
537
+ // Views: the encoder copies a chunk's bytes into the buffers it
538
+ // enqueues, so the record's bytes stay whole for a re-send.
539
+ chunks.push(...w7Chunks(path, record.bytes));
540
+ }
541
+ const deletePaths = this.deletes.size > 0 ? [...this.deletes] : undefined;
542
+ const ownedOnly = this.options.failPerOwner === true
543
+ && files.length > 0
544
+ && files.every((file) => file.meta !== undefined)
545
+ && this.directories.size === 0
546
+ && this.deletes.size === 0
547
+ && (sentPin === null || !this.records.has(sentPin.path));
548
+ const paths = this.owned.size;
549
+ const symlinks = this.symlinks;
550
+ this.options.onCut?.({ wave, mtimeMs: mtime, files, directories });
551
+ // The payload holds the wave's bytes now; the buffer lets go of them so
552
+ // the facet holds one copy while the stream drains.
553
+ this.records.clear();
554
+ this.bufferedOwners.clear();
555
+ this.directories.clear();
556
+ this.deletes.clear();
557
+ this.owned.clear();
558
+ this.ownedDirectories.clear();
559
+ this.ownedPathBytes = 0;
560
+ this.bufferedBytes = 0;
561
+ this.symlinks = 0;
562
+ this.counters.maxWavePaths = Math.max(this.counters.maxWavePaths, paths);
563
+ this.counters.maxWaveBytes = Math.max(this.counters.maxWaveBytes, waveBytes);
564
+ this.inFlightSymlinks = symlinks;
565
+ const sentAt = Date.now();
566
+ const opened = this.open({ inodes, chunks, deletePaths, streams }, waveBytes);
567
+ const published = opened.then(({ open, streamed }) => this.sendAttempts(open, streamed, wave)).then((result) => {
568
+ try {
569
+ const error = waveResultError(result);
570
+ if (error)
571
+ throw error;
572
+ const rpcWallMs = Date.now() - sentAt;
573
+ if (sentPin !== null && this.pin === sentPin)
574
+ sentPin.durable = true;
575
+ this.inFlightSymlinks = 0;
576
+ this.lastPublishedWave = wave;
577
+ this.counters.waves++;
578
+ this.counters.files += files.length;
579
+ this.counters.bytes += waveBytes;
580
+ this.counters.rpcWallMs += rpcWallMs;
581
+ this.counters.maxRpcWallMs = Math.max(this.counters.maxRpcWallMs, rpcWallMs);
582
+ this.options.onWave?.({ wave, files: files.length, bytes: waveBytes, rpcWallMs, receipts: parseReceipts(result) });
583
+ }
584
+ finally {
585
+ disposeRpcResource(result);
586
+ }
587
+ }).catch((error) => {
588
+ const failure = error instanceof WaveFailure ? error : new WaveFailure(wave, error);
589
+ this.inFlightSymlinks = 0;
590
+ // A wave whose every record has an owner fails those owners, and
591
+ // the owners of writes its records superseded; the writer goes on
592
+ // for the rest. Anything unowned in it (a directory, a removal, the
593
+ // pin, a record without meta) fails the writer.
594
+ if (ownedOnly) {
595
+ for (const owner of owners)
596
+ this.failedOwners.set(owner, failure);
597
+ return;
598
+ }
599
+ if (this.failure === null)
600
+ this.failure = failure;
601
+ throw this.failure;
602
+ }).finally(() => {
603
+ if (this.inFlight === published)
604
+ this.inFlight = null;
605
+ });
606
+ // Its failure reaches whoever waits next; it is never unobserved. In
607
+ // flight from here, before its encode is awaited: its records are no
608
+ // longer buffered, so settled() must wait for it.
609
+ published.catch(() => { });
610
+ this.inFlight = published;
611
+ // The next wave buffers only once this one is encoded and its records
612
+ // let go, so a producer holds one wave's bytes beside the next, not two;
613
+ // its answer is still awaited only by the cut after it.
614
+ await opened.catch(() => { });
615
+ }
616
+ /**
617
+ * How a wave of `bytes` content crosses: an attempt's stream, made anew
618
+ * for each attempt.
619
+ *
620
+ * Across a transport (a supervisor that opens epochs), a wave held in
621
+ * memory is encoded whole before it is sent, and the transport takes it in
622
+ * SEND_SLICE_BYTES pieces, never waiting on the encoder. A re-send sends
623
+ * the same bytes, and the wave's records are let go once it is encoded.
624
+ * Measured, eight producers into one session, Markflow's file sizes: the
625
+ * encoder's stream, pulled through the watchdog as the transport read it,
626
+ * took 1,002 files/s; the wave encoded first, 1,374 (re-chunking that
627
+ * stream to 64 KiB or 1 MiB gained nothing). A wave with a streamed
628
+ * source is never held whole, nor is a lone file larger than a wave, and a
629
+ * session's own writer has no transport to wait on: they stream as the
630
+ * encoder makes them.
631
+ */
632
+ async open(payload, bytes) {
633
+ const streamed = (payload.streams?.length ?? 0) > 0;
634
+ // A file larger than a wave travels in a wave of its own: never copied whole.
635
+ if (streamed || bytes > WAVE_BYTES || this.options.supervisor.openWaveWriter === undefined) {
636
+ return { open: encoderOf(payload), streamed };
637
+ }
638
+ const whole = await encodeWriteBatch(payload);
639
+ this.counters.wholeWaves++;
640
+ return { open: piecesOf(whole, SEND_SLICE_BYTES), streamed: false };
641
+ }
642
+ /** Send one wave (sendWaveAttempts) under this writer's epoch. */
643
+ sendAttempts(open, streamed, wave) {
644
+ return sendWaveAttempts({
645
+ supervisor: this.options.supervisor,
646
+ writer: () => this.currentEpoch(),
647
+ open,
648
+ streamed,
649
+ wave,
650
+ ...(this.options.retry === undefined ? {} : { retry: this.options.retry }),
651
+ resent: (lost) => {
652
+ this.counters.retries++;
653
+ this.options.onResend?.(lost);
654
+ },
655
+ });
656
+ }
657
+ /**
658
+ * The epoch this writer's waves are fenced under: opened before its first
659
+ * wave, and again once half of WAVE_EPOCH_TTL_MS has passed, so a wave is
660
+ * never sent under an epoch about to close.
661
+ */
662
+ async currentEpoch() {
663
+ const supervisor = this.options.supervisor;
664
+ if (supervisor.openWaveWriter === undefined)
665
+ return null;
666
+ const now = Date.now();
667
+ const held = this.epoch === null ? null : await this.epoch;
668
+ if (held === null || (held.writer !== null && now - held.openedAt >= WAVE_EPOCH_TTL_MS / 2)) {
669
+ // Called as a method of the supervisor, never through .call/.apply:
670
+ // on an RPC stub those are remote method names too.
671
+ this.epoch = supervisor.openWaveWriter().then((writer) => ({ writer, openedAt: now }));
672
+ }
673
+ return (await this.epoch).writer;
674
+ }
675
+ /** The directories the buffered records publish, shallowest first. */
676
+ publishedDirectories() {
677
+ const dirs = new Set();
678
+ const { root, worktreeRoot = null } = this.options;
679
+ const collect = (path) => {
680
+ let current = path;
681
+ while (current) {
682
+ if (root && current !== root && !current.startsWith(root + '/'))
683
+ break;
684
+ if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/'))
685
+ break;
686
+ if (dirs.has(current))
687
+ break;
688
+ dirs.add(current);
689
+ if (current === root)
690
+ break;
691
+ current = parentOf(current);
692
+ }
693
+ };
694
+ for (const path of this.records.keys())
695
+ collect(parentOf(path));
696
+ for (const dir of this.directories)
697
+ collect(dir);
698
+ return [...dirs].sort((left, right) => {
699
+ const depth = left.split('/').length - right.split('/').length;
700
+ return depth || (left < right ? -1 : left > right ? 1 : 0);
701
+ });
702
+ }
703
+ bufferPin() {
704
+ const pin = this.pin;
705
+ if (!pin)
706
+ return;
707
+ // A durable pin asserts presence, not churn: re-writing identical bytes
708
+ // re-arms the receiver's content GC for no change. Re-buffer only when a
709
+ // buffered record claims the path.
710
+ if (pin.durable && !this.records.has(pin.path) && !this.deletes.has(pin.path))
711
+ return;
712
+ this.buffer(pin.path, { kind: 'file', mode: 0o644, bytes: pin.bytes.slice(), meta: undefined });
713
+ }
714
+ }
715
+ const GLOBAL_TIMERS = {
716
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
717
+ clearTimeout: (timer) => clearTimeout(timer),
718
+ };
719
+ /**
720
+ * Send one wave, again while its transport is lost (see the module's
721
+ * comment), and answer with what the session answered. The one way every
722
+ * W7 producer sends: the wave writer, and a process's filesystem client.
723
+ */
724
+ export async function sendWaveAttempts(options) {
725
+ const { backoffMs, stallMs, answerDeadlineMs } = options.retry
726
+ ?? { backoffMs: LOST_CALL_RESEND_BACKOFF_MS, stallMs: LOST_STREAM_STALL_MS, answerDeadlineMs: LOST_STREAM_ANSWER_MS };
727
+ const timers = options.timers ?? GLOBAL_TIMERS;
728
+ for (let attempt = 0;; attempt++) {
729
+ const writer = await options.writer();
730
+ const attemptStream = abortable(options.open(), stallMs, answerDeadlineMs, timers);
731
+ const fence = writer === null ? undefined : { writer, wave: options.wave, attempt: attempt + 1, ...options.sequence };
732
+ // Called as a method of the supervisor, never through .call/.apply: on an RPC stub those are remote method names too.
733
+ const answer = options.owner === undefined
734
+ ? options.supervisor.writeBatchStream(attemptStream.stream, fence)
735
+ : options.supervisor.writeBatchStream(attemptStream.stream, fence, options.owner);
736
+ try {
737
+ return await Promise.race([answer, attemptStream.lost]);
738
+ }
739
+ catch (error) {
740
+ const lost = error instanceof WaveLost || isLostFencedCall(error);
741
+ // Given up: the attempt reads nothing more either (its writer retires its epoch).
742
+ if (lost && (options.streamed || attempt >= backoffMs.length))
743
+ attemptStream.abort(error);
744
+ if (!lost || options.streamed || attempt >= backoffMs.length)
745
+ throw error;
746
+ // The abandoned attempt can read nothing more, and its late answer is dropped.
747
+ attemptStream.abort(error);
748
+ answer.then(disposeRpcResource, () => { });
749
+ options.resent?.(lostCallAttributes({
750
+ operation: 'writeBatchStream',
751
+ attempt: attempt + 1,
752
+ of: backoffMs.length,
753
+ reason: error instanceof Error ? error.message : String(error),
754
+ }));
755
+ await new Promise((resolve) => { timers.setTimeout(resolve, retryDelayMs(backoffMs, attempt)); });
756
+ }
757
+ finally {
758
+ attemptStream.settle();
759
+ }
760
+ }
761
+ }
762
+ /** A wave's encoded bytes as each attempt's stream (in SEND_SLICE_BYTES pieces, each a copy). */
763
+ export function waveAttemptsOf(bytes) {
764
+ return piecesOf(bytes, SEND_SLICE_BYTES);
765
+ }
766
+ class WaveLost extends Error {
767
+ constructor(message) {
768
+ super(message);
769
+ this.name = 'WaveLost';
770
+ }
771
+ }
772
+ /** An attempt's stream as the encoder makes it, read from `payload`. */
773
+ function encoderOf(payload) {
774
+ return () => encodeWriteBatchStream(payload);
775
+ }
776
+ /**
777
+ * An attempt's stream as `bytes` in `size`-byte pieces, each its own copy:
778
+ * the watched stream they pass through transfers what it enqueues, and the
779
+ * bytes stay whole for a re-send. (A function of its own, so the closure
780
+ * holds the bytes and nothing the encoder read them from.)
781
+ */
782
+ function piecesOf(bytes, size) {
783
+ return () => slices(bytes, size);
784
+ }
785
+ function slices(bytes, size) {
786
+ let at = 0;
787
+ return new ReadableStream({
788
+ pull(controller) {
789
+ if (at >= bytes.byteLength) {
790
+ controller.close();
791
+ return;
792
+ }
793
+ const end = Math.min(bytes.byteLength, at + size);
794
+ controller.enqueue(bytes.slice(at, end));
795
+ at = end;
796
+ },
797
+ }, { highWaterMark: 0 });
798
+ }
799
+ /**
800
+ * One attempt's stream, watched from the writer's side: `lost` rejects when
801
+ * nothing has read it for `stallMs` before it ended, or no answer came
802
+ * `answerDeadlineMs` after it ended; `abort` errors it so the attempt reads
803
+ * nothing more. Reading is the bytes drained from the stream's queue: a
804
+ * reader taking a large piece in small reads drains it without a pull.
805
+ */
806
+ function abortable(stream, stallMs, answerDeadlineMs, timers) {
807
+ const reader = stream.getReader();
808
+ let target = null;
809
+ let timer = null;
810
+ let declareLost = () => { };
811
+ const lost = new Promise((_, reject) => { declareLost = reject; });
812
+ lost.catch(() => { });
813
+ const watch = (ms, message) => {
814
+ if (timer !== null)
815
+ timers.clearTimeout(timer);
816
+ timer = timers.setTimeout(() => declareLost(new WaveLost(message)), ms);
817
+ };
818
+ /** Bytes enqueued and not yet read (the queue's total, as desiredSize below a mark of 0). */
819
+ const queued = () => Math.max(0, -(target?.desiredSize ?? 0));
820
+ // Lost when a stall period passes with nothing drained from the queue.
821
+ const watchReads = () => {
822
+ if (timer !== null)
823
+ timers.clearTimeout(timer);
824
+ const unread = queued();
825
+ timer = timers.setTimeout(() => {
826
+ if (queued() < unread)
827
+ watchReads();
828
+ else
829
+ declareLost(new WaveLost(`writeBatchStream stalled: nothing read it for ${stallMs} ms`));
830
+ }, stallMs);
831
+ };
832
+ watchReads();
833
+ let sourceDone = false;
834
+ const source = {
835
+ type: 'bytes',
836
+ start(controller) {
837
+ target = controller;
838
+ },
839
+ async pull(controller) {
840
+ const next = sourceDone ? null : await reader.read();
841
+ if (next === null || next.done) {
842
+ sourceDone = true;
843
+ // The stream has ended once its reader asks for more with nothing
844
+ // queued; a pull may come while it still drains the last piece.
845
+ if (queued() > 0)
846
+ return;
847
+ watch(answerDeadlineMs, `writeBatchStream unanswered ${answerDeadlineMs} ms after its stream ended`);
848
+ controller.close();
849
+ // A reader waiting with its own buffer is answered "done" only so.
850
+ controller.byobRequest?.respond(0);
851
+ return;
852
+ }
853
+ controller.enqueue(next.value);
854
+ watchReads();
855
+ },
856
+ cancel(reason) {
857
+ return reader.cancel(reason);
858
+ },
859
+ };
860
+ return {
861
+ stream: new ReadableStream(source, { highWaterMark: 0 }),
862
+ lost,
863
+ abort(reason) {
864
+ try {
865
+ target?.error(reason);
866
+ }
867
+ catch { /* already closed */ }
868
+ reader.cancel(reason).catch(() => { });
869
+ },
870
+ settle() {
871
+ if (timer !== null)
872
+ timers.clearTimeout(timer);
873
+ timer = null;
874
+ },
875
+ };
876
+ }
877
+ export function createWaveWriter(options) {
878
+ return new WaveWriter(options);
879
+ }