Skip to content

Bun.$ .text() mangles non-ASCII output on Windows into uXXXX without the backslash #47479

Open

Description

@umhapal

Bun.$'s .text() mangles non-ASCII output on Windows: every non-ASCII character comes back as uXXXX with the backslash dropped, so the result cannot be decoded back.

Repro

Bun 1.4.4, Windows 10.0.26300 (x64), cmd.exe as the shell.

// t.js
const out = await Bun.$`cmd /c echo 中文测试`.text();
console.log(JSON.stringify(out));
$ bun t.js
"u4E2Du6587u6D4Bu8BD5\r\n"

Expected "中文测试\r\n". The escapes are missing their leading backslash — it is u4E2D, not \u4E2D — so the string is not even valid JSON-escape input.

Same result with a direct (non-cmd) invocation:

await Bun.$`echo 中文测试`.text()      // "u4E2Du6587u6D4Bu8BD5\n"
await Bun.$`cmd /c echo テスト`.text() // "u30C6u30B9u30C8\r\n"

ASCII is unaffected: await Bun.$cmd /c echo hello.text() → "hello\r\n".

The bytes on the pipe are correct

Bun.spawn reading the same command returns proper UTF-8, so the corruption is in the Bun.$ decode path, not in the child process or the console code page:

const p = Bun.spawn(['cmd','/c','echo 中文测试'], { stdout:'pipe' });
const buf = new Uint8Array(await new Response(p.stdout).arrayBuffer());
// e4 b8 ad e6 96 87 e6 b5 8b e8 af 95 0d 0a
new TextDecoder('utf8').decode(buf); // "中文测试\r\n"

node:child_process is also correct on the same machine:

execSync('cmd /c "echo 中文测试"', { encoding: 'utf8' }) // "中文测试\r\n"
execSync('cmd /c "echo 中文测试"').toString('utf8')      // "中文测试\r\n"

.bytes() is affected too

await Bun.$`cmd /c echo 中文测试`.bytes()
// {0:117, 1:52, 2:69, 3:50, ...}  → "u4E2D..." as bytes

So the corruption happens before .bytes(), not in .text() specifically. The only correct route I found is Bun.spawn + reading stdout directly.

Why it matters

Bun.$ is the documented ergonomic way to run a command and capture output. On Windows, any command that prints non-ASCII — file paths with CJK characters, dir listings, git output with a localized message, a CLI that prints a Unicode table — silently returns garbage that looks like an escape sequence. It fails loudly only if you notice the missing backslash.

Environment

Bun v1.4.4 (38afee6c8)
Windows 10.0.26300 x64

This is not console code page: chcp is irrelevant here because the raw bytes on the pipe already decode correctly with TextDecoder('utf8').

Activity

  1. umhapal commented on Oct 30, 2026

    @umhapal
    Author

    Correction after more testing: the corruption is on the input side, not the decode side. My original report blamed .text() decoding. That was wrong. The command string itself is rewritten before it reaches the shell.

    What actually happens

    Bun.$ takes the template literal's literal text and escapes every non-ASCII character into uXXXX — with the backslash dropped — before spawning the child. The shell receives u4E2Du6587, not 中文.

    Proof: make the shell write what it received to a file, then read the raw bytes.

    await Bun.$`cmd /c echo 中文 > a.txt`.quiet();
    await Bun.file('a.txt').text()   // "u4E2Du6587\r\n"

    If the child had received 中文 and only the reader mangled it, the file would contain the UTF-8 bytes e4 b8 ad e6 96 87. It does not — the file contains the ASCII bytes for u4E2Du6587. The child never saw the Chinese.

    Two other facts that pin it down

    Interpolation is not affected. Only literal text inside the tagged template is rewritten:

    await Bun.$`cmd /c echo 中文 > a.txt`.quiet();      // a.txt = "u4E2Du6587"
    const v = '中文';
    await Bun.$`cmd /c echo ${v} > b.txt`.quiet();      // b.txt = "中文"   ← correct

    Unicode escapes are also rewritten (just lowercased), so the bug is a general "strip the backslash from any escape" behaviour rather than something specific to raw CJK:

    await Bun.$`cmd /c echo \u4e2d\u6587 > c.txt`.quiet();  // c.txt = "u4e2du6587"

    Why my original diagnosis was wrong

    I inferred "decode path" from Bun.spawn working and Bun.$ failing on the same command. That difference is real, but it does not locate the bug: Bun.spawn receives the string as a plain argv element and never goes through template-literal parsing, so it never hits the rewrite. The .bytes() result I cited as evidence that "corruption happens before .bytes()" was consistent with both hypotheses — I did not test the child's actual input.

    The one test that settles it is the redirect-to-file one above. I should have run that first.

    Corrected repro

    // t.js
    const v = '中文';
    await Bun.$`cmd /c echo ${v} > interp.txt`.quiet();
    await Bun.$`cmd /c echo 中文 > literal.txt`.quiet();
    console.log('interp :', JSON.stringify(await Bun.file('interp.txt').text()));  // "中文\r\n"
    console.log('literal:', JSON.stringify(await Bun.file('literal.txt').text())); // "u4E2Du6587\r\n"
    $ bun t.js
    interp : "中文\r\n"
    literal: "u4E2Du6587\r\n"
    

    Bun v1.4.4 (38afee6), Windows 10.0.26300 x64.

    Workaround

    Interpolate the string instead of writing it literally:

    const cmd = 'echo 中文测试';
    await Bun.$`cmd /c ${cmd}`.text();

    Note on scope

    I cannot tell from the outside whether this is Windows-specific. The escaping happens before any child process exists, so it plausibly reproduces on every platform where Bun.$ parses a template literal — worth checking on Linux/macOS before deciding how to label it.

  2. 84dnnvbdvp-debug commented on Nov 1, 2026

    @84dnnvbdvp-debug

    One concrete regression-test gap in current main: test/js/bun/shell/bunshell.test.ts, describe("unicode") currently covers Unicode through interpolation (echo ${whatsupbro}), and a separate backslash-escape case, but not a non-ASCII character written directly in the literal segment of a $ tagged template. That difference is exactly what the corrected repro isolates.

    A focused regression alongside basic would compare await $echo 中文 against `const value = "中文"; await $`echo ${value}, expecting the same UTF-8 中文\n output. Run it on Windows (the reported failing platform) and at least one Unix target before deciding whether the parser bug is Windows-specific. The reporter's redirect-to-file test is still the stronger child-input discriminator; this proposed suite case specifically closes the presently untested literal-versus-interpolation path. This is a source-inspected test suggestion, not a claim that I reran Bun 1.4.4.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions