Skip to content

Cast recordings corrupt multi-byte UTF-8 characters at 4096-byte chunk boundaries (mojibake with oh-my-posh / opencode TUI / bulk output) #5

Closed

Description

@soroshsabz

Summary

Start-InteractiveTranscript corrupts multi-byte UTF-8 characters in the recorded .cast file whenever more than 4096 bytes of output are pending in the PTY output pipe. The truncated UTF-8 sequences at each 4096-byte read boundary decode to U+FFFD (replacement character, EF BF BD) and are written permanently into the cast. The live console passthrough is unaffected (raw bytes are forwarded untouched), so everything looks fine during recording - the corruption only appears on playback (Show-InteractiveTranscript, asciinema play, agg renders).

This hits unicode-heavy workflows exactly like oh-my-posh prompts (powerline/nerd-font glyphs) and full-screen TUIs such as opencode, plus everyday commands that emit bulk unicode text.

Environment

  • Bsn.Host 0.1.0 (PowerShell Gallery), Bsn.Common 0.1.0
  • Windows 11 10.0.26300, Windows Terminal
  • Recording host: PowerShell 7.6.9 (also reproduced with Windows PowerShell 5.1 as the caller)
  • Verified with oh-my-posh 29.0.2 and opencode 2.2.5 sessions

Repro A - deterministic bulk write (interactive session)

Start-InteractiveTranscript          # recorded nested shell
# inside the recorded session:
[Console]::Out.Write([string][char]0x2580 * 60000)   # 180 KB, 3 bytes per char
exit

Result on my machine: cast contains 50 output events; 20 of them are corrupted, 36 U+FFFD replacement characters in total. The corruption pattern is exactly the chunk boundary: one event ends with U+FFFD (dangling first byte of a split sequence) and the next event starts with U+FFFD (orphaned continuation bytes).

Repro B - everyday command (no synthetic writes)

Inside a recorded session, print a 291 KB UTF-8 file in a single write:

Get-Content .\big_utf8.txt -Raw      # box-drawing, arrows, check marks, CJK

Result: cast has 74 output events, 54 of them contain U+FFFD, 86 replacement characters total. Playback shows mojibake lines like ??┌─┬─? where every split glyph is destroyed.

Why TUI / prompt users hit it

  • The ConPTY renderer flushes frames continuously, so paced output stays under the 4096-byte read buffer and survives. In my recordings an opencode full-screen paint at 120 columns was 3631 bytes - just under the limit. At typical wider terminal widths the same paint exceeds 4096 bytes and gets split mid-glyph.
  • oh-my-posh prompts render fine when each burst is small, but any burst > 4096 bytes (long prompts with many segments, PSReadLine prediction-list redraws while typing/pasting fast, output scrolling) splits at byte 4096 with 2-in-3 probability per multi-byte char boundary.

Root cause

src/Bsn.Host/src/PtyRecorder.cs, PumpOutput() reads raw bytes into a 4096-byte buffer and hands each chunk to CastWriter.WriteEvent(), which decodes each chunk independently:

// PumpOutput
byte[] buffer = new byte[4096];
...
cast.WriteEvent(clock.Elapsed.TotalSeconds, false, chunk);

// CastWriter.WriteEvent
string text = Escape(Encoding.UTF8.GetString(chunk));

Encoding.UTF8.GetString() on a chunk that ends mid-sequence replaces the truncated bytes with U+FFFD; the next chunk's leading continuation bytes produce another U+FFFD. Both the v2 and v3 writers share this code path, and input recording (-RecordInput) has the same problem for i events.

Suggested fix

Keep a persistent Decoder (or carry the trailing incomplete byte sequence) across reads in PumpOutput, and only hand complete sequences to CastWriter. The live proxy path already forwards raw bytes, so only the cast write path needs to change. A Pester test that records a > 4096-byte multi-byte burst (via ConvertTo-BsnAsciicast/Get-BsnCastTimeline fixtures) and asserts zero U+FFFD in the events would lock this in.

Artifacts from my runs (cast files with the corruption): C:\Users\sooro\AppData\Local\Temp\opencode\bsnhost-bugreport\ (h2_utf8.cast, I_getcontent_raw.cast). Happy to attach them if useful.

Activity

  1. soroshsabz commented on Nov 6, 2026

    @soroshsabz
    MemberAuthor

    Related findings from the same investigation session (independently reproducible, separate root causes): #6 (cast writer buffering loses the header/tail on abnormal termination), #7 (-Command mangled by the cmd.exe wrapper), #8 (empty cast + success result when the caller has redirected stdio, e.g. running Start-InteractiveTranscript from the opencode TUI shell).

  2. soroshsabz commented on Nov 6, 2026

    @soroshsabz
    MemberAuthor

    Evidence artifacts attached as a gist (secret link, accessible to anyone with the URL): https://gist.github.com/soroshsabz/c5fe45f2150347b335222fa10038b625

    • h2_utf8.cast - deterministic 180 KB multi-byte burst: 50 events, 20 corrupted, 36 U+FFFD
    • I_getcontent_raw.cast - everyday Get-Content -Raw command: 74 events, 54 corrupted, 86 U+FFFD
    • big_utf8.txt / graw.ps1 - the exact input used
    • verify.ps1 - byte scanner, e.g. powershell -File .\verify.ps1 .\h2_utf8.cast

    Repro tooling used for the whole investigation session (console input-injection helper + recording runners): https://gist.github.com/soroshsabz/8e74e9664da1e60241a1b73cf8443666

  3. self-assigned this
    on Nov 6, 2026
  4. added theissue type on Nov 6, 2026
  5. added 2 commits that reference this issue on Nov 6, 2026
  6. soroshsabz commented on Nov 6, 2026

    @soroshsabz
    MemberAuthor

    Reopening with field evidence: 0.2.1 recordings of OpenCode still showed corruption. Root cause analysis found a SECOND mechanism, distinct from the chunk-boundary decoder bug fixed in #12:

    Diagnosis (probes against the real ConPTY engine)

    • A fresh pseudo console starts with the OEM output codepage, not UTF-8 (probe: recorded chcp.com output shows "Active code page: 437").
    • OEM-codepage-decoding conhosts decode a child's stdout bytes with that codepage. Byte-writing TUIs - Go/Bubble Tea applications such as OpenCode, Node raw writes, cmd /c type - emit UTF-8 bytes, so one box-drawing character (U+2500, UTF-8 E2 94 80) arrives as three CP-decoded characters (CP437 Γ ö Ç) before the recorder sees anything.
    • pwsh's own output is immune (it writes through WriteConsoleW), which is why synthetic pwsh probes passed while real byte-writing TUIs corrupted. This matches the field symptom exactly: ASCII text fine, border/border-glyph lines mojibake.

    Note: the corruption pattern in the field report (valid mojibake letters, not U+FFFD) is the codepage signature; the chunk-boundary bug fixed earlier produces U+FFFD replacement instead. Both are now covered.

    Fix (PR #16): the recorded command line bootstraps [Console]::OutputEncoding/InputEncoding to UTF-8 (the programmatic chcp 65001) before the user's command or interactive shell runs - verified end to end with a byte-writing child and a real OpenCode session recording (zero mojibake, borders intact).

    New per-scenario system tests in InteractiveTranscript.System.Tests.ps1:

  7. added 2 commits that reference this issue on Nov 7, 2026
    b877b19
    6944d75
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions