@volter/browser-wali 0.1.5 → 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.
- package/README.md +73 -0
- package/dist/browser-wali-command.d.ts +3 -0
- package/dist/browser-wali-command.d.ts.map +1 -1
- package/dist/browser-wali-command.js +2 -1
- package/dist/browser-wali-command.js.map +1 -1
- package/dist/capabilities.d.ts +73 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +86 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/child-processes.d.ts +24 -0
- package/dist/child-processes.d.ts.map +1 -0
- package/dist/child-processes.js +134 -0
- package/dist/child-processes.js.map +1 -0
- package/dist/codec-protocol.d.ts +66 -0
- package/dist/codec-protocol.d.ts.map +1 -0
- package/dist/codec-protocol.js +59 -0
- package/dist/codec-protocol.js.map +1 -0
- package/dist/codec-remote.d.ts +33 -0
- package/dist/codec-remote.d.ts.map +1 -0
- package/dist/codec-remote.js +190 -0
- package/dist/codec-remote.js.map +1 -0
- package/dist/codec-worker.d.ts +2 -0
- package/dist/codec-worker.d.ts.map +1 -0
- package/dist/codec-worker.js +438 -0
- package/dist/codec-worker.js.map +1 -0
- package/dist/coherent-file-locks.d.ts.map +1 -1
- package/dist/coherent-file-locks.js +17 -5
- package/dist/coherent-file-locks.js.map +1 -1
- package/dist/descriptors.d.ts +49 -0
- package/dist/descriptors.d.ts.map +1 -0
- package/dist/descriptors.js +115 -0
- package/dist/descriptors.js.map +1 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -2
- package/dist/events.js.map +1 -1
- package/dist/filesystem-protocol.d.ts +28 -1
- package/dist/filesystem-protocol.d.ts.map +1 -1
- package/dist/filesystem-protocol.js +18 -0
- package/dist/filesystem-protocol.js.map +1 -1
- package/dist/filesystem-remote.d.ts +26 -2
- package/dist/filesystem-remote.d.ts.map +1 -1
- package/dist/filesystem-remote.js +132 -23
- package/dist/filesystem-remote.js.map +1 -1
- package/dist/filesystem-worker.js +274 -34
- package/dist/filesystem-worker.js.map +1 -1
- package/dist/filesystem.d.ts +30 -1
- package/dist/filesystem.d.ts.map +1 -1
- package/dist/filesystem.js +180 -50
- package/dist/filesystem.js.map +1 -1
- package/dist/fork-worker.d.ts +2 -0
- package/dist/fork-worker.d.ts.map +1 -0
- package/dist/fork-worker.js +52 -0
- package/dist/fork-worker.js.map +1 -0
- package/dist/host.d.ts +36 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +153 -0
- package/dist/host.js.map +1 -0
- package/dist/loopback-host.d.ts +25 -1
- package/dist/loopback-host.d.ts.map +1 -1
- package/dist/loopback-host.js +447 -13
- package/dist/loopback-host.js.map +1 -1
- package/dist/memory.d.ts +65 -2
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +306 -49
- package/dist/memory.js.map +1 -1
- package/dist/module-layout.d.ts +2 -0
- package/dist/module-layout.d.ts.map +1 -1
- package/dist/module-layout.js +4 -2
- package/dist/module-layout.js.map +1 -1
- package/dist/monotonic-clock.d.ts +12 -0
- package/dist/monotonic-clock.d.ts.map +1 -0
- package/dist/monotonic-clock.js +14 -0
- package/dist/monotonic-clock.js.map +1 -0
- package/dist/network-protocol.d.ts +12 -1
- package/dist/network-protocol.d.ts.map +1 -1
- package/dist/network-protocol.js +6 -0
- package/dist/network-protocol.js.map +1 -1
- package/dist/network-worker.js +149 -20
- package/dist/network-worker.js.map +1 -1
- package/dist/network.d.ts +15 -3
- package/dist/network.d.ts.map +1 -1
- package/dist/network.js +74 -39
- package/dist/network.js.map +1 -1
- package/dist/process-adapter.d.ts +4 -1
- package/dist/process-adapter.d.ts.map +1 -1
- package/dist/process-adapter.js +3 -3
- package/dist/process-adapter.js.map +1 -1
- package/dist/process-bridge.d.ts +43 -0
- package/dist/process-bridge.d.ts.map +1 -1
- package/dist/process-bridge.js +30 -1
- package/dist/process-bridge.js.map +1 -1
- package/dist/program.d.ts +60 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +92 -31
- package/dist/program.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +491 -134
- package/dist/runtime.js.map +1 -1
- package/dist/select.d.ts +16 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +100 -0
- package/dist/select.js.map +1 -0
- package/dist/shared-pipes.d.ts +17 -6
- package/dist/shared-pipes.d.ts.map +1 -1
- package/dist/shared-pipes.js +94 -70
- package/dist/shared-pipes.js.map +1 -1
- package/dist/signals.d.ts +48 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +232 -0
- package/dist/signals.js.map +1 -0
- package/dist/snapshot-filesystem.d.ts +128 -2
- package/dist/snapshot-filesystem.d.ts.map +1 -1
- package/dist/snapshot-filesystem.js +481 -53
- package/dist/snapshot-filesystem.js.map +1 -1
- package/dist/spawn-policy.d.ts +5 -0
- package/dist/spawn-policy.d.ts.map +1 -0
- package/dist/spawn-policy.js +29 -0
- package/dist/spawn-policy.js.map +1 -0
- package/dist/stdin.d.ts +2 -0
- package/dist/stdin.d.ts.map +1 -1
- package/dist/stdin.js +4 -0
- package/dist/stdin.js.map +1 -1
- package/dist/symlink.d.ts +8 -0
- package/dist/symlink.d.ts.map +1 -1
- package/dist/symlink.js +13 -0
- package/dist/symlink.js.map +1 -1
- package/dist/system.d.ts +152 -1
- package/dist/system.d.ts.map +1 -1
- package/dist/system.js +226 -7
- package/dist/system.js.map +1 -1
- package/dist/thread-coordinator-worker.d.ts +1 -0
- package/dist/thread-coordinator-worker.d.ts.map +1 -1
- package/dist/thread-coordinator-worker.js +1 -0
- package/dist/thread-coordinator-worker.js.map +1 -1
- package/dist/thread-worker.js +241 -122
- package/dist/thread-worker.js.map +1 -1
- package/dist/threads.d.ts +12 -2
- package/dist/threads.d.ts.map +1 -1
- package/dist/threads.js +30 -10
- package/dist/threads.js.map +1 -1
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket-peer.d.ts.map +1 -1
- package/dist/websocket-peer.js +4 -14
- package/dist/websocket-peer.js.map +1 -1
- package/dist/worker-program-protocol.d.ts +70 -3
- package/dist/worker-program-protocol.d.ts.map +1 -1
- package/dist/worker-program-worker.js +115 -23
- package/dist/worker-program-worker.js.map +1 -1
- package/dist/worker-program.d.ts +25 -1
- package/dist/worker-program.d.ts.map +1 -1
- package/dist/worker-program.js +470 -61
- package/dist/worker-program.js.map +1 -1
- package/dist/worker-standby.d.ts +3 -0
- package/dist/worker-standby.d.ts.map +1 -0
- package/dist/worker-standby.js +17 -0
- package/dist/worker-standby.js.map +1 -0
- package/package.json +20 -6
|
@@ -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 =
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
this.
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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
|
-
:
|
|
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.
|
|
400
|
+
existsSync(path) { return this.node(normalize(path)) !== undefined; }
|
|
47
401
|
statSync(path) {
|
|
48
402
|
const normalized = normalize(path);
|
|
49
|
-
const node = this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 [...
|
|
556
|
+
return [...names];
|
|
136
557
|
}
|
|
137
558
|
unlinkSync(path) {
|
|
138
559
|
this.mutate(() => {
|
|
139
560
|
const normalized = normalize(path);
|
|
140
|
-
const node = this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|