@sproutboat/runtime 0.34.0 → 0.34.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 +6 -0
- package/package.json +1 -1
- package/src/entry-crash.test.js +53 -4
- package/src/native-fetch-prelude.js +31 -0
- package/test/native-unhandled-rejection.ts +133 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.34.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e62b72d: An unhandled promise rejection no longer ends the process. A D1 call that failed and was never awaited (`db.prepare("INSERT ...").run()` that broke a foreign key) threw its error out of Porffor's promise job loop into the host's "Uncaught ..." exit, taking every in-flight request down even with a try/catch around the call. As on Workers it is now reported: a log line (`Uncaught (in promise) ...`) and an `unhandledrejection` event (`PromiseRejectionEvent` with `promise` and `reason`) on the global scope when `addEventListener("unhandledrejection", ...)` has a listener, and the process and other requests carry on. This applies at every compatibility date, because a crash is a bug rather than a behaviour an app can depend on. The toolchain's `patchUnhandledRejections` calls the runtime's `globalThis.__sbOnUnhandledRejection`; plain Porffor without it keeps the throw. Applies to every binding call that returns a rejecting Promise (D1, KV, R2, Durable Objects, Queues, fetch). An un-awaited `async function` that throws is reported the same way (it used to be dropped silently): the C runtime that rejects it now hands the promise to the same check.
|
|
8
|
+
|
|
3
9
|
## 0.34.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
package/src/entry-crash.test.js
CHANGED
|
@@ -18,10 +18,7 @@ const load = new Function(
|
|
|
18
18
|
// Every Porffor.c`...` block in the prelude only runs inside a function body
|
|
19
19
|
// (never at load time), so a no-op tag covers all of them: __sbCpuMs and
|
|
20
20
|
// __sbEnv just see an empty result, same as "native op returned nothing."
|
|
21
|
-
const H = load(
|
|
22
|
-
{ c: () => "" },
|
|
23
|
-
new Proxy({}, { get: () => undefined }),
|
|
24
|
-
);
|
|
21
|
+
const H = load({ c: () => "" }, new Proxy({}, { get: () => undefined }));
|
|
25
22
|
|
|
26
23
|
const req = (overrides = {}) => ({
|
|
27
24
|
headers: { get: (k) => (overrides.headers && overrides.headers[k.toLowerCase()]) ?? null },
|
|
@@ -61,3 +58,55 @@ test("a throwing fetch handler doesn't stop the next request from being served",
|
|
|
61
58
|
const second = await H.__sbEntry(handlers, req({ boom: false }));
|
|
62
59
|
expect(await second.text()).toBe("ok");
|
|
63
60
|
});
|
|
61
|
+
|
|
62
|
+
// A promise that rejects with nobody waiting on it (an un-awaited D1 run() that broke a
|
|
63
|
+
// foreign key) must be reported, not end the process: Porffor's promise job loop calls
|
|
64
|
+
// globalThis.__sbOnUnhandledRejection (toolchain patchUnhandledRejections).
|
|
65
|
+
test("an unhandled rejection is logged and fires an unhandledrejection event", () => {
|
|
66
|
+
const logged = [];
|
|
67
|
+
const consoleError = console.error;
|
|
68
|
+
const dispatch = globalThis.dispatchEvent;
|
|
69
|
+
const Ctor = globalThis.PromiseRejectionEvent;
|
|
70
|
+
const events = [];
|
|
71
|
+
console.error = (line) => logged.push(String(line));
|
|
72
|
+
globalThis.PromiseRejectionEvent = class {
|
|
73
|
+
constructor(type, init) {
|
|
74
|
+
this.type = type;
|
|
75
|
+
this.reason = init.reason;
|
|
76
|
+
this.promise = init.promise;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
globalThis.dispatchEvent = (event) => events.push(event);
|
|
80
|
+
try {
|
|
81
|
+
const promise = Promise.reject(new Error("D1_ERROR: FOREIGN KEY constraint failed"));
|
|
82
|
+
promise.catch(() => {});
|
|
83
|
+
globalThis.__sbOnUnhandledRejection(new Error("D1_ERROR: FOREIGN KEY constraint failed"), promise);
|
|
84
|
+
} finally {
|
|
85
|
+
console.error = consoleError;
|
|
86
|
+
globalThis.dispatchEvent = dispatch;
|
|
87
|
+
globalThis.PromiseRejectionEvent = Ctor;
|
|
88
|
+
}
|
|
89
|
+
expect(logged.join("\n")).toContain("Uncaught (in promise)");
|
|
90
|
+
expect(logged.join("\n")).toContain("FOREIGN KEY");
|
|
91
|
+
expect(events).toHaveLength(1);
|
|
92
|
+
expect(events[0].type).toBe("unhandledrejection");
|
|
93
|
+
expect(events[0].reason.message).toContain("FOREIGN KEY");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a throwing unhandledrejection listener does not escape the hook", () => {
|
|
97
|
+
const consoleError = console.error;
|
|
98
|
+
const dispatch = globalThis.dispatchEvent;
|
|
99
|
+
const Ctor = globalThis.PromiseRejectionEvent;
|
|
100
|
+
console.error = () => {};
|
|
101
|
+
globalThis.PromiseRejectionEvent = class {};
|
|
102
|
+
globalThis.dispatchEvent = () => {
|
|
103
|
+
throw new Error("listener broke");
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
expect(() => globalThis.__sbOnUnhandledRejection(new Error("x"), undefined)).not.toThrow();
|
|
107
|
+
} finally {
|
|
108
|
+
console.error = consoleError;
|
|
109
|
+
globalThis.dispatchEvent = dispatch;
|
|
110
|
+
globalThis.PromiseRejectionEvent = Ctor;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -3789,6 +3789,22 @@ if (typeof globalThis.EventTarget !== "function") {
|
|
|
3789
3789
|
globalThis.EventTarget = EventTarget;
|
|
3790
3790
|
globalThis.AbortSignal = AbortSignal;
|
|
3791
3791
|
globalThis.AbortController = AbortController;
|
|
3792
|
+
// Workers' global event target: `addEventListener("unhandledrejection", fn)` hears every
|
|
3793
|
+
// promise that rejected with nobody waiting on it (see __sbOnUnhandledRejection).
|
|
3794
|
+
class PromiseRejectionEvent extends Event {
|
|
3795
|
+
constructor(type, init) {
|
|
3796
|
+
super(type, init);
|
|
3797
|
+
this.promise = init ? init.promise : undefined;
|
|
3798
|
+
this.reason = init ? init.reason : undefined;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
const globalTarget = new EventTarget();
|
|
3802
|
+
globalThis.PromiseRejectionEvent = PromiseRejectionEvent;
|
|
3803
|
+
if (typeof globalThis.addEventListener !== "function") {
|
|
3804
|
+
globalThis.addEventListener = (type, listener, options) => globalTarget.addEventListener(type, listener, options);
|
|
3805
|
+
globalThis.removeEventListener = (type, listener, options) => globalTarget.removeEventListener(type, listener, options);
|
|
3806
|
+
globalThis.dispatchEvent = (event) => globalTarget.dispatchEvent(event);
|
|
3807
|
+
}
|
|
3792
3808
|
}
|
|
3793
3809
|
|
|
3794
3810
|
// TextEncoderStream and TextDecoderStream over the Web Streams ponyfill (W2 conformance).
|
|
@@ -8983,6 +8999,21 @@ function __sbLogUncaught(where, error) {
|
|
|
8983
8999
|
}
|
|
8984
9000
|
}
|
|
8985
9001
|
|
|
9002
|
+
// A promise rejected and nothing was waiting on it (an un-awaited `db.prepare(...).run()`
|
|
9003
|
+
// that failed, say). As on Workers the request, the process and every other request go on:
|
|
9004
|
+
// the rejection is logged, and an "unhandledrejection" event fires on the global scope when
|
|
9005
|
+
// something listens. Porffor's promise job loop calls this hook (patchUnhandledRejections);
|
|
9006
|
+
// without it, it throws the reason out of the event loop and the process exits.
|
|
9007
|
+
globalThis.__sbOnUnhandledRejection = function (reason, promise) {
|
|
9008
|
+
__sbLogUncaught("promise", reason);
|
|
9009
|
+
try {
|
|
9010
|
+
if (__sbIsFn(globalThis.PromiseRejectionEvent) && __sbIsFn(globalThis.dispatchEvent))
|
|
9011
|
+
globalThis.dispatchEvent(new globalThis.PromiseRejectionEvent("unhandledrejection", { promise, reason, cancelable: true }));
|
|
9012
|
+
} catch (error) {
|
|
9013
|
+
__sbLogUncaught("unhandledrejection listener", error);
|
|
9014
|
+
}
|
|
9015
|
+
};
|
|
9016
|
+
|
|
8986
9017
|
// Shared by alarm and scheduled: both reply with an empty 204 once their
|
|
8987
9018
|
// handler (and anything it queued via `waitUntil`) has settled.
|
|
8988
9019
|
async function __sb204WithDrain(res, tasks) {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// A promise that rejects with nobody waiting on it (a binding call that failed and was never
|
|
9
|
+
// awaited, say a D1 run() that broke a foreign key) must not take the process down, whatever
|
|
10
|
+
// the compatibility date. As on Workers it is logged, an "unhandledrejection" listener hears
|
|
11
|
+
// it, and the next request is served.
|
|
12
|
+
const handler = `addEventListener("unhandledrejection", (event) => {
|
|
13
|
+
globalThis.__heard = (globalThis.__heard || []).concat(String(event.reason && event.reason.message));
|
|
14
|
+
});
|
|
15
|
+
export default {async fetch(request) {
|
|
16
|
+
const path = new URL(request.url).pathname;
|
|
17
|
+
if (path === "/reject") {
|
|
18
|
+
try {
|
|
19
|
+
Promise.reject(new Error("D1_ERROR: FOREIGN KEY constraint failed"));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return new Response("caught", { status: 500 });
|
|
22
|
+
}
|
|
23
|
+
return new Response("fired");
|
|
24
|
+
}
|
|
25
|
+
if (path === "/promise") {
|
|
26
|
+
try {
|
|
27
|
+
new Promise(function (resolve, reject) { reject(new Error("kv failed")); });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return new Response("caught", { status: 500 });
|
|
30
|
+
}
|
|
31
|
+
return new Response("fired");
|
|
32
|
+
}
|
|
33
|
+
if (path === "/async") {
|
|
34
|
+
async function failNow() { throw new Error("async function failed"); }
|
|
35
|
+
failNow();
|
|
36
|
+
return new Response("fired");
|
|
37
|
+
}
|
|
38
|
+
if (path === "/heard") return new Response(JSON.stringify(globalThis.__heard || []));
|
|
39
|
+
return new Response("pong");
|
|
40
|
+
}};`;
|
|
41
|
+
|
|
42
|
+
function freePort(): number {
|
|
43
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
44
|
+
const port = listener.port;
|
|
45
|
+
listener.stop(true);
|
|
46
|
+
return port;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const date of ["2026-09-28", "2026-10-24"]) {
|
|
50
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-unhandled-"));
|
|
51
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
52
|
+
try {
|
|
53
|
+
const porffor = await ensurePorffor();
|
|
54
|
+
const node = Bun.which("node");
|
|
55
|
+
if (!node) throw new Error("native unhandled rejection test needs node on PATH");
|
|
56
|
+
const [core, broker] = await Promise.all([
|
|
57
|
+
readFile(preludePath, "utf8"),
|
|
58
|
+
readFile(transportPath("broker"), "utf8"),
|
|
59
|
+
]);
|
|
60
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
61
|
+
const generated = join(workdir, "unhandled.generated.js");
|
|
62
|
+
const binary = join(workdir, "unhandled-native");
|
|
63
|
+
await writeFile(
|
|
64
|
+
generated,
|
|
65
|
+
wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), undefined, undefined, undefined, date),
|
|
66
|
+
);
|
|
67
|
+
const compile = Bun.spawn(
|
|
68
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
69
|
+
{
|
|
70
|
+
cwd: workdir,
|
|
71
|
+
env: process.env,
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
stderr: "pipe",
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
77
|
+
compile.exited,
|
|
78
|
+
new Response(compile.stdout).text(),
|
|
79
|
+
new Response(compile.stderr).text(),
|
|
80
|
+
]);
|
|
81
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
82
|
+
|
|
83
|
+
const port = freePort();
|
|
84
|
+
server = Bun.spawn([binary], {
|
|
85
|
+
cwd: workdir,
|
|
86
|
+
env: { ...process.env, PORT: String(port), SB_BROKER_TOKEN: "native-test-token" },
|
|
87
|
+
stdout: "ignore",
|
|
88
|
+
stderr: "pipe",
|
|
89
|
+
});
|
|
90
|
+
let ready = false;
|
|
91
|
+
for (let attempt = 0; attempt < 50 && !ready; attempt++) {
|
|
92
|
+
try {
|
|
93
|
+
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
|
|
94
|
+
ready = true;
|
|
95
|
+
} catch {
|
|
96
|
+
if (server.exitCode !== null) break;
|
|
97
|
+
await Bun.sleep(100);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
assert(ready, `compiled handler did not start (${date})`);
|
|
101
|
+
const get = async (path: string) => (await fetch(`http://127.0.0.1:${port}${path}`)).text();
|
|
102
|
+
|
|
103
|
+
assert.equal(await get("/reject"), "fired");
|
|
104
|
+
assert.equal(await get("/pong"), "pong", `the request after an unhandled rejection failed (${date})`);
|
|
105
|
+
assert.equal(await get("/promise"), "fired");
|
|
106
|
+
assert.equal(await get("/async"), "fired");
|
|
107
|
+
await Bun.sleep(300);
|
|
108
|
+
assert.equal(await get("/pong"), "pong", `the request after a rejected promise failed (${date})`);
|
|
109
|
+
assert.equal(server.exitCode, null, `the server exited on an unhandled rejection (${date})`);
|
|
110
|
+
assert.equal(await get("/pong"), "pong", `the request after an unawaited async function threw failed (${date})`);
|
|
111
|
+
const heard = JSON.parse(await get("/heard"));
|
|
112
|
+
assert(
|
|
113
|
+
heard.some((message: string) => message.includes("FOREIGN KEY")),
|
|
114
|
+
`no unhandledrejection event for the rejected promise (${date}): ${JSON.stringify(heard)}`,
|
|
115
|
+
);
|
|
116
|
+
assert(
|
|
117
|
+
heard.some((message: string) => message.includes("async function failed")),
|
|
118
|
+
`no unhandledrejection event for the async function (${date}): ${JSON.stringify(heard)}`,
|
|
119
|
+
);
|
|
120
|
+
server.kill(9);
|
|
121
|
+
await server.exited;
|
|
122
|
+
const log = await new Response(server.stderr).text();
|
|
123
|
+
assert(log.includes("Uncaught (in promise)"), `the rejection was not logged (${date}): ${log}`);
|
|
124
|
+
server = undefined;
|
|
125
|
+
console.log(`Native unhandled rejections are reported, not fatal (${date})`);
|
|
126
|
+
} finally {
|
|
127
|
+
if (server) {
|
|
128
|
+
server.kill(9);
|
|
129
|
+
await server.exited;
|
|
130
|
+
}
|
|
131
|
+
await rm(workdir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|