@nimbus-sh/platform 0.8.2 → 0.13.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.
Files changed (67) hide show
  1. package/dist/composition.d.ts +3 -2
  2. package/dist/composition.d.ts.map +1 -1
  3. package/dist/composition.js +34 -19
  4. package/dist/crc32.d.ts +5 -5
  5. package/dist/crc32.d.ts.map +1 -1
  6. package/dist/crc32.js +8 -12
  7. package/dist/diag-counters.d.ts +18 -10
  8. package/dist/diag-counters.d.ts.map +1 -1
  9. package/dist/diag-counters.js +7 -20
  10. package/dist/heap-estimate.d.ts +5 -20
  11. package/dist/heap-estimate.d.ts.map +1 -1
  12. package/dist/heap-estimate.js +6 -33
  13. package/dist/heavy-alloc-coord.d.ts +131 -11
  14. package/dist/heavy-alloc-coord.d.ts.map +1 -1
  15. package/dist/heavy-alloc-coord.js +241 -22
  16. package/dist/hex.d.ts +20 -0
  17. package/dist/hex.d.ts.map +1 -0
  18. package/dist/hex.js +29 -0
  19. package/dist/install-phase.d.ts +1 -15
  20. package/dist/install-phase.d.ts.map +1 -1
  21. package/dist/lexical-path.d.ts +11 -0
  22. package/dist/lexical-path.d.ts.map +1 -0
  23. package/dist/lexical-path.js +19 -0
  24. package/dist/limits.d.ts +4 -0
  25. package/dist/limits.d.ts.map +1 -1
  26. package/dist/limits.js +25 -0
  27. package/dist/module-source.d.ts.map +1 -1
  28. package/dist/module-source.js +2 -1
  29. package/dist/oom-classify.d.ts +4 -0
  30. package/dist/oom-classify.d.ts.map +1 -1
  31. package/dist/oom-classify.js +6 -0
  32. package/dist/realm-builtin.d.ts +2 -0
  33. package/dist/realm-builtin.d.ts.map +1 -0
  34. package/dist/realm-builtin.js +11 -0
  35. package/dist/utf8.d.ts +13 -1
  36. package/dist/utf8.d.ts.map +1 -1
  37. package/dist/utf8.js +67 -1
  38. package/dist/w7-frame.d.ts +47 -13
  39. package/dist/w7-frame.d.ts.map +1 -1
  40. package/dist/w7-frame.js +42 -24
  41. package/dist/wave-writer.d.ts +11 -6
  42. package/dist/wave-writer.d.ts.map +1 -1
  43. package/dist/wave-writer.js +28 -29
  44. package/dist/weighted-credit-pool.d.ts +43 -0
  45. package/dist/weighted-credit-pool.d.ts.map +1 -1
  46. package/dist/weighted-credit-pool.js +250 -46
  47. package/dist/worker-code.d.ts +48 -0
  48. package/dist/worker-code.d.ts.map +1 -0
  49. package/dist/worker-code.js +10 -0
  50. package/package.json +1 -1
  51. package/src/composition.ts +41 -21
  52. package/src/crc32.ts +9 -12
  53. package/src/diag-counters.ts +23 -22
  54. package/src/heap-estimate.ts +11 -40
  55. package/src/heavy-alloc-coord.ts +275 -20
  56. package/src/hex.ts +29 -0
  57. package/src/install-phase.ts +1 -15
  58. package/src/lexical-path.ts +17 -0
  59. package/src/limits.ts +29 -0
  60. package/src/module-source.ts +3 -1
  61. package/src/oom-classify.ts +8 -0
  62. package/src/realm-builtin.ts +22 -0
  63. package/src/utf8.ts +69 -1
  64. package/src/w7-frame.ts +74 -33
  65. package/src/wave-writer.ts +28 -27
  66. package/src/weighted-credit-pool.ts +256 -51
  67. package/src/worker-code.ts +38 -0
