@sproutboat/runtime 0.38.0 → 0.40.0

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,17 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.40.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1ae7843: From the proposed compatibility date 2026-11-16, `fetch()` on the standalone binary runs off the event loop. It used to run each request to the end before it returned, so 20 concurrent fetches to a 100 ms endpoint took 2,000 ms and every other request waited behind them. They now go to a pool of up to 32 I/O threads (the same client sproutd runs on 8), the handler suspends, and the server keeps serving; the 20 fetches take about 100 ms. The reply is delivered on the loop through the uWS waker the standalone server already installs for outbound WebSockets, so JavaScript still runs on one thread. Earlier dates keep the synchronous call, because a handler that relied on one request at a time across an `await fetch()` now interleaves with others, as it does in workerd. Behaviour on sproutd and through the broker is unchanged.
8
+
9
+ ## 0.39.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 7030f76: Database clients that need only fetch or WebSocket (`@libsql/client`, `@neondatabase/serverless`, `@supabase/supabase-js`, `@upstash/redis`, `@planetscale/database`) run unmodified. In the runtime: `fetch()` returns a promise on the standalone binary from the compatibility date that already promises it (2026-10-22), so `fetch(url).then(...)` works and a refused request rejects; a fetched 204, 205 or 304 response no longer throws "Response with null body status cannot have body"; from the proposed compatibility date 2026-11-15 the Node/Bun/Deno global check in the bundle skips a use behind a `typeof` feature test, a global the bundle declares itself, and mentions inside strings and comments, and `Error.captureStackTrace` exists. In the toolchain: a typed array with an underlying object (a `Buffer` polyfill) shadows the builtin methods it redefines, so `buf.toString("utf8")` is the Buffer one.
14
+
3
15
  ## 0.38.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,15 @@ function __sbEnvValue() {
12
12
  function __sbSetCompat(date) {
13
13
  __sbCompatNow = date;
14
14
  Request._cd = date;
15
+ if (date >= "2026-11-15" && Error.captureStackTrace === undefined) Error.captureStackTrace = __sbCaptureStackTrace;
16
+ }
17
+ // V8's Error.captureStackTrace(target): drivers (pg, @neondatabase/serverless) call it on the errors they
18
+ // build and rethrow. Porffor keeps no frames, so the stack is the first line, as Error.stack is.
19
+ function __sbCaptureStackTrace(target) {
20
+ if (Object(target) !== target) throw new TypeError("Invalid argument");
21
+ const name = target.name === undefined ? "Error" : String(target.name);
22
+ const message = target.message === undefined ? "" : String(target.message);
23
+ Object.defineProperty(target, "stack", { value: message === "" ? name : name + ": " + message, configurable: true, writable: true, enumerable: false });
15
24
  }
16
25
  function __sbSetEnv(value) {
17
26
  __sbEnvNow = value;
@@ -4253,8 +4262,12 @@ function __sbRequestBody(body) {
4253
4262
  }
4254
4263
 
4255
4264
  function __sbRawBodyResponse(body, init) {
4256
- const resp = new Response(body == null ? "" : body, init);
4257
- if (body != null) {
4265
+ // 204, 205 and 304 carry no body: the constructor refuses even an empty string for them, so a
4266
+ // fetch() of any 204 (a DELETE, a PostgREST write) threw before the caller saw the response.
4267
+ const status = init == null ? 200 : init.status;
4268
+ const bodiless = status === 204 || status === 205 || status === 304;
4269
+ const resp = new Response(bodiless ? null : body == null ? "" : body, init);
4270
+ if (body != null && !bodiless) {
4258
4271
  let decoded = null;
4259
4272
  resp.text = () => {
4260
4273
  if (decoded === null) decoded = __sbFromUtf8(body);
@@ -12243,7 +12256,19 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
12243
12256
  function __sbUpstream(op, msg, label, signal, always) {
12244
12257
  if (signal) signal.throwIfAborted();
12245
12258
  const promised = always || __sbCompatDate() >= "2026-10-22" || __sbAsyncAtAnyDate();
12246
- if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
12259
+ if (!promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
12260
+ if (!__sbHttpAsyncEnabled()) {
12261
+ // A host that runs the exchange to the end before it returns (the standalone binary) still hands
12262
+ // back a promise from 2026-10-22: fetch(url).then(...) is how most libraries call it, and a refusal
12263
+ // rejects instead of throwing. Earlier dates keep the Response (the embedded transport's
12264
+ // __sbAsyncAtAnyDate is true for sproutd's sake, not for this).
12265
+ if (!always && __sbCompatDate() < "2026-10-22") return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
12266
+ try {
12267
+ return Promise.resolve(__sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg)));
12268
+ } catch (e) {
12269
+ return Promise.reject(e);
12270
+ }
12271
+ }
12247
12272
  if (__sbCompatDate() >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
12248
12273
  return new Promise((resolve, reject) => {
12249
12274
  let onAbort = null;
@@ -0,0 +1,44 @@
1
+ import { expect, test } from "bun:test";
2
+ import { GUARDED_GLOBALS_DATE, validateHttpSyncSource } from "./source";
3
+
4
+ const wrap = (body: string) => `var d={fetch(){${body}return new Response("ok");}};export{d as default};`;
5
+
6
+ const GUARDED = [
7
+ // a bundled `buffer` polyfill declares its own Buffer
8
+ `function Buffer(a){return a;}Buffer.isBuffer=function(b){return b instanceof Buffer;};`,
9
+ // upstash: a polyfill behind a test for another global
10
+ `if(typeof atob==="undefined"){global.atob=(b)=>Buffer.from(b,"base64").toString("utf8");}`,
11
+ // supabase-js: a Node branch behind a feature test
12
+ `function b64(d){if(typeof Buffer!=="undefined")return Buffer.from(d).toString("base64");return btoa(d);}`,
13
+ `if(typeof Deno!=="undefined"&&typeof Deno.unrefTimer==="function"){Deno.unrefTimer(t);}`,
14
+ // @neondatabase/serverless: the feature test names the member itself
15
+ `var B=typeof globalThis.Buffer=="function"&&typeof globalThis.Buffer.allocUnsafe=="function"?globalThis.Buffer:null;`,
16
+ // an error message that mentions an API
17
+ `throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported");`,
18
+ `// process.env is read here in Node\n`,
19
+ ];
20
+
21
+ test("before the guarded-globals date every Node global match fails, as it always did", () => {
22
+ for (const body of GUARDED) {
23
+ expect(validateHttpSyncSource(wrap(body), "2026-10-22").ok).toBe(false);
24
+ expect(validateHttpSyncSource(wrap(body)).ok).toBe(false);
25
+ }
26
+ });
27
+
28
+ test("from the guarded-globals date a feature-tested use, a string and a comment pass", () => {
29
+ for (const body of GUARDED) expect(validateHttpSyncSource(wrap(body), GUARDED_GLOBALS_DATE).ok).toBe(true);
30
+ });
31
+
32
+ test("from the guarded-globals date an unguarded use still fails", () => {
33
+ for (const body of [
34
+ `var x=process.env.TOKEN;`,
35
+ `var b=Buffer.from("a");`,
36
+ `var b=new Buffer(4);`,
37
+ `var x=Bun.file("a");`,
38
+ // a feature test far above does not guard this one
39
+ `if(typeof Deno!=="undefined"){}${" ".repeat(700)}var x=process.env.TOKEN;`,
40
+ ]) {
41
+ const result = validateHttpSyncSource(wrap(body), GUARDED_GLOBALS_DATE);
42
+ expect(result.ok).toBe(false);
43
+ }
44
+ });
package/src/source.ts CHANGED
@@ -6,6 +6,8 @@ export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
6
6
  // bundling there are no imports left to reject, and a dependency reaching for a
7
7
  // Node API has to fail exactly as hand-written code would. A bare specifier
8
8
  // that resolves to nothing never gets this far — the bundler fails first.
9
+ const NODE_GLOBAL_USE = /\b(process|Bun|Deno|Buffer)\.[a-zA-Z_$]|\bnew\s+Buffer\s*\(/;
10
+
9
11
  const alwaysForbidden: Array<[RegExp, string]> = [
10
12
  [/^\s*import\s/m, "an import survived bundling — only static imports can be resolved at build time"],
11
13
  [/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
@@ -20,13 +22,48 @@ const alwaysForbidden: Array<[RegExp, string]> = [
20
22
  // No whitespace is allowed around the `.`: real member access never has
21
23
  // any (`process.env`, always contiguous), while prose mentioning the word
22
24
  // does (a bundled comment ending "...unique to this process. The id...").
23
- [/\b(process|Bun|Deno|Buffer)\.[a-zA-Z_$]|\bnew\s+Buffer\s*\(/, "Node, Bun, and Deno APIs are not supported"],
25
+ [NODE_GLOBAL_USE, "Node, Bun, and Deno APIs are not supported"],
24
26
  // `node:` only means something as an import specifier; a bare substring
25
27
  // match would also flag it appearing in an ordinary string a dependency
26
28
  // happens to construct (a doc link, a log message).
27
29
  [/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
28
30
  ];
29
31
 
32
+ /**
33
+ * From this compatibility date the Node/Bun/Deno global check skips a use that sits behind a
34
+ * `typeof X` feature test (supabase-js, @neondatabase/serverless and many others carry a Node or
35
+ * Deno branch next to the Web one), a global the bundle declares itself (a bundled `buffer`
36
+ * polyfill) and one that only appears inside a string or comment. Earlier
37
+ * dates keep the plain pattern match. Proposed value, to be chosen by a maintainer.
38
+ */
39
+ export const GUARDED_GLOBALS_DATE = "2026-11-15";
40
+
41
+ const STRINGS_AND_COMMENTS = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
42
+
43
+ /**
44
+ * True when the module reaches for a Node/Bun/Deno global that it neither declares itself nor
45
+ * feature-tests first. A bundled polyfill declares its own `Buffer` (`function Buffer(){}`), and a
46
+ * Node branch sits behind `typeof X !== "undefined"` or `typeof atob === "undefined"`.
47
+ */
48
+ function usesNodeGlobalUnguarded(module: string): boolean {
49
+ const code = module.replace(STRINGS_AND_COMMENTS, (m) => (m[0] === "/" ? " " : m[0] + m[0]));
50
+ const declared = (name: string) => new RegExp("(?:function|class|var|let|const)\\s+" + name + "\\b").test(code);
51
+ const local = new Set(["process", "Bun", "Deno", "Buffer"].filter(declared));
52
+ const re = new RegExp(NODE_GLOBAL_USE.source, "g");
53
+ for (let m = re.exec(code); m !== null; m = re.exec(code)) {
54
+ const name = m[1] ?? "Buffer";
55
+ if (local.has(name)) continue;
56
+ const before = code.slice(Math.max(0, m.index - 600), m.index);
57
+ // `typeof globalThis.Buffer.from`: the use is the feature test itself.
58
+ if (/typeof\s+(?:(?:globalThis|self|global|window)\.)?$/.test(before)) continue;
59
+ if (new RegExp("typeof\\s+(?:(?:globalThis|self|global|window)\\.)?" + name + "\\b").test(before)) continue;
60
+ // `if (typeof atob === "undefined") { ... Buffer.from(...) }`: a test for something else, just before.
61
+ if (/typeof\s+[\w$.]+\s*[!=]==?\s*""/.test(before.slice(-200))) continue;
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+
30
67
  /** `new WebSocket(url)` connects out from this compatibility date (earlier, only WebSocketPair makes one). */
31
68
  export const OUTBOUND_WEBSOCKET_DATE = "2026-11-01";
32
69
 
@@ -48,7 +85,12 @@ export function validateHttpSyncSource(module: string, compatibilityDate?: strin
48
85
  "handler must default-export an object with fetch(request), scheduled() or queue(), or a WorkerEntrypoint class",
49
86
  );
50
87
  }
51
- for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
88
+ const guardedGlobals = compatibilityDate !== undefined && compatibilityDate >= GUARDED_GLOBALS_DATE;
89
+ for (const [pattern, message] of alwaysForbidden) {
90
+ if (pattern === NODE_GLOBAL_USE && guardedGlobals ? !usesNodeGlobalUnguarded(source) : !pattern.test(source))
91
+ continue;
92
+ errors.push(message);
93
+ }
52
94
  if (
53
95
  /\bWebSocket\s*\(/.test(source) &&
54
96
  !(compatibilityDate !== undefined && compatibilityDate >= OUTBOUND_WEBSOCKET_DATE)
@@ -3145,11 +3145,16 @@ typedef struct sb_async_job {
3145
3145
  char *host, *path, *method, *headers, *body, *out;
3146
3146
  size_t body_len;
3147
3147
  } sb_async_job;
3148
+ // sproutd runs one host per sprout among hundreds, so a few threads each; a standalone binary is the
3149
+ // only process of its app, and workerd lets one request hold 50 subrequests.
3148
3150
  #define SB_ASYNC_THREADS 8
3151
+ #define SB_ASYNC_THREADS_STANDALONE 32
3149
3152
  static pthread_mutex_t sb_async_lock = PTHREAD_MUTEX_INITIALIZER;
3150
3153
  static sb_async_job *sb_async_queue = 0, *sb_async_queue_tail = 0, *sb_async_done = 0, *sb_async_current = 0;
3151
3154
  static int sb_async_pipe[2] = { -1, -1 };
3152
3155
  static int sb_async_threads = 0;
3156
+ // The standalone server's way back onto its loop (uWS defer, patchAsyncWaker); set by sb_async_set_waker.
3157
+ static void (*sb_wsc_waker)(void) = 0;
3153
3158
 
3154
3159
  int sb_async_enable(void) {
3155
3160
  if (sb_async_pipe[0] >= 0) return sb_async_pipe[0];
@@ -3160,7 +3165,10 @@ int sb_async_enable(void) {
3160
3165
  }
3161
3166
  return sb_async_pipe[0];
3162
3167
  }
3163
- int sb_async_enabled(void) { return sb_async_pipe[0] >= 0; }
3168
+ // A host runs requests off its loop when it watches the pipe (sproutd) or gave a waker (standalone).
3169
+ int sb_async_enabled(void) { return sb_async_pipe[0] >= 0 || sb_wsc_waker != 0; }
3170
+ // Only the pipe host (sproutd) has always answered fetch() with a promise; see __sbAsyncAtAnyDate.
3171
+ int sb_async_piped(void) { return sb_async_pipe[0] >= 0; }
3164
3172
  static void sb_async_free(sb_async_job* j) {
3165
3173
  free(j->host); free(j->path); free(j->method); free(j->headers); free(j->body); free(j->out); free(j);
3166
3174
  }
@@ -3168,8 +3176,12 @@ static void sb_async_free(sb_async_job* j) {
3168
3176
  static void sb_async_finish_locked(sb_async_job* j) {
3169
3177
  j->next = sb_async_done;
3170
3178
  sb_async_done = j;
3171
- char one = 1;
3172
- while (write(sb_async_pipe[1], &one, 1) < 0 && errno == EINTR) {}
3179
+ if (sb_async_pipe[1] >= 0) {
3180
+ char one = 1;
3181
+ while (write(sb_async_pipe[1], &one, 1) < 0 && errno == EINTR) {}
3182
+ }
3183
+ // uWS's defer is thread-safe and never takes sb_async_lock, so calling it here cannot deadlock.
3184
+ if (sb_wsc_waker) sb_wsc_waker();
3173
3185
  }
3174
3186
  static void* sb_async_thread(void* unused) {
3175
3187
  (void)unused;
@@ -3192,7 +3204,7 @@ static void sb_async_start(sb_async_job* j) {
3192
3204
  j->next = 0;
3193
3205
  if (sb_async_queue_tail) sb_async_queue_tail->next = j; else sb_async_queue = j;
3194
3206
  sb_async_queue_tail = j;
3195
- int spawn = sb_async_threads < SB_ASYNC_THREADS;
3207
+ int spawn = sb_async_threads < (sb_async_pipe[0] >= 0 ? SB_ASYNC_THREADS : SB_ASYNC_THREADS_STANDALONE);
3196
3208
  if (spawn) sb_async_threads++;
3197
3209
  pthread_mutex_unlock(&sb_async_lock);
3198
3210
  if (!spawn) return;
@@ -3236,7 +3248,7 @@ static void (*sb_wsc_drain_hook)(void) = 0;
3236
3248
  // Host: the fd became readable. Fires each finished request's callback.
3237
3249
  void sb_async_drain(void) {
3238
3250
  char buf[64];
3239
- while (read(sb_async_pipe[0], buf, sizeof(buf)) > 0) {}
3251
+ if (sb_async_pipe[0] >= 0) while (read(sb_async_pipe[0], buf, sizeof(buf)) > 0) {}
3240
3252
  if (sb_wsc_drain_hook) sb_wsc_drain_hook();
3241
3253
  pthread_mutex_lock(&sb_async_lock);
3242
3254
  sb_async_job* done = sb_async_done;
@@ -3294,7 +3306,6 @@ typedef struct sb_wsc {
3294
3306
  static pthread_mutex_t sb_wsc_lock = PTHREAD_MUTEX_INITIALIZER;
3295
3307
  static sb_wsc* sb_wscs = 0;
3296
3308
  static unsigned sb_wsc_next = 0;
3297
- static void (*sb_wsc_waker)(void) = 0;
3298
3309
  void sb_async_set_waker(void (*wake)(void)) { sb_wsc_waker = wake; }
3299
3310
  static int sb_wsc_pipe_enabled(void) { return sb_async_pipe[0] >= 0; }
3300
3311
  static void sb_async_poke(void) {
@@ -4318,12 +4329,21 @@ function __sbHttpAsyncResult(asyncId) {
4318
4329
  // placeholder timer cleared, the reply is dropped when it arrives.
4319
4330
  function __sbCancelAsync() {}
4320
4331
 
4321
- // sproutd ran fetch() off its loop, as a promise, at every compatibility date (B18).
4332
+ // sproutd ran fetch() off its loop, as a promise, at every compatibility date (B18). The standalone
4333
+ // server (a waker, no pipe) keeps the date its fetch() promise has always had.
4322
4334
  function __sbAsyncAtAnyDate() {
4323
- return true;
4335
+ let res = false;
4336
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4337
+ Porffor.c`
4338
+ int sb_async_piped(void);
4339
+ if (sb_async_piped()) res = porf_box(1, 2);
4340
+ `;
4341
+ return res;
4324
4342
  }
4325
4343
 
4326
- // Whether the host runs async requests (sb_async_enable was called).
4344
+ // Whether the host runs async requests: sproutd (sb_async_enable was called) at any date, the
4345
+ // standalone server (it gave a waker) from the date its fetch() leaves the loop, so an app that
4346
+ // relied on one request at a time across an `await fetch()` keeps that until it opts in.
4327
4347
  function __sbHttpAsyncEnabled() {
4328
4348
  let res = false;
4329
4349
  // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
@@ -4331,7 +4351,7 @@ function __sbHttpAsyncEnabled() {
4331
4351
  int sb_async_enabled(void);
4332
4352
  if (sb_async_enabled()) res = porf_box(1, 2);
4333
4353
  `;
4334
- return res;
4354
+ return res && (__sbAsyncAtAnyDate() || __sbCompatDate() >= "2026-11-16");
4335
4355
  }
4336
4356
 
4337
4357
  // The outbound client's JSON reply -> the fetch op's result.
@@ -0,0 +1,9 @@
1
+ Repro for standalone `fetch()` off the event loop (compatibility date 2026-11-16).
2
+
3
+ ```sh
4
+ bun slow.ts 19100 & # /slow?delay=MS, /echo
5
+ sproutboat build --standalone --target host . # compatibility_date 2026-11-16
6
+ PORT=19201 SB_EGRESS_ALLOW=127.0.0.1 ./dist/af-fan &
7
+ curl 'localhost:19201/fan?n=20&delay=100' # about 110 ms; 2,040 ms at 2026-11-15
8
+ curl 'localhost:19201/order?delay=200' & curl 'localhost:19201/order?delay=200' # interleave from 11-16
9
+ ```
@@ -0,0 +1,17 @@
1
+ // bun /tmp/af/slow.ts PORT : /slow?delay=ms answers "slow-<i>" after delay; /echo returns the body
2
+ Bun.serve({
3
+ port: Number(process.argv[2] || 19100),
4
+ hostname: "0.0.0.0",
5
+ async fetch(req) {
6
+ const u = new URL(req.url);
7
+ if (u.pathname === "/slow") {
8
+ await Bun.sleep(Number(u.searchParams.get("delay") || 100));
9
+ return new Response("slow-" + (u.searchParams.get("i") ?? "x"));
10
+ }
11
+ if (u.pathname === "/echo")
12
+ return new Response("echo:" + (await req.text()) + ":" + req.headers.get("x-test"), {
13
+ headers: { "content-type": "text/x-echo" },
14
+ });
15
+ return new Response("nf", { status: 404 });
16
+ },
17
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "af-fan",
3
+ "main": "src/index.js",
4
+ "compatibility_date": "2026-11-16",
5
+ }
@@ -0,0 +1,50 @@
1
+ let counter = 0;
2
+ export default {
3
+ async fetch(request) {
4
+ const u = new URL(request.url);
5
+ if (u.pathname === "/ping") return new Response("ok");
6
+ const base = u.searchParams.get("base") || "http://127.0.0.1:19100";
7
+ if (u.pathname === "/fan") {
8
+ const n = Number(u.searchParams.get("n") || 20);
9
+ const delay = u.searchParams.get("delay") || "100";
10
+ const t0 = Date.now();
11
+ const rs = await Promise.all(
12
+ Array.from({ length: n }, (_, i) => fetch(base + "/slow?i=" + i + "&delay=" + delay).then((r) => r.text())),
13
+ );
14
+ return Response.json({ n, ms: Date.now() - t0, bodies: rs.slice(0, 3), last: rs[n - 1] });
15
+ }
16
+ if (u.pathname === "/one") {
17
+ const r = await fetch(base + "/slow?delay=" + (u.searchParams.get("delay") || "0"));
18
+ return new Response(await r.text(), { status: r.status });
19
+ }
20
+ if (u.pathname === "/post") {
21
+ const r = await fetch(base + "/echo", { method: "POST", body: "hello-" + ++counter, headers: { "x-test": "1" } });
22
+ return Response.json({ status: r.status, text: await r.text(), ct: r.headers.get("content-type") });
23
+ }
24
+ if (u.pathname === "/refuse") {
25
+ try {
26
+ await fetch("http://127.0.0.1:1/");
27
+ return new Response("no error", { status: 500 });
28
+ } catch (e) {
29
+ return Response.json({ rejected: true, name: e.name, message: String(e.message) });
30
+ }
31
+ }
32
+ if (u.pathname === "/abort") {
33
+ const ac = new AbortController();
34
+ setTimeout(() => ac.abort(), 20);
35
+ try {
36
+ await fetch(base + "/slow?delay=500", { signal: ac.signal });
37
+ return new Response("no abort", { status: 500 });
38
+ } catch (e) {
39
+ return Response.json({ aborted: true, name: e.name });
40
+ }
41
+ }
42
+ if (u.pathname === "/order") {
43
+ // global state across an await fetch
44
+ const mine = ++counter;
45
+ await fetch(base + "/slow?delay=" + (u.searchParams.get("delay") || "50")).then((r) => r.text());
46
+ return Response.json({ mine, after: counter });
47
+ }
48
+ return new Response("not found", { status: 404 });
49
+ },
50
+ };
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """Restart stress for a standalone binary (docs/standalone.md, "Restarting without dropping a request").
3
+
4
+ standalone-restart-stress.py BINARY --mode MODE [--restarts N] [--keepalive N] [--fresh N] [--writes]
5
+
6
+ Linux. Continuous load against one port while the binary is replaced N times; the count that matters
7
+ is how many requests failed. MODE says how the swap is done:
8
+
9
+ overlap the old recipe: start the new copy on the same port (SO_REUSEPORT), SIGTERM the old one.
10
+ Closing the old listener resets whatever sits in its accept queue.
11
+ stop-start SIGTERM the old copy, wait for it, start the new one. Nothing listens in between.
12
+ systemd a socket + service pair of user units under `systemd --user`; each swap is
13
+ `systemctl --user restart`. The socket stays open, so connections queue.
14
+ usr2 run the binary directly; each swap replaces the file on disk (install + rename) and sends
15
+ SIGUSR2. The running copy starts the new one on its listening socket and drains.
16
+
17
+ The load is FRESH threads (a connection per request) and KEEPALIVE threads (one connection reused,
18
+ as a proxy's pool does). A reused connection the server closes in the same instant is retried once on
19
+ a new connection, as every client retries an idempotent request, and counted separately. With --writes
20
+ half the requests are POST /w against a D1 table (the app's /n route returns the row count), and the
21
+ run ends by checking that every acknowledged write is in the table, so a swap that loses or doubles one
22
+ shows up. The app is expected to answer GET /x with 200 and, for --writes, POST /w with the new row id.
23
+
24
+ Prints one JSON line; exit status 1 on any failed request or lost write.
25
+ """
26
+ import argparse, http.client, json, os, shutil, signal, socket, subprocess, sys, tempfile, threading, time
27
+ from pathlib import Path
28
+
29
+ ap = argparse.ArgumentParser()
30
+ ap.add_argument("binary")
31
+ ap.add_argument("--mode", required=True, choices=["overlap", "stop-start", "systemd", "usr2"])
32
+ ap.add_argument("--restarts", type=int, default=30)
33
+ ap.add_argument("--keepalive", type=int, default=3)
34
+ ap.add_argument("--fresh", type=int, default=3)
35
+ ap.add_argument("--writes", action="store_true")
36
+ ap.add_argument("--fresh-delay-ms", type=float, default=3, help="pause between a fresh loader's requests (conntrack: every connection leaves a TIME_WAIT entry, and a full nf_conntrack table silently drops SYNs)")
37
+ ap.add_argument("--settle-ms", type=int, default=250, help="pause between swaps")
38
+ ap.add_argument("--env", action="append", default=[], help="KEY=value for the binary")
39
+ args = ap.parse_args()
40
+
41
+ binary = Path(args.binary).resolve()
42
+ scratch = Path(tempfile.mkdtemp(prefix="sb-restart-"))
43
+ installed = scratch / "app"
44
+ shutil.copy(binary, installed)
45
+ installed.chmod(0o755)
46
+ data = scratch / "data"
47
+ data.mkdir()
48
+ with socket.socket() as s:
49
+ s.bind(("127.0.0.1", 0))
50
+ port = s.getsockname()[1]
51
+ env = dict(os.environ, PORT=str(port), SB_DATA_DIR=str(data))
52
+ for item in args.env:
53
+ k, _, v = item.partition("=")
54
+ env[k] = v
55
+
56
+ stop = [False]
57
+ lock = threading.Lock()
58
+ stats = {"ok": 0, "retried": 0, "writes_acked": 0}
59
+ failures = []
60
+ latencies = []
61
+ t_start = time.time()
62
+
63
+
64
+ def note_failure(kind, err):
65
+ with lock:
66
+ failures.append({"t": round(time.time() - t_start, 3), "kind": kind, "error": err})
67
+
68
+
69
+ def one_request(conn, write):
70
+ t0 = time.time()
71
+ if write:
72
+ conn.request("POST", "/w", body=b"x")
73
+ else:
74
+ conn.request("GET", "/x")
75
+ r = conn.getresponse()
76
+ body = r.read()
77
+ dt = time.time() - t0
78
+ with lock:
79
+ latencies.append(dt)
80
+ return r.status, body.decode(errors="replace"), r.will_close
81
+
82
+
83
+ def record(status, body, write, kind):
84
+ if status == 200 or (write and status in (200, 201)):
85
+ with lock:
86
+ stats["ok"] += 1
87
+ if write:
88
+ stats["writes_acked"] += 1
89
+ else:
90
+ note_failure(kind, f"status {status} {body[:40]}")
91
+
92
+
93
+ def fresh_loader(index):
94
+ n = 0
95
+ while not stop[0]:
96
+ time.sleep(args.fresh_delay_ms / 1000)
97
+ n += 1
98
+ write = args.writes and (n + index) % 2 == 0
99
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
100
+ try:
101
+ status, body, _ = one_request(c, write)
102
+ record(status, body, write, "fresh")
103
+ except Exception as e:
104
+ note_failure("fresh", type(e).__name__ + ": " + str(e)[:80])
105
+ finally:
106
+ c.close()
107
+
108
+
109
+ def keepalive_loader(index):
110
+ conn = None
111
+ n = 0
112
+ while not stop[0]:
113
+ n += 1
114
+ write = args.writes and (n + index) % 2 == 0
115
+ reused = conn is not None
116
+ if conn is None:
117
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
118
+ try:
119
+ status, body, closing = one_request(conn, write)
120
+ if closing:
121
+ conn.close()
122
+ conn = None
123
+ record(status, body, write, "keepalive")
124
+ except Exception as e:
125
+ conn.close()
126
+ conn = None
127
+ if not reused or write:
128
+ # A write is not retried: it may have been applied, and the point is to see it fail.
129
+ note_failure("keepalive", ("new connection: " if not reused else "write on reused connection: ") + type(e).__name__ + ": " + str(e)[:80])
130
+ continue
131
+ c2 = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
132
+ try:
133
+ status, body, _ = one_request(c2, False)
134
+ with lock:
135
+ stats["retried"] += 1
136
+ record(status, body, False, "keepalive")
137
+ except Exception as e2:
138
+ note_failure("keepalive", "retry failed: " + type(e2).__name__ + ": " + str(e2)[:80])
139
+ finally:
140
+ c2.close()
141
+
142
+
143
+ def conntrack():
144
+ try:
145
+ return int(Path("/proc/sys/net/netfilter/nf_conntrack_count").read_text())
146
+ except OSError:
147
+ return 0
148
+
149
+
150
+ def wait_ready(timeout=10):
151
+ deadline = time.time() + timeout
152
+ while time.time() < deadline:
153
+ try:
154
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
155
+ c.request("GET", "/x")
156
+ if c.getresponse().status == 200:
157
+ c.close()
158
+ return True
159
+ except Exception:
160
+ pass
161
+ time.sleep(0.05)
162
+ return False
163
+
164
+
165
+ procs = []
166
+ unit = f"sbrs-{os.getpid()}"
167
+ unit_dir = Path.home() / ".config/systemd/user"
168
+ log_n = [0]
169
+
170
+
171
+ def spawn(extra_env=None):
172
+ log_n[0] += 1
173
+ log = open(scratch / f"run{log_n[0]}.log", "w")
174
+ p = subprocess.Popen([str(installed)], cwd=scratch, env=dict(env, **(extra_env or {})), stdout=log, stderr=log, start_new_session=True)
175
+ procs.append(p)
176
+ return p
177
+
178
+
179
+ def exited(p, limit):
180
+ try:
181
+ p.wait(limit)
182
+ return True
183
+ except subprocess.TimeoutExpired:
184
+ return False
185
+
186
+
187
+ def alive(pid, child=None):
188
+ """Is pid still running? A zombie nobody reaped (the container case) is gone, and our own child is reaped here."""
189
+ if child is not None:
190
+ return child.poll() is None
191
+ try:
192
+ with open(f"/proc/{pid}/stat") as f:
193
+ return f.read().rsplit(")", 1)[1].split()[0] != "Z"
194
+ except OSError:
195
+ return False
196
+
197
+
198
+ def replace_binary():
199
+ tmp = scratch / "app.new"
200
+ shutil.copy(binary, tmp)
201
+ tmp.chmod(0o755)
202
+ os.replace(tmp, installed)
203
+
204
+
205
+ def sctl(*a):
206
+ return subprocess.run(["systemctl", "--user", *a], capture_output=True, text=True)
207
+
208
+
209
+ summary = {"mode": args.mode, "binary": str(binary), "port": port}
210
+ threads = []
211
+ try:
212
+ while conntrack() > 20000:
213
+ time.sleep(5)
214
+ summary["conntrack_at_start"] = conntrack()
215
+ swap_ms = []
216
+ if args.mode == "systemd":
217
+ # Real unit files, not systemd-run: a transient service is garbage-collected when it stops,
218
+ # which takes its socket with it. These are the units docs/standalone.md describes.
219
+ unit_dir.mkdir(parents=True, exist_ok=True)
220
+ (unit_dir / f"{unit}.socket").write_text(f"[Socket]\nListenStream=127.0.0.1:{port}\nBacklog=4096\nTriggerLimitIntervalSec=0\n")
221
+ (unit_dir / f"{unit}.service").write_text(
222
+ f"[Unit]\nRequires={unit}.socket\n# A stress run restarts several times a second; the default limit (5 starts in 10 s) would fail the unit.\nStartLimitIntervalSec=0\n\n[Service]\nExecStart={installed}\nWorkingDirectory={scratch}\n"
223
+ f"Environment=PORT={port} SB_DATA_DIR={data}\n"
224
+ + "".join(f"Environment={item}\n" for item in args.env)
225
+ + "KillSignal=SIGTERM\nTimeoutStopSec=30\nMemoryMax=256M\nMemorySwapMax=0\n"
226
+ )
227
+ for step in (["daemon-reload"], ["start", f"{unit}.socket"]):
228
+ r = sctl(*step)
229
+ if r.returncode:
230
+ raise SystemExit("systemctl " + " ".join(step) + " failed: " + r.stderr)
231
+ else:
232
+ current = spawn()
233
+ newest_pid = current.pid
234
+ if not wait_ready():
235
+ raise SystemExit("the binary did not answer GET /x")
236
+ t_start = time.time()
237
+ for i in range(args.fresh):
238
+ threads.append(threading.Thread(target=fresh_loader, args=(i,), daemon=True))
239
+ for i in range(args.keepalive):
240
+ threads.append(threading.Thread(target=keepalive_loader, args=(i,), daemon=True))
241
+ for t in threads:
242
+ t.start()
243
+ time.sleep(1)
244
+ seen_logs = set()
245
+ for i in range(args.restarts):
246
+ t0 = time.time()
247
+ if args.mode == "overlap":
248
+ nxt = spawn()
249
+ time.sleep(0.4) # up and accepting beside the old one
250
+ current.send_signal(signal.SIGTERM)
251
+ if not exited(current, 15):
252
+ note_failure("restart", "old copy did not exit in 15 s")
253
+ current.kill()
254
+ current = nxt
255
+ elif args.mode == "stop-start":
256
+ current.send_signal(signal.SIGTERM)
257
+ if not exited(current, 15):
258
+ note_failure("restart", "old copy did not exit in 15 s")
259
+ current.kill()
260
+ current = spawn()
261
+ elif args.mode == "systemd":
262
+ replace_binary()
263
+ r = sctl("restart", f"{unit}.service")
264
+ if r.returncode:
265
+ note_failure("restart", "systemctl restart: " + r.stderr[:80])
266
+ elif args.mode == "usr2":
267
+ replace_binary()
268
+ pid = newest_pid
269
+ os.kill(pid, signal.SIGUSR2)
270
+ # The successor is the old copy's child; it logs its pid into the old copy's log file.
271
+ deadline = time.time() + 15
272
+ newest_pid = None
273
+ logfile = scratch / f"run1.log"
274
+ while time.time() < deadline and newest_pid is None:
275
+ for line in logfile.read_text().splitlines():
276
+ if "restart: started" in line and line not in seen_logs:
277
+ seen_logs.add(line)
278
+ newest_pid = int(line.rsplit(" ", 1)[1])
279
+ if newest_pid is None:
280
+ time.sleep(0.05)
281
+ if newest_pid is None:
282
+ note_failure("restart", "no successor started")
283
+ break
284
+ # wait for the old pid to be gone
285
+ while time.time() < deadline and alive(pid, current if i == 0 else None):
286
+ time.sleep(0.02)
287
+ if not wait_ready(10):
288
+ note_failure("restart", "successor never answered")
289
+ swap_ms.append(round((time.time() - t0) * 1000))
290
+ time.sleep(args.settle_ms / 1000)
291
+ time.sleep(1)
292
+ stop[0] = True
293
+ for t in threads:
294
+ t.join(30)
295
+ lost = None
296
+ if args.writes:
297
+ try:
298
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
299
+ c.request("GET", "/n")
300
+ rows = int(c.getresponse().read().decode().strip())
301
+ lost = stats["writes_acked"] - rows
302
+ summary["rows_in_table"] = rows
303
+ except Exception as e:
304
+ lost = f"could not read /n: {e}"
305
+ lat = sorted(latencies)
306
+ summary.update({
307
+ "restarts": args.restarts,
308
+ "swap_ms_median": sorted(swap_ms)[len(swap_ms) // 2] if swap_ms else 0,
309
+ "swap_ms_max": max(swap_ms or [0]),
310
+ "requests_ok": stats["ok"],
311
+ "reused_retried": stats["retried"],
312
+ "writes_acked": stats["writes_acked"],
313
+ "writes_unaccounted": lost,
314
+ "failed": len(failures),
315
+ "failures": failures[:12],
316
+ "latency_ms": {"p50": round(lat[len(lat) // 2] * 1000, 2) if lat else 0, "p99": round(lat[int(len(lat) * 0.99)] * 1000, 1) if lat else 0, "max": round(lat[-1] * 1000, 1) if lat else 0},
317
+ "seconds": round(time.time() - t_start, 1),
318
+ "conntrack_at_end": conntrack(),
319
+ "loaders": {"fresh": args.fresh, "keepalive": args.keepalive},
320
+ })
321
+ finally:
322
+ stop[0] = True
323
+ for p in procs:
324
+ if p.poll() is None:
325
+ try:
326
+ os.killpg(p.pid, signal.SIGTERM)
327
+ except ProcessLookupError:
328
+ pass
329
+ if args.mode == "usr2":
330
+ # successors are not our children: stop each by the pid it logged
331
+ for log in scratch.glob("run*.log"):
332
+ for line in log.read_text().splitlines():
333
+ if "restart: started" in line:
334
+ try:
335
+ os.kill(int(line.rsplit(" ", 1)[1]), signal.SIGKILL)
336
+ except (ProcessLookupError, ValueError):
337
+ pass
338
+ if args.mode == "systemd":
339
+ sctl("stop", f"{unit}.service", f"{unit}.socket")
340
+ for ext in ("socket", "service"):
341
+ (unit_dir / f"{unit}.{ext}").unlink(missing_ok=True)
342
+ sctl("daemon-reload")
343
+ time.sleep(0.5)
344
+ if not os.environ.get("KEEP_SCRATCH"):
345
+ shutil.rmtree(scratch, ignore_errors=True)
346
+ print(json.dumps(summary))
347
+ bad = summary.get("failed", 1) or (summary.get("writes_unaccounted") not in (None, 0))
348
+ sys.exit(1 if bad else 0)