Skip to content

Char.from_u32 of a surrogate fails in JS and the interpreter but prints invalid UTF-8 natively #1080

Closed

Description

@Dicklesworthstone

What you did

bend surrogate.bend              # default run
bend surrogate.bend -o s && ./s
bend surrogate.bend -o s.js && node s.js

What happened

Path Output Exit
default run max scalar = …, then bend: 55296 is not a Unicode scalar value 1
JS same as the default run 1
native binary max scalar = …, then surrogate = followed by bytes ED A0 80, then unreachable 0

Char.from_u32(55296) (U+D800, a surrogate) is accepted by the checker, since Char.from_u32 in base.bend is just Chr{x}. The JS runtime's char_new (bend2/comp.ts, around line 553) rejects surrogates and values above 0x10FFFF, while the C runtime prints the code point encoded as 3 bytes, which is not valid UTF-8, and carries on. Char.to_u32(Char.from_u32(55296)) likewise returns 55296 natively and fails in JS.

So the same program fails on one backend and succeeds with invalid output on the other. #809 fixed the related input side (invalid UTF-8 read by io_str); this is the output and construction side.

Suggested fix: decide what a Char outside the Unicode scalar range means and apply it in both runtimes, for example fail in C the way JS does, or have both print U+FFFD. Boundary tests at 0xD7FF, 0xD800, 0xDFFF, 0xE000, 0x10FFFF and 0x110000 would pin it.

The file

import Base

def main() -> IO(Unit):
  do IO<Unit>:
    IO.print("max scalar = " ++ Char.show(Char.from_u32(1114111)))
    IO.print("surrogate = " ++ Char.show(Char.from_u32(55296)))
    IO.print("unreachable")

bend --version

Bend 2.0.28 (main at 11c65a2, run from source with bun). Also seen on 2.0.13 and 2.0.16.

uname -sm

Darwin arm64

clang --version (the first line)

Apple clang version 17.0.0 (clang-1700.4.4.1)

