@daugasauron/dolly 0.0.0-stage → 0.1.4-rc.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -2
  3. package/dist/dolly-build-id.mjs +1 -0
  4. package/dist/dolly-dso-0.wasm +0 -0
  5. package/dist/dolly-image-build-id.mjs +1 -0
  6. package/dist/dolly-kernel-plugin-abi.mjs +2 -0
  7. package/dist/dolly-process-0.wasm +0 -0
  8. package/dist/dolly-process-abi.mjs +1 -0
  9. package/dist/dolly-process-dso.mjs +1575 -0
  10. package/dist/dolly-process-gate-0.wasm +0 -0
  11. package/dist/dolly-process-stack.mjs +50 -0
  12. package/dist/dolly-process-worker.mjs +275 -0
  13. package/dist/dolly-threads-0.wasm +0 -0
  14. package/dist/dolly.wasm +0 -0
  15. package/host/abi.mjs +5 -0
  16. package/host/dso/abi.mjs +13 -0
  17. package/host/dso/dso.mjs +26 -0
  18. package/host/dso/module.json +15 -0
  19. package/host/http/abi.mjs +28 -0
  20. package/host/http/broker.mjs +325 -0
  21. package/host/http/http.mjs +74 -0
  22. package/host/http/local-services.mjs +37 -0
  23. package/host/http/module.json +15 -0
  24. package/host/http/policy.mjs +293 -0
  25. package/host/modules.mjs +373 -0
  26. package/host/nena/abi.mjs +19 -0
  27. package/host/nena/module.json +15 -0
  28. package/host/nena/nena.mjs +131 -0
  29. package/host/packages/module.json +13 -0
  30. package/host/packages/packages.mjs +15 -0
  31. package/host/packages/service.mjs +59 -0
  32. package/host/requirements.mjs +62 -0
  33. package/host/runtime/abi.mjs +8 -0
  34. package/host/runtime/boot-files.mjs +45 -0
  35. package/host/runtime/grower.mjs +14 -0
  36. package/host/runtime/lease-bridge.mjs +65 -0
  37. package/host/runtime/module.json +16 -0
  38. package/host/runtime/runtime.mjs +209 -0
  39. package/host/snapshot/abi.mjs +16 -0
  40. package/host/snapshot/module.json +15 -0
  41. package/host/snapshot/snapshot.mjs +138 -0
  42. package/host/snapshot/transport.mjs +132 -0
  43. package/host/snapshot/ui.mjs +207 -0
  44. package/host/stack/abi.mjs +5 -0
  45. package/host/stack/module.json +17 -0
  46. package/host/stack/stack.mjs +19 -0
  47. package/host/threads/abi.mjs +8 -0
  48. package/host/threads/module.json +13 -0
  49. package/host/threads/threads.mjs +36 -0
  50. package/package.json +12 -4
  51. package/src/alphabetical.mjs +4 -0
  52. package/src/browser.mjs +237 -0
  53. package/src/build-log.mjs +30 -0
  54. package/src/image-entry.mjs +29 -0
  55. package/src/image-inputs.mjs +20 -0
  56. package/src/image-names.mjs +11 -0
  57. package/src/images.mjs +10 -0
  58. package/src/kernel-plugin.mjs +77 -0
  59. package/src/packaged-snapshot.mjs +175 -0
  60. package/src/page-chords.mjs +43 -0
  61. package/src/page-indicators.mjs +27 -0
  62. package/src/process-abi.mjs +69 -0
  63. package/src/process-constants.mjs +242 -0
  64. package/src/process-supervisor.mjs +1002 -0
  65. package/src/runtime-worker.mjs +55 -0
  66. package/src/session-store.mjs +198 -0
  67. package/src/snapshot-records.mjs +109 -0
  68. package/src/static-asset.mjs +97 -0
  69. package/src/terminal-text.mjs +72 -0
  70. package/src/version.mjs +3 -0
  71. package/src/wasm-interface.mjs +440 -0
  72. package/src/worker-image.mjs +137 -0
