Skip to content

Startup can panic when terminal probe parser state leaks into main input parsing #868

@tyx3211

Description

@tyx3211

Summary

edit can crash during startup when run over SSH from Windows Terminal + PowerShell into Linux. I reproduced this with both v2.0.0 and current main.

Illegal instruction (core dumped)

With local release-with-symbols builds from v2.0.0 and current main, the same failure resolves to a Rust panic:

thread 'main' panicked at crates/edit/src/framebuffer.rs:841:19:
chunk size must be non-zero

The crash is caused by startup terminal probing reusing the same vt::Parser instance as the main input loop. If setup_terminal() marks setup as done after a DA response while the current read leaves the shared parser in OscEsc/Osc, the Unix synthetic resize sequence (ESC[8;rows;cols t) can be consumed as OSC payload. Tui then remains at Size { width: 0, height: 0 }, and the first render panics in chunks_exact(0).

This may be related to #861, which reports the same visible Illegal instruction symptom.

Reported and reproduced by @tyx3211. Analysis/write-up assisted by Codex.

Environment

  • Local terminal/client: Windows Terminal launched from PowerShell, then OpenSSH SSH into Linux.
  • Client OS: Windows 11 25H2, build 26200.
  • Windows Terminal: 1.24.11321.0.
  • PowerShell: 7.5.3.
  • OpenSSH client: OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2.
  • Runtime location: edit itself runs on the remote Linux machine inside the SSH session.
  • $TERM: xterm-256color.
  • stty size during the log-only reproduction: 40 127.
  • edit: v2.0.0 release binary, invoked via PATH.
  • sha256sum "$(which edit)": ff7600a8ce7d4f4e8c1470461d04965e31933a1de1983c0223b27d01d3beca5d.
  • Binary BuildID: dcab5da0fce686422eefeba8860b7a5091d4ad1f.
  • v2.0.0 source revision used for the first reproducer: d3f8697.
  • Current main source revision reproduced with a log-only release-with-symbols build: 737450c.

Reproduction

This is timing-sensitive, but the following sequence reproduced it:

  1. Start Windows Terminal.
  2. Open PowerShell.
  3. SSH to the Linux machine.
  4. Press Ctrl+L.
  5. Type the command that starts edit, but do not press Enter yet.
  6. Scroll the mouse wheel so the prompt line moves to roughly the middle of the terminal viewport.
  7. Press Enter.
  8. Repeat if necessary.

The scroll step appears to increase the chance of hitting the startup response timing. I do not think it is a protocol precondition.

Captured terminal response payload

For debugging, I used a local Codex-authored log-only instrumentation patch around setup_terminal() to dump the raw bytes read from stdin. That temporary patch logged the read stage, byte length, and an escaped representation of each chunk before feeding the same bytes to the existing parser. It did not reset parser state, reorder bytes, or include the parser-lifecycle fix from the PR. The following payload is from current main at 737450cef9ee40030221d1ddc4e91a5e67173306.

SSH/TCP/PTY delivery is an ordered byte stream: read() boundaries are arbitrary, but byte order is preserved. These fragments are listed in receive order.

stage=setup_terminal len=63
escaped=\e]4;0;rgb:0c0c/0c0c/0c0c\e\\e]4;1;rgb:c5c5/0f0f/1f1f\e\\e]4;2;rgb:1

stage=setup_terminal len=96
escaped=313/a1a1/0e0e\e\\e]4;3;rgb:c1c1/9c9c/0000\e\\e]4;4;rgb:0000/3737/dada\e\\e]4;5;rgb:8888/1717/9898\e\\e]4

stage=setup_terminal len=96
escaped=;6;rgb:3a3a/9696/dddd\e\\e]4;7;rgb:cccc/cccc/cccc\e\\e]4;8;rgb:7676/7676/7676\e\\e]4;9;rgb:e7e7/4848/5

stage=setup_terminal len=80
escaped=656\e\\e]4;10;rgb:1616/c6c6/0c0c\e\\e]4;11;rgb:f9f9/f1f1/a5a5\e\\e]4;12;rgb:3b3b/7878/

stage=setup_terminal len=16
escaped=ffff\e\\e]4;13;rgb

stage=setup_terminal len=96
escaped=:b4b4/0000/9e9e\e\\e]4;14;rgb:6161/d6d6/d6d6\e\\e]4;15;rgb:f2f2/f2f2/f2f2\e\\e]10;rgb:cccc/cccc/cccc\e\

stage=setup_terminal len=96
escaped=\e]11;rgb:0c0c/0c0c/0c0c\e\\e[18;2R\e[?61;4;6;7;14;21;22;23;24;28;32;42;52c\e]4;0;rgb:0c0c/0c0c/0c0c\e

Important detail in the last fragment:

  • \e[?61;4;6;7;14;21;22;23;24;28;32;42;52c is the DA response. setup_terminal() treats any CSI with final byte c as the end marker.
  • After that DA response, another OSC 4 color response begins.
  • The fragment ends with a lone ESC, so after setup is marked done, the rest of the current read can leave the shared parser in an OSC/ST-related state (OscEsc, then Osc) when application input begins.

Diagnostic notes

  • A release-with-symbols build from v2.0.0 resolved the user-visible Illegal instruction to the Rust panic shown above.
  • A log-only release-with-symbols build from current main at 737450cef9ee40030221d1ddc4e91a5e67173306 reproduced the same panic and the same parser state.
  • The release-with-symbols builds used the release profile while keeping debug symbols:
    CARGO_PROFILE_RELEASE_STRIP=false CARGO_PROFILE_RELEASE_DEBUG=full cargo build --release -p edit.
  • A separate log-only build from clean v2.0.0 traced the bytes read by setup_terminal() without adding the parser-lifecycle fix, preserving the original crash behavior.
  • The proposed fix targets main and addresses the same lifecycle bug reproduced on both v2.0.0 and main@737450cef9ee40030221d1ddc4e91a5e67173306.

GDB evidence

For the stripped v2.0.0 release binary, the core showed SIGILL at an ud2 instruction inside the executable:

Core was generated by `.../edit'.
Program terminated with signal SIGILL, Illegal instruction.
#0  0x00005c86ab8fcebb in ?? ()
=> 0x5c86ab8fcebb: ud2

The current main log-only release-with-symbols build showed the same Rust panic path:

#15 edit::framebuffer::Framebuffer::render
    at .../core/src/slice/mod.rs:1243
#16 edit::tui::Tui::render
    at crates/edit/src/tui.rs:862
#17 edit::run
    at crates/edit/src/bin/edit/main.rs:181

The same frame showed:

tui = edit::tui::Tui {
    ...
    size: edit::helpers::Size { width: 0, height: 0 },
    framebuffer: edit::framebuffer::Framebuffer {
        buffers: [
            ... size: edit::helpers::Size { width: 0, height: 0 },
            ... size: edit::helpers::Size { width: 0, height: 0 },
        ],
    },
    ...
}

vt_parser = edit::vt::Parser {
    state: edit::vt::State::Osc,
    csi: edit::vt::Csi {
        params: [61, 4, 6, 7, 14, 21, 22, 23, 24, 28, 32, 42, 52, ...],
        param_count: 13,
        private_byte: '?',
        final_byte: 'c',
    },
}

So the OS/PTY had a real window size (stty reported 40x127), but Tui never received the synthetic resize event.

Root cause

The current startup flow is:

  1. run() creates let mut vt_parser = vt::Parser::new().
  2. setup_terminal(&mut tui, &mut state, &mut vt_parser) uses that parser to read startup probe responses.
  3. setup_terminal() stops as soon as it sees a DA response whose CSI final byte is c.
  4. run() then reuses the same vt_parser for the main input loop.
  5. On Unix, sys::inject_window_size_into_stdin() injects ESC[8;rows;cols t as a synthetic resize.
  6. If the shared parser is still in OscEsc/Osc, that synthetic resize is consumed as OSC data rather than emitted as Input::Resize.
  7. Tui remains at 0x0, and the first render hits chunks_exact(0).

Why I do not think this should be treated as terminal corruption

Windows Terminal appears to answer the startup OSC color queries using ST (ESC \) terminators. The payload also shows color responses arriving around/after the DA response and being split at read boundaries.

The captured bytes are not enough to prove Windows Terminal violated the protocol:

  • ST-terminated OSC replies are valid.
  • SSH/TCP/PTY delivery preserves byte order.
  • read() may split the byte stream at arbitrary positions.

The robust application-side rule is that startup probing should not leak parser state into the main application input parser.

The edit-side failure does not require an invalid terminal sequence. A legal stream may contain DA followed by a later OSC response, and read() may split an ST-terminated OSC response between ESC and \. DA is therefore not a transaction boundary for all earlier terminal queries, and the startup/main handoff should not depend on receiving all responses before DA.

Suggested fix

Isolate the startup probe parser lifecycle and make startup probing hand off only from a clean parser boundary:

  • setup_terminal() should own a local vt::Parser.
  • The main input loop should create/use a fresh vt::Parser after setup.
  • DA should start a bounded drain phase, not immediately end probing.
  • Normal handoff should require the probe parser to be back at ground after a read chunk has been fully parsed.
  • If a terminal response never completes, probing should stop at a hard deadline rather than blocking startup indefinitely.

I have a targeted PR prepared for this: #869.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions