functionalscript 0.35.0 → 0.35.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.
@@ -9,4 +9,5 @@ export declare const proof: {
9
9
  validPadding: () => void;
10
10
  knownVectors: () => void;
11
11
  decodeOverflow: () => void;
12
+ encodeLargeVecIsSlow: () => void;
12
13
  };
@@ -1,5 +1,5 @@
1
1
  import { assertEq } from "../asserts/module.f.js";
2
- import { empty, vec } from "../types/bit_vec/module.f.js";
2
+ import { empty, vec, repeat, vec8 } from "../types/bit_vec/module.f.js";
3
3
  import { encode, decode } from "./module.f.js";
4
4
  const check = (s, v) => {
5
5
  assertEq(encode(v), s);
@@ -83,4 +83,28 @@ export const proof = {
83
83
  const oversized = 'A'.repeat(174_764);
84
84
  assertEq(decode(oversized), null);
85
85
  },
86
+ // Regression guard: `encode` used to be quadratic in the input size, not
87
+ // linear. `baseN`'s `vecToString` (`fs/base_n/module.f.ts`) popped one
88
+ // 6-bit chunk off the front at a time via `popFront`, and each `popFront`
89
+ // re-masked the entire remaining bigint through `vec()`'s `m & ui` —
90
+ // O(remaining length) — so encoding a vector of `n` bits cost O(n²). A
91
+ // 90,000-byte input took ~5.6s on Node and ~18.4s on Bun (Bun's `bigint`
92
+ // has a much higher per-operation constant, see PR #1190), reliably
93
+ // blowing `bun test`'s 5s per-test timeout — the same class of failure
94
+ // that hit `cas_get content:true` on a comparably-sized binary blob in CI
95
+ // (PR #1201).
96
+ //
97
+ // Fixed by rewriting `vecToString` as a balanced recursive split
98
+ // (`unpackToString`), mirroring the `chunkList`/`unpackChunkList`
99
+ // divide-and-conquer already used by `u8List` and by concatenation
100
+ // (`msb.concat` / `unpackListToVec`, PR #1192) — true O(n log n) now,
101
+ // since each half is masked to its own length before recursing. The same
102
+ // call now takes ~50ms on Node and ~225ms on Bun. No timing assertion
103
+ // (duration varies by engine/machine) — this just exercises the call and
104
+ // relies on the test runner's own per-test timing to catch a regression.
105
+ encodeLargeVecIsSlow: () => {
106
+ const big = repeat(90000n)(vec8(0xffn));
107
+ const result = encode(big);
108
+ assertEq(result?.length, 120_000);
109
+ },
86
110
  };
@@ -18,9 +18,10 @@ import type { Nullable } from '../types/nullable/module.f.ts';
18
18
  */
19
19
  export type BaseN = {
20
20
  /**
21
- * Encodes a bit vector by repeatedly popping `bits`-wide MSB chunks and
22
- * indexing them into the alphabet. A trailing partial chunk shorter than
23
- * `bits` is left-padded with zeros (the underlying `popFront` semantics).
21
+ * Encodes a bit vector by splitting it into `bits`-wide MSB chunks
22
+ * (`chunkList`, a balanced divide-and-conquer split, not a linear scan)
23
+ * and indexing each into the alphabet. A trailing partial chunk shorter
24
+ * than `bits` is left-padded with zeros.
24
25
  */
25
26
  readonly vecToString: (v: Vec) => string;
26
27
  /**
@@ -11,10 +11,14 @@
11
11
  *
12
12
  * @module
13
13
  */
14
- import { msb, lsb, length, vec } from "../types/bit_vec/module.f.js";
15
- import {} from "../types/list/module.f.js";
16
- const { popFront } = msb;
14
+ import { msb, lsb, vec, chunkList, unpack } from "../types/bit_vec/module.f.js";
15
+ import { fold } from "../types/list/module.f.js";
16
+ import { compose } from "../types/function/module.f.js";
17
+ const { unpackSplit } = msb;
17
18
  const { tryListToVec: reversedListToVec } = lsb;
19
+ // `chunkList(msb)` doesn't depend on `bits` or `v` — shared across every
20
+ // `baseN(...)` codec (base64, cbase32, ...).
21
+ const chunkListMsb = chunkList(msb);
18
22
  /**
19
23
  * Builds a {@link BaseN} codec for a fixed chunk width and alphabet.
20
24
  *
@@ -26,21 +30,29 @@ const { tryListToVec: reversedListToVec } = lsb;
26
30
  * `i`/`l`→`1`, `o`→`0`.
27
31
  */
28
32
  export const baseN = (bits, alphabet, normalize) => {
29
- const popFrontN = popFront(bits);
30
33
  const vecN = vec(bits);
31
34
  const toIndex = normalize === undefined
32
35
  ? (c) => alphabet.indexOf(c)
33
36
  : (c) => alphabet.indexOf(normalize(c));
37
+ const unpackSplitBits = unpackSplit(bits);
38
+ // Converts one `<= bits`-wide chunk (as yielded by `chunkList`, already
39
+ // masked to its own length) to its alphabet index. A trailing partial
40
+ // chunk shorter than `bits` is left-padded with zeros: `unpackSplit`'s
41
+ // shift amount goes negative, which per spec becomes a left shift.
42
+ const chunkToIndex = (chunk) => {
43
+ const u = unpack(chunk);
44
+ return Number(u.length < bits ? unpackSplitBits(u)[0] : u.uint);
45
+ };
46
+ // Folds directly over `chunkList`'s lazy list in one pass — faster than
47
+ // `map` into a second lazy list before joining, since there's no second
48
+ // list to allocate/traverse.
49
+ const chunkToString = (chunk) => (acc) => acc + alphabet[chunkToIndex(chunk)];
34
50
  return {
35
- vecToString: v => {
36
- let result = '';
37
- while (length(v) > 0n) {
38
- const [r, rest] = popFrontN(v);
39
- result += alphabet[Number(r)];
40
- v = rest;
41
- }
42
- return result;
43
- },
51
+ // `chunkListMsb(bits)` then `fold(chunkToString)('')` — neither half
52
+ // depends on `v`, so `compose` builds (and this closure captures)
53
+ // the composed function once per `baseN(...)` codec; no need to name
54
+ // the halves separately just to get that one-time build.
55
+ vecToString: compose(chunkListMsb(bits))(fold(chunkToString)('')),
44
56
  stringToVec: s => {
45
57
  // Build a reversed chunk list, bailing out at the first invalid
46
58
  // character so malformed input is rejected in O(prefix) time and
@@ -4,6 +4,8 @@ export declare const proof: {
4
4
  getMetaLargeMultiChunkTextThenBinary: () => void;
5
5
  getContentLargeBlobTooLargeError: () => void;
6
6
  getContentMissingHashIsError: () => void;
7
+ getContentBase64InflationOverflowWritesInternalError: () => void;
8
+ getContentBase64NearBoundarySucceeds: () => void;
7
9
  getContentDoubleEscapedOverflowWritesInternalError: () => void;
8
10
  toolsListAdvertisesThreeTools: () => void;
9
11
  addReturnsHash: () => void;
@@ -142,6 +142,15 @@ const asciiChunk = repeat(maxLengthBytes)(vec8(0x61n));
142
142
  const symbolChunk = repeat(maxLengthBytes / 2n)(u8ListToVec(msb)([0xc3, 0xa9]));
143
143
  // A full chunk of 0xFF — an invalid UTF-8 lead byte, so binary (no magic match).
144
144
  const binaryChunk = repeat(maxLengthBytes)(vec8(0xffn));
145
+ // 100,000 raw bytes of 0xFF: comfortably under `maxLengthBytes` (so `cas_get`'s
146
+ // own `length > maxLengthBytes` guard does not fire), but base64 inflates it to
147
+ // ceil(100_000/3)*4 = 133,336 chars — already past `maxLength` (131,072 bytes)
148
+ // before the JSON-RPC envelope adds anything. Small enough to stay clear of the
149
+ // separate `base64Encode`-near-`maxLength` padding bug (see `base64OfA` above).
150
+ const oversizedBase64Chunk = repeat(100000n)(vec8(0xffn));
151
+ // 90,000 raw bytes: base64 inflates to 120,000 chars, leaving ~11 KiB of margin
152
+ // under `maxLength` once the envelope is added — the paired "still succeeds" case.
153
+ const boundaryBase64Chunk = repeat(90000n)(vec8(0xffn));
145
154
  // 33,000 `"` bytes (0x22): valid UTF-8, so `cas_get` classifies it as text and
146
155
  // takes the double-JSON-escaping path (`toJson` builds the inner CAS JSON, then
147
156
  // the outer `stringifyJson` embeds that as the `text` field). Each quote costs
@@ -185,6 +194,51 @@ export const proof = {
185
194
  assertEq(resultOf(resp).isError, true);
186
195
  assert(textOf(resp).includes('no such hash'));
187
196
  },
197
+ // A blob at exactly `maxLengthBytes` passes cas_get's own raw-length guard
198
+ // (it checks the *stored* size, not the encoded response), but base64
199
+ // inflation of a 100,000-byte blob alone already exceeds `maxLength` before
200
+ // the JSON-RPC envelope is even added. The transport's `writeResponse`
201
+ // (`tryUtf8`) must catch this and write a JSON-RPC internal-error response —
202
+ // carrying the *original request's* `id` — instead of crashing the process.
203
+ // See `fs/mcp/stdio/module.f.ts` `writeResponse`.
204
+ //
205
+ // This test originally timed out under `bun test`'s native 5s per-test
206
+ // limit (12-14s observed in CI on PR #1201) — the cost was in
207
+ // `base64Encode`, quadratic before the `baseN.vecToString` fix (see
208
+ // `fs/base64/proof.f.ts` `encodeLargeVecIsSlow`). Now well under budget on
209
+ // both engines.
210
+ getContentBase64InflationOverflowWritesInternalError: () => {
211
+ const root = { 'home': { 'user': { 'cas_upload': { 'big': [oversizedBase64Chunk] } } } };
212
+ const [addResp] = runStdio(root)([
213
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
214
+ ]);
215
+ const hash = textOf(addResp);
216
+ const [, getResp] = runStdio(root)([
217
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
218
+ call(3, 'cas_get', { hash, content: true }),
219
+ ]);
220
+ const err = getResp;
221
+ assertEq(err.error?.code, -32603);
222
+ assertEq(err.id, 3);
223
+ },
224
+ // The paired boundary case: a blob whose base64 inflation leaves enough
225
+ // margin under `maxLength` for the envelope — confirms the fallback above
226
+ // only triggers on genuine overflow, not on every `content:true` binary read.
227
+ getContentBase64NearBoundarySucceeds: () => {
228
+ const root = { 'home': { 'user': { 'cas_upload': { 'big': [boundaryBase64Chunk] } } } };
229
+ const [addResp] = runStdio(root)([
230
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
231
+ ]);
232
+ const hash = textOf(addResp);
233
+ const [, getResp] = runStdio(root)([
234
+ call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
235
+ call(3, 'cas_get', { hash, content: true }),
236
+ ]);
237
+ assert(!resultOf(getResp).isError);
238
+ const result = JSON.parse(textOf(getResp));
239
+ assertEq(result.type, 'base64');
240
+ assertEq(result.length, 90_000);
241
+ },
188
242
  // A text blob made entirely of `"` characters keeps the *raw* content
189
243
  // (33,000 bytes) well under `maxLengthBytes`, but each quote costs 2 bytes on
190
244
  // the inner CAS-JSON escape and 4 bytes once the outer JSON-RPC envelope
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "functionalscript",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "**/*.js",