#798·Bend

JS backend: String.split/String.length recurse per character and overflow the JS stack on ~10k+ char strings (found by DeepSeek V4.1 Flash)

Author: FariaDevCreated Sep 18, 2026Updated Sep 18, 2026

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:

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$:

javascript
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):

javascript
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 --versionbend 2.0.5
  • Bun 1.4.2, Node 26.8.2, macOS arm64
  • Artifact from the bend2 plugin, target browser, format esm, no minify

Possible directions

  • Emit String.split/String.length as loops, or move the recursive call into tail position under the existing run_loop/run_jump trampoline.
  • Or use native JS operations where semantics match (splitting on a char, .length) — the way String.take already 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.