@volter/browser-wali 0.1.4 → 0.1.6

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 (158) hide show
  1. package/README.md +73 -0
  2. package/dist/browser-wali-command.d.ts +3 -0
  3. package/dist/browser-wali-command.d.ts.map +1 -1
  4. package/dist/browser-wali-command.js +2 -1
  5. package/dist/browser-wali-command.js.map +1 -1
  6. package/dist/capabilities.d.ts +73 -1
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +86 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/child-processes.d.ts +24 -0
  11. package/dist/child-processes.d.ts.map +1 -0
  12. package/dist/child-processes.js +134 -0
  13. package/dist/child-processes.js.map +1 -0
  14. package/dist/codec-protocol.d.ts +66 -0
  15. package/dist/codec-protocol.d.ts.map +1 -0
  16. package/dist/codec-protocol.js +59 -0
  17. package/dist/codec-protocol.js.map +1 -0
  18. package/dist/codec-remote.d.ts +33 -0
  19. package/dist/codec-remote.d.ts.map +1 -0
  20. package/dist/codec-remote.js +190 -0
  21. package/dist/codec-remote.js.map +1 -0
  22. package/dist/codec-worker.d.ts +2 -0
  23. package/dist/codec-worker.d.ts.map +1 -0
  24. package/dist/codec-worker.js +438 -0
  25. package/dist/codec-worker.js.map +1 -0
  26. package/dist/coherent-file-locks.d.ts.map +1 -1
  27. package/dist/coherent-file-locks.js +17 -5
  28. package/dist/coherent-file-locks.js.map +1 -1
  29. package/dist/descriptors.d.ts +49 -0
  30. package/dist/descriptors.d.ts.map +1 -0
  31. package/dist/descriptors.js +115 -0
  32. package/dist/descriptors.js.map +1 -0
  33. package/dist/events.d.ts.map +1 -1
  34. package/dist/events.js +2 -2
  35. package/dist/events.js.map +1 -1
  36. package/dist/filesystem-protocol.d.ts +28 -1
  37. package/dist/filesystem-protocol.d.ts.map +1 -1
  38. package/dist/filesystem-protocol.js +18 -0
  39. package/dist/filesystem-protocol.js.map +1 -1
  40. package/dist/filesystem-remote.d.ts +26 -2
  41. package/dist/filesystem-remote.d.ts.map +1 -1
  42. package/dist/filesystem-remote.js +132 -23
  43. package/dist/filesystem-remote.js.map +1 -1
  44. package/dist/filesystem-worker.js +274 -34
  45. package/dist/filesystem-worker.js.map +1 -1
  46. package/dist/filesystem.d.ts +30 -1
  47. package/dist/filesystem.d.ts.map +1 -1
  48. package/dist/filesystem.js +180 -50
  49. package/dist/filesystem.js.map +1 -1
  50. package/dist/fork-worker.d.ts +2 -0
  51. package/dist/fork-worker.d.ts.map +1 -0
  52. package/dist/fork-worker.js +52 -0
  53. package/dist/fork-worker.js.map +1 -0
  54. package/dist/host.d.ts +36 -0
  55. package/dist/host.d.ts.map +1 -0
  56. package/dist/host.js +153 -0
  57. package/dist/host.js.map +1 -0
  58. package/dist/loopback-host.d.ts +25 -1
  59. package/dist/loopback-host.d.ts.map +1 -1
  60. package/dist/loopback-host.js +447 -13
  61. package/dist/loopback-host.js.map +1 -1
  62. package/dist/memory.d.ts +65 -2
  63. package/dist/memory.d.ts.map +1 -1
  64. package/dist/memory.js +306 -49
  65. package/dist/memory.js.map +1 -1
  66. package/dist/module-layout.d.ts +2 -0
  67. package/dist/module-layout.d.ts.map +1 -1
  68. package/dist/module-layout.js +4 -2
  69. package/dist/module-layout.js.map +1 -1
  70. package/dist/monotonic-clock.d.ts +12 -0
  71. package/dist/monotonic-clock.d.ts.map +1 -0
  72. package/dist/monotonic-clock.js +14 -0
  73. package/dist/monotonic-clock.js.map +1 -0
  74. package/dist/network-protocol.d.ts +12 -1
  75. package/dist/network-protocol.d.ts.map +1 -1
  76. package/dist/network-protocol.js +6 -0
  77. package/dist/network-protocol.js.map +1 -1
  78. package/dist/network-worker.js +149 -20
  79. package/dist/network-worker.js.map +1 -1
  80. package/dist/network.d.ts +15 -3
  81. package/dist/network.d.ts.map +1 -1
  82. package/dist/network.js +74 -39
  83. package/dist/network.js.map +1 -1
  84. package/dist/process-adapter.d.ts +4 -1
  85. package/dist/process-adapter.d.ts.map +1 -1
  86. package/dist/process-adapter.js +3 -3
  87. package/dist/process-adapter.js.map +1 -1
  88. package/dist/process-bridge.d.ts +43 -0
  89. package/dist/process-bridge.d.ts.map +1 -1
  90. package/dist/process-bridge.js +30 -1
  91. package/dist/process-bridge.js.map +1 -1
  92. package/dist/program.d.ts +60 -1
  93. package/dist/program.d.ts.map +1 -1
  94. package/dist/program.js +92 -31
  95. package/dist/program.js.map +1 -1
  96. package/dist/runtime.d.ts.map +1 -1
  97. package/dist/runtime.js +491 -134
  98. package/dist/runtime.js.map +1 -1
  99. package/dist/select.d.ts +16 -0
  100. package/dist/select.d.ts.map +1 -0
  101. package/dist/select.js +100 -0
  102. package/dist/select.js.map +1 -0
  103. package/dist/shared-pipes.d.ts +17 -6
  104. package/dist/shared-pipes.d.ts.map +1 -1
  105. package/dist/shared-pipes.js +94 -70
  106. package/dist/shared-pipes.js.map +1 -1
  107. package/dist/signals.d.ts +48 -0
  108. package/dist/signals.d.ts.map +1 -0
  109. package/dist/signals.js +232 -0
  110. package/dist/signals.js.map +1 -0
  111. package/dist/snapshot-filesystem.d.ts +128 -2
  112. package/dist/snapshot-filesystem.d.ts.map +1 -1
  113. package/dist/snapshot-filesystem.js +481 -53
  114. package/dist/snapshot-filesystem.js.map +1 -1
  115. package/dist/spawn-policy.d.ts +5 -0
  116. package/dist/spawn-policy.d.ts.map +1 -0
  117. package/dist/spawn-policy.js +29 -0
  118. package/dist/spawn-policy.js.map +1 -0
  119. package/dist/stdin.d.ts +2 -0
  120. package/dist/stdin.d.ts.map +1 -1
  121. package/dist/stdin.js +4 -0
  122. package/dist/stdin.js.map +1 -1
  123. package/dist/symlink.d.ts +8 -0
  124. package/dist/symlink.d.ts.map +1 -1
  125. package/dist/symlink.js +13 -0
  126. package/dist/symlink.js.map +1 -1
  127. package/dist/system.d.ts +152 -1
  128. package/dist/system.d.ts.map +1 -1
  129. package/dist/system.js +226 -7
  130. package/dist/system.js.map +1 -1
  131. package/dist/thread-coordinator-worker.d.ts +1 -0
  132. package/dist/thread-coordinator-worker.d.ts.map +1 -1
  133. package/dist/thread-coordinator-worker.js +1 -0
  134. package/dist/thread-coordinator-worker.js.map +1 -1
  135. package/dist/thread-worker.js +241 -122
  136. package/dist/thread-worker.js.map +1 -1
  137. package/dist/threads.d.ts +12 -2
  138. package/dist/threads.d.ts.map +1 -1
  139. package/dist/threads.js +30 -10
  140. package/dist/threads.js.map +1 -1
  141. package/dist/types.d.ts +39 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/websocket-peer.d.ts.map +1 -1
  144. package/dist/websocket-peer.js +4 -14
  145. package/dist/websocket-peer.js.map +1 -1
  146. package/dist/worker-program-protocol.d.ts +70 -3
  147. package/dist/worker-program-protocol.d.ts.map +1 -1
  148. package/dist/worker-program-worker.js +115 -23
  149. package/dist/worker-program-worker.js.map +1 -1
  150. package/dist/worker-program.d.ts +25 -1
  151. package/dist/worker-program.d.ts.map +1 -1
  152. package/dist/worker-program.js +470 -61
  153. package/dist/worker-program.js.map +1 -1
  154. package/dist/worker-standby.d.ts +3 -0
  155. package/dist/worker-standby.d.ts.map +1 -0
  156. package/dist/worker-standby.js +17 -0
  157. package/dist/worker-standby.js.map +1 -0
  158. package/package.json +21 -7
@@ -1,55 +1,409 @@
1
+ import { PROJECT_ROOTS } from "@volter/browser-runtime/browser-pack-store.js";
2
+ import { encodeBase64, decodeBase64 } from "@volter/browser-runtime/browser-base64.js";
3
+ /**
4
+ * The tree's nodes by path, with each directory's names beside them, so a
5
+ * listing is a lookup. It was a scan of every path: 7 ms a listing in a tree
6
+ * of 100,000 entries, which `git status` pays per directory (2026-09-24).
7
+ */
8
+ class NodeMap extends Map {
9
+ names = new Map();
10
+ static rooted() {
11
+ const nodes = new NodeMap();
12
+ nodes.set("/", rootNode());
13
+ return nodes;
14
+ }
15
+ set(path, node) {
16
+ if (!this.has(path) && path !== "/") {
17
+ const directory = parent(path);
18
+ let names = this.names.get(directory);
19
+ if (!names) {
20
+ names = new Set();
21
+ this.names.set(directory, names);
22
+ }
23
+ names.add(path.slice(directory === "/" ? 1 : directory.length + 1));
24
+ }
25
+ return super.set(path, node);
26
+ }
27
+ delete(path) {
28
+ if (!super.delete(path))
29
+ return false;
30
+ const directory = parent(path);
31
+ const names = this.names.get(directory);
32
+ names?.delete(path.slice(directory === "/" ? 1 : directory.length + 1));
33
+ if (names?.size === 0)
34
+ this.names.delete(directory);
35
+ return true;
36
+ }
37
+ clear() {
38
+ super.clear();
39
+ this.names.clear();
40
+ }
41
+ /** The names directly under a directory. */
42
+ namesIn(directory) {
43
+ return [...(this.names.get(directory) ?? [])];
44
+ }
45
+ /** A path and everything under it, parents first, found through the names rather than a scan. */
46
+ subtree(path) {
47
+ const found = [];
48
+ const pending = [path];
49
+ while (pending.length > 0) {
50
+ const current = pending.shift();
51
+ const node = this.get(current);
52
+ if (!node)
53
+ continue;
54
+ found.push([current, node]);
55
+ for (const name of this.names.get(current) ?? [])
56
+ pending.push(current === "/" ? `/${name}` : `${current}/${name}`);
57
+ }
58
+ return found;
59
+ }
60
+ }
1
61
  /** Small transferable filesystem used inside WALI process workers. */
