@daugasauron/dolly 0.1.5-rc.0 → 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 (76) hide show
  1. package/README.md +51 -78
  2. package/coi-serviceworker.js +23 -8
  3. package/dist/dolly-build-id.mjs +1 -1
  4. package/dist/dolly-image-build-id.mjs +1 -1
  5. package/dist/dolly-process-0.wasm +0 -0
  6. package/dist/dolly-process-abi.mjs +1 -1
  7. package/dist/dolly-process-dso.mjs +19 -11
  8. package/dist/dolly-process-worker.mjs +88 -33
  9. package/dist/dolly-runtime-worker.mjs +1544 -0
  10. package/dist/dolly.wasm +0 -0
  11. package/host/dso/dso.mjs +2 -2
  12. package/host/dso/module.json +4 -1
  13. package/host/http/abi.mjs +3 -1
  14. package/host/http/broker.mjs +17 -7
  15. package/host/http/http.mjs +19 -5
  16. package/host/http/module.json +3 -1
  17. package/host/http/policy.mjs +13 -6
  18. package/host/http/refusals.mjs +77 -0
  19. package/host/input/abi.mjs +1 -1
  20. package/host/modules.mjs +64 -23
  21. package/host/nena/module.json +2 -1
  22. package/host/nena/nena.mjs +2 -0
  23. package/host/packages/abi.mjs +27 -0
  24. package/host/packages/module.json +6 -4
  25. package/host/packages/packages.mjs +23 -8
  26. package/host/packages/service.mjs +46 -20
  27. package/host/packages/transport.mjs +123 -0
  28. package/host/packages/worker.mjs +13 -9
  29. package/host/requirements.mjs +1 -1
  30. package/host/runtime/abi.mjs +32 -0
  31. package/host/runtime/lease-bridge.mjs +13 -8
  32. package/host/runtime/module.json +3 -3
  33. package/host/runtime/runtime.mjs +3 -3
  34. package/host/snapshot/abi.mjs +2 -1
  35. package/host/snapshot/module.json +1 -1
  36. package/host/snapshot/snapshot.mjs +80 -57
  37. package/host/snapshot/transport.mjs +10 -3
  38. package/host/snapshot/ui.mjs +64 -39
  39. package/host/stack/module.json +1 -0
  40. package/host/stack/stack.mjs +1 -1
  41. package/host/threads/module.json +6 -1
  42. package/host/threads/threads.mjs +32 -13
  43. package/package.json +2 -2
  44. package/{src → page}/browser.mjs +37 -23
  45. package/page/generated.mjs +8 -0
  46. package/{src → page}/image-entry.mjs +10 -6
  47. package/{src → page}/image-inputs.mjs +11 -0
  48. package/{src → page}/image-names.mjs +2 -2
  49. package/{src → page}/kernel-plugin.mjs +1 -1
  50. package/{src → page}/pack-store.mjs +1 -1
  51. package/{src → page}/packaged-snapshot.mjs +56 -46
  52. package/{src → page}/process-constants.mjs +72 -15
  53. package/{src → page}/process-supervisor.mjs +155 -227
  54. package/{src → page}/snapshot-records.mjs +63 -44
  55. package/{src → page}/static-asset.mjs +36 -20
  56. package/{src → page}/version.mjs +1 -1
  57. package/{src → page}/worker-image.mjs +20 -9
  58. package/{src → site}/build-worker.mjs +36 -25
  59. package/{src → site}/custom-image.mjs +2 -10
  60. package/{src → site}/dollyfile-graph.mjs +1 -1
  61. package/{src → site}/dollyfile-view.mjs +1 -1
  62. package/{src → site}/image-artifact.mjs +9 -8
  63. package/{src → site}/image-build.mjs +4 -4
  64. package/{src → site}/image-builder.mjs +5 -4
  65. package/site/offline.mjs +53 -0
  66. package/{src → site}/session-store.mjs +47 -38
  67. package/src/runtime-worker.mjs +0 -57
  68. /package/{src → page}/build-log.mjs +0 -0
  69. /package/{src → page}/images.mjs +0 -0
  70. /package/{src → page}/page-chords.mjs +0 -0
  71. /package/{src → page}/page-indicators.mjs +0 -0
  72. /package/{src → page}/process-abi.mjs +0 -0
  73. /package/{src → page}/sha256.mjs +0 -0
  74. /package/{src → page}/terminal-text.mjs +0 -0
  75. /package/{src → page}/wasm-interface.mjs +0 -0
  76. /package/{src → site}/alphabetical.mjs +0 -0
@@ -1,6 +1,5 @@
1
- import { DOLLY_IMAGES, imageBase } from "../../src/images.mjs";
2
- import { loadPackagedSnapshotMetadata } from "../../src/packaged-snapshot.mjs";
3
- import { MAX_SNAPSHOT_BYTES } from "../../src/snapshot-records.mjs";
1
+ import { imageBase } from "../../page/images.mjs";
2
+ import { MAX_SNAPSHOT_BYTES } from "../../page/snapshot-records.mjs";
4
3
 
5
4
  const PACKAGES_ORIGIN = "https://packages.dolly.invalid";
6
5
  const PACKAGE_LIMITS = Object.freeze({
@@ -9,35 +8,58 @@ const PACKAGE_LIMITS = Object.freeze({
9
8
  // Snapshots served per page: enough for every package, not for a loop.
10
9
  maxSnapshots: 64,
11
10
  });
12
- const packagePath = /^\/v1\/packages\/([0-9a-f]{64})$/;
11
+ const directoryPath = "/v1/directory";
12
+ const packagePath = /^\/v1\/packages\/([0-9a-f]{64})\/from\/([^/]+)$/;
13
13
 
14
14
  function reply(body, status, type = "text/plain; charset=utf-8") {
15
15
  return new Response(body, { status, headers: { "content-type": type, "cache-control": "no-store" } });
16
16
  }
17
17
 
18
- // GET /v1/packages/SHA256 is the exact snapshot of the package whose recipe
19
- // has that pin, verified from the published packs and merged as the guest
20
- // reads it. One snapshot is served at a time, until the guest has read it or
21
- // the broker's timeout cancels it.
18
+ // The image directory a path segment names, or null: an http or https URL of a
19
+ // directory, written as the URL parser writes it, with nothing but a path.
20
+ function directoryOf(segment) {
21
+ let text;
22
+ try { text = decodeURIComponent(segment); } catch { return null; }
23
+ const url = URL.parse(text);
24
+ return url?.href === text && /^https?:$/.test(url.protocol) && url.pathname.endsWith("/") &&
25
+ !url.username && !url.password && !url.search && !url.hash ? url : null;
26
+ }
27
+
28
+ // GET /v1/directory is the image directory the page loads its images from,
29
+ // amy's server unless the session names another. GET /v1/packages/SHA256/from/
30
+ // DIRECTORY is the exact snapshot of the package whose recipe has that pin in
31
+ // that directory's catalog, verified from its packs and merged as the guest
32
+ // reads it. Another directory than the page's is read only where the page's
33
+ // HTTP policy admits a GET of it (`admit`), and only packages built for this
34
+ // runtime pass. One snapshot is served at a time, until the guest has read it
35
+ // or the broker's timeout cancels it.
22
36
  export class PackageService {
23
- constructor() {
37
+ constructor(admit) {
38
+ this.admit = admit;
24
39
  this.active = false;
25
40
  this.served = 0;
26
41
  }
27
42
  get origin() { return PACKAGES_ORIGIN; }
28
43
  authorize(url, method, bytes) {
29
- return method === "GET" && bytes === 0 && packagePath.test(url.pathname) ? PACKAGE_LIMITS : undefined;
44
+ return method === "GET" && bytes === 0 && (url.pathname === directoryPath || packagePath.test(url.pathname))
45
+ ? PACKAGE_LIMITS : undefined;
30
46
  }
31
47
  async fetch(url, init) {
32
- const sha256 = packagePath.exec(url.pathname)[1];
33
- const definition = DOLLY_IMAGES.find(candidate => candidate.role === "package" && candidate.sha256 === sha256);
34
- if (!definition) return reply("This release publishes no package with that recipe pin\n", 404);
35
- if (this.active) return reply("Another package snapshot is being served\n", 409);
36
- if (this.served >= PACKAGE_LIMITS.maxSnapshots) return reply("Package snapshot quota exceeded\n", 429);
48
+ if (url.pathname === directoryPath) return reply(imageBase.href);
49
+ const [, sha256, segment] = packagePath.exec(url.pathname);
50
+ const directory = directoryOf(segment);
51
+ if (!directory) return reply("not the URL of an image directory\n", 400);
52
+ if (directory.href !== imageBase.href) {
53
+ try { this.admit(directory); } catch {
54
+ return reply(`this page's HTTP policy does not admit ${directory.href}\n`, 403);
55
+ }
56
+ }
57
+ if (this.active) return reply("another package snapshot is being served\n", 409);
58
+ if (this.served >= PACKAGE_LIMITS.maxSnapshots) return reply("this page's package snapshot quota is spent\n", 429);
37
59
  this.active = true;
38
- this.served += 1;
39
60
  // The Worker (worker.mjs) answers each message in turn: the first with the
40
- // packs checked, every later one with the snapshot's next chunk.
61
+ // package found and its packs checked, every later one with the snapshot's
62
+ // next chunk.
41
63
  const worker = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module", name: "dolly-packages" });
42
64
  let released = false, refuse;
43
65
  const release = () => {
@@ -56,12 +78,16 @@ export class PackageService {
56
78
  else worker.postMessage(message);
57
79
  });
58
80
  try {
59
- const metadata = await loadPackagedSnapshotMetadata(definition.image);
60
- await ask({ base: imageBase.href, metadata });
81
+ if ((await ask({ directory: directory.href, sha256 })).missing) {
82
+ release();
83
+ return reply(`${directory.href} publishes no package with that recipe pin\n`, 404);
84
+ }
61
85
  } catch (error) {
62
86
  release();
63
- return reply(`${definition.image}: ${error.message}\n`, 502);
87
+ return reply(`${directory.href}: ${error.message}\n`, 502);
64
88
  }
89
+ // A package found counts against the quota; a miss or an offline directory does not.
90
+ this.served += 1;
65
91
  const body = new ReadableStream({
66
92
  async pull(controller) {
67
93
  try {
@@ -0,0 +1,123 @@
1
+ import { DOLLY_ERRNO as errno } from "../../page/process-constants.mjs";
2
+ import * as A from "./abi.mjs";
3
+
4
+ const capacity = A.DOLLY_PACKAGES_CHUNK_CAPACITY;
5
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
6
+ const encoder = new TextEncoder();
7
+ // Snapshots served per page: enough for every package, not for a loop.
8
+ const maxSnapshots = 64;
9
+ // The index is one chunk.
10
+ const maxIndexBytes = capacity;
11
+
12
+ // The Worker's end of packages@0's mailbox (dolly-packages-0.wat): it answers
13
+ // each request the kernel reports, one chunk at a time, from the page's own
14
+ // image directory, `imageBase`; another directory is refused until the page's
15
+ // HTTP policy judges it here.
16
+ export class PackagesTransport {
17
+ constructor(buffer, address, imageBase) {
18
+ this.imageBase = imageBase;
19
+ if (!(buffer instanceof SharedArrayBuffer) || !Number.isSafeInteger(address) || address <= 0 || address % 4 ||
20
+ address > buffer.byteLength - A.DOLLY_PACKAGES_CHUNK_OFFSET - capacity) throw new TypeError("invalid packages mailbox");
21
+ this.words = new Int32Array(buffer, address, 16);
22
+ this.directory = new Uint8Array(buffer, address + A.DOLLY_PACKAGES_DIRECTORY_OFFSET, A.DOLLY_PACKAGES_DIRECTORY_CAPACITY);
23
+ this.pin = new Uint8Array(buffer, address + A.DOLLY_PACKAGES_PIN_OFFSET, A.DOLLY_PACKAGES_PIN_BYTES);
24
+ this.bytes = new Uint8Array(buffer, address + A.DOLLY_PACKAGES_CHUNK_OFFSET, capacity);
25
+ this.active = null;
26
+ this.served = 0;
27
+ this.reported = Atomics.load(this.words, A.DOLLY_PACKAGES_WORD_REQUEST);
28
+ this.retiredSeen = Atomics.load(this.words, A.DOLLY_PACKAGES_WORD_CANCELLED);
29
+ Atomics.store(this.words, A.DOLLY_PACKAGES_WORD_ENABLED, 1);
30
+ }
31
+
32
+ // At each service tick: a new request is answered, a retired one ends.
33
+ service() {
34
+ const request = Atomics.load(this.words, A.DOLLY_PACKAGES_WORD_REQUEST);
35
+ const cancelled = Atomics.load(this.words, A.DOLLY_PACKAGES_WORD_CANCELLED);
36
+ if (request !== this.reported) {
37
+ this.reported = request;
38
+ void this.answer();
39
+ }
40
+ if (cancelled !== this.retiredSeen) {
41
+ this.retiredSeen = cancelled;
42
+ if (this.active && this.retired(this.active.sequence)) this.active.retire();
43
+ }
44
+ }
45
+
46
+ retired(sequence) {
47
+ return Atomics.load(this.words, A.DOLLY_PACKAGES_WORD_REQUEST) !== sequence ||
48
+ Atomics.load(this.words, A.DOLLY_PACKAGES_WORD_CANCELLED) === sequence;
49
+ }
50
+
51
+ async answer() {
52
+ const words = this.words, sequence = Atomics.load(words, A.DOLLY_PACKAGES_WORD_REQUEST);
53
+ if (this.active || sequence === Atomics.load(words, A.DOLLY_PACKAGES_WORD_COMPLETED)) return;
54
+ let retire, worker;
55
+ const retirement = new Promise(resolve => { retire = resolve; });
56
+ this.active = { sequence, retire };
57
+ let size = 0;
58
+ const publish = async (bytes, eof, error = 0) => {
59
+ // The kernel notifies the consumed word after writing each chunk.
60
+ for (let consumed; (consumed = Atomics.load(words, A.DOLLY_PACKAGES_WORD_CONSUMED)) !== Atomics.load(words, A.DOLLY_PACKAGES_WORD_CHUNK);) {
61
+ if (this.retired(sequence)) return;
62
+ await Promise.race([Atomics.waitAsync(words, A.DOLLY_PACKAGES_WORD_CONSUMED, consumed).value, retirement]);
63
+ }
64
+ if (this.retired(sequence)) return;
65
+ this.bytes.set(bytes);
66
+ Atomics.store(words, A.DOLLY_PACKAGES_WORD_LENGTH, bytes.length);
67
+ Atomics.store(words, A.DOLLY_PACKAGES_WORD_ERROR, error);
68
+ Atomics.store(words, A.DOLLY_PACKAGES_WORD_EOF, eof ? 1 : 0);
69
+ Atomics.store(words, A.DOLLY_PACKAGES_WORD_SIZE, size);
70
+ Atomics.add(words, A.DOLLY_PACKAGES_WORD_CHUNK, 1);
71
+ };
72
+ const whole = bytes => { size = bytes.length; return publish(bytes, true); };
73
+ const refuse = code => publish(new Uint8Array(), true, code);
74
+ try {
75
+ const kind = Atomics.load(words, A.DOLLY_PACKAGES_WORD_KIND);
76
+ const directory = decoder.decode(this.directory.slice(0, Atomics.load(words, A.DOLLY_PACKAGES_WORD_DIRECTORY_LENGTH)));
77
+ if (kind === A.DOLLY_PACKAGES_KIND_DIRECTORY) return await whole(encoder.encode(this.imageBase.href));
78
+ if (directory !== this.imageBase.href) return await refuse(errno.EACCES);
79
+ if (kind === A.DOLLY_PACKAGES_KIND_INDEX) {
80
+ const response = await fetch(new URL("amy-index.txt", this.imageBase), { credentials: "same-origin", redirect: "error" });
81
+ const length = Number(response.headers.get("content-length") ?? 0);
82
+ if (!response.ok || length > maxIndexBytes) return await refuse(response.ok ? errno.EFBIG : errno.ENOENT);
83
+ const bytes = new Uint8Array(await response.arrayBuffer());
84
+ return await (bytes.length > maxIndexBytes ? refuse(errno.EFBIG) : whole(bytes));
85
+ }
86
+ if (this.served >= maxSnapshots) return await refuse(errno.EDQUOT);
87
+ // The packages Worker (worker.mjs) finds the package and merges its
88
+ // checked packs into its snapshot, a chunk per message.
89
+ worker = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module", name: "dolly-packages" });
90
+ const ask = message => new Promise((resolve, reject) => {
91
+ worker.onmessage = ({ data }) => data.error === undefined ? resolve(data) : reject(new Error(data.error));
92
+ worker.onerror = event => { event.preventDefault(); reject(new Error(event.message || "package Worker failed")); };
93
+ worker.postMessage(message);
94
+ });
95
+ const found = await ask({ directory, sha256: decoder.decode(this.pin.slice()) });
96
+ if (found.missing) return await refuse(errno.ENOENT);
97
+ if (!Number.isSafeInteger(found.size) || found.size <= 0 || found.size > A.DOLLY_PACKAGES_MAX_SIZE) return await refuse(errno.EFBIG);
98
+ // A package found counts against the quota; a miss does not.
99
+ this.served += 1;
100
+ size = found.size;
101
+ let sent = 0;
102
+ for (;;) {
103
+ const { done, value } = await ask({});
104
+ if (done) break;
105
+ for (let offset = 0; offset < value.length; offset += capacity) {
106
+ const piece = value.subarray(offset, Math.min(value.length, offset + capacity));
107
+ sent += piece.length;
108
+ await publish(piece, sent === size);
109
+ if (this.retired(sequence)) return;
110
+ }
111
+ }
112
+ if (sent !== size) await refuse(errno.EIO);
113
+ } catch {
114
+ await refuse(errno.EIO);
115
+ } finally {
116
+ worker?.terminate();
117
+ Atomics.store(words, A.DOLLY_PACKAGES_WORD_COMPLETED, sequence);
118
+ this.active = null;
119
+ }
120
+ }
121
+
122
+ close() { Atomics.store(this.words, A.DOLLY_PACKAGES_WORD_ENABLED, 0); this.active?.retire(); }
123
+ }
@@ -1,16 +1,20 @@
1
- // The packages@0 Worker merges one package's packs into its snapshot, a chunk
2
- // per request, off the page's thread: the page must answer the guest's reads
3
- // at once, since a guest that finds no record waits 10 ms.
4
- import { loadImages } from "../../src/images.mjs";
5
- import { packagedSystemSnapshot } from "../../src/packaged-snapshot.mjs";
1
+ // The packages@0 Worker reads one package of an image directory and merges its
2
+ // packs into its snapshot, a chunk per request, off the page's thread: the page
3
+ // must answer the guest's reads at once, since a guest that finds no record
4
+ // waits 10 ms.
5
+ import { DOLLY_IMAGES, loadImages } from "../../page/images.mjs";
6
+ import { loadPackagedSnapshotMetadata, packagedSystemSnapshot } from "../../page/packaged-snapshot.mjs";
6
7
 
7
8
  let chunks;
8
9
  onmessage = async ({ data }) => {
9
10
  try {
10
- if (data.metadata) {
11
- await loadImages(data.base);
12
- chunks = await packagedSystemSnapshot(data.metadata);
13
- postMessage({ done: false });
11
+ if (data.directory) {
12
+ await loadImages(data.directory);
13
+ const definition = DOLLY_IMAGES.find(candidate => candidate.role === "package" && candidate.sha256 === data.sha256);
14
+ if (!definition) return postMessage({ missing: true });
15
+ const metadata = await loadPackagedSnapshotMetadata(definition.image);
16
+ chunks = await packagedSystemSnapshot(metadata);
17
+ postMessage({ done: false, size: metadata.byteLength });
14
18
  } else {
15
19
  const { done, value } = await chunks.next();
16
20
  postMessage({ done, value }, done ? [] : [value.buffer]);
@@ -1,6 +1,6 @@
1
1
  import { DOLLY_HOST_RECORD_BYTES as recordBytes, DOLLY_HOST_NAME_BYTES as nameBytes,
2
2
  DOLLY_HOST_DIGEST_BYTES as digestBytes, DOLLY_HOST_MAX_RECORDS as maxRecords } from "./abi.mjs";
3
- import { hex } from "../src/static-asset.mjs";
3
+ import { hex } from "../page/static-asset.mjs";
4
4
 
5
5
  const pattern = /^([a-z][a-z0-9-]{0,30})@(0|[1-9][0-9]{0,4})$/;
6
6
 
@@ -1,4 +1,10 @@
1
1
  // Generated from abi/dolly-supervisor-0.wat, abi/dolly-image-0.wat, abi/dolly-process-0.wat, abi/dolly-process-gate-0.wat, include/dolly/process.h.
2
+ export const DOLLY_PROCESS_EVENT_LAUNCH = 1;
3
+ export const DOLLY_PROCESS_EVENT_INTERRUPT = 2;
4
+ export const DOLLY_PROCESS_EVENT_TERMINATE = 3;
5
+ export const DOLLY_PROCESS_EVENT_WAKE = 4;
6
+ export const DOLLY_PROCESS_EVENT_KIND_SHIFT = 48;
7
+ export const DOLLY_PROCESS_EVENT_VALUE_SHIFT = 32;
2
8
  export const DOLLY_TERMINAL_WORD_RESULT_SEQUENCE = 0;
3
9
  export const DOLLY_TERMINAL_WORD_RESULT_STATUS = 1;
4
10
  export const DOLLY_TERMINAL_WORD_FOREGROUND_PID = 2;
@@ -6,4 +12,30 @@ export const DOLLY_TERMINAL_WORD_FOREGROUND_INTERRUPTIBLE = 3;
6
12
  export const DOLLY_TERMINAL_WORD_INTERRUPT_SEQUENCE = 4;
7
13
  export const DOLLY_TERMINAL_WORD_INTERRUPT_TARGET_PID = 5;
8
14
  export const DOLLY_FS_BLOCK_SIZE = 1048576;
15
+ export const DOLLY_DEVICE_HEADER_BYTES = 32;
16
+ export const DOLLY_DEVICE_REPLY_HEADER_BYTES = 64;
17
+ export const DOLLY_DEVICE_REPLY_WORD_STATE = 0;
18
+ export const DOLLY_DEVICE_REPLY_WORD_SCOPE = 1;
19
+ export const DOLLY_DEVICE_REPLY_WORD_SEQUENCE = 2;
20
+ export const DOLLY_DEVICE_REPLY_WORD_ERRNO = 3;
21
+ export const DOLLY_DEVICE_REPLY_WORD_LENGTH = 4;
22
+ export const DOLLY_DEVICE_REPLY_PUBLISHED = 1;
23
+ export const DOLLY_SNAPSHOT_VERSION = 2;
24
+ export const DOLLY_SNAPSHOT_HEADER_BYTES = 16;
25
+ export const DOLLY_SNAPSHOT_RECORD_HEADER_BYTES = 16;
26
+ export const DOLLY_SNAPSHOT_RECORD_DIRECTORY = 1;
27
+ export const DOLLY_SNAPSHOT_RECORD_FILE = 2;
28
+ export const DOLLY_SNAPSHOT_RECORD_SYMLINK = 3;
29
+ export const DOLLY_SNAPSHOT_RECORD_DELETED = 4;
30
+ export const DOLLY_SNAPSHOT_MAX_PATHS = 100000;
31
+ export const DOLLY_SNAPSHOT_MAX_MEBIBYTES = 2048;
32
+ export const DOLLY_SNAPSHOT_PATH_MIN_BYTES = 2;
33
+ export const DOLLY_SNAPSHOT_PATH_MAX_BYTES = 4095;
34
+ export const DOLLY_SNAPSHOT_SYMLINK_MIN_BYTES = 1;
35
+ export const DOLLY_SNAPSHOT_SYMLINK_MAX_BYTES = 4095;
36
+ export const DOLLY_ENTRY_VERSION = 1;
37
+ export const DOLLY_ENTRY_HEADER_BYTES = 16;
38
+ export const DOLLY_ENTRY_MAX_ARGUMENTS = 256;
39
+ export const DOLLY_ENTRY_ARGUMENT_MAX_BYTES = 4096;
40
+ export const DOLLY_ENTRY_MAX_BYTES = 65536;
9
41
  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 });
@@ -1,21 +1,26 @@
1
- import { DOLLY_ERRNO as E } from "./abi.mjs";
1
+ import { DOLLY_DEVICE_REPLY_HEADER_BYTES as header, DOLLY_DEVICE_REPLY_PUBLISHED, DOLLY_DEVICE_REPLY_WORD_ERRNO,
2
+ DOLLY_DEVICE_REPLY_WORD_LENGTH, DOLLY_DEVICE_REPLY_WORD_SCOPE, DOLLY_DEVICE_REPLY_WORD_SEQUENCE, DOLLY_DEVICE_REPLY_WORD_STATE,
3
+ DOLLY_ERRNO as E } from "./abi.mjs";
2
4
 
3
- // The Worker side of a device leased per process (src/device-lease.c). Each
5
+ // The Worker side of a device leased per process (kernel/device-lease.c). Each
4
6
  // packet a process submits (dolly_device_header, runtime.h, then the
5
7
  // operation's body) is checked whole here and goes to the page as
6
8
  // `TYPE-request` with its operation, scope, sequence and body; the page's
7
- // `TYPE-complete` is published in the lease's reply slot, a 64-byte header and
9
+ // `TYPE-complete` is published in the lease's reply slot, a header and
8
10
  // `replyBytes`. `version`, `open` and `close` are the device's.
9
11
  export function createLeaseBridge({ type, version, slots: count, packetBytes, replyBytes, open, close }, memory, mailbox, send, complete) {
10
- const slots = new Array(count), stride = 64 + replyBytes;
12
+ const slots = new Array(count), stride = header + replyBytes;
11
13
  if (!Number.isSafeInteger(mailbox) || mailbox <= 0 || mailbox % 4 ||
12
14
  mailbox > memory.buffer.byteLength - count * stride) throw new Error(`Invalid ${type} mailbox`);
13
15
  // The reply to the call a slot's lease waits for; its state is published last.
14
16
  function publish(slot, entry, error, bytes) {
15
- const words = new Int32Array(memory.buffer, mailbox + slot * stride, 16);
16
- new Uint8Array(memory.buffer, mailbox + slot * stride + 64, bytes.length).set(bytes);
17
- Atomics.store(words, 1, entry.scope); Atomics.store(words, 2, entry.sequence);
18
- Atomics.store(words, 3, error); Atomics.store(words, 4, bytes.length); Atomics.store(words, 0, 1);
17
+ const words = new Int32Array(memory.buffer, mailbox + slot * stride, header / 4);
18
+ new Uint8Array(memory.buffer, mailbox + slot * stride + header, bytes.length).set(bytes);
19
+ Atomics.store(words, DOLLY_DEVICE_REPLY_WORD_SCOPE, entry.scope);
20
+ Atomics.store(words, DOLLY_DEVICE_REPLY_WORD_SEQUENCE, entry.sequence);
21
+ Atomics.store(words, DOLLY_DEVICE_REPLY_WORD_ERRNO, error);
22
+ Atomics.store(words, DOLLY_DEVICE_REPLY_WORD_LENGTH, bytes.length);
23
+ Atomics.store(words, DOLLY_DEVICE_REPLY_WORD_STATE, DOLLY_DEVICE_REPLY_PUBLISHED);
19
24
  entry.pending = false;
20
25
  }
21
26
  function acknowledge(message) {
@@ -10,7 +10,7 @@
10
10
  "host": "runtime.mjs",
11
11
  "contracts": ["../../abi/dolly-supervisor-0.wat", "../../abi/dolly-image-0.wat"],
12
12
  "process": ["../../abi/dolly-process-0.wat", "../../abi/dolly-process-gate-0.wat"],
13
- "headers": ["../../include/dolly/runtime.h", "../../include/dolly/process.h", "../../include/dolly/host.h", "../../include/dolly/host-abi.h", "../../include/dolly/toolchain.h"],
14
- "kernel": ["../../src/dolly.c", "../../src/process-kernel.c", "../../src/system-snapshot.c", "../../src/device-lease.c", "../../src/file-blocks.cpp", "../../src/libc-host.c"],
15
- "client": ["../../src/process/runtime-adapter.c", "../../src/process/socket-refusals.c", "../../src/process/libc-stubs.c"]
13
+ "headers": ["../../include/dolly/runtime.h", "../../include/dolly/process.h", "../../include/dolly/host.h", "../../include/dolly/host-abi.h", "../../include/dolly/image-abi.h", "../../include/dolly/toolchain.h"],
14
+ "kernel": ["../../kernel/dolly.c", "../../kernel/process-kernel.c", "../../kernel/pty.c", "../../kernel/system-snapshot.c", "../../kernel/device-lease.c", "../../kernel/file-blocks.cpp", "../../kernel/libc-host.c"],
15
+ "client": ["../../process/runtime-adapter.c", "../../process/socket-refusals.c", "../../process/libc-stubs.c"]
16
16
  }
@@ -1,6 +1,6 @@
1
- import { DollyProcessSupervisor } from "../../src/process-supervisor.mjs";
1
+ import { DollyProcessSupervisor } from "../../page/process-supervisor.mjs";
2
2
  import { createLeaseBridge } from "./lease-bridge.mjs";
3
- import { instantiateKernelPlugin } from "../../src/kernel-plugin.mjs";
3
+ import { instantiateKernelPlugin } from "../../page/kernel-plugin.mjs";
4
4
  import { DOLLY_ERRNO as E } from "./abi.mjs";
5
5
  import { DOLLY_TERMINAL_WORD_RESULT_SEQUENCE, DOLLY_TERMINAL_WORD_RESULT_STATUS, DOLLY_TERMINAL_WORD_FOREGROUND_PID,
6
6
  DOLLY_TERMINAL_WORD_FOREGROUND_INTERRUPTIBLE, DOLLY_TERMINAL_WORD_INTERRUPT_SEQUENCE,
@@ -219,7 +219,7 @@ export function worker({ send, runtimeBase, abi, service }) {
219
219
  },
220
220
  // A resident library linked into the kernel (--dolly-kernel-plugin), such as the display's.
221
221
  kernelPlugin(bytes, kernel) { return instantiateKernelPlugin(bytes, kernel, memory); },
222
- // The Worker side of a device a module leases per process (src/device-lease.c).
222
+ // The Worker side of a device a module leases per process (kernel/device-lease.c).
223
223
  leaseBridge(lease, mailbox, send) {
224
224
  return createLeaseBridge(lease, memory, mailbox, send, () => supervisor?.serviceDeferred());
225
225
  },
@@ -1,5 +1,5 @@
1
1
  // Generated from host/snapshot/dolly-snapshot-0.wat, include/dolly/process.h.
2
- export const DOLLY_SESSION_HEADER_SIZE = 64;
2
+ export const DOLLY_SESSION_MAILBOX_HEADER_SIZE = 64;
3
3
  export const DOLLY_SESSION_TRANSFER_CAPACITY = 1048576;
4
4
  export const DOLLY_SESSION_WORD_REQUEST_SEQUENCE = 0;
5
5
  export const DOLLY_SESSION_WORD_COMPLETED_SEQUENCE = 1;
@@ -11,4 +11,5 @@ export const DOLLY_SESSION_WORD_CHUNK_EOF = 6;
11
11
  export const DOLLY_SESSION_WORD_TOTAL_SIZE_LOW = 7;
12
12
  export const DOLLY_SESSION_WORD_TOTAL_SIZE_HIGH = 8;
13
13
  export const DOLLY_SESSION_WORD_CANCELLED_SEQUENCE = 9;
14
+ export const DOLLY_SESSION_WORD_CHANGES = 10;
14
15
  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 });
@@ -2,7 +2,7 @@
2
2
  "name": "snapshot",
3
3
  "version": 0,
4
4
  "gives": "save the session in this browser and restore it",
5
- "fixed": "its baseline is the image as loaded",
5
+ "fixed": "a save is the whole filesystem but /dev, /proc and /run",
6
6
  "dependencies": ["runtime@0"],
7
7
  "phase": "image",
8
8
  "imports": [],
@@ -1,14 +1,16 @@
1
1
  import { SessionTransport } from "./transport.mjs";
2
+ import { DOLLY_SESSION_TRANSFER_CAPACITY } from "./abi.mjs";
2
3
  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";
4
+ import { DOLLY_IMAGE_BUILD_ID } from "../../page/generated.mjs";
5
+ import { DOLLY_IMAGES } from "../../page/images.mjs";
6
+ import { sha256 } from "../../page/static-asset.mjs";
7
+ import { DOLLY_SESSION_FORMAT_VERSION, loadStoredSession, sessionCompatible,
8
+ sessionLoadUrl, validSessionName, DOLLY_SESSION_MAX_BYTES } from "../../site/session-store.mjs";
8
9
 
9
10
  // A session route (/session/?name=NAME), or boot()'s session option on a page
10
- // of one's own, boots the saved session's image and restores its delta; with
11
- // recover=1 the site's route unpacks the files into a fresh system image instead.
11
+ // of one's own, boots the saved session's image from the save, which is its whole
12
+ // filesystem: no image is loaded. With recover=1 the site's route unpacks the
13
+ // files into a fresh system image instead.
12
14
  export async function boot(route) {
13
15
  if (!route.loadSession) return undefined;
14
16
  if (route.mode !== "snapshot") throw new Error("invalid Dolly route configuration");
@@ -25,14 +27,17 @@ export async function boot(route) {
25
27
  if (record.formatVersion !== DOLLY_SESSION_FORMAT_VERSION) throw new Error("This save uses an unsupported recovery format");
26
28
  if (!DOLLY_IMAGES.some(definition => definition.image === "system")) throw new Error("File recovery needs the system image in this distribution");
27
29
  image = "system";
28
- } else if (!sessionCompatible(record, DOLLY_IMAGES, DOLLY_BUILD_ID, DOLLY_IMAGE_BUILD_ID)) {
29
- throw new Error(`This save belongs to an older runtime or image recipe. It has not been deleted or overwritten.${listed}`);
30
+ } else if (!sessionCompatible(record, DOLLY_IMAGES, DOLLY_IMAGE_BUILD_ID)) {
31
+ throw new Error(`This save was made on another build of this version. It has not been deleted or overwritten.${listed}`);
32
+ } else if (image === "custom" &&
33
+ await sha256(new TextEncoder().encode(record.customImage.source)) !== record.customImage.artifact.recipeSha256) {
34
+ // A custom save's Dollyfile names the boot's host modules. The record carries both the file
35
+ // and its digest, so this proves the save is whole, not where it came from: an imported save
36
+ // boots with the modules its file names, as a Dollyfile pasted into /custom/ would.
37
+ throw new Error(`This save's Dollyfile is not the one its image was built from.${listed}`);
30
38
  }
31
- const bytes = await decodeSessionSnapshot(record);
32
- record.bytes.transfer(0);
33
- record.bytes = undefined;
34
39
  return { image, label: `${recovering ? "RECOVER FILES FROM" : "RESTORE SESSION"} ${name}`,
35
- custom: image === "custom" ? record.customImage : undefined, configuration: { bytes, name, recovering } };
40
+ custom: image === "custom" ? record.customImage : undefined, configuration: { save: record.bytes, name, recovering, updatedAt: record.updatedAt } };
36
41
  }
37
42
 
38
43
  // The page's capture: the Worker reads the session's chunks out of the mailbox
@@ -57,70 +62,88 @@ function sessionCapture({ send, ask }) {
57
62
  }
58
63
 
59
64
  export function browser(page) {
60
- const { bytes, name, recovering } = page.configuration, capture = sessionCapture(page);
61
- const session = mountSessionSave(page, () => capture, name === undefined ? null : { name, recovering });
65
+ const { save, name, recovering, updatedAt } = page.configuration, capture = sessionCapture(page);
66
+ const session = mountSessionSave(page, () => capture, name === undefined ? null : { name, recovering, updatedAt });
62
67
  return {
63
- configuration: bytes === undefined ? {} : { bytes, ...(recovering ? { recover: name } : {}) },
64
- transfers: bytes ? [bytes] : [],
68
+ configuration: save === undefined ? {} : { save, ...(recovering ? { recover: name } : {}) },
65
69
  page: { get sessionName() { return session.name; }, saveSession: session.save },
66
70
  claimsKey: session.claimsKey,
67
71
  entryStarted: session.entryStarted,
68
72
  // An ended image leaves the session this tab saved or restored to open again.
69
73
  ended: () => session.name && page.site ? [{ text: `Open saved session ${session.name}`,
70
74
  href: sessionLoadUrl(session.name, page.site).href }] : [],
71
- messages: { "snapshot-chunk": capture.chunk },
75
+ messages: { "snapshot-chunk": capture.chunk, "snapshot-changed": ({ changed }) => session.changed(changed) },
72
76
  dispose() { session.abort(); },
73
77
  };
74
78
  }
75
79
 
76
- export function worker({ get, ask, configuration: { bytes, recover } }) {
77
- let kernel, transport, cancelled;
78
- if (bytes !== undefined && (!(bytes instanceof ArrayBuffer) || bytes.byteLength < 16 ||
79
- bytes.byteLength > DOLLY_SESSION_MAX_BYTES)) throw new Error("invalid Dolly session snapshot");
80
- if (recover !== undefined && (!validSessionName(recover) || bytes === undefined)) {
81
- throw new Error("invalid Dolly file recovery request");
80
+ export function worker({ get, ask, send, configuration: { save, recover } }) {
81
+ let kernel, transport, cancelled, changed = false;
82
+ // One step of a save, and word to the page when processes have changed what
83
+ // the last save holds, or no longer have (the kernel publishes its count here).
84
+ const service = () => {
85
+ kernel?.dolly_session_service();
86
+ if (transport && transport.changed !== changed) send({ type: "snapshot-changed", changed: changed = transport.changed });
87
+ };
88
+ if (save !== undefined && (!(save instanceof Blob) || save.size === 0 || save.size > DOLLY_SESSION_MAX_BYTES)) {
89
+ throw new Error("invalid Dolly session snapshot");
82
90
  }
83
- function restore(kernel) {
84
- const memory = get("runtime").memory;
85
- const address = Number(kernel.dolly_session_restore_address(BigInt(bytes.byteLength)));
86
- if (!Number.isSafeInteger(address) || address <= 0 || address > memory.buffer.byteLength - bytes.byteLength) {
87
- throw new Error("invalid session restore memory range");
88
- }
89
- new Uint8Array(memory.buffer, address, bytes.byteLength).set(new Uint8Array(bytes));
90
- if (kernel.dolly_session_restore(BigInt(bytes.byteLength)) !== 0) {
91
- throw new Error("Dolly session filesystem restore failed; the saved copy is unchanged");
92
- }
91
+ if (recover !== undefined && (!validSessionName(recover) || save === undefined)) {
92
+ throw new Error("invalid Dolly file recovery request");
93
93
  }
94
- async function recoverFiles({ supervisor, stage, files }) {
95
- const path = "/tmp/dolly-session-recovery.delta", destination = `/workspace/recovered-${recover}`;
96
- stage(`recovering saved files into ${destination}...`);
97
- files.write(path, new Uint8Array(bytes));
98
- try {
99
- const program = "/usr/bin/session-recover";
100
- if ((await supervisor.spawn([program, path, destination])).status !== 0) {
101
- throw new Error("File recovery failed; the original saved session is unchanged");
94
+ // Hands the kernel the save a transfer range at a time, as it is decompressed.
95
+ async function stream({ kernel, memory, stage }, doing, failed) {
96
+ const address = Number(kernel.dolly_session_transfer_address());
97
+ const reader = save.stream().pipeThrough(new DecompressionStream("gzip")).getReader();
98
+ let taken = 0, reported = performance.now();
99
+ for (;;) {
100
+ const { done, value } = await reader.read();
101
+ if (done) break;
102
+ for (let offset = 0; offset < value.length; offset += DOLLY_SESSION_TRANSFER_CAPACITY) {
103
+ const piece = value.subarray(offset, offset + DOLLY_SESSION_TRANSFER_CAPACITY);
104
+ new Uint8Array(memory.buffer, address, piece.length).set(piece);
105
+ if (kernel.dolly_session_restore_write(BigInt(piece.length), 0) !== 0) throw failed();
102
106
  }
103
- } finally { files.remove(path); }
107
+ taken += value.length;
108
+ if (performance.now() - reported >= 1000) {
109
+ stage(`${doing}: ${Math.floor(taken / 1024 / 1024)} MiB`);
110
+ reported = performance.now();
111
+ }
112
+ }
113
+ if (kernel.dolly_session_restore_write(0n, 1) !== 0) throw failed();
114
+ save = undefined;
104
115
  }
116
+ const restores = () => save !== undefined && recover === undefined;
105
117
  return {
118
+ restoresFilesystem: restores,
119
+ // The save is the whole filesystem: the kernel empties the boot's and takes it.
120
+ async restoreFilesystem(context) {
121
+ if (!restores()) return false;
122
+ context.stage("restoring the saved session...");
123
+ if (context.kernel.dolly_session_restore_begin() !== 0) throw new Error("Dolly could not empty the filesystem for the saved session");
124
+ await stream(context, "restoring the saved session",
125
+ () => new Error("Dolly session filesystem restore failed; the saved copy is unchanged"));
126
+ context.stage("saved session restored");
127
+ return true;
128
+ },
129
+ // File recovery: the kernel takes the save's files and directories under
130
+ // /home and /workspace into a new directory of the system image.
106
131
  async imageRestored(context) {
107
- const { kernel, image, stage } = context;
108
- if (kernel.dolly_session_base_capture() !== 0) throw new Error("Dolly could not index the session baseline");
109
- if (bytes === undefined) return;
110
- if (recover !== undefined) {
111
- if (image !== "system") throw new Error("invalid Dolly file recovery request");
112
- await recoverFiles(context);
113
- } else {
114
- stage("restoring named session filesystem...");
115
- restore(kernel);
116
- stage("named session filesystem restored");
117
- }
118
- bytes.transfer(0);
119
- bytes = undefined;
132
+ if (recover === undefined) return;
133
+ if (context.image !== "system") throw new Error("invalid Dolly file recovery request");
134
+ const destination = `/workspace/recovered-${recover}`, { kernel, memory, stage } = context;
135
+ stage(`recovering saved files into ${destination}...`);
136
+ const failed = () => new Error("File recovery failed; the original saved session is unchanged");
137
+ const names = new TextEncoder().encode(`${destination}\0/home\0/workspace\0`);
138
+ new Uint8Array(memory.buffer, Number(kernel.dolly_session_transfer_address()), names.length).set(names);
139
+ if (kernel.dolly_session_recover_begin(BigInt(names.length)) !== 0) throw failed();
140
+ await stream(context, "recovering saved files", failed);
120
141
  },
121
- service() { kernel?.dolly_session_service(); },
142
+ service,
122
143
  messages: {
123
144
  "snapshot-capture"() {
145
+ // The count before the capture clears it, so the page has it ahead of the reply.
146
+ service();
124
147
  const controller = cancelled = new AbortController();
125
148
  return transport.capture({ signal: controller.signal,
126
149
  onChunk: chunk => ask({ type: "snapshot-chunk", bytes: chunk }, [chunk.buffer]) });