@threadedstack/wmd-web 0.1.19 → 0.1.20

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 CHANGED
@@ -159,10 +159,11 @@ The interface this host implements is in [docs/host-interface.md](docs/host-inte
159
159
 
160
160
  `createBuilder` runs BuildKit (moby/buildkit v0.33.0) in a privileged builder machine in the page and builds a
161
161
  Dockerfile there. The result is `{index, packs}`, the shape `pullImage` returns, so `bootWmd` can run it at once.
162
- Chromium, Firefox and WebKit all pass the same 18 build steps (plan `docs/plans/web-buildkit-impl.md`, section 10).
162
+ Chromium, Firefox and WebKit all pass the same 18 build steps, and Node and Go projects for arm64 and amd64 (plan
163
+ `docs/plans/web-buildkit-impl.md`, section 10).
163
164
 
164
165
  ```js
165
- import { createBuilder } from 'wmd-web/build'
166
+ import { buildImage, createBuilder } from 'wmd-web/build'
166
167
 
167
168
  const builder = await createBuilder({
168
169
  vmUrl, files, // as bootWmd: the engine and the builder arch's guest files
@@ -176,6 +177,9 @@ const builder = await createBuilder({
176
177
  const r = await builder.build({ context: { Dockerfile: 'FROM alpine:3.20\nRUN echo hi > /x\n' } })
177
178
  // r: {index, packs, log, path: 'fast'|'buildkit', ms, cached}
178
179
  await builder.close()
180
+
181
+ // or, for one build: createBuilder + build + close in one call
182
+ const r2 = await buildImage({ ...builderOptions, context, timeoutMs: 30 * 60_000 })
179
183
  ```
180
184
 
181
185
  - **The cache stays.** The disk outlives the builder and the visit. After a reload, or after a tab was killed
@@ -190,7 +194,24 @@ await builder.close()
190
194
  for layer the same as BuildKit's (40 compared cases): under 0.4 s once its base image is pulled, where a warm
191
195
  BuildKit takes 5 to 14 s.
192
196
  - **Errors:** `BuildError {step, tail, reason}`: `reason` is `'no-space'`, `'quota'`, `'disk'`, `'timeout'`, `'pin'`
193
- or `'secret'`. Cancelling with `signal` stops the step and leaves nothing behind.
197
+ or `'secret'`. Cancelling with `signal` stops the step and leaves nothing behind. A failed build also carries `jit`
198
+ (the engine's last JIT messages), `net` (the guest network's counters and events), and, when the machine itself
199
+ stopped, `machine` (why, and its last output).
200
+ - **RAM and vCPUs.** The default is 1536 MiB and 2 vCPUs (`ram`, `cpus`). Small builds and the proxy project in the
201
+ plan fit. A build that compiles a native addon or bundles a large app needed 3072 MiB and 4 vCPUs (plan 10.5); a
202
+ single `RUN` step must finish within `timeoutMs`, since BuildKit resumes from its cache only between steps.
203
+ - **WebKit** builds Go and Node images and the real projects of the plan, but slower: its code space fills and new
204
+ code runs in the interpreter for a while (a `{t:'slow', reason:'webkit-code-space'}` event says so).
205
+ - In WebKit, node in `RUN` steps runs its JavaScript in the interpreter (D-553, `nodeInterpreted`, an
206
+ `{t:'node-interpreted'}` event): node's own JIT writes code faster than WebKit's code pool takes it, and a
207
+ 1,600-package `pnpm` install did not end in 27 minutes. A preload bound at `/dev/.wmd-node.js` and named in
208
+ `NODE_OPTIONS` (`--require`) turns off V8's compilers; WebAssembly still works. The image and the cache keys do
209
+ not change. `nodeInterpreted: false` turns it off; `true` turns it on elsewhere.
210
+ - npm closes a TLS handshake that is not done 15 s after the connect. The guest network lets 4 handshakes go at
211
+ once, sends a ClientHello that waited on a fresh connection, and sends an unanswered one again once (D-552); it
212
+ dials a stream again when the remote closes it before any byte crossed (D-551).
213
+ - WebKit cannot read a Blob of 128 MiB or more whole (`NotReadableError`). Read the `tar` Blob a build returns
214
+ with `blobBytes(blob)` from `wmd-web/build`, which reads it in slices (D-554).
194
215
  - **The builder images** are built with `make web-builder` (wmd builds them; Docker Desktop is not used) into
195
216
  `web/dist/builder/builder-<arch>.tar`, and their sha256 is pinned in `src/build/pin.js`. `make web-release` puts
196
217
  them next to the release tgz.
package/RELEASE.json CHANGED
@@ -1,66 +1,50 @@
1
1
  {
2
2
  "name": "wmd-web",
3
- "version": "0.4.0",
4
- "commit": "89688f12e0caef39ab0a159b39c00f3525e31051",
5
- "previous": "wmd-web-v0.1.0",
3
+ "version": "0.5.1",
4
+ "commit": "f0089e9f12e73d951b5a3a47f962764edc1c39a3",
5
+ "previous": "wmd-web-v0.5.0",
6
6
  "decisions": [
7
- "D-486",
8
- "D-488",
9
- "D-494",
10
- "D-498",
11
- "D-505",
12
- "D-506",
13
- "D-507",
14
- "D-509",
15
- "D-511",
16
- "D-516",
17
- "D-518",
18
- "D-531",
19
- "D-533",
20
- "D-539",
21
- "D-542",
22
- "D-544",
23
- "D-545",
24
- "D-547",
25
- "D-548"
7
+ "D-552",
8
+ "D-554"
26
9
  ],
27
- "vmSources": "7a707b82c43d3f1eb999476cad68e7ce79cc9cb5",
10
+ "vmSources": "543f2ee1a363719c2a973fff350d54a54d325286",
28
11
  "guest": {
29
12
  "arch": "arm64",
30
13
  "files": {
31
14
  "vm.wasm": {
32
- "sha256": "40299e37e788189e6d3bea9d9cdaefa241a5acf27931e6aae14117c100b036ec",
33
- "bytes": 1561499
15
+ "sha256": "73bee71d7453a130f7991c7cead3bc12787050116217f635f9f523f584d2577d",
16
+ "bytes": 1562868
34
17
  },
35
18
  "Image": {
36
- "sha256": "1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b",
19
+ "sha256": "6952a8d4f1bafec9c3e3b17e2c4af20ab5de8a7086956d087979804be2a01353",
37
20
  "bytes": 39049224
38
21
  },
39
22
  "Image-4k": {
40
- "sha256": "6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b",
23
+ "sha256": "47a1dcf06bcd1cc31fb4f02adbd1e035bc9e9a243fe57f3efd71b4a287581de2",
41
24
  "bytes": 38828040
42
25
  },
43
26
  "initramfs.cpio.gz": {
44
- "sha256": "f008aa9e0f6b4e913242f9982d3b194dba7dc39042a24bc89be64630672125af",
45
- "bytes": 4685204
27
+ "sha256": "efa6eb2338cb01d87eeebce8035ed7a26b00edcfc3b9db963b38272715d2ee9d",
28
+ "bytes": 4683846
46
29
  }
47
30
  }
48
31
  },
49
32
  "files": {
50
- "README.md": "3c12ca29aab5160b955ba3f50035f19be1dbf0278c4fb006e253a37ac9efbe6a",
33
+ "README.md": "6962e410444bcf44ba6dd8498673e2de17ee4f8b96e1d29044a55b8f0dbcd8de",
51
34
  "export-page/coi-sw.js": "0300e670b413cb2e0aae35843fc583546bbf6687df971ab6df41cfa3b0d494d7",
52
35
  "export-page/index.html": "f5450c89e63601bbbbffef421f03491b231d1f143070012c1c5315d4b0059f43",
53
36
  "export-page/run.css": "b57adf1e76553549c1f89ff157b7973409dedc21889cd3932948cd05b51adc6f",
54
37
  "export-page/run.js": "717636a632a40469106ddf1383e198eaac5d83651b5866d0699ec04b82462b7f",
55
- "guest/arm64/Image": "1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b",
56
- "guest/arm64/Image-4k": "6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b",
57
- "guest/arm64/initramfs.cpio.gz": "f008aa9e0f6b4e913242f9982d3b194dba7dc39042a24bc89be64630672125af",
58
- "guest/arm64/vm.wasm": "40299e37e788189e6d3bea9d9cdaefa241a5acf27931e6aae14117c100b036ec",
59
- "package.json": "778ef2457ab01f302387c811d796b7c61f97feb095725da285655629965f2b5e",
38
+ "guest/arm64/Image": "6952a8d4f1bafec9c3e3b17e2c4af20ab5de8a7086956d087979804be2a01353",
39
+ "guest/arm64/Image-4k": "47a1dcf06bcd1cc31fb4f02adbd1e035bc9e9a243fe57f3efd71b4a287581de2",
40
+ "guest/arm64/initramfs.cpio.gz": "efa6eb2338cb01d87eeebce8035ed7a26b00edcfc3b9db963b38272715d2ee9d",
41
+ "guest/arm64/vm.wasm": "73bee71d7453a130f7991c7cead3bc12787050116217f635f9f523f584d2577d",
42
+ "package.json": "7d43c05047d0a3ea56b4a7709f48a60de32e15c3bdf328d55435c46637981b40",
60
43
  "src/base.js": "982f8e46dbb727ec542a494ebdb884ccd19a31ff4e091425b2d1fc01b0d4754a",
61
- "src/boot.js": "62f5cf901d797d53bef584b3c6c0c7fb1a4cc8e2450e9570d6289c72036ed025",
62
- "src/build/builder.js": "751ea022e1f6f0c6ab79bd4efd6cfe0d705268ff7f699af87dcc64d1871ccc52",
63
- "src/build/context.js": "4baf1884d366455f3f76f397575d515671585ebf5deb27bc35bc2b1dd9c6bc11",
44
+ "src/blob.js": "bb2a2f0d87ce236280a7be2f246c926010dfe07f649c27da5e70d8eb5eaf7061",
45
+ "src/boot.js": "b1819d2b8abd46fd42f10a986719a311bedca705fc368a4cd19cc121b04b57d2",
46
+ "src/build/builder.js": "0d5355c8570b0ecedce25602ff7f60c49b7daaed669426328f05f52e26048213",
47
+ "src/build/context.js": "d38097c61dbdadc4d41290c010425a4422f499aba473fa2b83f2a5c41e543571",
64
48
  "src/build/dockerfile.js": "5957fe3d4ac8ff8b312f980ee04bd1d98ad4373a61f9a33e02f1ff1108b6ea53",
65
49
  "src/build/dockerignore.js": "8fc03d09da020f43ea483979b1183891b71d76f012379ab7813838ea0e53c546",
66
50
  "src/build/fast.js": "bf7373a43cce995b21751c317a58d18438f9fd2f670266b314b1151ef3cdb9e8",
@@ -93,8 +77,8 @@
93
77
  "src/host/clock.js": "aba6af3a8b1a985ae340892222e4b482d33afe20f5c8b3a4c7403ce87d1e247c",
94
78
  "src/host/ctl.js": "ca2ebbb940c9668cb0573c959cd2ff8982f96d58dfdb8dce39d304b89e033dd7",
95
79
  "src/host/disk-map.js": "3d7e0d7d488ce25fc6a0e4dc0a9b1c8fc2af96bac1c03491a1d30be392af268b",
96
- "src/host/disk-worker.js": "9d470cdc72d9e1fbf955e466ec2562abe780f2c6d89174ca3e4fbb1828f8dd76",
97
- "src/host/disk.js": "373e23539e3916674b7e8708d2e3ae22578214129e56936765db5c8f465cb343",
80
+ "src/host/disk-worker.js": "6e82bfeb4aa3f8b02bd7cb2de7427def26114e016f6b05196c8485d7fc8315ee",
81
+ "src/host/disk.js": "e941e62cc2d9a83582d53c80c09c5f0f682508f77390237bedff4051c489b2cd",
98
82
  "src/host/errno.js": "26021d85962084be2c43c98fb3accafc2a8f11fbf3c8a5b8d484bd6a088ae4db",
99
83
  "src/host/fs.js": "d6753f74db35b7b01baa8709fc7fdcdfc8d638dfe42f454b0a833b22111e8f4a",
100
84
  "src/host/gcnudge.js": "4a6569cb73ae24a11fc4ac8c467380bd9efba0bb5fa9867b797bc4f153ab15ab",
@@ -103,7 +87,7 @@
103
87
  "src/host/jit-hub-core.js": "f8d39dd0e4cbd42568bf56a8a7105f321d33bcf2d5faf8756d45a1ed692bd344",
104
88
  "src/host/jit-hub.js": "cf3fc9f9d4e3fb50c41d6de5e2523f380d3c6c1feaf61c15d90cde89aec2f4c3",
105
89
  "src/host/jit-snap.js": "3932f36047c9edeb7f59282d1c37fef19c8f5a9fe4bd0192fb93aa64030fe51d",
106
- "src/host/jit.js": "cb39f90df63752a7d4077f6972731cf4b1d8026cb4ca594e32f9203be6fe1edb",
90
+ "src/host/jit.js": "72ef7334506fde2b3724cee93b52a987fbd6d45473b8de93d914db8a80ae9e17",
107
91
  "src/host/layout.js": "d5bc5aa9292630b204438df5d6278fda6daa2a65acc061e1c57e0272b32fecc8",
108
92
  "src/host/monitor.js": "ae1741ba35668bcc6192642246494433b175668933921634c699af5a0a69acc0",
109
93
  "src/host/p9mount.js": "43246601fc1e27d5294742e4482ed166a50c665cb5f526c3646a47dd27e06150",
@@ -113,14 +97,14 @@
113
97
  "src/host/wasi.js": "fbafa239b8fb4083f7e8c910b1adab38901e8e7cffd247284a9abb2847eaf4bd",
114
98
  "src/host/wmd-imports.js": "3a00562f7c9c7787ab3841b14dde5d903a78f43ed19c43a2f5e31fd8a14c09d2",
115
99
  "src/host/yamux.js": "f4044f14f7e836224bdb4b1e53e59c565ee478b83b2e428fffb9f7a048768f79",
116
- "src/image-files.js": "7bac16bd278d17ba291dfe4771564627b5efbdda927b429018a75e8b906dd4cd",
100
+ "src/image-files.js": "19f90a7d3c8accae5093e53bb85fe7e158ac1dd18866283722171eccb1d7e37a",
117
101
  "src/net/dhcp-dns.js": "fdfa7c7d5cbbb4a8774b7dee195bf2df22a0c3fe8f20ac9694b24485fa8f25d5",
118
102
  "src/net/net-worker.js": "0a2ce998f69807e386ded7ff1bee53687a1d59ed76d7ef46e2eb6f958b14ad38",
119
103
  "src/net/packet.js": "d658f26fa84324a88d7dbc26af9c4a9c10e977c3060f16f7db431496eacd1127",
120
104
  "src/net/peer.js": "d83cf85639f6197fe26c877eb79f8c9afc017bf2362dc2b22a8b6bd0f45f3f19",
121
105
  "src/net/proto.js": "abfc8cd6f4331cbc98fcb1b0bba0795ff3042cc7db10476673ead1723249eee0",
122
106
  "src/net/relay-client.js": "4101de76dc0251db46839dc786cce677e4020795adf022bdce14bcf7edf8f864",
123
- "src/net/stack.js": "cd451cce7130f08e59cc19c961d2a5fca8416e577a40df1419ab6d5046018891",
107
+ "src/net/stack.js": "b721e4e116b0611abfe8ec1c6f2e212efd89a11f745fef2222ca5de474d8c0a9",
124
108
  "src/net/switch.js": "bb047fb1ceeb10a84b5475916ead916362accf0e6f42a86561653dfad0c6c50d",
125
109
  "src/net/wisp-client.js": "653961c4b735f2f74e5ea89e75c387f15ec8b8d9ed4c45d06c7d6b2a04968d42",
126
110
  "src/net/wisp-transport.js": "2b71201f3c686078c867e8995bf5c1cd6d47a0d415f476f3a33fbd5729a55f74",
@@ -128,7 +112,7 @@
128
112
  "src/registry/client.js": "fd38550e9afd82c5cb498513d245c33357a71697b3b2e6dd78538934bcc9d879",
129
113
  "src/registry/converter.js": "1fb8294fd5275f6772c27130cc9ba70bd46fafaf440884d42448036618229f37",
130
114
  "src/registry/hub.js": "cd672323bd1f96bd1d7e9eed80d4f36891e919e456fc0deb378fa0c7e3b6f249",
131
- "src/registry/load-tar.js": "9dd2b5d8884c64247339b141ab5a57b8ab1a2e007c32aa45b1103b37405b0ed7",
115
+ "src/registry/load-tar.js": "219d6701f5c938e27e3ca36ade8fcea8114a5f2b1511496f0dbd60137290867f",
132
116
  "src/registry/oci.js": "6a51ea16b34a65c0e732730f3e07d7ad16c50f6943a486ec05ca6aa3e98676a1",
133
117
  "src/registry/pack-worker.js": "fd6798851cb7815dacfcb67fc78c41654209c5504d8fd343aae524e99de1c741",
134
118
  "src/registry/pull.js": "24f2e01a76c9f7f27896af69b989f8c97f0ca31712b3e8110aaa5b5e3148b04b",
package/guest/arm64/Image CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/guest/arm64/Image
3
3
  size: 39049224 bytes
4
- sha256: 1d296c86b1fee0a10d0c2571b7e19854fe85ca0250cc9fadfe75eb268e48be7b
4
+ sha256: 6952a8d4f1bafec9c3e3b17e2c4af20ab5de8a7086956d087979804be2a01353
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/guest/arm64/Image-4k
3
3
  size: 38828040 bytes
4
- sha256: 6296e3d9fc7bb06a26340bb4453b5f0970d6b55452903546ba2c762820e2df5b
4
+ sha256: 47a1dcf06bcd1cc31fb4f02adbd1e035bc9e9a243fe57f3efd71b4a287581de2
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadedstack/wmd-web",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/threadedstack/wmd-releases",
6
6
  "repository": {
@@ -9,9 +9,9 @@
9
9
  },
10
10
  "description": "Browser host for wmd's guest vm.wasm (Web Workers + shared memory + WASI preview1)",
11
11
  "wmd": {
12
- "tag": "v0.1.19",
13
- "commit": "89688f12e0caef39ab0a159b39c00f3525e31051",
14
- "web": "0.4.0"
12
+ "tag": "v0.1.20",
13
+ "commit": "f0089e9f12e73d951b5a3a47f962764edc1c39a3",
14
+ "web": "0.5.1"
15
15
  },
16
16
  "type": "module",
17
17
  "main": "./src/boot.js",
package/src/blob.js ADDED
@@ -0,0 +1,12 @@
1
+ // D-554: WebKit (Playwright's WebKit 26 here) cannot read a Blob of 128 MiB or more whole: arrayBuffer() and stream()
2
+ // fail with NotReadableError ("The I/O read operation failed."). Slices of the same Blob read. Measured with 128, 256
3
+ // and 400 MiB Blobs; 64 MiB read whole. Readers of large slices (a tar entry, a layer) go through here too.
4
+ const SLICE = 16 << 20
5
+
6
+ /** A Blob's bytes, read in 16 MiB slices. */
7
+ export async function blobBytes(b) {
8
+ if (b.size <= SLICE) return new Uint8Array(await b.arrayBuffer())
9
+ const out = new Uint8Array(b.size)
10
+ for (let o = 0; o < b.size; o += SLICE) out.set(new Uint8Array(await b.slice(o, o + SLICE).arrayBuffer()), o)
11
+ return out
12
+ }
package/src/boot.js CHANGED
@@ -192,6 +192,13 @@ export const WEBKIT_CODE_BUDGET_MIB = 144
192
192
  */
193
193
  export const WEBKIT_JIT_THRESHOLD = 1000
194
194
 
195
+ /**
196
+ * D-551: how deep compiled guest calls may nest in WebKit (vm.wasm's WMD_MIRROR_DEPTH; 32, and 64 for region
197
+ * calls, elsewhere). An amd64 go build in WebKit trapped with "Maximum call stack size exceeded" at about 33
198
+ * frames of block code, which killed the machine; Chromium and Firefox ran the same build at 32.
199
+ */
200
+ export const WEBKIT_MIRROR_DEPTH = 8
201
+
195
202
  const MiB = 1 << 20
196
203
 
197
204
  /**
@@ -342,7 +349,7 @@ export function fmaFuses() {
342
349
 
343
350
  /**
344
351
  * W32: the environment vm.wasm starts with in this browser: vmEnv's, plus
345
- * WMD_JIT_THRESHOLD=WEBKIT_JIT_THRESHOLD in WebKit, W30's WMD_TRACE_MAX for the engine and
352
+ * WMD_JIT_THRESHOLD=WEBKIT_JIT_THRESHOLD and D-551's WMD_MIRROR_DEPTH=WEBKIT_MIRROR_DEPTH in WebKit, W30's WMD_TRACE_MAX for the engine and
346
353
  * W33's WMD_RESCUE_MS, D-539's WMD_FMA_FUSED=1 where the engine fuses relaxed_madd (fmaFuses) and, in
347
354
  * Chromium, D-542's WMD_RGN_DUP=1 (regions copy sampled-hot compiled blocks of their page; Firefox's go ran
348
355
  * slower with it, WebKit did not move), each unless the caller sets it. Kept out of the boot key (bootKeyOf hashes vmEnv's list), so a checkpoint or base
@@ -355,6 +362,7 @@ export function browserEnv(env, nav = globalThis.navigator, fused = fmaFuses())
355
362
  const has = k => env.some(e => e.startsWith(`${k}=`))
356
363
  const add = []
357
364
  if (isWebKit(nav) && !has('WMD_JIT_THRESHOLD')) add.push(`WMD_JIT_THRESHOLD=${WEBKIT_JIT_THRESHOLD}`)
365
+ if (isWebKit(nav) && !has('WMD_MIRROR_DEPTH')) add.push(`WMD_MIRROR_DEPTH=${WEBKIT_MIRROR_DEPTH}`) // D-551
358
366
  if (!has('WMD_TRACE_MAX')) add.push(`WMD_TRACE_MAX=${BROWSER_TRACE_MAX[engineOf(nav)]}`)
359
367
  if (!has('WMD_RESCUE_MS')) add.push(`WMD_RESCUE_MS=${BROWSER_RESCUE_MS}`)
360
368
  if (!has('WMD_FMA_FUSED') && fused) add.push('WMD_FMA_FUSED=1') // D-539
@@ -6,7 +6,8 @@
6
6
  // the image back as a Docker tar on buildctl's stdout and converts it with loadImageTar. A Dockerfile with no RUN
7
7
  // is built in the page instead (fast.js), unless fast: false.
8
8
 
9
- import { bootWmd } from '../boot.js'
9
+ import { blobBytes } from '../blob.js'
10
+ import { bootWmd, isWebKit } from '../boot.js'
10
11
  import { MAIN_PROC_ID, containerBundle, fillRunRequest } from '../host/ctl.js'
11
12
  import { DiskError, deleteDisk, openDisk, sweepDisks } from '../host/disk.js'
12
13
  import { p9MountOptions } from '../host/p9mount.js'
@@ -15,6 +16,8 @@ import { packContext, readTarFile } from './context.js'
15
16
  import { BUILDER_PINS } from './pin.js'
16
17
  import { FastRefused, fastBuild, fastEligible } from './fast.js'
17
18
 
19
+ export { blobBytes } // D-554: read the tar Blob a build returns with it (WebKit cannot read 128 MiB or more whole)
20
+
18
21
  export const ARCHES = {
19
22
  arm64: {
20
23
  platform: 'linux/arm64',
@@ -41,13 +44,68 @@ const FULL_MIB = 64 // /bk with less free than this during a failed build: the b
41
44
  const enc = new TextEncoder()
42
45
  /** host/jit.js's line when the engine's code pool is full (D-515). */
43
46
  export const POOL_FULL_RE = /wmd-web: jit: the engine's code pool is full/
47
+ const JIT_LINE_RE = /^(?:vm\.wasm: JIT|wmd-web: jit:)/
48
+ const JIT_LINES = 60 // guest/host JIT messages kept (a ring)
49
+ const JIT_EVENTS = 200 // 'jit' events sent in a builder's life: the vm repeats some of them at every release
50
+ const JIT_KINDS = [
51
+ [/the engine's code pool is full/, 'pool-full'],
52
+ [/the engine compiles code again/, 'pool-back'],
53
+ [/blocks run in the interpreter from now on/, 'interpreter-from-now-on'], // vm cache_release: release_dead
54
+ [/waiting before releasing again/, 'release-deferred'], // D-445 grace window
55
+ [/JIT release/, 'release'],
56
+ [/module budget reached|budget \d+ KiB from now on|refused more code/, 'budget'],
57
+ ]
58
+ /** What a JIT message line says, or null for a line that is not one. */
59
+ export function jitKind(line) {
60
+ if (!JIT_LINE_RE.test(line)) return null
61
+ return JIT_KINDS.find(([re]) => re.test(line))?.[1] || 'other'
62
+ }
63
+ /**
64
+ * The guest's stderr into whole lines: the JIT's messages are kept in a ring with the time (performance.now, ms)
65
+ * and sent as {t:'jit', what, line} events, so a build that crawls in the interpreter can be told from one that hangs.
66
+ */
67
+ export function jitWatch({ onEvent = () => {}, max = JIT_LINES, maxEvents = JIT_EVENTS, now = () => Math.round(performance.now()) } = {}) {
68
+ const ring = []
69
+ let buf = ''
70
+ let events = 0
71
+ let skipped = 0
72
+ const line = l => {
73
+ const what = jitKind(l)
74
+ if (!what) return
75
+ const at = now()
76
+ ring.push({ at, what, line: l.slice(0, 400) })
77
+ if (ring.length > max) ring.shift()
78
+ if (what === 'other') return
79
+ if (events < maxEvents || what === 'interpreter-from-now-on') {
80
+ events++
81
+ onEvent({ t: 'jit', what, line: l.slice(0, 400) })
82
+ } else skipped++
83
+ }
84
+ return {
85
+ push(text) {
86
+ buf += text
87
+ let i
88
+ while ((i = buf.indexOf('\n')) >= 0) {
89
+ line(buf.slice(0, i))
90
+ buf = buf.slice(i + 1)
91
+ }
92
+ if (buf.length > 4096) buf = buf.slice(-4096)
93
+ },
94
+ lines: () => ring.slice(),
95
+ skipped: () => skipped,
96
+ }
97
+ }
44
98
 
45
99
  export class BuildError extends Error {
46
- constructor(message, { step = null, tail = '', reason = null } = {}) {
100
+ constructor(message, { step = null, tail = '', reason = null, jit = null, machine = null } = {}) {
47
101
  super(message)
48
102
  this.name = 'BuildError'
49
103
  this.step = step
50
104
  this.tail = tail
105
+ this.jit = jit // the builder's last JIT messages ([{at, what, line}]), when it kept them
106
+ // when buildctl's stream ended without an exit (code 125): why it closed, whether the machine ended, the
107
+ // machine's stderr tail and the start script's last lines
108
+ this.machine = machine
51
109
  this.reason = reason // 'no-space' | 'quota' | 'disk' | 'timeout' | 'pin' | 'secret' | null
52
110
  }
53
111
  }
@@ -220,7 +278,7 @@ async function indexDigest(index) {
220
278
  async function bytesOf(x, fetchFn) {
221
279
  if (x instanceof Uint8Array) return x
222
280
  if (x instanceof ArrayBuffer) return new Uint8Array(x)
223
- if (typeof Blob !== 'undefined' && x instanceof Blob) return new Uint8Array(await x.arrayBuffer())
281
+ if (typeof Blob !== 'undefined' && x instanceof Blob) return blobBytes(x)
224
282
  const r = await fetchFn(x)
225
283
  if (!r.ok) throw new Error(`${x}: ${r.status} ${r.statusText}`)
226
284
  return new Uint8Array(await r.arrayBuffer())
@@ -348,10 +406,88 @@ function aborted(signal) {
348
406
  * @param {string[]} [o.registryMirrors] Docker Hub mirrors (host[:port], in order) BuildKit tries before docker.io; list a plain-http one
349
407
  * in httpRegistries too
350
408
  * @param {Record<string,string>} [o.registryCAs] host -> PEM: CAs BuildKit checks that registry's certificate against
409
+ * @param {boolean} [o.nodeInterpreted] D-553: node in RUN steps runs its JavaScript in the interpreter (a preload turns
410
+ * off V8's compilers; WebAssembly stays). Default: on in WebKit, off elsewhere.
411
+ * Without it, WebKit cannot keep up with the code node's JIT writes, and an npm
412
+ * install of 1,600 packages does not end in 30 minutes.
351
413
  * @param {Function} [o.onEvent]
352
414
  * @param {AbortSignal} [o.signal]
353
415
  * @param {object} [o.boot] extra bootWmd options (tests)
354
416
  */
417
+ /**
418
+ * D-553: the preload node runs in a RUN step (NODE_OPTIONS --require). It turns off V8's compilers, so node runs its
419
+ * JavaScript in the interpreter and writes no new machine code; WebAssembly stays (node-gyp's undici needs it, which
420
+ * --jitless removed). Each flag only where that V8 has it (V8 prints an error for an unknown one): measured on node
421
+ * 12, 14, 16, 18, 20, 22 and 24.
422
+ */
423
+ export const NODE_PRELOAD = `try {
424
+ var v = process.versions.v8.split('.').map(Number), m = v[0] * 100 + v[1], f = ['--no-opt']
425
+ if (m >= 708) f.push('--regexp-interpret-all')
426
+ if (m >= 904) f.push('--no-sparkplug', '--liftoff-only')
427
+ if (m >= 1100) f.push('--no-maglev')
428
+ require('v8').setFlagsFromString(f.join(' '))
429
+ } catch (e) {}
430
+ `
431
+
432
+ /**
433
+ * D-553: buildkit-runc that makes node in a RUN step load NODE_PRELOAD (runc ... --bundle <dir> ...). BuildKit writes
434
+ * config.json on one line (json.Encoder). awk walks it as JSON, strings and escapes included (inside a string a quote
435
+ * is \", so the keys it looks for cannot occur there):
436
+ * - it appends a read-only bind of /usr/local/lib/wmd-node.js at /dev/.wmd-node.js to the end of "mounts", after
437
+ * BuildKit's /dev tmpfs, so the file is in that tmpfs and never in the step's layer;
438
+ * - then it adds --require=/dev/.wmd-node.js to the first "env":[ (process.env)'s NODE_OPTIONS, or a NODE_OPTIONS
439
+ * element at the front.
440
+ * Without a "mounts" array it changes nothing (a --require of a missing file would stop node).
441
+ */
442
+ export const RUNC_NODE = String.raw`#!/bin/sh
443
+ b=
444
+ p=
445
+ for a in "$@"; do
446
+ case "$a" in --bundle=*) b=$(printf %s "$a" | cut -c10-) ;; esac
447
+ case "$p" in --bundle|-b) b=$a ;; esac
448
+ p=$a
449
+ done
450
+ c="$b/config.json"
451
+ if [ -n "$b" ] && [ -f "$c" ]; then
452
+ awk -v M='{"destination":"/dev/.wmd-node.js","type":"bind","source":"/usr/local/lib/wmd-node.js","options":["rbind","ro"]}' -v R='--require=/dev/.wmd-node.js' '
453
+ function shut(s, st, n, i, ch, ins, esc, d) {
454
+ n = length(s); ins = 0; esc = 0; d = 0
455
+ for (i = st; i <= n; i++) {
456
+ ch = substr(s, i, 1)
457
+ if (ins) { if (esc) esc = 0; else if (ch == "\\") esc = 1; else if (ch == "\"") ins = 0 }
458
+ else if (ch == "\"") ins = 1
459
+ else if (ch == "[" || ch == "{") d++
460
+ else if (ch == "]" || ch == "}") { if (!d) return i; d-- }
461
+ }
462
+ return 0
463
+ }
464
+ {
465
+ s = $0
466
+ k = index(s, ",\"mounts\":["); if (!k) k = index(s, "{\"mounts\":[")
467
+ if (!k || index(s, "\"destination\":\"/dev/.wmd-node.js\"")) { print s; next }
468
+ st = k + 11; e = shut(s, st); if (!e) { print s; next }
469
+ s = substr(s, 1, e - 1) (e == st ? "" : ",") M substr(s, e)
470
+ k = index(s, "\"env\":["); if (!k) { print s; next }
471
+ st = k + 7; n = length(s); ins = 0; esc = 0; found = 0; q = 0; L = length(R) + 1
472
+ for (i = st; i <= n; i++) {
473
+ ch = substr(s, i, 1)
474
+ if (ins) {
475
+ if (esc) esc = 0
476
+ else if (ch == "\\") esc = 1
477
+ else if (ch == "\"") {
478
+ ins = 0
479
+ if (substr(s, q, 14) == "\"NODE_OPTIONS=") { found = 1; s = substr(s, 1, i - 1) " " R substr(s, i); i += L; n += L }
480
+ }
481
+ } else if (ch == "\"") { ins = 1; q = i }
482
+ else if (ch == "]") break
483
+ }
484
+ if (!found) s = substr(s, 1, st - 1) "\"NODE_OPTIONS=" R "\"" (substr(s, st, 1) == "]" ? "" : ",") substr(s, st)
485
+ print s
486
+ }' "$c" > "$c.wmd" && mv "$c.wmd" "$c"
487
+ fi
488
+ exec /usr/bin/buildkit-runc "$@"
489
+ `
490
+
355
491
  export async function createBuilder(o) {
356
492
  const archName = o.arch || 'arm64'
357
493
  const arch = ARCHES[archName]
@@ -359,11 +495,13 @@ export async function createBuilder(o) {
359
495
  if (o.disk !== false) checkNamespace(o.disk?.namespace)
360
496
  if (!o.relay?.url) throw new TypeError('relay: {url}: the builder pulls base images through it')
361
497
  const fetchFn = o.fetch || globalThis.fetch.bind(globalThis)
498
+ // every event carries `at`: performance.now() in ms (`ms` is a duration in some events)
362
499
  const emit = ev => {
363
500
  try {
364
- o.onEvent?.(ev)
501
+ o.onEvent?.({ at: Math.round(performance.now()), ...ev })
365
502
  } catch {}
366
503
  }
504
+ const jit = jitWatch({ onEvent: emit })
367
505
  const ram = o.ram || 1536
368
506
  const cpus = o.cpus || 2
369
507
  const t0 = performance.now()
@@ -398,7 +536,16 @@ export async function createBuilder(o) {
398
536
  ...(o.registryMirrors?.length ? [`WMD_BK_MIRRORS=${o.registryMirrors.map(checkHost).join(',')}`] : []),
399
537
  ...(o.registryCAs && Object.keys(o.registryCAs).length ? [`WMD_BK_CA=${Object.entries(o.registryCAs).map(([h, pem]) => `${checkHost(h)}=${b64utf8(pem)}`).join(' ')}`] : []),
400
538
  ]
401
- const argv = ['/usr/local/bin/wmd-buildkitd']
539
+ const interp = o.nodeInterpreted ?? isWebKit()
540
+ // D-553: BuildKit starts each RUN step with buildkit-runc from PATH; a wrapper earlier in PATH makes node in the step
541
+ // load a preload that turns off V8's compilers, then runs the real one. The image and the cache keys stay as they are.
542
+ const argv = interp
543
+ ? ['/bin/sh', '-c', 'mkdir -p /usr/local/lib && printf %s "$WMD_NODE_PRELOAD" > /usr/local/lib/wmd-node.js && printf %s "$WMD_RUNC_WRAP" > /usr/local/bin/buildkit-runc && chmod 0755 /usr/local/bin/buildkit-runc && exec /usr/local/bin/wmd-buildkitd']
544
+ : ['/usr/local/bin/wmd-buildkitd']
545
+ if (interp) {
546
+ env.push(`WMD_RUNC_WRAP=${RUNC_NODE}`, `WMD_NODE_PRELOAD=${NODE_PRELOAD}`)
547
+ emit({ t: 'node-interpreted', message: "node in RUN steps runs its JavaScript in the interpreter: WebKit's code pool cannot keep up with node's JIT" })
548
+ }
402
549
  const b = containerBundle({ args: argv, env, cwd: '/', hostname: 'buildkitsandbox', networkDisabled: false, ip: '192.168.127.2', gateway: '192.168.127.1', privileged: true })
403
550
  const append = [...arch.console, 'rdinit=/sbin/init', 'loglevel=4', 'norandmaps', 'wmd.init', 'wmd.p9tag=wmdroot', 'wmd.control=vport', 'wmd.exit'].join(' ')
404
551
  const args = [
@@ -425,6 +572,7 @@ export async function createBuilder(o) {
425
572
  ...(disk ? { disk } : {}),
426
573
  onStderr: s => {
427
574
  stderr = (stderr + s).slice(-16384)
575
+ jit.push(s)
428
576
  // WebKit: the engine's code pool filled, so new code waits and the guest runs slower (D-515; plan 5.10)
429
577
  if (!slowSaid && POOL_FULL_RE.test(s)) {
430
578
  slowSaid = true
@@ -482,6 +630,8 @@ export async function createBuilder(o) {
482
630
  throw e
483
631
  }
484
632
  emit({ t: 'ready', ms: Math.round(performance.now() - t0), platform: arch.platform })
633
+ let endedWith = null
634
+ ended.then(r => (endedWith = { code: r?.code ?? null, reason: r?.reason ?? null }), () => {})
485
635
 
486
636
  let seq = 0
487
637
  let busy = false
@@ -539,14 +689,14 @@ export async function createBuilder(o) {
539
689
  clearTimeout(killTimer)
540
690
  signal?.removeEventListener('abort', kill)
541
691
  }
542
- if (!binary) return { code: r.code ?? null, out, err }
692
+ if (!binary) return { code: r.code ?? null, out, err, error: r.error ?? null }
543
693
  const bytes = new Uint8Array(size)
544
694
  let at = 0
545
695
  for (const c of chunks) {
546
696
  bytes.set(c, at)
547
697
  at += c.length
548
698
  }
549
- return { code: r.code ?? null, out: bytes, err }
699
+ return { code: r.code ?? null, out: bytes, err, error: r.error ?? null }
550
700
  })()
551
701
  }
552
702
  const sh = (cmd, opts) => inGuest(['/bin/sh', '-c', cmd], opts)
@@ -563,6 +713,15 @@ export async function createBuilder(o) {
563
713
  inGuest,
564
714
  sh,
565
715
  log: () => mainLog.lines,
716
+ jitLines: () => jit.lines(),
717
+ /** The machine's state for a failure report: {ended ({code, reason} or null), stderr tail, start script's tail}. */
718
+ machineState: () => ({ ended: endedWith, stderr: stderr.slice(-4000), main: mainLog.tail.slice(-3000) }),
719
+ /** The guest network's counters and its last 40 events (resets, refusals, D-551 redials), or null. */
720
+ netStats: async () => {
721
+ const n = await vm.net?.stats?.().catch(() => null)
722
+ if (!n?.stack) return n ?? null
723
+ return { stack: { ...n.stack, events: (n.stack.events || []).slice(-40) }, relay: n.relay ?? null }
724
+ },
566
725
  /** Free MiB and free inodes on /bk. */
567
726
  async room() {
568
727
  const r = await sh("df -Pm /bk | awk 'NR==2{print $4}'; df -Pi /bk | awk 'NR==2{print $4, $2}'")
@@ -609,6 +768,7 @@ async function keepBuilt(store, index, packs) {
609
768
  async function runBuild(builder, req, emit) {
610
769
  const t0 = performance.now()
611
770
  const onEvent = ev => {
771
+ ev = { at: Math.round(performance.now()), ...ev }
612
772
  emit(ev)
613
773
  try {
614
774
  req.onEvent?.(ev)
@@ -650,7 +810,18 @@ async function runBuild(builder, req, emit) {
650
810
  const dir = `/run/ctx/${id}`
651
811
  // req.signal and timeoutMs cover everything from here: the context, the upload, buildctl and the conversion
652
812
  const ac = new AbortController()
653
- const timer = req.timeoutMs ? setTimeout(() => ac.abort(new BuildError(`the build ran past ${req.timeoutMs} ms`, { reason: 'timeout' })), req.timeoutMs) : null
813
+ let lastStep = null
814
+ const timer = req.timeoutMs
815
+ ? setTimeout(
816
+ () => {
817
+ const e = new BuildError(`the build ran past ${req.timeoutMs} ms${lastStep ? `, in the step ${lastStep}` : ''}`, { reason: 'timeout', step: lastStep, tail: log.tail.slice(-4000), jit: builder.jitLines?.() ?? null })
818
+ ac.abort(e)
819
+ // the network's counters join the error while buildctl is stopped (SIGINT, then 5 s), before it is thrown
820
+ builder.netStats?.().then(n => (e.net = n), () => {})
821
+ },
822
+ req.timeoutMs,
823
+ )
824
+ : null
654
825
  const onAbort = () => ac.abort(new BuildCancelled())
655
826
  req.signal?.addEventListener('abort', onAbort, { once: true })
656
827
  const check = () => {
@@ -698,7 +869,6 @@ async function runBuild(builder, req, emit) {
698
869
  if (req.push?.auth) xenv.push(`DOCKER_CONFIG=${dir}/auth`)
699
870
  args.push('--output', `type=docker,${req.tag ? `name=${req.tag},` : ''}dest=-`)
700
871
  if (req.push) args.push('--output', `type=image,name=${req.push.name},push=true${req.push.insecure ? ',registry.insecure=true' : ''}`)
701
- let lastStep = null
702
872
  const errLines = [] // buildctl's own error lines (no step), not the steps' output
703
873
  const pr = progressReader({
704
874
  onStep: ev => {
@@ -737,8 +907,22 @@ async function runBuild(builder, req, emit) {
737
907
  if (builder.disk?.events?.slice?.(diskMark).some(e => e.reason === 'quota')) reason = 'quota'
738
908
  if (!reason && /input\/output error|read-only file system/i.test(tail)) reason = 'disk'
739
909
  const failed = [...pr.vertexes.values()].find(v => v.error)
740
- const why = failed?.error || tail.split('\n').filter(Boolean).pop() || `buildctl exited ${r.code}`
741
- throw new BuildError(`the build failed${failed ? ` at ${failed.name}` : ''}: ${why.slice(0, 500)}`, { step: failed?.name || lastStep, tail, reason })
910
+ const why = failed?.error || tail.split('\n').filter(Boolean).pop() || `buildctl exited ${r.code}${r.error ? ` (${r.error})` : ''}`
911
+ // a stream that ended with no exit frame: the machine's side says why
912
+ let machine = null
913
+ if (r.code === 125 && r.error) {
914
+ await new Promise(res => setTimeout(res, 2000)) // let a machine that is going down report it
915
+ machine = { stream: r.error, ...(builder.machineState?.() ?? {}) }
916
+ }
917
+ const err = new BuildError(`the build failed${failed ? ` at ${failed.name}` : ''}: ${why.slice(0, 500)}`, { step: failed?.name || lastStep, tail, reason, jit: builder.jitLines?.() ?? null, machine })
918
+ err.net = (await builder.netStats?.().catch(() => null)) ?? null
919
+ // buildkitd's side (a session it closed, a worker error): its last log lines, when the machine still answers
920
+ if (r.code !== 125) {
921
+ const d = await Promise.race([builder.sh('tail -n 40 /run/buildkitd.log').catch(() => null), new Promise(res => setTimeout(res, 10_000, null))])
922
+ err.daemonLog = d?.out ? d.out.slice(-6000) : null
923
+ }
924
+ check() // a cancel or timeout during these waits wins
925
+ throw err
742
926
  }
743
927
  onEvent({ t: 'export', bytes: r.out.length })
744
928
  const t1 = performance.now()
@@ -784,7 +968,8 @@ async function ensureRoom(builder, onEvent, signal) {
784
968
  export async function buildImage(o) {
785
969
  const builder = await createBuilder(o)
786
970
  try {
787
- return await builder.build(o)
971
+ // o.onEvent already hears every build event through the builder: given to build() too, each would come twice
972
+ return await builder.build({ ...o, onEvent: undefined })
788
973
  } finally {
789
974
  await builder.close()
790
975
  }
@@ -1,6 +1,7 @@
1
1
  // The build context (docs/plans/web-buildkit-impl.md 5.6): turn a file source, a plain object or a tar into the
2
2
  // one tar the page sends to the builder. .dockerignore is applied here with a port of moby's patternmatcher, so
3
3
  // ignored files are never read; BuildKit applies it again and stays the source of truth.
4
+ import { blobBytes } from '../blob.js'
4
5
  import { matcher, parseDockerignore } from './dockerignore.js'
5
6
  import { decodeText, scanTar, tarWriter } from './tar.js'
6
7
 
@@ -108,7 +109,7 @@ function tarReader(input) {
108
109
  const u8 = input instanceof ArrayBuffer ? new Uint8Array(input) : input
109
110
  const total = blob ? input.size : u8.length
110
111
  const read = blob
111
- ? async (o, n) => new Uint8Array(await input.slice(o, o + n).arrayBuffer())
112
+ ? (o, n) => blobBytes(input.slice(o, o + n))
112
113
  : (o, n) => u8.subarray(o, o + n)
113
114
  return { blob, u8, total, read }
114
115
  }
@@ -175,7 +176,7 @@ async function packTar(input, dockerfile, lim) {
175
176
  if (err instanceof ContextError) throw err
176
177
  throw new ContextError('bad-source', '', `build context: ${err.message}`)
177
178
  }
178
- const tar = blob ? new Uint8Array(await input.arrayBuffer()) : u8
179
+ const tar = blob ? await blobBytes(input) : u8
179
180
  return { tar, files, bytes, ignored: 0, dockerfile }
180
181
  }
181
182
 
@@ -71,7 +71,14 @@ function serve(mailbox, disk, closeFiles) {
71
71
  let ops = 0
72
72
  const post = () => self.postMessage({ t: 'stats', stats: { ...stats, ...disk.usage() } })
73
73
  for (;;) {
74
- if (Atomics.load(i32, 5) === 1) break
74
+ const ctl = Atomics.load(i32, 5)
75
+ if (ctl === 1) break
76
+ if (ctl === 2) {
77
+ // tests (gate 9): the Worker ends with no word, as a crashed one does; a vCPU waiting on it gives up after WAIT_MS
78
+ closeFiles()
79
+ self.close()
80
+ return
81
+ }
75
82
  const s = Atomics.load(i32, 1)
76
83
  if (s === STATE.GONE) break
77
84
  if (s !== STATE.POSTED) {
package/src/host/disk.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // i32[2] op 0 read, 1 write, 2 flush, 3 discard (blk.c's BLK_OP_*)
11
11
  // i32[3] len bytes (u32)
12
12
  // i32[4] result 0 or a negative errno
13
- // i32[5] control 1: the page asks the Worker to flush and close
13
+ // i32[5] control 1: the page asks the Worker to flush and close; 2 (tests): it ends at once and answers nothing
14
14
  // f64[3] off byte offset (bytes 24..31)
15
15
  // data from byte 64, DATA bytes; longer reads and writes go in pieces
16
16
  //
package/src/host/jit.js CHANGED
@@ -69,6 +69,8 @@ const POOL_CHECK_MS = 2000 // D-515: at most one code pool check a thread in thi
69
69
  const POOL_PROBE_N = 1500000 // D-515: loop passes of the check: about 2 ms compiled, 40 ms interpreted
70
70
  const POOL_SLOW_X = 6 // D-515: a check this many times slower than this thread's fastest means the pool is full
71
71
  const POOL_LEARN = 3 / 4 // D-515: the budget from then on, of the modules live when the pool filled
72
+ const POOL_RECOVER_N = 5 // a learned budget grows again after this many checks in a row that found room (10 s at POOL_CHECK_MS)
73
+ const POOL_RECOVER_X = 4 / 3 // ... by this factor each time (the inverse of POOL_LEARN), never past maxLiveBytes
72
74
 
73
75
  // counters after the header: each a pair of Int32 cells (low word, unsigned; high word).
74
76
  // D-419: not BigInt64 cells: Atomics.add on a BigInt64Array returns a new BigInt, garbage a
@@ -404,6 +406,38 @@ export function createJitDoor({ store, memory, ram = null, log = () => {}, compi
404
406
 
405
407
  let poolNext = 0
406
408
  let poolFast = Infinity
409
+ let poolRoom = 0 // checks in a row that found room, with the shared budget unchanged
410
+ let budgetSeen = -1 // the shared budget at the last check: a change (another Worker grew or lowered it) starts the count again
411
+ /**
412
+ * The engine has had room for POOL_RECOVER_N checks in a row: a budget learned while released code
413
+ * was not yet freed (WebKit frees it 30-40 s late, D-445) grows back by POOL_RECOVER_X, and is
414
+ * dropped once it reaches maxLiveBytes (the original cap). Without a cap there is nothing to
415
+ * return to.
416
+ */
417
+ function recoverBudget() {
418
+ if (!maxLiveBytes) return
419
+ // the budget is shared and every Worker checks: without this, N Workers would grow it N times as fast
420
+ const seen = Atomics.load(hdr, H_BUDGET_KIB)
421
+ if (seen !== budgetSeen) {
422
+ budgetSeen = seen
423
+ poolRoom = 0
424
+ }
425
+ if (++poolRoom < POOL_RECOVER_N) return
426
+ poolRoom = 0
427
+ if (!seen) return
428
+ lock()
429
+ try {
430
+ const cur = Atomics.load(hdr, H_BUDGET_KIB)
431
+ if (!cur) return
432
+ const next = Math.ceil(cur * POOL_RECOVER_X)
433
+ const done = next * 1024 >= maxLiveBytes
434
+ Atomics.store(hdr, H_BUDGET_KIB, done ? 0 : next)
435
+ budgetSeen = done ? 0 : next
436
+ log(`wmd-web: jit: the engine has had room for ${POOL_RECOVER_N} checks; budget ${done ? `back to its cap of ${maxLiveBytes >> 10} KiB` : `${next} KiB`}`)
437
+ } finally {
438
+ unlock()
439
+ }
440
+ }
407
441
  /**
408
442
  * D-515: before a new module is compiled. While the engine's code pool is full (codePool, checked
409
443
  * at most once each poolEvery) the answer is JIT_LATER: the guest interprets the blocks and asks
@@ -423,8 +457,10 @@ export function createJitDoor({ store, memory, ram = null, log = () => {}, compi
423
457
  if (ms > poolFast * POOL_SLOW_X) ms = Math.min(ms, codePool()) // not a pause of this thread
424
458
  poolFast = Math.min(poolFast, Math.max(ms, 0.05))
425
459
  const full = ms > poolFast * POOL_SLOW_X
460
+ if (full) poolRoom = 0
426
461
  const at = () => new Date().toISOString().slice(11, 19)
427
462
  if (!full) {
463
+ recoverBudget()
428
464
  if (was && Atomics.compareExchange(hdr, H_POOL_FULL, 1, 0) === 1) {
429
465
  add('poolBack')
430
466
  log(`wmd-web: jit: the engine compiles code again (${at()})`)
@@ -4,6 +4,7 @@
4
4
  // `USER name`, devcontainer `remoteUser` or `--chown=app:app` (resolveUser). W66: another image's files,
5
5
  // for `COPY --from` and multi-stage builds (walk).
6
6
 
7
+ import { blobBytes } from './blob.js'
7
8
  import { ENOENT, FsError } from './fs/errno.js'
8
9
  import { Overlay } from './fs/overlay.js'
9
10
  import { Pack, bytesSource } from './fs/pack.js'
@@ -19,7 +20,7 @@ async function layerBytes(l, i, fetchFn) {
19
20
  }
20
21
  if (l instanceof Uint8Array) return l
21
22
  if (l instanceof ArrayBuffer || (typeof SharedArrayBuffer !== 'undefined' && l instanceof SharedArrayBuffer)) return new Uint8Array(l)
22
- if (typeof Blob !== 'undefined' && l instanceof Blob) return new Uint8Array(await l.arrayBuffer())
23
+ if (typeof Blob !== 'undefined' && l instanceof Blob) return blobBytes(l)
23
24
  throw new TypeError(`layer ${i}: a URL, bytes or a Blob, not ${Object.prototype.toString.call(l)}`)
24
25
  }
25
26
 
package/src/net/stack.js CHANGED
@@ -57,6 +57,22 @@ const HALF_OPEN_IDLE_MS = 10 * 60_000
57
57
  const SYNACK_GIVE_UP_MS = 60_000 // Linux: tcp_synack_retries (5), about 63 s
58
58
  const GUEST_CLOSED_IDLE_MS = 120_000 // the guest's FIN is in, the remote says nothing: its kernel forgot it (FIN_WAIT2, 60 s)
59
59
  const MAX_PENDING = 32 // handshakes not finished at once (more get a RST), so abandoned ones cannot take every relay slot
60
+ // D-551: npm's CDN ends (FIN) a connection whose TLS handshake is not done 15 s after the connect, and the relay
61
+ // connects when the guest's SYN comes. A slow guest (WebKit) can miss that before its ClientHello. A remote that
62
+ // ends a connection nothing has crossed, REDIAL_MIN_MS or more after it opened, is dialled once more instead.
63
+ const REDIAL_MIN_MS = 5_000
64
+ // D-552: a guest that runs many TLS handshakes at once (pnpm opens 16) splits its CPU among them, and in a slow guest
65
+ // (WebKit) none ends inside npm's 15 s. At most TLS_HANDSHAKES run at once: a ClientHello waits here for its turn, then
66
+ // goes out on a stream dialled again when the one it has is TLS_FRESH_MS old. A remote that ends a stream before it
67
+ // answers the ClientHello gets the hello once more on a new stream. A handshake holds its turn at most TLS_TURN_MS.
68
+ const TLS_HANDSHAKES = 4
69
+ const TLS_FRESH_MS = 3_000
70
+ const TLS_TURN_MS = 20_000
71
+ const TLS_HELLO_MAX = 16 << 10 // bytes kept for a replay
72
+ const T_NONE = 0 // no guest bytes yet
73
+ const T_WAIT = 1 // a ClientHello waits for a turn
74
+ const T_TURN = 2 // the handshake has a turn
75
+ const T_DONE = 3 // past the handshake, or not TLS
60
76
  const INTERNAL_ZONE = 'wmd.internal'
61
77
 
62
78
  const P_CONNECTING = 1 // waiting for the relay's CONNECTED
@@ -122,6 +138,14 @@ class Conn {
122
138
  this.lastRemote = 0 // the last data or end from the relay
123
139
  this.bytesIn = 0 // guest -> relay
124
140
  this.bytesOut = 0 // relay -> guest
141
+ this.redialAt = 0 // D-551: when the stream was dialled again (0: not); its answer is pending while redialing
142
+ this.redialing = false
143
+ this.dialAt = 0 // when the relay stream the guest's bytes go to connected
144
+ this.tls = T_NONE // D-552
145
+ this.tlsAt = 0 // when the handshake got its turn
146
+ this.hello = null // the guest's bytes sent before the remote answered (a replay sends them again)
147
+ this.helloBytes = 0
148
+ this.helloReplayed = false
125
149
  }
126
150
  inflight() {
127
151
  return seqDiff(this.sndNxt, this.sndUna)
@@ -154,6 +178,9 @@ export class NetStack {
154
178
  this.peers = o.peers || null // W56: the switch's guests (peer.js)
155
179
  this.now = o.now || (() => performance.now())
156
180
  this.maxConns = o.maxConns ?? 256
181
+ this.tlsMax = o.tlsHandshakes ?? TLS_HANDSHAKES
182
+ this.tlsTurns = new Set() // D-552: handshakes with a turn
183
+ this.tlsWait = [] // and those waiting for one, oldest first
157
184
  this.log = o.log || (() => {})
158
185
  this.conns = new Map() // key -> Conn
159
186
  this.byId = new Map() // relay stream id -> Conn
@@ -473,6 +500,7 @@ export class NetStack {
473
500
  /** The relay's answer to a connect. */
474
501
  onConnected(id, code, credit, detail) {
475
502
  const c = this.byId.get(id)
503
+ if (c?.redialing) return this.#redialed(c, code, credit, detail)
476
504
  if (!c || c.phase !== P_CONNECTING) {
477
505
  if (!c && code === E.OK) this.relay.abort(id) // a connect that came back after we gave up on it
478
506
  return
@@ -494,7 +522,7 @@ export class NetStack {
494
522
  if (this.stats.connectMs.length > 100) this.stats.connectMs.shift()
495
523
  c.relayCredit = credit
496
524
  c.phase = P_SYNACK
497
- c.synAckAt = this.now()
525
+ c.synAckAt = c.dialAt = this.now()
498
526
  c.sndNxt = seqAdd(c.iss, 1)
499
527
  this.#synAck(c)
500
528
  }
@@ -615,6 +643,8 @@ export class NetStack {
615
643
 
616
644
  /** Send held guest bytes to the relay as credit allows, in messages of up to MAX_DATA (one per segment cost a WebSocket message each). */
617
645
  #flushToRelay(c) {
646
+ if (c.redialing) return // D-551: the new stream has no answer yet; #redialed flushes
647
+ if (c.tls !== T_DONE && c.via === this.relay && !this.#tlsGate(c)) return
618
648
  while (c.toRelay.length && c.relayCredit > 0) {
619
649
  const n = Math.min(c.toRelayBytes, c.relayCredit, MAX_DATA)
620
650
  let part
@@ -637,6 +667,13 @@ export class NetStack {
637
667
  }
638
668
  c.toRelayBytes -= n
639
669
  c.relayCredit -= n
670
+ if (c.tls === T_TURN && !c.bytesOut && c.hello) {
671
+ // past the cap the hello is not kept at all (a replay with a gap would be garbage)
672
+ if (c.helloBytes + n <= TLS_HELLO_MAX) {
673
+ c.hello.push(part.slice())
674
+ c.helloBytes += n
675
+ } else c.hello = null
676
+ }
640
677
  c.via.data(c.id, part)
641
678
  }
642
679
  if (c.guestFin && !c.toRelay.length && !c.relayEndSent) {
@@ -676,16 +713,119 @@ export class NetStack {
676
713
  onRelayEnd(id) {
677
714
  const c = this.byId.get(id)
678
715
  if (!c) return
716
+ if (this.#redial(c) || this.#tlsReplay(c)) return
679
717
  c.remoteEnd = true
680
718
  c.lastRemote = this.now()
681
719
  this.wantsSend.add(c)
682
720
  this.#trySend(c)
683
721
  }
684
722
 
723
+ /** D-551: the remote ended a stream nothing has crossed, a while after it opened: dial it again, once, and say nothing to the guest. False when it does not qualify. */
724
+ #redial(c) {
725
+ if (c.redialAt || c.via !== this.relay || c.phase !== P_OPEN || c.bytesOut || c.bytesIn - c.toRelayBytes || c.guestFin) return false
726
+ if (this.now() - c.dialAt < REDIAL_MIN_MS) return false
727
+ this.stats.redials = (this.stats.redials || 0) + 1
728
+ this.#dialAgain(c, 'ended unused')
729
+ return true
730
+ }
731
+
732
+ /** A new relay stream for c, the old one aborted; the guest hears nothing. Its bytes wait in toRelay until #redialed. */
733
+ #dialAgain(c, why) {
734
+ this.relay.abort(c.id)
735
+ this.byId.delete(c.id)
736
+ c.id = this.nextId++
737
+ this.byId.set(c.id, c)
738
+ c.relayCredit = 0
739
+ c.redialing = true
740
+ c.redialAt = this.now()
741
+ this.#event({ ev: 'redial', why, to: `${u32ToIp(c.rip)}:${c.rport}`, afterMs: Math.round(c.redialAt - c.dialAt) })
742
+ this.relay.connect(c.id, u32ToIp(c.rip), c.rport)
743
+ }
744
+
745
+ /** D-552: may the guest's bytes go to the relay now? Sorts a stream's first bytes into TLS or not, and holds a ClientHello until it has a turn. */
746
+ #tlsGate(c) {
747
+ if (c.tls === T_TURN) {
748
+ // the guest's bytes after the server's first flight end its part of the handshake
749
+ if (c.bytesOut && c.toRelayBytes) this.#tlsDone(c)
750
+ return true
751
+ }
752
+ if (c.tls === T_WAIT) return false
753
+ if (!c.toRelayBytes) return true
754
+ const h = c.toRelay[0]
755
+ if (c.bytesOut || h[0] !== 0x16 || (h.length > 1 && h[1] !== 0x03)) {
756
+ c.tls = T_DONE // the remote spoke first, or not a TLS handshake record
757
+ return true
758
+ }
759
+ if (this.tlsTurns.size >= this.tlsMax) {
760
+ c.tls = T_WAIT
761
+ this.tlsWait.push(c)
762
+ this.stats.tlsWaits = (this.stats.tlsWaits || 0) + 1
763
+ return false
764
+ }
765
+ return this.#tlsStart(c)
766
+ }
767
+
768
+ /** The handshake gets a turn. False when its stream is dialled again first (#redialed sends the hello). */
769
+ #tlsStart(c) {
770
+ c.tls = T_TURN
771
+ c.tlsAt = this.now()
772
+ c.hello = []
773
+ c.helloBytes = 0
774
+ this.tlsTurns.add(c)
775
+ if (!c.redialing && c.phase === P_OPEN && c.bytesIn === c.toRelayBytes && this.now() - c.dialAt >= TLS_FRESH_MS) {
776
+ this.stats.tlsFresh = (this.stats.tlsFresh || 0) + 1
777
+ this.#dialAgain(c, 'fresh for a ClientHello')
778
+ return false
779
+ }
780
+ return true
781
+ }
782
+
783
+ #tlsDone(c) {
784
+ c.tls = T_DONE
785
+ c.hello = null
786
+ if (this.tlsTurns.delete(c)) this.#tlsNext()
787
+ }
788
+
789
+ /** Turns free: the oldest waiting hellos go. */
790
+ #tlsNext() {
791
+ while (this.tlsTurns.size < this.tlsMax && this.tlsWait.length) {
792
+ const c = this.tlsWait.shift()
793
+ if (c.tls !== T_WAIT || this.conns.get(c.key) !== c) continue // closed, or its 4-tuple reused by a new connection
794
+ if (this.#tlsStart(c)) this.#flushToRelay(c)
795
+ }
796
+ }
797
+
798
+ /** D-552: the remote ended or reset a stream before it answered the guest's ClientHello: send the hello once more on a new stream. */
799
+ #tlsReplay(c) {
800
+ if (c.tls !== T_TURN || c.bytesOut || c.helloReplayed || !c.hello || !c.helloBytes || c.guestFin || c.via !== this.relay) return false
801
+ c.helloReplayed = true
802
+ c.toRelay.unshift(...c.hello)
803
+ c.toRelayBytes += c.helloBytes
804
+ c.hello = []
805
+ c.helloBytes = 0
806
+ this.stats.tlsReplays = (this.stats.tlsReplays || 0) + 1
807
+ this.#dialAgain(c, 'replay the ClientHello')
808
+ return true
809
+ }
810
+
811
+ #redialed(c, code, credit, detail) {
812
+ c.redialing = false
813
+ if (code !== E.OK) return this.#close(c, `redial: ${E_NAMES[code] || code}${detail ? ` (${detail})` : ''}`, false)
814
+ c.dialAt = this.now()
815
+ c.relayCredit = credit
816
+ this.#flushToRelay(c)
817
+ if (this.#rcvWnd(c) > c.lastWndSent) {
818
+ c.needAck = true
819
+ this.acks.add(c)
820
+ }
821
+ }
822
+
685
823
  onRelayAbort(id, code, reason) {
686
824
  const c = this.byId.get(id)
687
825
  if (!c) return
826
+ if (c.redialing) return this.#redialed(c, code || E.GONE, 0, reason)
688
827
  if (c.phase === P_CONNECTING) return this.onConnected(id, code || E.GONE, 0, reason)
828
+ if (this.#tlsReplay(c)) return
689
829
  this.#event({ ev: 'abort', to: `${u32ToIp(c.rip)}:${c.rport}`, why: E_NAMES[code] || code, reason })
690
830
  this.#close(c, `relay: ${E_NAMES[code] || code} ${reason || ''}`.trim(), false)
691
831
  }
@@ -765,6 +905,12 @@ export class NetStack {
765
905
  this.byId.delete(c.id)
766
906
  this.wantsSend.delete(c)
767
907
  this.acks.delete(c)
908
+ if (c.tls === T_TURN) this.#tlsDone(c)
909
+ else if (c.tls === T_WAIT) {
910
+ c.tls = T_DONE
911
+ const i = this.tlsWait.indexOf(c)
912
+ if (i >= 0) this.tlsWait.splice(i, 1)
913
+ }
768
914
  }
769
915
 
770
916
  /** After a batch of input: one ACK per connection that needs one. */
@@ -782,6 +928,12 @@ export class NetStack {
782
928
  tick() {
783
929
  const t = this.now()
784
930
  for (const c of [...this.conns.values()]) {
931
+ if (c.redialing && t - c.redialAt > CONNECT_TIMEOUT_MS) {
932
+ this.relay.abort(c.id)
933
+ this.#redialed(c, E.TIMEOUT, 0, 'no answer from the relay')
934
+ continue
935
+ }
936
+ if (c.tls === T_TURN && t - c.tlsAt > TLS_TURN_MS) this.#tlsDone(c) // a slow handshake goes on without its turn
785
937
  if (c.phase === P_CONNECTING) {
786
938
  if (t - c.openedAt > CONNECT_TIMEOUT_MS) {
787
939
  c.via.abort(c.id)
@@ -11,6 +11,7 @@
11
11
  // copy is the layer handed to the converter Worker); a Blob is read one entry at a time, so the whole tar is
12
12
  // never in memory at once.
13
13
 
14
+ import { blobBytes } from '../blob.js'
14
15
  import { createConverter } from './converter.js'
15
16
  import { RegistryError, sha256 } from './hub.js'
16
17
  import { CONVERTER } from './pull.js'
@@ -126,7 +127,7 @@ function source(input) {
126
127
  if (input instanceof Uint8Array) return { size: input.length, read: async (off, len) => input.subarray(off, off + len) }
127
128
  if (input instanceof ArrayBuffer) return source(new Uint8Array(input))
128
129
  if (typeof Blob !== 'undefined' && input instanceof Blob) {
129
- return { size: input.size, read: async (off, len) => new Uint8Array(await input.slice(off, off + len).arrayBuffer()) }
130
+ return { size: input.size, read: (off, len) => blobBytes(input.slice(off, off + len)) }
130
131
  }
131
132
  throw new TypeError('loadImageTar: the tar as a Uint8Array, ArrayBuffer or Blob')
132
133
  }
package/wmdpack.wasm CHANGED
Binary file