@sproutboat/toolchain 0.4.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @sproutboat/toolchain
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 969add9: Fix (baronunread/sproutboat#168): a `fetch` handler that resolves a promise
8
+ with a plain object — the common shape for a JSON response — could wedge a
9
+ standalone/native-fetch build at 100% CPU after a handful of requests, with no
10
+ error and no log line. Root cause: `__Porffor_promise_resolve`'s duck-typing
11
+ probe for `.then` walks the value's prototype chain with no termination
12
+ guard, and under certain allocator conditions the walk hits a fixed point
13
+ instead of `null`/`undefined`, spinning forever.
14
+
15
+ `patchPromiseTs` now guards the probe the same way `_internal_object.ts`'s own
16
+ prototype-chain walks already do elsewhere in the runtime: track the previous
17
+ prototype and stop once the "next" pointer stops advancing, reading a
18
+ fixed-point chain as "no `.then` found" instead of spinning. Not specific to
19
+ `Date.prototype.toISOString()` — an earlier note wrongly correlated the bug
20
+ with that one call; see `sproutboat-cli/patches/UPSTREAM.md`.
21
+
3
22
  ## 0.4.0
4
23
 
5
24
  ### Minor Changes
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@sproutboat/toolchain",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/baronunread/sproutboat-packages.git",
9
+ "directory": "packages/toolchain"
10
+ },
6
11
  "main": "./src/index.ts",
7
12
  "types": "./src/index.ts",
