@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 +19 -0
- package/package.json +6 -1
- package/src/acquire.test.ts +15 -0
- package/src/patch.test.ts +58 -1
- package/src/patch.ts +70 -0
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.
|
|
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": {
|
package/src/acquire.test.ts
CHANGED
|
@@ -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
|
}
|