@sproutboat/runtime 0.6.5 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/entry-crash.test.js +63 -0
- package/src/native-fetch-prelude.js +55 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.6.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c2b0359: Fix (baronunread/sproutboat#179): `__sbEntry` called `handlers.fetch()` (and
|
|
8
|
+
the `scheduled`/`queue`/`alarm` trigger paths) with no try/catch at all. Any
|
|
9
|
+
synchronous throw, or an async handler's rejected promise, propagated all the
|
|
10
|
+
way up and took down the entire process — not just the request that hit it,
|
|
11
|
+
but every other in-flight and future request on that sprout, until whatever
|
|
12
|
+
supervises it restarts the binary.
|
|
13
|
+
|
|
14
|
+
A throw from `fetch()` now returns a 500 instead. `scheduled()` and `alarm()`
|
|
15
|
+
still reply 204 either way (they had no failure signal before this either).
|
|
16
|
+
A throw from `queue()` falls through to the existing default-ack pass, the
|
|
17
|
+
same outcome as a handler that returns without calling `ack()`/`retry()` on
|
|
18
|
+
every message.
|
|
19
|
+
|
|
20
|
+
`entry-crash.test.js` covers the fetch path: a sync throw, an async
|
|
21
|
+
rejection, and that a throwing request doesn't stop the next one from being
|
|
22
|
+
served.
|
|
23
|
+
|
|
3
24
|
## 0.6.5
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// #179 — an uncaught handler exception must turn into a 500 (or, for
|
|
2
|
+
// scheduled/queue/alarm, a swallowed failure), never a process-crashing
|
|
3
|
+
// throw/rejection out of __sbEntry. Same lift-the-prelude-and-eval approach
|
|
4
|
+
// as client-ip.test.js: the prelude is prepended as text and cannot import.
|
|
5
|
+
import { expect, test } from "bun:test";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
|
|
10
|
+
|
|
11
|
+
// oxlint-disable-next-line no-function-constructor -- lifting the whole text-only prelude for test
|
|
12
|
+
const load = new Function(
|
|
13
|
+
"Porffor",
|
|
14
|
+
"__envMap",
|
|
15
|
+
`${src}
|
|
16
|
+
return { __sbEntry, __sbRegisterDO };`,
|
|
17
|
+
);
|
|
18
|
+
// Every Porffor.c`...` block in the prelude only runs inside a function body
|
|
19
|
+
// (never at load time), so a no-op tag covers all of them: __sbCpuMs and
|
|
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
|
+
);
|
|
25
|
+
|
|
26
|
+
const req = (overrides = {}) => ({
|
|
27
|
+
headers: { get: (k) => (overrides.headers && overrides.headers[k.toLowerCase()]) ?? null },
|
|
28
|
+
cf: undefined,
|
|
29
|
+
...overrides,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a synchronously-throwing fetch handler returns 500, not a throw", async () => {
|
|
33
|
+
const handlers = {
|
|
34
|
+
fetch() {
|
|
35
|
+
throw new Error("boom");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const res = await H.__sbEntry(handlers, req());
|
|
39
|
+
expect(res.status).toBe(500);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an async fetch handler whose promise rejects returns 500, not a rejection", async () => {
|
|
43
|
+
const handlers = {
|
|
44
|
+
async fetch() {
|
|
45
|
+
throw new Error("boom");
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const res = await H.__sbEntry(handlers, req());
|
|
49
|
+
expect(res.status).toBe(500);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a throwing fetch handler doesn't stop the next request from being served", async () => {
|
|
53
|
+
const handlers = {
|
|
54
|
+
fetch(request) {
|
|
55
|
+
if (request.boom) throw new Error("boom");
|
|
56
|
+
return new Response("ok");
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const first = await H.__sbEntry(handlers, req({ boom: true }));
|
|
60
|
+
expect(first.status).toBe(500);
|
|
61
|
+
const second = await H.__sbEntry(handlers, req({ boom: false }));
|
|
62
|
+
expect(await second.text()).toBe("ok");
|
|
63
|
+
});
|
|
@@ -1703,15 +1703,31 @@ async function __sbDrainAll(pending) {
|
|
|
1703
1703
|
// one *this* async function creates, not a `.then()` derived from someone
|
|
1704
1704
|
// else's — see the note below on why that distinction matters here.
|
|
1705
1705
|
async function __sbFetchWithDrain(res, tasks) {
|
|
1706
|
-
|
|
1706
|
+
let r;
|
|
1707
|
+
try {
|
|
1708
|
+
r = res && __sbIsFn(res.then) ? await res : res;
|
|
1709
|
+
} catch {
|
|
1710
|
+
// #179 — a rejected handler promise must not take the process down with it.
|
|
1711
|
+
r = __sbErrorResponse();
|
|
1712
|
+
}
|
|
1707
1713
|
await __sbDrainWaitUntil(tasks);
|
|
1708
1714
|
return r;
|
|
1709
1715
|
}
|
|
1710
1716
|
|
|
1717
|
+
// #179 — turn an uncaught handler exception into a 500 instead of letting it
|
|
1718
|
+
// propagate past __sbEntry and crash the whole server.
|
|
1719
|
+
function __sbErrorResponse() {
|
|
1720
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1711
1723
|
// Shared by alarm and scheduled: both reply with an empty 204 once their
|
|
1712
1724
|
// handler (and anything it queued via `waitUntil`) has settled.
|
|
1713
1725
|
async function __sb204WithDrain(res, tasks) {
|
|
1714
|
-
|
|
1726
|
+
try {
|
|
1727
|
+
if (res && __sbIsFn(res.then)) await res;
|
|
1728
|
+
} catch {
|
|
1729
|
+
// #179 — same rule as fetch: a rejected scheduled/alarm promise must not crash the process.
|
|
1730
|
+
}
|
|
1715
1731
|
await __sbDrainWaitUntil(tasks);
|
|
1716
1732
|
return new Response("", { status: 204 });
|
|
1717
1733
|
}
|
|
@@ -1746,7 +1762,14 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1746
1762
|
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1747
1763
|
},
|
|
1748
1764
|
};
|
|
1749
|
-
|
|
1765
|
+
let __res;
|
|
1766
|
+
try {
|
|
1767
|
+
__res = handlers.fetch(request, ctx);
|
|
1768
|
+
} catch {
|
|
1769
|
+
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
1770
|
+
// whole process (and every other in-flight request) down with it.
|
|
1771
|
+
return __sbErrorResponse();
|
|
1772
|
+
}
|
|
1750
1773
|
// Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
|
|
1751
1774
|
// own return, which is why it's tail-called rather than chained on here.
|
|
1752
1775
|
if (__sbTasks.length || (__res && __sbIsFn(__res.then))) return __sbFetchWithDrain(__res, __sbTasks);
|
|
@@ -1766,10 +1789,16 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1766
1789
|
if (p && __sbIsFn(p.then)) __sbTasks.push(p);
|
|
1767
1790
|
},
|
|
1768
1791
|
};
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1792
|
+
let __sres;
|
|
1793
|
+
try {
|
|
1794
|
+
__sres = handlers.scheduled(
|
|
1795
|
+
{ cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
|
|
1796
|
+
ctx,
|
|
1797
|
+
);
|
|
1798
|
+
} catch {
|
|
1799
|
+
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
1800
|
+
return new Response("", { status: 204 });
|
|
1801
|
+
}
|
|
1773
1802
|
if (__sbTasks.length || (__sres && __sbIsFn(__sres.then))) return __sb204WithDrain(__sres, __sbTasks);
|
|
1774
1803
|
return new Response("", { status: 204 });
|
|
1775
1804
|
}
|
|
@@ -1797,7 +1826,13 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
1797
1826
|
const body = __sbReadJson(request);
|
|
1798
1827
|
const inst = __sbGetDOInstance(String(body.cls || ""), String(body.id || ""));
|
|
1799
1828
|
if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
|
|
1800
|
-
|
|
1829
|
+
let __ares;
|
|
1830
|
+
try {
|
|
1831
|
+
__ares = inst.alarm();
|
|
1832
|
+
} catch {
|
|
1833
|
+
// #179 — same rule as fetch: don't let a throw here crash the process.
|
|
1834
|
+
return new Response("", { status: 204 });
|
|
1835
|
+
}
|
|
1801
1836
|
// Was fire-and-forget before: an async `alarm()` and any `state.waitUntil()`
|
|
1802
1837
|
// it queued were both dropped the moment 204 went out. Await both.
|
|
1803
1838
|
if (inst.__sbTasks.length || (__ares && __sbIsFn(__ares.then))) return __sb204WithDrain(__ares, inst.__sbTasks);
|
|
@@ -1845,11 +1880,18 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
|
|
|
1845
1880
|
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
1846
1881
|
},
|
|
1847
1882
|
};
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1883
|
+
try {
|
|
1884
|
+
const __qres = handlers.queue(batch, ctx);
|
|
1885
|
+
// Was fire-and-forget: an async `queue()` had this default-ack pass run (and
|
|
1886
|
+
// the response go out) before its own `await`s did, so any ack()/retry()
|
|
1887
|
+
// past the first one never counted.
|
|
1888
|
+
if (__qres && __sbIsFn(__qres.then)) await __qres;
|
|
1889
|
+
} catch {
|
|
1890
|
+
// #179 — a throw here must not crash the process. Whatever ack()/retry()
|
|
1891
|
+
// calls the handler made before throwing still count; anything it never
|
|
1892
|
+
// touched falls through to the default-ack pass below, same as a handler
|
|
1893
|
+
// that returns without calling ack()/retry() on every message.
|
|
1894
|
+
}
|
|
1853
1895
|
// default: any message neither acked nor retried is treated as acked
|
|
1854
1896
|
for (let i = 0; i < messages.length; i++) {
|
|
1855
1897
|
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|