@sproutboat/runtime 0.6.4 → 0.6.5

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,45 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.6.5
4
+
5
+ ### Patch Changes
6
+
7
+ - d798206: Fix (baronunread/sproutboat#181): 0.6.4's `__sbToBytes` rewrite fixed the large
8
+ input it targeted and badly regressed the small one. An app hashing ~150-byte
9
+ values through `crypto.subtle.digest` on every request lost **64% of its
10
+ throughput** (4,700 to 1,680 req/s), tripled its p50 (4.6ms to 13.4ms) and grew
11
+ 41% in RSS (66.5 to 93.8 MB). 0.6.4 replaced a `s +=` loop with an
12
+ array-push-then-join unconditionally, and below about 512 bytes the array is
13
+ pure overhead: one boxed element per input byte, allocated and joined, where
14
+ the string append had nothing to allocate at all.
15
+
16
+ `__sbToBytes` now accumulates into a window and only creates an array once a
17
+ window fills. An input under 512 bytes touches no array and runs exactly like
18
+ the pre-0.6.4 code; a larger one caps the quadratic copy at one window and
19
+ joins the windows once, keeping the #180 fix (a 43KB body encodes in ~3ms
20
+ instead of ~52ms). Both branches flush only on a complete character, so a
21
+ surrogate pair can never be split across a window boundary. 512 is where the
22
+ two costs cross on a `porf native` build, and the curve is flat either side, so
23
+ it is a plateau rather than a tuned constant.
24
+
25
+ Measured end to end on the reporter's real workload, not just a microbench
26
+ (which under-predicted the regression by two orders of magnitude and would have
27
+ shipped the wrong fix): 4,690 req/s, p50 4.62ms, RSS 66.4 MB, matching the
28
+ pre-regression baseline on every axis.
29
+
30
+ Three other 0.6.4 changes are reverted to byte-identical 0.6.3, since they were
31
+ made for consistency rather than against a measured problem, and #181 is
32
+ evidence that array allocation at small sizes is the wrong trade in this
33
+ runtime: `__sbHex` and the outbound-fetch header builder in
34
+ `transport-embedded.js`, and `__SproutboatURLSearchParams.prototype.toString()`.
35
+ `__sbHexOrBytes` keeps its nibble fix, which drops a `slice(0, -1)` rewrite that
36
+ copied the accumulator twice per byte, but goes back to `+=`: it decodes an HMAC
37
+ signature, which is tens of bytes.
38
+
39
+ `to-bytes.test.js` gains the small-input coverage this needed: sizes either side
40
+ of the window boundary (0, 1, 150, 511, 512, 513, 1024, 1025) on both branches,
41
+ and a surrogate pair walked across the boundary.
42
+
3
43
  ## 0.6.4
4
44
 
5
45
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -171,14 +171,12 @@ class __SproutboatURLSearchParams {
171
171
  return this._keys.length;
172
172
  }
173
173
  toString() {
174
- // Array + one join("&"), not `out +=` in a loop: see __sbToBytes. A query
175
- // string is usually short, but a params object built from a large form or
176
- // a paginated cursor list is not, and the fix costs nothing.
177
- const out = [];
174
+ let out = "";
178
175
  for (let i = 0; i < this._keys.length; i++) {
179
- out.push(encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]));
176
+ if (i > 0) out += "&";
177
+ out += encodeURIComponent(this._keys[i]) + "=" + encodeURIComponent(this._vals[i]);
180
178
  }
181
- return out.join("&");
179
+ return out;
182
180
  }
183
181
  }
184
182
 
@@ -293,41 +291,73 @@ function __sbHashBits(algo) {
293
291
  if (n === "SHA-512" || n === "SHA512") return "512";
294
292
  return "";
295
293
  }
294
+ // Where __sbToBytes stops growing one string and starts a new window. 512 is
295
+ // where the two costs cross on a `porf native` build; the curve is flat either
296
+ // side of it, so this is a plateau, not a tuned constant.
297
+ const __SB_BYTES_WINDOW = 512;
296
298
  // -> a latin1 string, one char per byte. Strings are UTF-8 encoded (matching
297
299
  // TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
298
300
  function __sbToBytes(input) {
299
301
  if (input == null) return "";
300
- // Collected into an array and joined once at the end, not built with
301
- // repeated `out +=` -- Porffor's strings have no rope/cons optimization, so
302
- // `+=` in a loop copies the whole accumulated string on every append,
303
- // making the naive version O(n^2). Same fix as __sbFromUtf8 above, on the
304
- // encode side (sits behind every crypto.subtle call).
302
+ // Windowed accumulation (#180, then #181). Plain `s +=` in a loop is O(n^2)
303
+ // here, since Porffor's strings have no rope/cons optimization and every
304
+ // append copies the whole accumulated string -- that's #180, a 43KB body
305
+ // taking ~52ms to encode. But 0.6.4's unconditional array-push-then-join
306
+ // regressed the common case badly: a real app hashing ~150-byte inputs per
307
+ // request lost 64% of its throughput and gained 41% RSS (#181), far more
308
+ // than the per-call cost of the array ops measures in isolation.
309
+ //
310
+ // So: append into a window, and only once a window fills does an array come
311
+ // into existence to hold it. An input below __SB_BYTES_WINDOW allocates no
312
+ // array at all and runs exactly like the pre-#180 code; a large one caps the
313
+ // quadratic copy at one window and joins the windows once. Measured on a
314
+ // `porf native` build, this ties `+=` at 100-512 bytes and ties array+join
315
+ // at 8-43KB, with no threshold branch to keep in sync.
316
+ //
317
+ // Both branches flush only on a complete character, never mid-sequence, so a
318
+ // surrogate pair can't be split across two windows.
305
319
  if (__sbIsStr(input)) {
306
- const out = [];
320
+ let out = null;
321
+ let s = "";
307
322
  for (let i = 0; i < input.length; i++) {
308
323
  const c = input.charCodeAt(i);
309
- if (c < 0x80) out.push(String.fromCharCode(c));
310
- else if (c < 0x800) out.push(String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f)));
324
+ if (c < 0x80) s += String.fromCharCode(c);
325
+ else if (c < 0x800) s += String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f));
311
326
  else if (c >= 0xd800 && c < 0xdc00 && i + 1 < input.length) {
312
327
  const cp = 0x10000 + ((c - 0xd800) << 10) + (input.charCodeAt(++i) - 0xdc00);
313
- out.push(
328
+ s +=
314
329
  String.fromCharCode(0xf0 | (cp >> 18)) +
315
- String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
316
- String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
317
- String.fromCharCode(0x80 | (cp & 0x3f))
318
- );
330
+ String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
331
+ String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
332
+ String.fromCharCode(0x80 | (cp & 0x3f));
319
333
  } else
320
- out.push(
334
+ s +=
321
335
  String.fromCharCode(0xe0 | (c >> 12)) +
322
- String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
323
- String.fromCharCode(0x80 | (c & 0x3f))
324
- );
336
+ String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
337
+ String.fromCharCode(0x80 | (c & 0x3f));
338
+ if (s.length >= __SB_BYTES_WINDOW) {
339
+ if (out === null) out = [];
340
+ out.push(s);
341
+ s = "";
342
+ }
325
343
  }
344
+ if (out === null) return s;
345
+ if (s.length > 0) out.push(s);
326
346
  return out.join("");
327
347
  }
328
348
  const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
329
- const out = [];
330
- for (let i = 0; i < view.length; i++) out.push(String.fromCharCode(view[i] & 0xff));
349
+ let out = null;
350
+ let s = "";
351
+ for (let i = 0; i < view.length; i++) {
352
+ s += String.fromCharCode(view[i] & 0xff);
353
+ if (s.length >= __SB_BYTES_WINDOW) {
354
+ if (out === null) out = [];
355
+ out.push(s);
356
+ s = "";
357
+ }
358
+ }
359
+ if (out === null) return s;
360
+ if (s.length > 0) out.push(s);
331
361
  return out.join("");
332
362
  }
333
363
  function __sbBufFrom(latin1) {
@@ -443,20 +473,23 @@ function __sbEqCt(a, b) {
443
473
  // An even-length all-hex string -> its bytes; anything else -> its raw bytes.
444
474
  function __sbHexOrBytes(value) {
445
475
  if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
446
- // Array-and-join for the same reason as __sbToBytes above; the old version
447
- // was worse than a plain `+=` loop, since rewriting the last char with
448
- // `out.slice(0, -1) + ...` copied the whole string a second time per byte.
449
- // The high nibble is held in a local until its pair arrives instead.
450
- const out = [];
476
+ // The high nibble waits in a local until its pair arrives, instead of the
477
+ // old `out = out.slice(0, -1) + ...` rewrite, which copied the whole
478
+ // accumulated string a second time per byte. Deliberately still `+=` and
479
+ // not __sbToBytes' windowing: this decodes an HMAC signature, which is
480
+ // tens of bytes, and #181 showed array allocation is the expensive part at
481
+ // that size. ponytail: O(n^2) above a few KB, window it like __sbToBytes
482
+ // if a caller ever passes something that big.
483
+ let out = "";
451
484
  let hi = 0;
452
485
  for (let i = 0; i < value.length; i++) {
453
486
  const c = value.charCodeAt(i);
454
487
  const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
455
488
  if (d < 0) return __sbToBytes(value);
456
489
  if (i % 2 === 0) hi = d << 4;
457
- else out.push(String.fromCharCode(hi | d));
490
+ else out += String.fromCharCode(hi | d);
458
491
  }
459
- return out.join("");
492
+ return out;
460
493
  }
461
494
  return __sbToBytes(value);
462
495
  }
@@ -10,12 +10,13 @@ import { fileURLToPath } from "node:url";
10
10
  const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
11
11
  const isStrStart = src.indexOf("function __sbIsStr(");
12
12
  const isStrEnd = src.indexOf("function __sbIsFn(");
13
- const start = src.indexOf("function __sbToBytes(");
13
+ // Starts at the window constant, not the function: __sbToBytes closes over it.
14
+ const start = src.indexOf("const __SB_BYTES_WINDOW");
14
15
  const end = src.indexOf("function __sbBufFrom(");
15
16
  const hexStart = src.indexOf("function __sbHexOrBytes(");
16
17
  const hexEnd = src.indexOf("\nif (globalThis.crypto.subtle");
17
18
  if (isStrStart === -1 || isStrEnd === -1 || start === -1 || end === -1 || end < start) {
18
- throw new Error("__sbToBytes (or __sbIsStr) block not found in prelude");
19
+ throw new Error("__sbToBytes (or __sbIsStr / __SB_BYTES_WINDOW) block not found in prelude");
19
20
  }
20
21
  if (hexStart === -1 || hexEnd === -1 || hexEnd < hexStart) throw new Error("__sbHexOrBytes block not found in prelude");
21
22
 
@@ -90,6 +91,31 @@ test("a long high-byte typed array round-trips byte for byte", () => {
90
91
  expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
91
92
  });
92
93
 
94
+ // sproutboat#181: 0.6.4 encoded every size through one array-push-then-join
95
+ // path, which cost a real app 64% of its throughput on the ~150-byte inputs
96
+ // crypto.subtle sees per request. The encoder now only reaches for an array
97
+ // once a 512-byte window fills, so these sizes exercise both sides of that
98
+ // boundary and the seam between them. Correctness only; the performance claim
99
+ // lives in the benchmark, not here.
100
+ test("inputs either side of the window boundary encode byte-identically", () => {
101
+ for (const n of [0, 1, 150, 511, 512, 513, 1024, 1025]) {
102
+ const text = "a".repeat(n);
103
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
104
+ const bytes = new Uint8Array(n);
105
+ for (let i = 0; i < n; i++) bytes[i] = (i * 37 + 0x80) & 0xff;
106
+ expect(bytesOf(__sbToBytes(bytes))).toEqual([...bytes]);
107
+ }
108
+ });
109
+
110
+ test("a surrogate pair straddling the window boundary is not split", () => {
111
+ // Land the 4-byte sequence so its bytes would cross a 512-byte flush if the
112
+ // encoder ever flushed mid-character.
113
+ for (let pad = 508; pad <= 515; pad++) {
114
+ const text = "a".repeat(pad) + "🌱" + "b".repeat(4);
115
+ expect(bytesOf(__sbToBytes(text))).toEqual([...new TextEncoder().encode(text)]);
116
+ }
117
+ });
118
+
93
119
  // __sbHexOrBytes had the same O(n^2) shape on a third path (it backs the
94
120
  // `expected` side of the HMAC verify), plus a `slice(0, -1) + ...` rewrite that
95
121
  // copied the accumulated string a second time per byte. Same array+join fix.
@@ -1191,13 +1191,13 @@ function __sbEnsureSchema() {
1191
1191
  }
1192
1192
 
1193
1193
  function __sbHex(n) {
1194
- const out = [];
1194
+ let out = "";
1195
1195
  const bytes = __sbRandomBytes(String(n));
1196
1196
  for (let i = 0; i < bytes.length; i++) {
1197
1197
  const h = bytes.charCodeAt(i).toString(16);
1198
- out.push(h.length === 1 ? "0" + h : h);
1198
+ out += h.length === 1 ? "0" + h : h;
1199
1199
  }
1200
- return out.join("");
1200
+ return out;
1201
1201
  }
1202
1202
 
1203
1203
  function __sbEmbeddedDispatch(msg) {
@@ -1279,15 +1279,14 @@ function __sbEmbeddedDispatch(msg) {
1279
1279
  if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
1280
1280
  const allow = bindingsOutbound();
1281
1281
  if (allow.indexOf(url.host) === -1) throw new Error("host not in outbound allowlist: " + url.host);
1282
- const headerParts = [];
1282
+ let headerText = "";
1283
1283
  const pairs = msg.headers || [];
1284
1284
  for (let i = 0; i < pairs.length; i++) {
1285
1285
  const key = String(pairs[i][0]).toLowerCase();
1286
1286
  // Host, Connection and Content-Length are ours to set.
1287
1287
  if (key === "host" || key === "connection" || key === "content-length") continue;
1288
- headerParts.push(pairs[i][0] + ": " + pairs[i][1] + "\r\n");
1288
+ headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
1289
1289
  }
1290
- const headerText = headerParts.join("");
1291
1290
  const port = url.port ? url.port : tls ? "443" : "80";
1292
1291
  const reply = JSON.parse(
1293
1292
  __sbHttpRaw(