@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- const r = res && __sbIsFn(res.then) ? await res : res;
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
- if (res && __sbIsFn(res.then)) await res;
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
- const __res = handlers.fetch(request, ctx);
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
- const __sres = handlers.scheduled(
1770
- { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
1771
- ctx,
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
- const __ares = inst.alarm();
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
- const __qres = handlers.queue(batch, ctx);
1849
- // Was fire-and-forget: an async `queue()` had this default-ack pass run (and
1850
- // the response go out) before its own `await`s did, so any ack()/retry()
1851
- // past the first one never counted.
1852
- if (__qres && __sbIsFn(__qres.then)) await __qres;
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);