Activity

  1. jeromelaurens commented on Oct 5, 2026

    @jeromelaurens

    The same question seen from the JS lane's exports (2.0.35): such a character cannot be built there, but an export receives it from its host, and two of them stored one after the other in a String read back as one character.

    What you did

    Bend 2.0.35 (79df8d9c), Bun 1.3.14, macOS arm64.

    import Base
    
    def reverseOnto(s: String, acc: String) -> String:
      match s:
        case SNil{}: acc
        case SCon{c, rest}: reverseOnto(rest, SCon{c, acc})
    
    def reverseTwice(s: String) -> String:
      reverseOnto(reverseOnto(s, SNil{}), SNil{})
    bend fuse.bend -o fuse.mjs
    bun -e 'import m from "./fuse.mjs"; const s = "\udc00\ud800";
      const r = m.reverseTwice(s);
      console.log([...s].length, [...r].length, r === s)'

    What happened

    reverseTwice returns "𐀀" (one character, U+10000) for the
    input "\udc00\ud800" (two characters, lone surrogates): the output prints
    2 1 false. In Bend's model reverseTwice(s) == s for every string.

    The JS lane represents a String as a host string: SCon{h, t} is
    h + t, and a match reads the first code point, two code units when it is
    above 0xFFFF (OPTIMIZED.String.SCon in bend2/comp.ts). A lone U+D800
    put in front of a string that starts with a lone U+DC00 therefore becomes a
    surrogate pair, and the next match reads one character where two were
    stored. A program run by bend cannot build these characters
    (Char.from_u32 traps, #1080), but an export receives them: JavaScript
    strings may hold lone surrogates, and the export passes them as String
    arguments or as Char fields of data.

    Consequence in practice: a function that collects characters into a
    String and reads it again (an accumulator reversed at the end) changes
    its input; composed with a strict encoder, a text rejected for its lone
    surrogates becomes the four bytes of U+10000.

    What I expected

    The same characters back, as in Bend's model; or, if the JS lane
    does not support characters outside the scalar range, a rejection at the
    export boundary (as char_new does for Char.from_u32), so that the
    model's equations hold for every value a JS function can receive.

    Suggested fix

    Decide, with #1080, what a Char outside the scalar range means on the JS
    lane, and apply it at the export boundary: reject lone surrogates in
    String and Char arguments, or represent such a character so that
    concatenation cannot pair it.

    bend --version

    Bend 2.0.35 (79df8d9c).

    uname -sm

    Darwin arm64

  2. Giulio2002 commented on Oct 8, 2026

    @Giulio2002
    Contributor

    Update on 2.0.36 (commit 1d895ca, Linux x86_64, bun 1.4.2), re-verified in isolation — and a wider set of entry points that hit the same JS-side abort:

    • The interpreter half of this issue has been fixed: Char.from_u32(55296) now checks, runs and prints "\u{d800}" on lane 0 (bend t.bend). The C lane prints it too. Only the JS lane still aborts: bend: 55296 is not a Unicode scalar value, exit 1, through chr_new's scalar check.
    • The abort is broader than Char.from_u32, and reaches string literals:
    import Base
    
    def main() -> String:
      "a\u{d800}b"
    • interpreter: "a\u{d800}b" (exit 0); C: same (exit 0);
      bend t.bend -o t.js && bun t.js: bend: 55296 is not a Unicode scalar value, exit 1.
    • the same file with u{110000} or u{ffffffff}: interpreter/C print '\u{110000}' / '\u{ffffffff}'; JS aborts with the same message. Char.from_u32(1114112) and Char.from_u32(4294967295) likewise.
    • it fires even when the bad code is never shown — String.length/String.eq/String.is_empty over such a value abort the JS program the moment the value crosses chr_new.
    • Front-end intent (for whoever fixes it): lit_of (bend.ts ~1077-1083) deliberately keeps a literal spelling a surrogate or a codepoint past U+10FFFF as an explicit SCon chain, and chr_show (~1288-1297) deliberately renders it — so the abort contradicts both the checker's own representable-literal model and the C lane, rather than guarding an invariant the language has.
    • C-side note, same family: through IO.print the C backend writes a lone surrogate as raw invalid UTF-8 (ed a0 80) since io_utf8 (comp.ts ~5502) does not clamp — the C lane is fine on the pure/show path, only the byte-output side is unclamped.

    Found by an automated AI-model differential-testing campaign against bend 2.0.36; ~2,200 three-lane executions over string/char/list/map cases found no other lane disagreement.

  3. Giulio2002 commented on Oct 8, 2026

    @Giulio2002
    Contributor

    Correction to my earlier comment above — the interpreter half of this is only fixed for pure evaluation, not for the IO-run machinery every real program uses (2.0.36, commit 1d895ca, re-verified in isolation):

    import Base
    
    def main() -> IO(Unit):
      do IO<Unit>:
        IO.print(U32.show(Char.to_u32(Char.from_u32(4294967295))))
        IO.print(U32.show(Char.to_u32(Char.from_u32(65536))))
        IO.print(U32.show(Char.to_u32(Char.from_u32(55296))))
    • interpreter (bend t.bend): bend: 4294967295 is not a Unicode scalar value, exit 1;
    • the same computation with a pure main -> U32 / main -> String normalizes and prints correctly (4294967295, 5, …), so the plain-value path was indeed fixed;
    • JS lane: throws in every shape (pure and IO);
    • C lane: accepts the full U32 range everywhere.

    So the current three-lane state is: C fine everywhere; interpreter fine for pure programs, throwing under the IO run; JS throwing everywhere. Bonus oddity found while minimizing: inside one interpreter IO-run, the generic route building a List<&2, Char> of such chars folded by specialized readers succeeds with the correct value, while the direct Char.from_u32 beside it throws (61-char-code-range-divergence-generic.bend in the linked campaign directory) — the check is per-routing rather than per-language.

    Found by an automated AI-model differential-testing campaign against bend 2.0.36 (boxing/word-representation pass).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    laterAccepted; parked until we return to it

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions