JS backend: String.split/String.length recurse per character and overflow the JS stack on ~10k+ char strings (found by DeepSeek V4.1 Flash)
Summary
In the JS backend, String.split and String.length are emitted as per-character recursions. On inputs above ~10k characters (Node/V8) or ~15–20k (Bun), calling them throws RangeError: Maximum call stack size exceeded at runtime — not at check time. The existing trampoline (run_loop/run_jump) does not help because the recursive call is not in tail position (it is evaluated eagerly as an argument). String.take is fine (native slice), so this seems specific to the recursively-defined primitives.
Minimal repro
split-recursion.bend:
import Base
def parts(s: String) -> List<&2, String>:
String.split(s, ',')
def len(s: String) -> Nat:
String.length(s)
def take5(s: String) -> String:
String.take(s, 5n)Built with bun + the bend2 plugin (~/.bend/current/bend2/main.ts, target browser/esm, no minify) and called from JS with 'x'.repeat(n):
| n | String.split (Bun / Node) |
String.length (Bun / Node) |
String.take (Bun / Node) |
|---|---|---|---|
| 5 000 | ok / ok | ok / ok | ok / ok |
| 10 000 | ok / RangeError | ok / RangeError | ok / ok |
| 15 000 | ok / RangeError | ok / RangeError | ok / ok |
| 20 000 | RangeError / RangeError | ok / RangeError | ok / ok |
| 100 000 | RangeError / RangeError | RangeError / RangeError | ok / ok |
Generated code (from the artifact), showing the non-tail recursion in $String$split$:
function $String$split$(s_0, sep_0) {
if (s_0 === "") { return { $: "Con", ["head"]: "", ["tail"]: { $: "Nil" } }; }
const h_0 = s_0.codePointAt(0) > 65535 ? s_0.slice(0, 2) : s_0[0];
const t_0 = s_0.codePointAt(0) > 65535 ? s_0.slice(2) : s_0.slice(1);
const h_1 = h_0;
return run_jump($String$split$fin$, [h_1, run_loop($String$split$(t_0, sep_0)), run_loop($Char$is_eq$(h_1, sep_0))]);
}Runner used (works under both runtimes; build part needs Bun):
import core from './split-recursion.core.js';
const count = (xs) => { let n = 0; for (let l = xs; l && l.$ === 'Con'; l = l.tail) n++; return n; };
for (const n of [5000, 10000, 15000, 20000, 100000]) {
try { count(core.parts('x'.repeat(n))); console.log(n, 'split ok'); }
catch (e) { console.log(n, 'split ERRO:', e.message); }
}Real-world impact
An Electron app's logic ported to Bend uses String.split to frame a JSON-RPC line stream from a child process. Its buffers reach MBs (an 11 MB session dump). The generated artifact threw RangeError: Maximum call stack size exceeded inside $String$split$, which silently killed the response handling and surfaced as a connection timeout — extremely hard to trace from the app side. Any Bend program processing user data as strings (logs, JSON lines, pasted text, file contents) will hit this at a few KB–10k chars.
Environment
bend --version→bend 2.0.5- Bun 1.4.2, Node 26.8.2, macOS arm64
- Artifact from the bend2 plugin, target
browser, formatesm, no minify
Possible directions
- Emit
String.split/String.lengthas loops, or move the recursive call into tail position under the existingrun_loop/run_jumptrampoline. - Or use native JS operations where semantics match (splitting on a char,
.length) — the wayString.takealready appears to. - If the recursion is intentional, documenting the input-size limit would help. Related but distinct: #779 / #791 are about check-time depth; this is runtime.
Attribution
Found and reproduced by an AI coding agent — DeepSeek V4.1 Flash (via OpenCode) — while porting a real app (an Electron study desk) to Bend, at the request of the project owner. Numbers above are from this machine; happy to test a patch or provide the ported app's framing module as an additional test case.
Source: HigherOrderCO/Bend