@@ -0,0 +1,209 @@
1
+ import { DollyProcessSupervisor } from "../../src/process-supervisor.mjs";
2
+ import { createLeaseBridge } from "./lease-bridge.mjs";
3
+ import { instantiateKernelPlugin } from "../../src/kernel-plugin.mjs";
4
+ import { DOLLY_ERRNO as E } from "./abi.mjs";
5
+ import { DOLLY_TERMINAL_WORD_RESULT_SEQUENCE, DOLLY_TERMINAL_WORD_RESULT_STATUS, DOLLY_TERMINAL_WORD_FOREGROUND_PID,
6
+ DOLLY_TERMINAL_WORD_FOREGROUND_INTERRUPTIBLE, DOLLY_TERMINAL_WORD_INTERRUPT_SEQUENCE,
7
+ DOLLY_TERMINAL_WORD_INTERRUPT_TARGET_PID } from "./abi.mjs";
8
+
9
+ export function check() {
10
+ if (!globalThis.crossOriginIsolated) return "cross-origin isolation is required";
11
+ try { new WebAssembly.Memory({ initial: 1n, maximum: 1n, shared: true, address: "i64" }); }
12
+ catch { return "shared WebAssembly memory64 is unavailable"; }
13
+ return null;
14
+ }
15
+
16
+ // Bootstrap text only feeds the page's 1 MiB log; a longer write is refused.
17
+ const maxBootstrapMessage = 1024 * 1024;
18
+ // What crypto.getRandomValues fills in one call.
19
+ const maxEntropy = 65536;
20
+
21
+ // The terminal mailbox (abi/dolly-supervisor-0.wat), the Worker's end: the
22
+ // foreground command, shell results and the page's interrupt request, with or
23
+ // without a display.
24
+ class TerminalMailbox {
25
+ static resultSequence = DOLLY_TERMINAL_WORD_RESULT_SEQUENCE;
26
+ static resultStatus = DOLLY_TERMINAL_WORD_RESULT_STATUS;
27
+ static foregroundPid = DOLLY_TERMINAL_WORD_FOREGROUND_PID;
28
+ static foregroundInterruptible = DOLLY_TERMINAL_WORD_FOREGROUND_INTERRUPTIBLE;
29
+ static interruptSequence = DOLLY_TERMINAL_WORD_INTERRUPT_SEQUENCE;
30
+ static interruptTargetPid = DOLLY_TERMINAL_WORD_INTERRUPT_TARGET_PID;
31
+
32
+ constructor(buffer, address) {
33
+ if (!(buffer instanceof SharedArrayBuffer) || !Number.isSafeInteger(address) ||
34
+ address <= 0 || address % 4 !== 0 || address > buffer.byteLength - 24) {
35
+ throw new Error("Dolly supplied an invalid terminal mailbox");
36
+ }
37
+ this.words = new Int32Array(buffer, address, 6);
38
+ }
39
+
40
+ currentResultSequence() {
41
+ return Atomics.load(this.words, TerminalMailbox.resultSequence);
42
+ }
43
+
44
+ async waitForResult(sequence) {
45
+ while (Atomics.load(this.words, TerminalMailbox.resultSequence) === sequence) {
46
+ const waiting = Atomics.waitAsync(this.words, TerminalMailbox.resultSequence, sequence);
47
+ if (waiting.async) await waiting.value;
48
+ }
49
+ return Atomics.load(this.words, TerminalMailbox.resultStatus);
50
+ }
51
+
52
+ foregroundPid() {
53
+ return Atomics.load(this.words, TerminalMailbox.foregroundPid);
54
+ }
55
+
56
+ // The terminal's interrupt character (termios VINTR) while it signals, or 0.
57
+ foregroundInterruptKey() {
58
+ return Atomics.load(this.words, TerminalMailbox.foregroundInterruptible);
59
+ }
60
+
61
+ foregroundInterruptible() {
62
+ return this.foregroundInterruptKey() !== 0;
63
+ }
64
+
65
+ // The interrupt key, or a caller with none to give: Wasm interrupts the
66
+ // foreground command read here only if it still owns the terminal. False
67
+ // (ISIG clear, or another key) lets the key reach it as input.
68
+ interruptForeground(key) {
69
+ const pid = this.foregroundPid();
70
+ if (pid <= 0 || !this.foregroundInterruptible() || (key !== undefined && key !== this.foregroundInterruptKey())) return false;
71
+ Atomics.store(this.words, TerminalMailbox.interruptTargetPid, pid);
72
+ Atomics.add(this.words, TerminalMailbox.interruptSequence, 1);
73
+ return true;
74
+ }
75
+ }
76
+
77
+ // The page's terminal: the foreground program and its interrupt key as the
78
+ // Worker last reported them; the shell's results and interrupts, asked of the
79
+ // Worker, which decides on what it holds then.
80
+ export function browser({ ask }) {
81
+ let foreground = { pid: 0, interrupt: 0 };
82
+ const terminal = {
83
+ foregroundPid: () => foreground.pid,
84
+ foregroundInterruptKey: () => foreground.interrupt,
85
+ foregroundInterruptible: () => foreground.interrupt !== 0,
86
+ currentResultSequence: () => ask({ type: "terminal-result-sequence" }),
87
+ waitForResult: sequence => ask({ type: "terminal-result", sequence }),
88
+ interruptForeground: key => ask({ type: "terminal-interrupt", key }),
89
+ };
90
+ return {
91
+ terminal,
92
+ page: { terminal, get foregroundPid() { return foreground.pid; } },
93
+ messages: { "terminal-foreground"({ pid, interrupt }) { foreground = { pid, interrupt }; } },
94
+ };
95
+ }
96
+
97
+ export function worker({ send, runtimeBase, abi, service }) {
98
+ const memory = new WebAssembly.Memory({ initial: 1024n, maximum: 131072n, shared: true, address: "i64" });
99
+ // The kernel's memory grows in a Worker of its own (grower.mjs) while this
100
+ // one waits. Chrome counts a shared memory as external memory of the isolate
101
+ // that grows it; past twice its heap limit it then collects between every
102
+ // message of this one, and every system call takes fifteen times as long.
103
+ const growth = new BigInt64Array(new SharedArrayBuffer(16)), growthWord = new Int32Array(growth.buffer);
104
+ const grower = new Worker(new URL("./grower.mjs", import.meta.url), { type: "module", name: "dolly-memory" });
105
+ const growing = new Promise((resolve, reject) => {
106
+ grower.onmessage = resolve;
107
+ grower.onerror = event => reject(new Error(event.message || "the kernel memory Worker failed"));
108
+ });
109
+ grower.postMessage({ memory, control: growth.buffer });
110
+ let supervisor, threadProvider, terminal, reported = "";
111
+ // The foreground program, sent to the page when it changes.
112
+ function reportForeground() {
113
+ if (!terminal) return;
114
+ const foreground = { pid: terminal.foregroundPid(), interrupt: terminal.foregroundInterruptKey() };
115
+ const text = JSON.stringify(foreground);
116
+ if (text === reported) return;
117
+ reported = text;
118
+ send({ type: "terminal-foreground", ...foreground });
119
+ }
120
+ // Modules served inside the process Worker of the executables that record
121
+ // them, by requirement: the bundle (a Blob) that Worker imports and the
122
+ // configuration its serve() receives.
123
+ const processModules = new Map();
124
+ // The bytes a kernel import names, or null: inside kernel memory, at most `limit`.
125
+ const span = (address, length, limit) => {
126
+ const [start, size] = [address, length].map(Number);
127
+ return start >= 0 && size >= 0 && size <= limit && start <= memory.buffer.byteLength - size
128
+ ? new Uint8Array(memory.buffer, start, size) : null;
129
+ };
130
+ return {
131
+ memory,
132
+ // Before the kernel runs: it must not ask for memory before the Worker that grows it waits.
133
+ growing,
134
+ get terminal() { return terminal; },
135
+ reportForeground,
136
+ service: reportForeground,
137
+ messages: {
138
+ "terminal-result-sequence": () => terminal.currentResultSequence(),
139
+ "terminal-result": ({ sequence }) => terminal.waitForResult(sequence),
140
+ "terminal-interrupt": ({ key }) => terminal.interruptForeground(key),
141
+ },
142
+ bindings: {
143
+ "env.memory": memory,
144
+ "env.dolly_bootstrap_write_bytes": (address, length) => {
145
+ const bytes = span(address, length, maxBootstrapMessage);
146
+ if (!bytes) return -E.EINVAL;
147
+ send({ type: "bootstrap-bytes", bytes: bytes.slice() });
148
+ return 0;
149
+ },
150
+ "env.dolly_clock_realtime": () => Date.now(),
151
+ "env.dolly_clock_monotonic": () => performance.now(),
152
+ "env.dolly_memory_grow": pages => {
153
+ growth[1] = pages;
154
+ Atomics.store(growthWord, 0, 1);
155
+ Atomics.notify(growthWord, 0);
156
+ // A notify can be the grower's for the last growth, whose wait found
157
+ // it done already: this one is done when the word says so.
158
+ while (Atomics.load(growthWord, 0) === 1) Atomics.wait(growthWord, 0, 1);
159
+ const old = growth[1];
160
+ // This thread learns of the growth by an interrupt, which a wait that
161
+ // found it done already does not take: take it before the kernel
162
+ // touches the new pages, as a loop does at each turn.
163
+ if (old >= 0n) while (BigInt(memory.buffer.byteLength) < (old + pages) * 65536n);
164
+ return old;
165
+ },
166
+ // Web Crypto rejects views of shared memory: fill a private buffer, then copy.
167
+ "env.dolly_entropy": (address, length) => {
168
+ const bytes = span(address, length, maxEntropy);
169
+ if (!bytes) return -E.EINVAL;
170
+ bytes.set(crypto.getRandomValues(new Uint8Array(bytes.length)));
171
+ return 0;
172
+ },
173
+ },
174
+ // Both follow their modules, which nena@0 may enable or disable while the
175
+ // supervisor runs: undefined takes the provider or the module away.
176
+ setThreadProvider(provider) {
177
+ threadProvider = provider;
178
+ if (provider) { if (supervisor) supervisor.threadHost = provider; }
179
+ else supervisor?.withdrawThreads();
180
+ },
181
+ serveInProcess(requirement, module) {
182
+ if (module) processModules.set(requirement, module); else processModules.delete(requirement);
183
+ },
184
+ async supervisor(kernel) {
185
+ return supervisor ??= await DollyProcessSupervisor.create(kernel, memory, runtimeBase, abi, service, threadProvider, processModules);
186
+ },
187
+ start({ kernel }) {
188
+ terminal = new TerminalMailbox(memory.buffer, Number(kernel.dolly_terminal_mailbox_address()));
189
+ reportForeground();
190
+ },
191
+ serviceDeferred() { supervisor?.serviceDeferred(); },
192
+ // Services deferred work each time the page notifies words[index], a
193
+ // module's mailbox word, until the returned function is called.
194
+ serviceOnNotify(words, index) {
195
+ let stopped = false;
196
+ const wait = () => void Promise.resolve(Atomics.waitAsync(words, index, Atomics.load(words, index)).value)
197
+ .then(() => { if (stopped) return; supervisor?.serviceDeferred(); wait(); });
198
+ wait();
199
+ return () => { stopped = true; };
200
+ },
201
+ // A resident library linked into the kernel (--dolly-kernel-plugin), such as the display's.
202
+ kernelPlugin(bytes, kernel) { return instantiateKernelPlugin(bytes, kernel, memory); },
203
+ // The Worker side of a device a module leases per process (src/device-lease.c).
204
+ leaseBridge(lease, mailbox, send) {
205
+ return createLeaseBridge(lease, memory, mailbox, send, () => supervisor?.serviceDeferred());
206
+ },
207
+ dispose() { supervisor?.dispose(); grower.terminate(); },
208
+ };
209
+ }
@@ -0,0 +1,16 @@
1
+ // Generated from host/snapshot/dolly-snapshot-0.wat, include/dolly/process.h.
2
+ export const DOLLY_SESSION_HEADER_SIZE = 64;
3
+ export const DOLLY_SESSION_NAME_CAPACITY = 128;
4
+ export const DOLLY_SESSION_TRANSFER_CAPACITY = 1048576;
5
+ export const DOLLY_SESSION_WORD_REQUEST_SEQUENCE = 0;
6
+ export const DOLLY_SESSION_WORD_COMPLETED_SEQUENCE = 1;
7
+ export const DOLLY_SESSION_WORD_STATUS = 2;
8
+ export const DOLLY_SESSION_WORD_NAME_LENGTH = 3;
9
+ export const DOLLY_SESSION_WORD_CHUNK_SEQUENCE = 4;
10
+ export const DOLLY_SESSION_WORD_CHUNK_CONSUMED_SEQUENCE = 5;
11
+ export const DOLLY_SESSION_WORD_CHUNK_LENGTH = 6;
12
+ export const DOLLY_SESSION_WORD_CHUNK_EOF = 7;
13
+ export const DOLLY_SESSION_WORD_TOTAL_SIZE_LOW = 8;
14
+ export const DOLLY_SESSION_WORD_TOTAL_SIZE_HIGH = 9;
15
+ export const DOLLY_SESSION_WORD_CANCELLED_SEQUENCE = 10;
16
+ export const DOLLY_ERRNO = Object.freeze({ E2BIG: 1, EACCES: 2, EADDRINUSE: 3, EADDRNOTAVAIL: 4, EAFNOSUPPORT: 5, EAGAIN: 6, EALREADY: 7, EBADF: 8, EBADMSG: 9, EBUSY: 10, ECANCELED: 11, ECHILD: 12, ECONNABORTED: 13, ECONNREFUSED: 14, ECONNRESET: 15, EDEADLK: 16, EDESTADDRREQ: 17, EDOM: 18, EDQUOT: 19, EEXIST: 20, EFAULT: 21, EFBIG: 22, EHOSTUNREACH: 23, EIDRM: 24, EILSEQ: 25, EINPROGRESS: 26, EINTR: 27, EINVAL: 28, EIO: 29, EISCONN: 30, EISDIR: 31, ELOOP: 32, EMFILE: 33, EMLINK: 34, EMSGSIZE: 35, EMULTIHOP: 36, ENAMETOOLONG: 37, ENETDOWN: 38, ENETRESET: 39, ENETUNREACH: 40, ENFILE: 41, ENOBUFS: 42, ENODEV: 43, ENOENT: 44, ENOEXEC: 45, ENOLCK: 46, ENOLINK: 47, ENOMEM: 48, ENOMSG: 49, ENOPROTOOPT: 50, ENOSPC: 51, ENOSYS: 52, ENOTCONN: 53, ENOTDIR: 54, ENOTEMPTY: 55, ENOTRECOVERABLE: 56, ENOTSOCK: 57, ENOTTY: 59, ENXIO: 60, EOVERFLOW: 61, EOWNERDEAD: 62, EPERM: 63, EPIPE: 64, EPROTO: 65, EPROTONOSUPPORT: 66, EPROTOTYPE: 67, ERANGE: 68, EROFS: 69, ESPIPE: 70, ESRCH: 71, ESTALE: 72, ETIMEDOUT: 73, ETXTBSY: 74, EXDEV: 75, ENODATA: 116, ENOTSUP: 138 });
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "snapshot",
3
+ "version": 0,
4
+ "gives": "save the session in this browser and restore it",
5
+ "fixed": "its baseline is the image as loaded",
6
+ "dependencies": ["runtime@0"],
7
+ "phase": "image",
8
+ "imports": [],
9
+ "host": "snapshot.mjs",
10
+ "contracts": ["dolly-snapshot-0.wat"],
11
+ "process": [],
12
+ "headers": [],
13
+ "kernel": ["kernel.c"],
14
+ "client": []
15
+ }
@@ -0,0 +1,138 @@
1
+ import { SessionTransport } from "./transport.mjs";
2
+ import { mountSessionSave } from "./ui.mjs";
3
+ import { DOLLY_BUILD_ID } from "../../dist/dolly-build-id.mjs";
4
+ import { DOLLY_IMAGE_BUILD_ID } from "../../dist/dolly-image-build-id.mjs";
5
+ import { DOLLY_IMAGES } from "../../src/images.mjs";
6
+ import { DOLLY_SESSION_FORMAT_VERSION, decodeSessionSnapshot, loadStoredSession, sessionCompatible,
7
+ sessionLoadUrl, validSessionName, DOLLY_SESSION_MAX_BYTES } from "../../src/session-store.mjs";
8
+
9
+ // A session route (/session/?name=NAME) boots the saved session's image and
10
+ // restores its delta; with recover=1 it unpacks the files into a fresh system
11
+ // image instead.
12
+ export async function boot(route) {
13
+ if (!route.loadSession) return undefined;
14
+ if (route.mode !== "snapshot") throw new Error("invalid Dolly route configuration");
15
+ const query = new URL(location.href).searchParams;
16
+ const name = query.get("name"), recovering = query.get("recover") === "1";
17
+ if (!validSessionName(name)) throw new Error("The Dolly session URL has an invalid name. Open /sessions to see saved sessions.");
18
+ const record = await loadStoredSession(name);
19
+ if (record === null) throw new Error(`Session '${name}' was not found in this browser. Open /sessions to see saved sessions.`);
20
+ if (record.name !== name) throw new Error("Stored session name does not match its key");
21
+ let image = record.image;
22
+ if (recovering) {
23
+ if (record.formatVersion !== DOLLY_SESSION_FORMAT_VERSION) throw new Error("This save uses an unsupported recovery format");
24
+ if (!DOLLY_IMAGES.some(definition => definition.image === "system")) throw new Error("File recovery needs the system image in this distribution");
25
+ image = "system";
26
+ } else if (!sessionCompatible(record, DOLLY_IMAGES, DOLLY_BUILD_ID, DOLLY_IMAGE_BUILD_ID)) {
27
+ throw new Error("This save belongs to an older runtime or image recipe. It has not been deleted or overwritten. Open /sessions to see saved sessions.");
28
+ }
29
+ const bytes = await decodeSessionSnapshot(record);
30
+ record.bytes.transfer(0);
31
+ record.bytes = undefined;
32
+ return { image, label: `${recovering ? "RECOVER FILES FROM" : "RESTORE SESSION"} ${name}`,
33
+ custom: image === "custom" ? record.customImage : undefined, configuration: { bytes, name, recovering } };
34
+ }
35
+
36
+ // The page's capture: the Worker reads the session's chunks out of the mailbox
37
+ // and hands each one to onChunk here, waiting until it is consumed.
38
+ function sessionCapture({ send, ask }) {
39
+ let consume = null;
40
+ return {
41
+ async capture(name, { signal, onChunk }) {
42
+ signal?.throwIfAborted();
43
+ if (consume) throw new Error("A Dolly session save is already active");
44
+ const cancel = () => send({ type: "snapshot-cancel" });
45
+ consume = onChunk;
46
+ signal?.addEventListener("abort", cancel, { once: true });
47
+ try { return await ask({ type: "snapshot-capture", name }); }
48
+ finally {
49
+ consume = null;
50
+ signal?.removeEventListener("abort", cancel);
51
+ }
52
+ },
53
+ chunk: ({ bytes }) => consume?.(bytes),
54
+ };
55
+ }
56
+
57
+ export function browser(page) {
58
+ const { bytes, name, recovering } = page.configuration, capture = sessionCapture(page);
59
+ const session = mountSessionSave(page, () => capture, name === undefined ? null : { name, recovering });
60
+ return {
61
+ configuration: bytes === undefined ? {} : { bytes, ...(recovering ? { recover: name } : {}) },
62
+ transfers: bytes ? [bytes] : [],
63
+ page: { get sessionName() { return session.name; }, saveSession: session.save },
64
+ claimsKey: session.claimsKey,
65
+ entryStarted: session.entryStarted,
66
+ // An ended image leaves the session this tab saved or restored to open again.
67
+ ended: () => session.name && page.site ? [{ text: `Open saved session ${session.name}`,
68
+ href: sessionLoadUrl(session.name, page.site).href }] : [],
69
+ messages: { "snapshot-chunk": capture.chunk },
70
+ dispose() { session.abort(); },
71
+ };
72
+ }
73
+
74
+ export function worker({ get, ask, configuration: { bytes, recover } }) {
75
+ let kernel, transport, cancelled;
76
+ if (bytes !== undefined && (!(bytes instanceof ArrayBuffer) || bytes.byteLength < 16 ||
77
+ bytes.byteLength > DOLLY_SESSION_MAX_BYTES)) throw new Error("invalid Dolly session snapshot");
78
+ if (recover !== undefined && (!validSessionName(recover) || bytes === undefined)) {
79
+ throw new Error("invalid Dolly file recovery request");
80
+ }
81
+ function restore(kernel) {
82
+ const memory = get("runtime").memory;
83
+ const address = Number(kernel.dolly_session_restore_address(BigInt(bytes.byteLength)));
84
+ if (!Number.isSafeInteger(address) || address <= 0 || address > memory.buffer.byteLength - bytes.byteLength) {
85
+ throw new Error("invalid session restore memory range");
86
+ }
87
+ new Uint8Array(memory.buffer, address, bytes.byteLength).set(new Uint8Array(bytes));
88
+ if (kernel.dolly_session_restore(BigInt(bytes.byteLength)) !== 0) {
89
+ throw new Error("Dolly session filesystem restore failed; the saved copy is unchanged");
90
+ }
91
+ }
92
+ async function recoverFiles({ supervisor, stage, files }) {
93
+ const path = "/tmp/dolly-session-recovery.delta", destination = `/workspace/recovered-${recover}`;
94
+ stage(`recovering saved files into ${destination}...`);
95
+ files.write(path, new Uint8Array(bytes));
96
+ try {
97
+ const program = "/usr/bin/session-recover";
98
+ if ((await supervisor.spawn([program, path, destination])).status !== 0) {
99
+ throw new Error("File recovery failed; the original saved session is unchanged");
100
+ }
101
+ } finally { files.remove(path); }
102
+ }
103
+ return {
104
+ async imageRestored(context) {
105
+ const { kernel, image, stage } = context;
106
+ if (kernel.dolly_session_base_capture() !== 0) throw new Error("Dolly could not index the session baseline");
107
+ if (bytes === undefined) return;
108
+ if (recover !== undefined) {
109
+ if (image !== "system") throw new Error("invalid Dolly file recovery request");
110
+ await recoverFiles(context);
111
+ } else {
112
+ stage("restoring named session filesystem...");
113
+ restore(kernel);
114
+ stage("named session filesystem restored");
115
+ }
116
+ bytes.transfer(0);
117
+ bytes = undefined;
118
+ },
119
+ service() { kernel?.dolly_session_service(); },
120
+ messages: {
121
+ "snapshot-capture"({ name }) {
122
+ const controller = cancelled = new AbortController();
123
+ return transport.capture(name, { signal: controller.signal,
124
+ onChunk: chunk => ask({ type: "snapshot-chunk", bytes: chunk }, [chunk.buffer]) });
125
+ },
126
+ "snapshot-cancel"() { cancelled?.abort(); },
127
+ },
128
+ start({ kernel: exports, memory }) {
129
+ kernel = exports;
130
+ // The kernel serves the mailbox on a request, after each consumed chunk
131
+ // and on cancellation, in a task of its own.
132
+ transport = new SessionTransport(memory.buffer, Number(kernel.dolly_session_mailbox_address()),
133
+ Number(kernel.dolly_session_name_address()), Number(kernel.dolly_session_transfer_address()),
134
+ () => setTimeout(() => kernel?.dolly_session_service()));
135
+ },
136
+ dispose() { transport?.close(); },
137
+ };
138
+ }
@@ -0,0 +1,132 @@
1
+ import { DOLLY_SESSION_MAX_BYTES, validSessionName } from "../../src/session-store.mjs";
2
+ import { DOLLY_ERRNO } from "./abi.mjs";
3
+ import * as A from "./abi.mjs";
4
+
5
+ // The mailbox words as dolly-snapshot-0.wat names them, the Worker's end. No
6
+ // filesystem paths cross here. wake asks the kernel to serve the mailbox: on a
7
+ // request, after each consumed chunk and on cancellation; the kernel never
8
+ // waits for the page.
9
+ export class SessionTransport {
10
+ static requestSequence = A.DOLLY_SESSION_WORD_REQUEST_SEQUENCE;
11
+ static completedSequence = A.DOLLY_SESSION_WORD_COMPLETED_SEQUENCE;
12
+ static status = A.DOLLY_SESSION_WORD_STATUS;
13
+ static nameLength = A.DOLLY_SESSION_WORD_NAME_LENGTH;
14
+ static chunkSequence = A.DOLLY_SESSION_WORD_CHUNK_SEQUENCE;
15
+ static chunkConsumedSequence = A.DOLLY_SESSION_WORD_CHUNK_CONSUMED_SEQUENCE;
16
+ static chunkLength = A.DOLLY_SESSION_WORD_CHUNK_LENGTH;
17
+ static chunkEof = A.DOLLY_SESSION_WORD_CHUNK_EOF;
18
+ static totalSizeLow = A.DOLLY_SESSION_WORD_TOTAL_SIZE_LOW;
19
+ static totalSizeHigh = A.DOLLY_SESSION_WORD_TOTAL_SIZE_HIGH;
20
+ static cancelledSequence = A.DOLLY_SESSION_WORD_CANCELLED_SEQUENCE;
21
+
22
+ constructor(buffer, address, nameAddress, transferAddress, wake) {
23
+ const range = (start, size) => Number.isSafeInteger(start) && start > 0 && start <= buffer.byteLength - size;
24
+ if (!(buffer instanceof SharedArrayBuffer) || !range(address, A.DOLLY_SESSION_HEADER_SIZE) || address % 4 ||
25
+ !range(nameAddress, A.DOLLY_SESSION_NAME_CAPACITY) || !range(transferAddress, A.DOLLY_SESSION_TRANSFER_CAPACITY)) {
26
+ throw new Error("Dolly supplied an invalid session mailbox");
27
+ }
28
+ this.bytes = new Uint8Array(buffer);
29
+ this.words = new Int32Array(buffer, address, A.DOLLY_SESSION_HEADER_SIZE / 4);
30
+ this.nameAddress = nameAddress;
31
+ this.nameCapacity = A.DOLLY_SESSION_NAME_CAPACITY;
32
+ this.transferAddress = transferAddress;
33
+ this.transferCapacity = A.DOLLY_SESSION_TRANSFER_CAPACITY;
34
+ this.wake = wake;
35
+ this.closed = false;
36
+ this.cancel = null;
37
+ }
38
+
39
+ async capture(name, { signal, timeoutMilliseconds = 30000, onChunk } = {}) {
40
+ if (!validSessionName(name)) throw new TypeError("invalid Dolly session name");
41
+ if (onChunk !== undefined && typeof onChunk !== "function") throw new TypeError("invalid session consumer");
42
+ signal?.throwIfAborted();
43
+ if (this.closed) throw new Error("Snapshot provider closed");
44
+ const words = this.words;
45
+ const published = Atomics.load(words, SessionTransport.requestSequence);
46
+ if (published !== Atomics.load(words, SessionTransport.completedSequence)) {
47
+ throw new Error("A Dolly session save is already active");
48
+ }
49
+ const requested = (published + 1) | 0;
50
+ let chunkSequence = Atomics.load(words, SessionTransport.chunkSequence);
51
+ let deadline = performance.now() + timeoutMilliseconds;
52
+ const wait = async (index, ready) => {
53
+ for (;;) {
54
+ signal?.throwIfAborted();
55
+ if (this.closed) throw new Error("Snapshot provider closed");
56
+ // Compare and wait on the SAME observation: a publication between
57
+ // these operations makes waitAsync return not-equal, never a lost wake.
58
+ const observed = Atomics.load(words, index);
59
+ if (ready(observed)) return observed;
60
+ if (performance.now() >= deadline) throw new Error("Session save timed out; try again");
61
+ if (index === SessionTransport.chunkSequence &&
62
+ Atomics.load(words, SessionTransport.completedSequence) === requested) {
63
+ throw new Error("Dolly stopped the session transfer before it completed");
64
+ }
65
+ const waiting = Atomics.waitAsync(words, index, observed, 1000);
66
+ if (waiting.async) await waiting.value;
67
+ }
68
+ };
69
+ const cancel = () => {
70
+ Atomics.store(words, SessionTransport.cancelledSequence, requested);
71
+ // Wake this capture's own waits, and the kernel.
72
+ Atomics.notify(words, SessionTransport.chunkSequence);
73
+ Atomics.notify(words, SessionTransport.completedSequence);
74
+ this.wake();
75
+ };
76
+ this.cancel = cancel;
77
+ signal?.addEventListener("abort", cancel, { once: true });
78
+ let complete = false;
79
+ try {
80
+ const nameBytes = new TextEncoder().encode(name);
81
+ this.bytes.fill(0, this.nameAddress, this.nameAddress + this.nameCapacity);
82
+ this.bytes.set(nameBytes, this.nameAddress);
83
+ Atomics.store(words, SessionTransport.nameLength, nameBytes.length);
84
+ Atomics.store(words, SessionTransport.requestSequence, requested);
85
+ this.wake();
86
+ let snapshot;
87
+ let offset = 0;
88
+ let declaredTotal;
89
+ for (;;) {
90
+ const chunk = await wait(SessionTransport.chunkSequence, (value) => value !== chunkSequence);
91
+ if (chunk !== ((chunkSequence + 1) | 0)) throw new Error("Dolly session chunk sequence skipped");
92
+ const length = Atomics.load(words, SessionTransport.chunkLength) >>> 0;
93
+ const eof = Atomics.load(words, SessionTransport.chunkEof);
94
+ const total = (Atomics.load(words, SessionTransport.totalSizeHigh) >>> 0) * 2 ** 32 +
95
+ (Atomics.load(words, SessionTransport.totalSizeLow) >>> 0);
96
+ if (!Number.isSafeInteger(total) || total > DOLLY_SESSION_MAX_BYTES ||
97
+ (total !== 0 && total < 16) || (declaredTotal !== undefined && declaredTotal !== total) ||
98
+ length > this.transferCapacity || length > total - offset ||
99
+ (eof !== 0 && eof !== 1) || (!eof && length === 0)) {
100
+ throw new Error("Dolly published an invalid session chunk");
101
+ }
102
+ if (declaredTotal === undefined) {
103
+ declaredTotal = total;
104
+ if (!onChunk) snapshot = new Uint8Array(total);
105
+ }
106
+ const bytes = this.bytes.subarray(this.transferAddress, this.transferAddress + length);
107
+ if (onChunk) await onChunk(bytes.slice());
108
+ else snapshot.set(bytes, offset);
109
+ offset += length;
110
+ Atomics.store(words, SessionTransport.chunkConsumedSequence, chunk);
111
+ this.wake();
112
+ chunkSequence = chunk;
113
+ deadline = performance.now() + timeoutMilliseconds;
114
+ if (eof) break;
115
+ }
116
+ await wait(SessionTransport.completedSequence, (value) => value === requested);
117
+ const status = Atomics.load(words, SessionTransport.status);
118
+ if (status === -DOLLY_ERRNO.EFBIG) {
119
+ throw new Error(`Dolly session exceeds its ${DOLLY_SESSION_MAX_BYTES / 1024 / 1024} MiB limit; remove files or installed packages and save again`);
120
+ }
121
+ if (status !== 0) throw new Error(`Dolly session capture failed with status ${status}`);
122
+ if (offset < 16 || offset !== declaredTotal) throw new Error("Dolly session snapshot was incomplete");
123
+ complete = true;
124
+ return onChunk ? offset : snapshot.buffer;
125
+ } finally {
126
+ if (!complete) cancel();
127
+ signal?.removeEventListener("abort", cancel);
128
+ this.cancel = null;
129
+ }
130
+ }
131
+ close() { this.closed = true; this.cancel?.(); }
132
+ }