package/src/utf8.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * utf8.ts — the UTF-8 length of a string, without encoding it.
2
+ * utf8.ts — the UTF-8 length of a string, and the order of two strings by
3
+ * their UTF-8 bytes, without encoding them.
3
4
  *
4
5
  * Bounds on names and payloads are in bytes, and the hot ones (every path a
5
6
  * W7 wave or a listing page carries) were measured by encoding the string
@@ -7,7 +8,15 @@
7
8
  * TextEncoder would write: one to four per code point, and three for a
8
9
  * lone surrogate, which it writes as U+FFFD.
9
10
  */
11
+
12
+ /** From this many UTF-16 units, a string is counted natively (longUtf8Length). */
13
+ const LONG_UNITS = 256;
14
+ const encoder = new TextEncoder();
15
+ /** Where longUtf8Length's encodes land: written, never read. */
16
+ let scratch: Uint8Array | undefined;
17
+
10
18
  export function utf8Length(text: string): number {
19
+ if (text.length >= LONG_UNITS) return longUtf8Length(text);
11
20
  let bytes = text.length;
12
21
  for (let index = 0; index < text.length; index++) {
13
22
  const unit = text.charCodeAt(index);
@@ -29,3 +38,62 @@ export function utf8Length(text: string): number {
29
38
  }
30
39
  return bytes;
31
40
  }
41
+
42
+ /**
43
+ * A long string's UTF-8 length at the engine's speed: encoded a slice at a
44
+ * time into one reused buffer, only the count kept. encodeInto never splits
45
+ * a surrogate pair, so each slice starts on a code point.
46
+ */
47
+ function longUtf8Length(text: string): number {
48
+ scratch ??= new Uint8Array(256 * 1024);
49
+ let bytes = 0;
50
+ for (let read = 0; read < text.length;) {
51
+ const done = encoder.encodeInto(read === 0 ? text : text.slice(read), scratch);
52
+ read += done.read;
53
+ bytes += done.written;
54
+ }
55
+ return bytes;
56
+ }
57
+
58
+ /**
59
+ * The code point at `index` of `text`: a pair is one past U+FFFF, and a lone
60
+ * surrogate is its own (as WTF-8 writes it, between U+D7FF and U+E000).
61
+ */
62
+ function codePointAt(text: string, index: number): number {
63
+ const unit = text.charCodeAt(index);
64
+ if (unit < 0xd800 || unit > 0xdbff || index + 1 >= text.length) return unit;
65
+ const next = text.charCodeAt(index + 1);
66
+ return next >= 0xdc00 && next <= 0xdfff ? 0x10000 + ((unit - 0xd800) << 10) + (next - 0xdc00) : unit;
67
+ }
68
+
69
+ /**
70
+ * `a` against `b` (negative, 0, positive) as their UTF-8 bytes order them,
71
+ * as SQLite's `ORDER BY` of a text column and git's index do: code point
72
+ * order. A string UTF-8 cannot write (a lone surrogate) is ordered as WTF-8
73
+ * writes it, its surrogate a code point of its own: the order is total, as a
74
+ * listing's cursor needs, and decided at the first code point that differs,
75
+ * so a directory's entries follow it together. Units equal in both are
76
+ * passed as they are; two that differ, neither a surrogate, are their own
77
+ * code points; the rest, rare in a path, is surrogatesAt's.
78
+ */
79
+ export function compareUtf8(a: string, b: string): number {
80
+ const n = Math.min(a.length, b.length);
81
+ for (let i = 0; i < n; i++) {
82
+ const x = a.charCodeAt(i);
83
+ const y = b.charCodeAt(i);
84
+ if (x === y) continue;
85
+ if ((x < 0xd800 || x > 0xdfff) && (y < 0xd800 || y > 0xdfff)) return x - y;
86
+ return surrogatesAt(a, b, i);
87
+ }
88
+ return a.length - b.length;
89
+ }
90
+
91
+ /** compareUtf8 of `a` and `b`, equal before `i`, where they differ at `i` and one unit there is a surrogate. */
92
+ function surrogatesAt(a: string, b: string, i: number): number {
93
+ // A low half after a high one the two share: the code points begin at the high one.
94
+ const before = i > 0 ? a.charCodeAt(i - 1) : 0;
95
+ const x = a.charCodeAt(i);
96
+ const y = b.charCodeAt(i);
97
+ const at = before >= 0xd800 && before <= 0xdbff && ((x >= 0xdc00 && x <= 0xdfff) || (y >= 0xdc00 && y <= 0xdfff)) ? i - 1 : i;
98
+ return codePointAt(a, at) - codePointAt(b, at);
99
+ }
package/src/w7-frame.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import { crc32 } from './crc32.js';
17
17
  import { CHUNK_SIZE } from './limits.js';
18
18
  import { utf8Length } from './utf8.js';
19
+ import { rootClampedSegments } from './lexical-path.js';
19
20
 
20
21
  // The batch payload types live WITH the wire format that encodes them: every
21
22
  // producer and consumer of a W7 stream speaks exactly these records, and the
@@ -79,10 +80,14 @@ export interface BatchWritePayload {
79
80
  ops?: W7Op[];
80
81
  }
81
82
 
82
- /** One attribute change (setattr): the mode, the owner, or the times; what chmod, chown and utimes each make. */
83
+ /**
84
+ * One attribute change (setattr): the mode, the owner, or the times; what
85
+ * chmod, chown and utimes each make. An owner side that is null keeps what
86
+ * the file has when the change lands (chown's -1).
87
+ */
83
88
  export type W7Attrs =
84
89
  | { mode: number }
85
- | { uid: number; gid: number }
90
+ | { uid: number | null; gid: number | null }
86
91
  | { atime: number; mtime: number };
87
92
 
88
93
  /**
@@ -94,6 +99,23 @@ export type W7Attrs =
94
99
  * nothing makes a missing parent. The program-order counterpart of the
95
100
  * syscall, where `file` and `directory` are a checkout's upserts.
96
101
  */
102
+ /** What a W7 open asks of its file, as open(2)'s O_ACCMODE: to read it, to write it, or both. */
103
+ export type W7OpenAccess = 'read' | 'write' | 'readwrite';
104
+ function isOpenAccess(value: unknown): value is W7OpenAccess {
105
+ return value === 'read' || value === 'write' || value === 'readwrite';
106
+ }
107
+
108
+ /** The rights an open's access grants its description: what a call through it may do. */
109
+ export function openAccessRights(access: W7OpenAccess): { read: boolean; write: boolean } {
110
+ return { read: access !== 'write', write: access !== 'read' };
111
+ }
112
+
113
+ /** The permission bits an open asks of the file it reaches (4 read, 2 write): its access's, and write under `truncate`, as open(2)'s. */
114
+ export function openAccessMask(call: { access: W7OpenAccess; truncate?: true }): number {
115
+ const rights = openAccessRights(call.access);
116
+ return (rights.read ? 4 : 0) | (rights.write || call.truncate === true ? 2 : 0);
117
+ }
118
+
97
119
  export type W7Call =
98
120
  /**
99
121
  * `ino`, on a call that makes a name (writeFile, appendFile, mkdir,
@@ -125,6 +147,14 @@ export type W7Call =
125
147
  | { call: 'unlink'; path: string }
126
148
  | { call: 'rmdir'; path: string }
127
149
  | { call: 'symlink'; path: string; target: string; ino?: number }
150
+ /**
151
+ * copyfile as the session copies a file: the file \`from\` names (a link
152
+ * followed) to \`path\` (a link there followed, a file there replaced),
153
+ * by reference within one filesystem and by its bytes across, no byte of
154
+ * it in the wave. The copy's mode is a copy's there: a new file the
155
+ * source's less the umask, a file there its own.
156
+ */
157
+ | { call: 'copyFile'; path: string; from: string }
128
158
  /**
129
159
  * rm(1) as fs.rm makes it: a file or link unlinked, a directory with all
130
160
  * it holds when `recursive` (else EISDIR's refusal, as unlink's), and a
@@ -132,22 +162,27 @@ export type W7Call =
132
162
  */
133
163
  | { call: 'rm'; path: string; recursive?: true; force?: true }
134
164
  /** chown(2) of the link itself (lchown): the name's own entry, never what it names. */
135
- | { call: 'lchown'; path: string; uid: number; gid: number }
165
+ | { call: 'lchown'; path: string; uid: number | null; gid: number | null }
136
166
  /** utimes of the link itself (lutimes). */
137
167
  | { call: 'lutimes'; path: string; atime: number; mtime: number }
138
168
  /**
139
- * open(2) of a file to write it (and to read it when `read`), as the
140
- * session's own open decides it: a name made (`create`, `mode` less
141
- * `umask`) or refused (EEXIST when `exclusive`, ENOENT without `create`,
142
- * EISDIR, EACCES for any access asked that the file's mode refuses),
169
+ * open(2) of a file with the access `access` asks, as O_ACCMODE does
170
+ * (W7OpenAccess), as the session's own open decides it: a name made
171
+ * (`create`, `mode` less `umask`) or refused (EEXIST when `exclusive`,
172
+ * ENOENT without `create`, EISDIR, EACCES for any access asked that the
173
+ * file's mode refuses, write too under `truncate`: openAccessMask),
143
174
  * emptied when `truncate`, a link at the name followed unless `nofollow`
144
175
  * (ELOOP). Its answer is the file's stat. With `description` (an id the
145
176
  * process chose), the session keeps the open description under it, its
146
- * access fixed now; the description's write, append and ftruncate calls
147
- * name it, and its `close` ends it. `ino`: the number a name it makes is
148
- * given (a delegation's, as a mkdir's).
177
+ * rights the access's (openAccessRights); the description's write, append
178
+ * and ftruncate calls name it, and its `close` ends it. `ino`: the number
179
+ * a name it makes is given (a delegation's, as a mkdir's). `fchmod`: the
180
+ * file it opened then given exactly this mode (fchmod(2), as libuv's
181
+ * copyfile narrows its destination before a byte reaches it), part of the
182
+ * open: refused (EPERM: another's file), the open is refused and keeps no
183
+ * description, so a write through it is EBADF.
149
184
  */
150
- | { call: 'open'; path: string; mode: number; ino?: number; umask?: number; read?: true; create?: true; truncate?: true; exclusive?: true; nofollow?: true; description?: string }
185
+ | { call: 'open'; path: string; mode: number; access: W7OpenAccess; ino?: number; umask?: number; create?: true; truncate?: true; exclusive?: true; nofollow?: true; description?: string; fchmod?: number }
151
186
  /** close(2) of the session's open description `description` (an `open` call's). */
152
187
  | { call: 'close'; path: string; description: string };
153
188
 
@@ -677,7 +712,7 @@ async function* decodeRecords(
677
712
  if (v3) throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
678
713
  summary.pathCount++;
679
714
  summary.opCount++;
680
- const value = parseObject(payload, 'call', ['call', 'path'], ['mode', 'target', 'ino', 'size', 'existing', 'recursive', 'force', 'uid', 'gid', 'atime', 'mtime', 'umask', 'read', 'create', 'truncate', 'exclusive', 'nofollow', 'description']);
715
+ const value = parseObject(payload, 'call', ['call', 'path'], ['mode', 'target', 'from', 'ino', 'size', 'existing', 'recursive', 'force', 'uid', 'gid', 'atime', 'mtime', 'umask', 'access', 'create', 'truncate', 'exclusive', 'nofollow', 'description', 'fchmod']);
681
716
  yield { type: 'call', call: parsePathCall(value, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
682
717
  break;
683
718
  }
@@ -812,8 +847,8 @@ async function* encodeFile(file: EncoderFile, state: EncoderState): AsyncGenerat
812
847
  let fileCheck = 0;
813
848
  let chunkId = 0;
814
849
  const pieces = file.source === null ? givenChunks(file.chunks) : fixedChunks(file.inode, file.source);
850
+ const contentBytes = TEXT_ENCODER.encode(file.contentId);
815
851
  for await (const data of pieces) {
816
- const contentBytes = new TextEncoder().encode(file.contentId);
817
852
  const prefix = new Uint8Array(4 + contentBytes.length + 8);
818
853
  writeU32LE(prefix, 0, contentBytes.length);
819
854
  prefix.set(contentBytes, 4);
@@ -880,16 +915,20 @@ function encodeMetadataRecord(
880
915
  value: object,
881
916
  state?: EncoderState,
882
917
  ): Uint8Array[] {
883
- const payload = new TextEncoder().encode(JSON.stringify(value));
918
+ const payload = TEXT_ENCODER.encode(JSON.stringify(value));
884
919
  if (payload.byteLength > MAX_METADATA_BYTES) {
885
920
  throw new Error(`w7-frame: metadata record exceeds ${MAX_METADATA_BYTES} bytes`);
886
921
  }
887
- const header = recordHeader(tag, payload.byteLength);
922
+ // The header and the payload in one buffer, checked as one (crc32 composes).
923
+ const record = new Uint8Array(5 + payload.byteLength);
924
+ record[0] = tag;
925
+ writeU32LE(record, 1, payload.byteLength);
926
+ record.set(payload, 5);
888
927
  if (state && tag !== RecordTag.BatchEnd) {
889
- state.batchCheck = updateRecordCheck(state.batchCheck, header, payload);
928
+ state.batchCheck = updateRecordCheck(state.batchCheck, record);
890
929
  state.summary.recordCount++;
891
930
  }
892
- return [concatBytes(header, payload)];
931
+ return [record];
893
932
  }
894
933
 
895
934
  /**
@@ -1020,7 +1059,7 @@ function parseBatchBegin(bytes: Uint8Array, mode: W7Mode): BatchBeginMetadata {
1020
1059
  function parseAttrs(value: Record<string, unknown>, label: string): W7Attrs {
1021
1060
  const keys = Object.keys(value).sort().join(',');
1022
1061
  if (keys === 'mode') return { mode: u32(value.mode, `${label} mode`) };
1023
- if (keys === 'gid,uid') return { uid: u32(value.uid, `${label} uid`), gid: u32(value.gid, `${label} gid`) };
1062
+ if (keys === 'gid,uid') return { uid: ownerId(value.uid, `${label} uid`), gid: ownerId(value.gid, `${label} gid`) };
1024
1063
  if (keys === 'atime,mtime') return { atime: safeInteger(value.atime, `${label} atime`), mtime: safeInteger(value.mtime, `${label} mtime`) };
1025
1064
  throw new Error(`w7-frame: ${label} changes the mode, the owner or the times, one of them: got ${keys || 'nothing'}`);
1026
1065
  }
@@ -1108,6 +1147,9 @@ function parsePathCall(value: Record<string, unknown>, path: (value: unknown, la
1108
1147
  call: 'symlink', path: path(value.path, 'symlink path'), target: boundedString(value.target, 'symlink target', MAX_PATH_BYTES),
1109
1148
  ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'symlink ino') }),
1110
1149
  };
1150
+ case 'copyFile':
1151
+ if (keys !== 'call,from,path') break;
1152
+ return { call: 'copyFile', path: path(value.path, 'copyFile path'), from: path(value.from, 'copyFile from') };
1111
1153
  case 'ftruncate':
1112
1154
  if (keys.replace(',description', '').replace(',ino', '') !== 'call,path,size') break;
1113
1155
  return {
@@ -1131,25 +1173,27 @@ function parsePathCall(value: Record<string, unknown>, path: (value: unknown, la
1131
1173
  }
1132
1174
  case 'lchown':
1133
1175
  if (keys !== 'call,gid,path,uid') break;
1134
- return { call: 'lchown', path: path(value.path, 'lchown path'), uid: u32(value.uid, 'lchown uid'), gid: u32(value.gid, 'lchown gid') };
1176
+ return { call: 'lchown', path: path(value.path, 'lchown path'), uid: ownerId(value.uid, 'lchown uid'), gid: ownerId(value.gid, 'lchown gid') };
1135
1177
  case 'open': {
1136
- const flags = ['create', 'exclusive', 'nofollow', 'read', 'truncate'] as const;
1178
+ const flags = ['create', 'exclusive', 'nofollow', 'truncate'] as const;
1137
1179
  let required = keys;
1138
- for (const flag of ['create', 'description', 'exclusive', 'ino', 'nofollow', 'read', 'truncate', 'umask']) required = required.replace(`,${flag}`, '');
1139
- if (required !== 'call,mode,path') break;
1180
+ for (const flag of ['create', 'description', 'exclusive', 'fchmod', 'ino', 'nofollow', 'truncate', 'umask']) required = required.replace(`,${flag}`, '');
1181
+ if (required !== 'access,call,mode,path') break;
1140
1182
  for (const flag of flags) {
1141
1183
  if (value[flag] !== undefined && value[flag] !== true) throw new Error(`w7-frame: open ${flag} is true or absent`);
1142
1184
  }
1185
+ const access = value.access;
1186
+ if (!isOpenAccess(access)) throw new Error(`w7-frame: open access is 'read', 'write' or 'readwrite': got ${String(access)}`);
1143
1187
  return {
1144
- call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'),
1188
+ call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'), access,
1145
1189
  ...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'open ino') }),
1146
1190
  ...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'open umask') }),
1147
1191
  ...(value.create === true ? { create: true as const } : {}),
1148
1192
  ...(value.truncate === true ? { truncate: true as const } : {}),
1149
1193
  ...(value.exclusive === true ? { exclusive: true as const } : {}),
1150
1194
  ...(value.nofollow === true ? { nofollow: true as const } : {}),
1151
- ...(value.read === true ? { read: true as const } : {}),
1152
1195
  ...(value.description === undefined ? {} : { description: descriptionId(value.description, 'open description') }),
1196
+ ...(value.fchmod === undefined ? {} : { fchmod: u32(value.fchmod, 'open fchmod') }),
1153
1197
  };
1154
1198
  }
1155
1199
  case 'lutimes':
@@ -1384,15 +1428,7 @@ class PathOwnership {
1384
1428
  }
1385
1429
 
1386
1430
  function normalizePath(path: string): string {
1387
- const out: string[] = [];
1388
- for (const segment of path.split('/')) {
1389
- if (segment === '..') {
1390
- if (out.length > 0) out.pop();
1391
- } else if (segment !== '' && segment !== '.') {
1392
- out.push(segment);
1393
- }
1394
- }
1395
- return out.join('/');
1431
+ return rootClampedSegments(path).join('/');
1396
1432
  }
1397
1433
 
1398
1434
  function parentPath(path: string): string {
@@ -1415,6 +1451,11 @@ function safeInteger(value: unknown, label: string): number {
1415
1451
  return value as number;
1416
1452
  }
1417
1453
 
1454
+ /** A chown side: a uid or gid, or null, which keeps the file's. */
1455
+ function ownerId(value: unknown, label: string): number | null {
1456
+ return value === null ? null : u32(value, label);
1457
+ }
1458
+
1418
1459
  function u32(value: unknown, label: string): number {
1419
1460
  const integer = safeInteger(value, label);
1420
1461
  if (integer > 0xffff_ffff) throw new Error(`w7-frame: ${label} exceeds uint32`);
@@ -164,7 +164,7 @@ export interface WaveWriterOptions<Meta = undefined> {
164
164
  root: string | null;
165
165
  /** An existing worktree: only directories strictly below it are published. */
166
166
  worktreeRoot?: string | null;
167
- /** Record paths are relative to it (joined with '/'); omitted, they are VFS paths. */
167
+ /** Record paths are relative to it (joined with '/'; '' is the base itself); omitted, they are VFS paths. */
168
168
  base?: string;
169
169
  /** Wall-clock time (ms) after which no new wave starts. */
170
170
  deadline?: number | null;
@@ -300,7 +300,6 @@ export class WaveWriter<Meta = undefined> {
300
300
  private ownedPathBytes = 0;
301
301
  private pin: Pin | null = null;
302
302
  private inFlight: Promise<void> | null = null;
303
- private cutQueue: Promise<unknown> = Promise.resolve();
304
303
  private sequence = 0;
305
304
  private failure: WaveFailure | null = null;
306
305
  /** Owners (records' `meta`) whose records a failed wave carried. */
@@ -532,7 +531,8 @@ export class WaveWriter<Meta = undefined> {
532
531
  // ── Internals ────────────────────────────────────────────────────────
533
532
 
534
533
  private key(path: string): string {
535
- return this.options.base ? this.options.base + '/' + path : path;
534
+ if (!this.options.base) return path;
535
+ return path ? this.options.base + '/' + path : this.options.base;
536
536
  }
537
537
 
538
538
  private tally(): Tally {
@@ -551,13 +551,24 @@ export class WaveWriter<Meta = undefined> {
551
551
  if (admit) this.owned.add(path);
552
552
  }
553
553
 
554
- /** collectDirectoryPaths' chain from `path` upward, to the first directory already owned. */
555
- private walkChain(path: string, admit: boolean, tally: Tally): void {
554
+ /**
555
+ * The directories a wave publishes for something at or below `dir`:
556
+ * `dir` and each one above it, deepest first, up to the writer's root
557
+ * (included) and inside its worktree root.
558
+ */
559
+ private *chain(dir: string): Generator<string, void, undefined> {
556
560
  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
+ for (let current = dir; current; current = parentOf(current)) {
562
+ if (root && current !== root && !current.startsWith(root + '/')) return;
563
+ if (worktreeRoot !== null && !current.startsWith(worktreeRoot + '/')) return;
564
+ yield current;
565
+ if (current === root) return;
566
+ }
567
+ }
568
+
569
+ /** The chain from `path` upward, to the first directory already owned. */
570
+ private walkChain(path: string, admit: boolean, tally: Tally): void {
571
+ for (const current of this.chain(path)) {
561
572
  this.counters.ownershipVisits++;
562
573
  if (this.ownedDirectories.has(current)) break;
563
574
  if (admit) this.ownedDirectories.add(current);
@@ -566,8 +577,6 @@ export class WaveWriter<Meta = undefined> {
566
577
  tally.pathBytes += utf8Length(current);
567
578
  if (admit) this.owned.add(current);
568
579
  }
569
- if (current === root) break;
570
- current = parentOf(current);
571
580
  }
572
581
  }
573
582
 
@@ -636,16 +645,14 @@ export class WaveWriter<Meta = undefined> {
636
645
  }
637
646
 
638
647
  /**
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.
648
+ * Send the buffered wave once the one in flight has published. Only a
649
+ * mutation cuts, and mutations run one at a time, so at most one wave is
650
+ * in flight and one buffers.
641
651
  */
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> {
652
+ private async cut(): Promise<void> {
653
+ // A later turn than the mutation that asks: a pin set or cleared by a
654
+ // microtask queued before the cut is the wave's.
655
+ await Promise.resolve();
649
656
  if (this.inFlight) {
650
657
  const waitStarted = Date.now();
651
658
  await this.inFlight.catch(() => {});
@@ -848,16 +855,10 @@ export class WaveWriter<Meta = undefined> {
848
855
  /** The directories the buffered records publish, shallowest first. */
849
856
  private publishedDirectories(): string[] {
850
857
  const dirs = new Set<string>();
851
- const { root, worktreeRoot = null } = this.options;
852
858
  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;
859
+ for (const current of this.chain(path)) {
857
860
  if (dirs.has(current)) break;
858
861
  dirs.add(current);
859
- if (current === root) break;
860
- current = parentOf(current);
861
862
  }
862
863
  };
863
864
  for (const path of this.records.keys()) collect(parentOf(path));