Splitting a string and looping over the pieces in a Node-server endpoint runs many more times than there are pieces — because the server returns the result in the wrong shape.
Canonical message
clean-node-server's `string_split` host bridge returns a JSON-encoded length-prefixed string (e.g. `["a","b","c","d"]`) instead of a Clean Language list-layout pointer. The compiler emits `iterate part in parts` code that reads list size from offset 0 and string-pointer elements at offset 16 + i*4. When the host returns an LP-JSON-string, offset 0 holds the JSON byte length (17 for the 4-element case), so the loop runs 17 times reading garbage past the JSON bytes — explains the dashboard report "RUNTIME_ITERATE_SPLIT_DERIVED_LIST_WRONG_LENGTH" (fingerprint 2e54b6dd700e) that prints 17 instead of 4 in production endpoints. Same root cause as the bug filed against clean-server (fingerprint 68db26207477).
node-server
node-server
bridge
1
1
56.0
2026-06-19 19:30:36
2026-06-19 19:30:36
Latest Report Context
bug
unknown
foundation/spec/stdlib-reference.md line 172: `.split(delimiter)` returns `list`. The list ABI is documented in clean-language-compiler/src/stdlib/list_ops.rs::generate_list_allocate.
clean-node-server's `string_split` host bridge returns a JSON-encoded length-prefixed string (e.g. `["a","b","c","d"]`) instead of a Clean Language list-layout pointer. The compiler emits `iterate part in parts` code that reads list size from offset 0 and string-pointer elements at offset 16 + i*4. When the host returns an LP-JSON-string, offset 0 holds the JSON byte length (17 for the 4-element case), so the loop runs 17 times reading garbage past the JSON bytes — explains the dashboard report "RUNTIME_ITERATE_SPLIT_DERIVED_LIST_WRONG_LENGTH" (fingerprint 2e54b6dd700e) that prints 17 instead of 4 in production endpoints. Same root cause as the bug filed against clean-server (fingerprint 68db26207477).
// Compile with cln 0.30.325. Standalone (wasmtime_runner) prints "4\n4".
// Run through clean-node-server endpoint and you get "17" then a trap.
// Standalone passes because clean-language-compiler/src/bin/wasmtime_runner.rs
// returns a proper list pointer; the Node-server's bridge does not.
start:
string s = "a```b```c```d"
list parts = s.split("```")
print(parts.length().toString()) // expect 4
integer count = 0
iterate part in parts
count = count + 1
print(count.toString()) // expect 4 — gets 17 / traps
`string_split` returns a pointer to a Clean Language list with this exact layout (matches src/stdlib/list_ops.rs::generate_list_allocate and src/bin/wasmtime_runner.rs lines 1259–1329 in clean-language-compiler):
offset 0..3 : size (i32, = number of parts)
offset 4..7 : capacity (i32, = number of parts)
offset 8..11 : type_id (i32, = 3 for string elements)
offset 12..15 : padding (i32, = 0)
offset 16.. : N * i32 string-pointers, each pointing to a length-prefixed string
All allocations must advance the `__heap_ptr` global so subsequent allocations don't overlap. wasmtime_runner.rs in the compiler repo is the working reference implementation.
clean-node-server/src/bridge/string.ts line 129–134 implement `string_split` as:
string_split(lpStr: number, lpDelim: number): number {
const state = getState();
const str = readPrefixedString(state, lpStr);
const delim = readPrefixedString(state, lpDelim);
return writeString(state, JSON.stringify(str.split(delim)));
},
`writeString(state, JSON.stringify(...))` produces an LP-encoded JSON STRING, not a list pointer. The 4-byte length prefix at offset 0 is 17 for `["a","b","c","d"]`. The compiler's iterate codegen loads i32 from offset 0 as the list size, so it loops 17 times and reads garbage past the JSON bytes as i32 string pointers.
AI Analysis
clean-node-server/src/bridge/string.ts
Discovered during: Triaging dashboard fingerprint 2e54b6dd700e (originally filed against compiler)
Replace the body of `string_split` at clean-node-server/src/bridge/string.ts lines 129–134 with a list-pointer return that mirrors clean-language-compiler/src/bin/wasmtime_runner.rs lines 1196–1334. Concrete steps in TypeScript:
1. Read the source string and delimiter via `readPrefixedString`.
2. `const parts = str.split(delim); const n = parts.length;`
3. Read the `__heap_ptr` global from the WASM instance.
4. Reserve `16 + n*4` bytes at `heap_ptr` for the list header + element-pointer slots.
5. For each part, reserve `4 + utf8ByteLength(part)` bytes for an LP string allocation, tracking each part's offset.
6. Align the new heap top to 8 bytes; grow memory if needed; write the new heap pointer back to `__heap_ptr`.
7. Write list header: size = n (offset 0), capacity = n (offset 4), type_id = 3 (offset 8), padding = 0 (offset 12).
8. Write the N element pointers at offset 16; then for each part write 4-byte length + UTF-8 bytes at its reserved slot.
9. Return the list pointer.
The compiler's wasmtime_runner.rs is the reference. After fixing, also remove the matching `string_join` JSON-parse path (line 137+ in string.ts) so the compiler doesn't have to round-trip through JSON for the inverse op — file as a follow-up if needed.
Same change applies to clean-server (filed in parallel as compiler-reported bug fingerprint 68db26207477).