8
13
  "exports": {
@@ -94,6 +94,21 @@ async function fixture(): Promise<{ archive: string; sha256: string }> {
94
94
  "\n" +
95
95
  " if (body_owned) free(body_owned);\n" +
96
96
  "}\n",
97
+ "compiler/builtins/promise.ts":
98
+ "export const __Porffor_promise_resolve = (value: any, promise: any): void => {\n" +
99
+ " if (Porffor.type(value) == Porffor.TYPES.object) {\n" +
100
+ " const thenHash: i32 = __Porffor_object_hash('then');\n" +
101
+ " let probe: any = value;\n" +
102
+ " while (Porffor.type(probe) == Porffor.TYPES.object) {\n" +
103
+ " if (Porffor.object.lookup(probe, 'then', thenHash) != 0) break;\n" +
104
+ " probe = __Porffor_object_getPrototype(probe);\n" +
105
+ " }\n" +
106
+ " if (Porffor.type(probe) != Porffor.TYPES.object) {\n" +
107
+ " __ecma262_FulfillPromise(promise, value);\n" +
108
+ " return;\n" +
109
+ " }\n" +
110
+ " }\n" +
111
+ "};\n",
97
112
  };
98
113
  for (const [file, contents] of Object.entries(files)) {
99
114
  const path = join(source, file);
package/src/patch.test.ts CHANGED
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { patchRenderJs, patchUwebsockets } from "./patch";
5
+ import { patchPromiseTs, patchRenderJs, patchUwebsockets } from "./patch";
6
6
 
7
7
  // The parts of Porffor's compiler/render.js the patch anchors to.
8
8
  const RENDER = `void porf_native_fetch_runtime_init(void) {
@@ -240,3 +240,60 @@ test("#165: render.js routes console output to stderr, unbuffered, idempotently"
240
240
  await rm(root, { recursive: true, force: true });
241
241
  }
242
242
  });
243
+
244
+ // The part of Porffor's compiler/builtins/promise.ts the #168 patch anchors to.
245
+ const PROMISE_TS = `export const __Porffor_promise_resolve = (value: any, promise: any): void => {
246
+ if (Porffor.type(value) == Porffor.TYPES.object) {
247
+ // cheap prototype-chain probe for 'then' before the expensive Get below, does not invoke getters
248
+ const thenHash: i32 = __Porffor_object_hash('then');
249
+ let probe: any = value;
250
+ while (Porffor.type(probe) == Porffor.TYPES.object) {
251
+ if (Porffor.object.lookup(probe, 'then', thenHash) != 0) break;
252
+ probe = __Porffor_object_getPrototype(probe);
253
+ }
254
+ if (Porffor.type(probe) != Porffor.TYPES.object) {
255
+ __ecma262_FulfillPromise(promise, value);
256
+ return;
257
+ }
258
+ }
259
+ };
260
+ `;
261
+
262
+ test("#168: promise-resolve's then-probe gets a fixed-point guard, idempotently", async () => {
263
+ const root = await mkdtemp(join(tmpdir(), "sb-patch-promise-"));
264
+ try {
265
+ await mkdir(join(root, "compiler/builtins"), { recursive: true });
266
+ await writeFile(join(root, "compiler/builtins/promise.ts"), PROMISE_TS);
267
+ await patchPromiseTs(root);
268
+ const once = await readFile(join(root, "compiler/builtins/promise.ts"), "utf8");
269
+
270
+ expect(once).toContain("sproutboat #168");
271
+ expect(once).toContain("let probeFound: boolean = false;");
272
+ expect(once).toContain("if (Porffor.object.lookup(probe, 'then', thenHash) != 0) { probeFound = true; break; }");
273
+ // The fixed-point guard mirrors _internal_object.ts's lastProto idiom.
274
+ expect(once).toContain("Porffor.fastOr(probe == null, Porffor.IR.ptr(probe) == Porffor.IR.ptr(lastProto))");
275
+ expect(once).toContain("if (!probeFound) {");
276
+ // Belt-and-suspenders: a hard iteration cap terminates the loop even if
277
+ // some other corruption shape (a drifting pointer, a longer cycle) defeats
278
+ // the fixed-point check above.
279
+ expect(once).toContain("if (probeSteps > 64) break;");
280
+ // The old type-check-only exit condition is gone.
281
+ expect(once).not.toContain("if (Porffor.type(probe) != Porffor.TYPES.object) {");
282
+
283
+ await patchPromiseTs(root);
284
+ expect(await readFile(join(root, "compiler/builtins/promise.ts"), "utf8")).toBe(once);
285
+ } finally {
286
+ await rm(root, { recursive: true, force: true });
287
+ }
288
+ });
289
+
290
+ test("#168: a drifted promise builtin fails loudly instead of silently no-op'ing", async () => {
291
+ const root = await mkdtemp(join(tmpdir(), "sb-patch-promise-drift-"));
292
+ try {
293
+ await mkdir(join(root, "compiler/builtins"), { recursive: true });
294
+ await writeFile(join(root, "compiler/builtins/promise.ts"), "// nothing the patch recognizes\n");
295
+ await expect(patchPromiseTs(root)).rejects.toThrow(/anchor not found/);
296
+ } finally {
297
+ await rm(root, { recursive: true, force: true });
298
+ }
299
+ });
package/src/patch.ts CHANGED
@@ -410,6 +410,75 @@ const WRITE_RESPONSE_INJECT =
410
410
  "}";
411
411
  const WRITE_RESPONSE_MARKER = "porf_native_fetch_read_raw_bytes(body_value";
412
412
 
413
+ /**
414
+ * baronunread/sproutboat#168 — `__Porffor_promise_resolve`'s duck-typing probe
415
+ * for `.then` walks a value's prototype chain with no termination guard. Under
416
+ * certain allocator conditions `__Porffor_object_getPrototype` returns a stale
417
+ * "object" jsval instead of the `null`/`undefined` that should end the chain,
418
+ * so the walk never bottoms out — a true infinite spin (100% CPU, no error, no
419
+ * log line) that wedges the whole process, since native-fetch serves one
420
+ * request at a time on a single event loop. Hits any handler whose `fetch`
421
+ * resolves a promise with a plain object — the common shape for a JSON
422
+ * response — so this is not a rare pattern; onset is nondeterministic
423
+ * (allocator/pool reuse), typically after a handful of requests.
424
+ *
425
+ * `_internal_object.ts`'s own prototype-chain walks already guard against
426
+ * exactly this failure mode: track the previous prototype and stop once the
427
+ * "next" pointer stops advancing (a fixed point reads the same as reaching the
428
+ * end). This probe has no such guard. Mirror the same idiom here: an explicit
429
+ * `probeFound` flag (so a fixed-point break reads as "no `.then` found",
430
+ * matching how a legitimate null-terminated chain is read) plus the
431
+ * `lastProto` pointer comparison used everywhere else in the runtime.
432
+ *
433
+ * A hard iteration cap rides alongside the fixed-point check, not instead of
434
+ * it: the lldb trace showed the *same* stale pointer on every sample, which
435
+ * the fixed-point check alone catches on its very next iteration, but that's
436
+ * one observed corruption shape, not a guarantee about every allocator state
437
+ * that can produce this bug. A cap makes termination unconditional regardless
438
+ * of whether some other corrupted state instead drifts (a different bad
439
+ * pointer each time) or cycles across more than one node — real prototype
440
+ * chains are a handful of links deep, so 64 iterations is generous headroom
441
+ * with no risk of cutting off a legitimate lookup.
442
+ */
443
+ const THEN_PROBE_ANCHOR =
444
+ " const thenHash: i32 = __Porffor_object_hash('then');\n" +
445
+ " let probe: any = value;\n" +
446
+ " while (Porffor.type(probe) == Porffor.TYPES.object) {\n" +
447
+ " if (Porffor.object.lookup(probe, 'then', thenHash) != 0) break;\n" +
448
+ " probe = __Porffor_object_getPrototype(probe);\n" +
449
+ " }\n" +
450
+ " if (Porffor.type(probe) != Porffor.TYPES.object) {\n";
451
+ const THEN_PROBE_INJECT =
452
+ " const thenHash: i32 = __Porffor_object_hash('then');\n" +
453
+ " let probe: any = value;\n" +
454
+ " let lastProto: any = probe;\n" +
455
+ " let probeFound: boolean = false; // sproutboat #168: fixed-point chain reads as \"not found\", not a spin\n" +
456
+ " let probeSteps: i32 = 0; // sproutboat #168: hard cap, belt-and-suspenders alongside the fixed-point check\n" +
457
+ " while (Porffor.type(probe) == Porffor.TYPES.object) {\n" +
458
+ " if (Porffor.object.lookup(probe, 'then', thenHash) != 0) { probeFound = true; break; }\n" +
459
+ " probe = __Porffor_object_getPrototype(probe);\n" +
460
+ " if (Porffor.fastOr(probe == null, Porffor.IR.ptr(probe) == Porffor.IR.ptr(lastProto))) break;\n" +
461
+ " lastProto = probe;\n" +
462
+ " probeSteps++;\n" +
463
+ " if (probeSteps > 64) break;\n" +
464
+ " }\n" +
465
+ " if (!probeFound) {\n";
466
+ const THEN_PROBE_MARKER = "sproutboat #168";
467
+
468
+ /** The promise builtin: the #168 `.then`-probe livelock fix. Exported for tests. */
469
+ export async function patchPromiseTs(root: string): Promise<void> {
470
+ const file = resolve(root, "compiler/builtins/promise.ts");
471
+ let src = await readFile(file, "utf8");
472
+ if (src.includes(THEN_PROBE_MARKER)) return;
473
+ if (!src.includes(THEN_PROBE_ANCHOR)) {
474
+ throw new Error(
475
+ "could not patch Porffor's promise-resolve then-probe (#168): anchor not found in " +
476
+ `${file}. Porffor's promise builtin changed — check patches/UPSTREAM.md.`,
477
+ );
478
+ }
479
+ await writeFile(file, src.replace(THEN_PROBE_ANCHOR, THEN_PROBE_INJECT));
480
+ }
481
+
413
482
  const done = new Set<string>();
414
483
 
415
484
  /** Edits to Porffor's uWebSockets shim: `[marker, anchor, inject, what]`. */
@@ -520,5 +589,6 @@ export async function ensurePorfforPatched(root: string): Promise<void> {
520
589
  await patchCompilerArgs(root);
521
590
  await patchUwebsockets(root);
522
591
  await patchRenderJs(root);
592
+ await patchPromiseTs(root);
523
593
  done.add(root);
524
594
  }