2
62
  export class SnapshotWaliFileSystem {
3
- nodes = new Map([["/", rootNode()]]);
63
+ nodes = NodeMap.rooted();
64
+ /**
65
+ * The project's store, when the tree is over one (ADR-0042): its index
66
+ * answers every path this tree has not itself touched, and a path is made a
67
+ * node when first asked for. Building every node up front was 272 ms of each
68
+ * process's start in a tree of 100,000 entries, and a process that can fork
69
+ * paid it twice, once more in its filesystem worker (2026-09-24).
70
+ */
71
+ lower;
72
+ /** The store's paths this tree removed or moved away; everything under one is gone too. */
73
+ hidden = new Set();
4
74
  nextInode = 2;
5
75
  mutationDepth = 0;
6
76
  mutated = false;
7
77
  mutationHandler;
78
+ /**
79
+ * The paths mutated since the last {@link takeChangedPaths}, tracked only
80
+ * while a mutation handler is installed -- i.e. only for the coordinator's
81
+ * page-facing delta channel, which is the one caller that drains it. Nobody
82
+ * else drains, so nobody else accumulates.
83
+ */
84
+ changedPaths;
8
85
  static fromSnapshot(snapshot) {
9
86
  const filesystem = new SnapshotWaliFileSystem();
10
87
  filesystem.replaceSnapshot(snapshot);
11
88
  return filesystem;
12
89
  }
90
+ /**
91
+ * The tree as the project's store holds it, each file's bytes read from
92
+ * the store when the program first opens it; the snapshot the page sent
93
+ * is metadata, and the entries in it that carry content, a program's
94
+ * private files, are written over the store's.
95
+ */
96
+ static fromStore(store, snapshot) {
97
+ const filesystem = new SnapshotWaliFileSystem();
98
+ filesystem.lower = store;
99
+ filesystem.mutate(() => {
100
+ // The page's metadata for the store's own paths repeats the store; only
101
+ // what differs from it is applied, so a tree of any size costs its
102
+ // differences here, not its size.
103
+ const differing = snapshot.files.filter((source) => {
104
+ const entry = source;
105
+ const normalized = normalize(entry.path);
106
+ if (normalized === "/")
107
+ return false;
108
+ if (entry.content !== undefined)
109
+ return true;
110
+ const held = store.entries.get(normalized);
111
+ if (!held)
112
+ return true;
113
+ if ((entry.type === "directory") !== (held.type === "directory"))
114
+ return true;
115
+ return (entry.mode !== undefined && entry.mode !== (held.mode & 0o7777))
116
+ || (entry.mtimeMs !== undefined && entry.mtimeMs !== held.mtimeMs);
117
+ });
118
+ for (const source of differing.sort((left, right) => depth(left.path) - depth(right.path))) {
119
+ const entry = source;
120
+ const normalized = normalize(entry.path);
121
+ if (entry.type === "directory") {
122
+ if (!filesystem.node(normalized))
123
+ filesystem.mkdirSync(normalized, { recursive: true });
124
+ }
125
+ else if (entry.content !== undefined) {
126
+ filesystem.writeFileSync(normalized, decodeBase64(entry.content));
127
+ }
128
+ else if (!filesystem.node(normalized)) {
129
+ continue;
130
+ }
131
+ const node = filesystem.node(normalized);
132
+ if (node) {
133
+ if (entry.mode !== undefined)
134
+ node.mode = entry.mode;
135
+ if (entry.mtimeMs !== undefined)
136
+ node.atimeMs = node.mtimeMs = entry.mtimeMs;
137
+ }
138
+ }
139
+ filesystem.mutated = false;
140
+ });
141
+ return filesystem;
142
+ }
143
+ /** A path's node: one this tree holds, or the store's entry for it, made a node the first time it is asked for. */
144
+ node(path) {
145
+ const held = this.nodes.get(path);
146
+ if (held || !this.lower || path === "/")
147
+ return held;
148
+ if (this.isHidden(path))
149
+ return undefined;
150
+ const entry = this.lower.entries.get(path);
151
+ if (!entry)
152
+ return undefined;
153
+ const node = this.lowerNode(path, entry);
154
+ this.nodes.set(path, node);
155
+ return node;
156
+ }
157
+ lowerNode(path, entry) {
158
+ const store = this.lower;
159
+ return entry.type === "directory"
160
+ ? { type: "directory", fromStore: true, mode: entry.mode & 0o7777, atimeMs: entry.mtimeMs, mtimeMs: entry.mtimeMs, ino: this.nextInode++ }
161
+ : { type: "file", fromStore: true, load: () => store.read(path), size: entry.length, mode: entry.mode & 0o7777, atimeMs: entry.mtimeMs, mtimeMs: entry.mtimeMs, ino: this.nextInode++ };
162
+ }
163
+ /**
164
+ * The tree for a peer that reads the same store: only what differs from it.
165
+ * A tree that has removed any of the store's paths answers whole, since a
166
+ * snapshot names what is there and cannot name what is not.
167
+ */
168
+ toStoreOverlaySnapshot() {
169
+ if (!this.lower || this.hidden.size > 0)
170
+ return this.toSnapshot();
171
+ const store = this.lower;
172
+ const overlay = [...this.nodes.entries()].filter(([path, node]) => {
173
+ if (path === "/")
174
+ return false;
175
+ const held = store.entries.get(path);
176
+ return !(node.fromStore && !node.dirty && held && node.mode === (held.mode & 0o7777) && node.mtimeMs === held.mtimeMs);
177
+ });
178
+ return { files: overlay.map(([path, node]) => node.type === "directory"
179
+ ? { path, type: "directory", mode: node.mode, mtimeMs: node.mtimeMs }
180
+ : node.load && !node.dirty
181
+ ? this.sized(path, node)
182
+ : { path, type: "file", content: encodeBase64(node.content ?? new Uint8Array()), mode: node.mode, mtimeMs: node.mtimeMs }) };
183
+ }
184
+ isHidden(path) {
185
+ if (this.hidden.size === 0)
186
+ return false;
187
+ for (let current = path; current !== "/"; current = parent(current)) {
188
+ if (this.hidden.has(current))
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+ /** A store path this tree no longer has. */
194
+ hide(path) {
195
+ if (this.lower?.entries.has(path))
196
+ this.hidden.add(path);
197
+ }
198
+ /** A path made again where the store's was removed: it is new, and the store's contents under it stay gone. */
199
+ unhide(path) {
200
+ if (!this.lower || !this.isHidden(path))
201
+ return;
202
+ this.hidden.delete(path);
203
+ for (const name of this.lower.childrenOf(path))
204
+ this.hidden.add(path === "/" ? `/${name}` : `${path}/${name}`);
205
+ }
206
+ /** Every path of the tree, the store's untouched ones as they are there. */
207
+ everyNode() {
208
+ const every = [...this.nodes.entries()];
209
+ if (!this.lower)
210
+ return every;
211
+ for (const [path, entry] of this.lower.entries) {
212
+ if (path === "/" || this.nodes.has(path) || this.isHidden(path))
213
+ continue;
214
+ every.push([path, this.lowerNode(path, entry)]);
215
+ }
216
+ return every;
217
+ }
13
218
  setMutationHandler(handler) {
14
219
  this.mutationHandler = handler;
220
+ this.changedPaths = handler ? new Set() : undefined;
221
+ }
222
+ /**
223
+ * THE PATHS THIS TREE HAS MUTATED, and then it forgets them.
224
+ *
225
+ * This is what the page-facing delta channel publishes, and it exists
226
+ * because the alternative -- diff two whole-tree snapshots -- is quadratic
227
+ * in a session's own history. `dirty` (above) stops a flush re-encoding
228
+ * files the PAGE wrote, but nothing cleared it for files the GUEST wrote,
229
+ * so every file Blender had ever written was re-base64-encoded on every
230
+ * later flush. MEASURED 2026-09-19 against Blender's real 1,751-file,
231
+ * 76 MB staged tree: `toWrittenSnapshot()` costs 0.2 ms on a fresh session
232
+ * and 1,447 ms forty-one calls in, and the channel calls it three times per
233
+ * host round trip. That single curve is the whole "WALI is ~100x slower
234
+ * than Emscripten" floor, and its growth with session age is why a floor
235
+ * measured on one model looked like a property of the model.
236
+ *
237
+ * Clearing `dirty` on publish would NOT have been safe: an entry the page
238
+ * already holds is compared by SIZE, so a same-length rewrite of the export
239
+ * arena would have silently dropped out of the patch. Naming the paths
240
+ * avoids the comparison altogether -- and it is the same shape the HOST
241
+ * side of this channel already uses (`worker-program.ts`'s
242
+ * `scheduleFilesystemImport`: "THE CHANGED PATHS, ONE AT A TIME, never the
243
+ * whole tree"), which had been fixed on that side only.
244
+ */
245
+ takeChangedPaths() {
246
+ const paths = this.changedPaths ?? new Set();
247
+ this.changedPaths = this.changedPaths ? new Set() : undefined;
248
+ return paths;
15
249
  }
250
+ /** These paths are not news: file coherence carried them to the page already. */
251
+ dropChangedPaths(paths) {
252
+ if (!this.changedPaths)
253
+ return;
254
+ for (const path of paths)
255
+ this.changedPaths.delete(normalize(path));
256
+ }
257
+ /**
258
+ * One path as the page must record it: bytes for a file, the bare node for
259
+ * a directory, and undefined when the path is gone (the caller's removal).
260
+ * Always content-carrying, never sized -- the caller named this path
261
+ * because it changed, so a size would be exactly the wrong answer.
262
+ */
263
+ writtenEntry(path) {
264
+ const normalized = normalize(path);
265
+ const node = this.node(normalized);
266
+ if (!node)
267
+ return undefined;
268
+ const details = { mode: node.mode, mtimeMs: node.mtimeMs };
269
+ if (node.type === "directory")
270
+ return { path: normalized, type: "directory", ...details };
271
+ return {
272
+ path: normalized,
273
+ type: "file",
274
+ content: encodeBase64(this.bytesOf(node)),
275
+ ...details,
276
+ };
277
+ }
278
+ /**
279
+ * The tree as a snapshot describes it. An entry with content is written;
280
+ * an entry without keeps the bytes this tree already holds for that path,
281
+ * which is what a snapshot from another realm over the same store says.
282
+ */
16
283
  replaceSnapshot(snapshot) {
17
284
  this.mutate(() => {
18
- this.nodes.clear();
19
- this.nodes.set("/", rootNode());
20
- this.nextInode = 2;
21
- this.markMutation();
285
+ // A whole tree replaces everything, the store's layer with it: what the
286
+ // snapshot does not name is gone.
287
+ const previous = new Map(this.everyNode());
288
+ this.lower = undefined;
289
+ this.hidden.clear();
290
+ this.nodes = NodeMap.rooted();
291
+ // THE WHOLE TREE CHANGED, so every path on either side is a changed
292
+ // path: the ones that survive are republished, the ones that do not
293
+ // become removals. Costly and correct, and reached only by a child
294
+ // process's restore -- never by the directory channel's own round trip.
295
+ for (const path of previous.keys())
296
+ this.markMutation(path);
297
+ this.markMutation("/");
22
298
  const entries = snapshot.files.slice().sort((left, right) => depth(left.path) - depth(right.path));
23
299
  for (const source of entries) {
24
300
  const entry = source;
25
- if (entry.path !== "/") {
26
- if (entry.type === "directory")
27
- this.mkdirSync(entry.path, { recursive: true });
28
- else
29
- this.writeFileSync(entry.path, decodeBase64(entry.content ?? ""));
301
+ const normalized = normalize(entry.path);
302
+ if (normalized !== "/") {
303
+ if (entry.type === "directory") {
304
+ this.mkdirSync(normalized, { recursive: true });
305
+ }
306
+ else if (entry.content !== undefined) {
307
+ this.writeFileSync(normalized, decodeBase64(entry.content));
308
+ // RESTORED, NOT WRITTEN. These bytes came FROM the page; sending
309
+ // them back is the whole cost `dirty` exists to avoid.
310
+ const restored = this.nodes.get(normalized);
311
+ if (restored)
312
+ restored.dirty = false;
313
+ }
314
+ else {
315
+ const kept = previous.get(normalized);
316
+ this.mkdirSync(parent(normalized), { recursive: true });
317
+ // An entry with no bytes names a file this worker held before
318
+ // (kept as it is) or one whose bytes are in no realm it reads: a
319
+ // tree the page left out of this image on purpose. The second is
320
+ // absent here, its directories present. It was an empty, dirty
321
+ // file once, and the exit patch wrote that emptiness back over
322
+ // the page's bytes (measured 2026-09-21: a 512 KiB file became 0).
323
+ if (kept?.type === "file")
324
+ this.nodes.set(normalized, kept);
325
+ }
30
326
  }
31
- const node = this.nodes.get(normalize(entry.path));
327
+ const node = this.node(normalized);
32
328
  if (node) {
33
329
  if (entry.mode !== undefined)
34
330
  node.mode = entry.mode;
35
331
  if (entry.mtimeMs !== undefined)
36
332
  node.atimeMs = node.mtimeMs = entry.mtimeMs;
333
+ this.markMutation(normalized);
37
334
  }
38
335
  }
39
336
  });
40
337
  }
338
+ /**
339
+ * The tree for a peer that holds NOTHING: every file's bytes, except the
340
+ * ones still in the store, which such a peer reads from the store itself.
341
+ *
342
+ * This is the TRANSFER form, and it has two consumers that both materialize
343
+ * a tree from it and would otherwise get empty files: the coordinator
344
+ * worker a threaded program's filesystem is handed to (`runtime.ts` ->
345
+ * `WaliFilesystemCoordinator.create`) and `FilesystemOperation.Snapshot`.
346
+ * MEASURED 2026-09-19 by shipping the other behaviour here for one commit:
347
+ * Blender booted to "Failed to import encodings module" because
348
+ * `python313.zip` reached the coordinator as a size. For a peer that
349
+ * already holds the unwritten rest, ask {@link toWrittenSnapshot}.
350
+ */
41
351
  toSnapshot() {
42
352
  return { files: [...this.nodes.entries()].map(([path, node]) => node.type === "directory"
43
353
  ? { path, type: "directory", mode: node.mode, mtimeMs: node.mtimeMs }
44
- : { path, type: "file", content: encodeBase64(node.content ?? new Uint8Array()), mode: node.mode, mtimeMs: node.mtimeMs }) };
354
+ : node.load && !node.dirty
355
+ ? this.sized(path, node)
356
+ : { path, type: "file", content: encodeBase64(node.content ?? new Uint8Array()), mode: node.mode, mtimeMs: node.mtimeMs }).concat(this.untouchedStoreEntries()) };
357
+ }
358
+ /** The store's entries this tree has not made nodes of, as sized entries: their bytes are the store's. */
359
+ untouchedStoreEntries() {
360
+ const untouched = [];
361
+ if (!this.lower)
362
+ return untouched;
363
+ for (const [path, entry] of this.lower.entries) {
364
+ if (path === "/" || this.nodes.has(path) || this.isHidden(path))
365
+ continue;
366
+ untouched.push(entry.type === "directory"
367
+ ? { path, type: "directory", mode: entry.mode & 0o7777, mtimeMs: entry.mtimeMs }
368
+ : { path, type: "file", size: entry.length, mode: entry.mode & 0o7777, mtimeMs: entry.mtimeMs });
369
+ }
370
+ return untouched;
371
+ }
372
+ /**
373
+ * The tree for a peer that ALREADY HOLDS what this filesystem has not
374
+ * written: bytes for files written here, a size for everything else.
375
+ *
376
+ * WHY, measured 2026-09-19 with Blender's 78 MB runtime tree (2,285 files)
377
+ * in the guest: `toSnapshot()` costs 1739 ms and allocates 96.2 MB of
378
+ * base64 string, and the page-facing delta channel calls it on a 100 ms
379
+ * debounce after every guest mutation plus twice more inside every host
380
+ * import -- so one round trip of a long-lived program spent seconds
381
+ * re-encoding files nobody had written, and the editor's Blender session
382
+ * wedged on it. Against the same tree this answers in 0 ms and allocates
383
+ * nothing.
384
+ *
385
+ * Safe for that channel and only for it: the page sent those bytes, and
386
+ * `createProcessFilesystemPatch` compares a sized entry against a
387
+ * content-carrying one by SIZE, so an untouched file falls out of the patch
388
+ * exactly as it did when it carried its own base64.
389
+ */
390
+ toWrittenSnapshot() {
391
+ return { files: [...this.nodes.entries()].map(([path, node]) => node.type === "directory"
392
+ ? { path, type: "directory", mode: node.mode, mtimeMs: node.mtimeMs }
393
+ : node.dirty
394
+ ? { path, type: "file", content: encodeBase64(node.content ?? new Uint8Array()), mode: node.mode, mtimeMs: node.mtimeMs }
395
+ : this.sized(path, node)).concat(this.untouchedStoreEntries()) };
396
+ }
397
+ sized(path, node) {
398
+ return { path, type: "file", size: node.content?.byteLength ?? node.size ?? 0, mode: node.mode, mtimeMs: node.mtimeMs };
45
399
  }
46
- existsSync(path) { return this.nodes.has(normalize(path)); }
400
+ existsSync(path) { return this.node(normalize(path)) !== undefined; }
47
401
  statSync(path) {
48
402
  const normalized = normalize(path);
49
- const node = this.nodes.get(normalized);
403
+ const node = this.node(normalized);
50
404
  if (!node)
51
405
  throw new Error(`ENOENT: no such file or directory, stat '${normalized}'`);
52
- const size = node.content?.byteLength ?? 0;
406
+ const size = node.content?.byteLength ?? node.size ?? 0;
53
407
  return {
54
408
  isFile: () => node.type === "file",
55
409
  isDirectory: () => node.type === "directory",
@@ -69,29 +423,93 @@ export class SnapshotWaliFileSystem {
69
423
  };
70
424
  }
71
425
  lstatSync(path) { return this.statSync(path); }
426
+ async readFile(path) { return this.readFileSync(path); }
72
427
  readFileSync(path) {
73
428
  const normalized = normalize(path);
74
- const node = this.nodes.get(normalized);
429
+ const node = this.node(normalized);
430
+ if (!node)
431
+ throw new Error(`ENOENT: no such file, open '${normalized}'`);
432
+ if (node.type !== "file")
433
+ throw new Error(`EISDIR: illegal operation on a directory, read '${normalized}'`);
434
+ return this.bytesOf(node).slice();
435
+ }
436
+ /** The file's bytes, read from the store the first time. */
437
+ bytesOf(node) {
438
+ if (node.content === undefined && node.load) {
439
+ node.content = node.load();
440
+ node.size = node.content.byteLength;
441
+ }
442
+ return node.content ?? new Uint8Array();
443
+ }
444
+ readRangeSync(path, offset, length) {
445
+ const normalized = normalize(path);
446
+ const node = this.node(normalized);
75
447
  if (!node)
76
448
  throw new Error(`ENOENT: no such file, open '${normalized}'`);
77
449
  if (node.type !== "file")
78
450
  throw new Error(`EISDIR: illegal operation on a directory, read '${normalized}'`);
79
- return (node.content ?? new Uint8Array()).slice();
451
+ return this.bytesOf(node).slice(offset, offset + length);
452
+ }
453
+ /**
454
+ * Write in place, growing the backing buffer geometrically. `content` is a
455
+ * view; its `buffer` may be larger than the file, and every reader of a
456
+ * node goes through the view (`bytesOf`, `writtenEntry`, `toSnapshot`), so
457
+ * the spare capacity is never observed. A file that does not exist yet is
458
+ * created; a gap past the old end is zero.
459
+ */
460
+ writeRangeSync(path, offset, data) {
461
+ this.mutate(() => {
462
+ const normalized = normalize(path);
463
+ this.mkdirSync(parent(normalized), { recursive: true });
464
+ this.unhide(normalized);
465
+ const existing = this.node(normalized);
466
+ if (existing?.type === "directory")
467
+ throw new Error(`EISDIR: illegal operation on a directory, write '${normalized}'`);
468
+ const current = existing ? this.bytesOf(existing) : new Uint8Array();
469
+ const end = offset + data.byteLength;
470
+ let content;
471
+ if (end <= current.byteLength) {
472
+ content = current;
473
+ }
474
+ else if (end <= current.buffer.byteLength - current.byteOffset && existing?.dirty) {
475
+ // Room in a buffer this store already grew for the file (a dirty
476
+ // node's buffer is ours; a loaded or snapshot one may be shared).
477
+ content = new Uint8Array(current.buffer, current.byteOffset, end);
478
+ content.fill(0, current.byteLength, offset);
479
+ }
480
+ else {
481
+ content = new Uint8Array(new ArrayBuffer(Math.max(end, current.byteLength * 2, 4096)), 0, end);
482
+ content.set(current);
483
+ }
484
+ content.set(data, offset);
485
+ this.nodes.set(normalized, {
486
+ type: "file",
487
+ content,
488
+ dirty: true,
489
+ mode: existing?.type === "file" ? existing.mode : 0o644,
490
+ atimeMs: Date.now(),
491
+ mtimeMs: Date.now(),
492
+ ino: existing?.type === "file" ? existing.ino : this.nextInode++,
493
+ });
494
+ this.markMutation(normalized);
495
+ });
80
496
  }
81
497
  writeFileSync(path, data) {
82
498
  this.mutate(() => {
83
499
  const normalized = normalize(path);
84
500
  this.mkdirSync(parent(normalized), { recursive: true });
85
- const existing = this.nodes.get(normalized);
501
+ this.unhide(normalized);
502
+ const existing = this.node(normalized);
86
503
  this.nodes.set(normalized, {
87
504
  type: "file",
88
505
  content: typeof data === "string" ? new TextEncoder().encode(data) : data.slice(),
506
+ dirty: true,
89
507
  mode: existing?.type === "file" ? existing.mode : 0o644,
90
508
  atimeMs: Date.now(),
91
509
  mtimeMs: Date.now(),
92
510
  ino: existing?.type === "file" ? existing.ino : this.nextInode++,
93
511
  });
94
- this.markMutation();
512
+ this.markMutation(normalized);
95
513
  });
96
514
  }
97
515
  mkdirSync(path, options) {
@@ -103,47 +521,51 @@ export class SnapshotWaliFileSystem {
103
521
  let current = "";
104
522
  for (let index = 0; index < segments.length; index += 1) {
105
523
  current += `/${segments[index]}`;
106
- const existing = this.nodes.get(current);
524
+ const existing = this.node(current);
107
525
  if (existing?.type === "file")
108
526
  throw new Error(`ENOTDIR: not a directory, mkdir '${current}'`);
109
527
  if (existing)
110
528
  continue;
111
529
  if (!options?.recursive && index !== segments.length - 1)
112
530
  throw new Error(`ENOENT: no such directory, mkdir '${current}'`);
531
+ this.unhide(current);
113
532
  const now = Date.now();
114
533
  this.nodes.set(current, { type: "directory", mode: 0o755, atimeMs: now, mtimeMs: now, ino: this.nextInode++ });
115
- this.markMutation();
534
+ this.markMutation(current);
116
535
  }
117
536
  });
118
537
  }
119
538
  readdirSync(path) {
120
539
  const normalized = normalize(path);
121
- const node = this.nodes.get(normalized);
540
+ const node = this.node(normalized);
122
541
  if (!node)
123
542
  throw new Error(`ENOENT: no such directory, scandir '${normalized}'`);
124
543
  if (node.type !== "directory")
125
544
  throw new Error(`ENOTDIR: not a directory, scandir '${normalized}'`);
126
- const prefix = normalized === "/" ? "/" : `${normalized}/`;
127
- const children = new Set();
128
- for (const candidate of this.nodes.keys()) {
129
- if (candidate.startsWith(prefix) && candidate !== normalized) {
130
- const child = candidate.slice(prefix.length).split("/", 1)[0];
131
- if (child)
132
- children.add(child);
545
+ const names = new Set(this.nodes.namesIn(normalized));
546
+ if (this.lower && !this.isHidden(normalized)) {
547
+ // The store's roots are its top: no entry of it names them as children of `/`.
548
+ const lowerNames = normalized === "/"
549
+ ? PROJECT_ROOTS.filter((root) => this.lower.entries.has(root)).map((root) => root.slice(1))
550
+ : this.lower.childrenOf(normalized);
551
+ for (const name of lowerNames) {
552
+ if (!this.isHidden(normalized === "/" ? `/${name}` : `${normalized}/${name}`))
553
+ names.add(name);
133
554
  }
134
555
  }
135
- return [...children];
556
+ return [...names];
136
557
  }
137
558
  unlinkSync(path) {
138
559
  this.mutate(() => {
139
560
  const normalized = normalize(path);
140
- const node = this.nodes.get(normalized);
561
+ const node = this.node(normalized);
141
562
  if (!node)
142
563
  throw new Error(`ENOENT: no such file, unlink '${normalized}'`);
143
564
  if (node.type !== "file")
144
565
  throw new Error(`EISDIR: illegal operation on a directory, unlink '${normalized}'`);
145
566
  this.nodes.delete(normalized);
146
- this.markMutation();
567
+ this.hide(normalized);
568
+ this.markMutation(normalized);
147
569
  });
148
570
  }
149
571
  rmdirSync(path) {
@@ -151,7 +573,7 @@ export class SnapshotWaliFileSystem {
151
573
  const normalized = normalize(path);
152
574
  if (normalized === "/")
153
575
  throw new Error("EPERM: cannot remove root directory");
154
- const node = this.nodes.get(normalized);
576
+ const node = this.node(normalized);
155
577
  if (!node)
156
578
  throw new Error(`ENOENT: no such directory, rmdir '${normalized}'`);
157
579
  if (node.type !== "directory")
@@ -159,44 +581,59 @@ export class SnapshotWaliFileSystem {
159
581
  if (this.readdirSync(normalized).length > 0)
160
582
  throw new Error(`ENOTEMPTY: directory not empty, rmdir '${normalized}'`);
161
583
  this.nodes.delete(normalized);
162
- this.markMutation();
584
+ this.hide(normalized);
585
+ this.markMutation(normalized);
163
586
  });
164
587
  }
165
588
  renameSync(oldPath, newPath) {
166
589
  this.mutate(() => {
167
590
  const source = normalize(oldPath);
168
591
  const destination = normalize(newPath);
169
- const node = this.nodes.get(source);
592
+ const node = this.node(source);
170
593
  if (!node)
171
594
  throw new Error(`ENOENT: no such file or directory, rename '${source}'`);
172
595
  this.mkdirSync(parent(destination), { recursive: true });
173
- const moved = [...this.nodes.entries()].filter(([path]) => path === source || path.startsWith(`${source}/`));
596
+ // What the store holds under the source moves too: it is made nodes here first.
597
+ if (this.lower && !this.isHidden(source)) {
598
+ for (const [path] of this.lower.descendants(source))
599
+ this.node(path);
600
+ }
601
+ const moved = this.nodes.subtree(source);
174
602
  for (const [path] of moved)
175
603
  this.nodes.delete(path);
176
- for (const [path, value] of moved)
604
+ this.hide(source);
605
+ this.unhide(destination);
606
+ for (const [path, value] of moved) {
607
+ // A file moved is a file the page must write at its new path: its bytes come along.
608
+ if (value.type === "file") {
609
+ this.bytesOf(value);
610
+ value.dirty = true;
611
+ }
177
612
  this.nodes.set(`${destination}${path.slice(source.length)}`, value);
178
- this.markMutation();
613
+ this.markMutation(path);
614
+ this.markMutation(`${destination}${path.slice(source.length)}`);
615
+ }
179
616
  });
180
617
  }
181
618
  chmodSync(path, mode) {
182
619
  this.mutate(() => {
183
620
  const normalized = normalize(path);
184
- const node = this.nodes.get(normalized);
621
+ const node = this.node(normalized);
185
622
  if (!node)
186
623
  throw new Error(`ENOENT: no such file or directory, chmod '${normalized}'`);
187
624
  node.mode = mode & 0o7777;
188
- this.markMutation();
625
+ this.markMutation(normalized);
189
626
  });
190
627
  }
191
628
  utimesSync(path, atimeMs, mtimeMs) {
192
629
  this.mutate(() => {
193
630
  const normalized = normalize(path);
194
- const node = this.nodes.get(normalized);
631
+ const node = this.node(normalized);
195
632
  if (!node)
196
633
  throw new Error(`ENOENT: no such file or directory, utimes '${normalized}'`);
197
634
  node.atimeMs = atimeMs;
198
635
  node.mtimeMs = mtimeMs;
199
- this.markMutation();
636
+ this.markMutation(normalized);
200
637
  });
201
638
  }
202
639
  mutate(operation) {
@@ -212,8 +649,9 @@ export class SnapshotWaliFileSystem {
212
649
  }
213
650
  }
214
651
  }
215
- markMutation() {
652
+ markMutation(path) {
216
653
  this.mutated = true;
654
+ this.changedPaths?.add(normalize(path));
217
655
  }
218
656
  }
219
657
  function rootNode() {
@@ -234,14 +672,4 @@ function normalize(path) {
234
672
  }
235
673
  function parent(path) { return path.slice(0, path.lastIndexOf("/")) || "/"; }
236
674
  function depth(path) { return path.split("/").length; }
237
- function decodeBase64(value) {
238
- const binary = atob(value);
239
- return Uint8Array.from(binary, (character) => character.charCodeAt(0));
240
- }
241
- function encodeBase64(value) {
242
- let binary = "";
243
- for (const byte of value)
244
- binary += String.fromCharCode(byte);
245
- return btoa(binary);
246
- }
247
675
  //# sourceMappingURL=snapshot-filesystem.js.map