Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ lru = "0.16"
[dev-dependencies]
serial_test = "3"

# vt100 0.16.2 panics when a downward resize orphans a double-width character's
# "wide" flag and it's later erased (index out of bounds in Row::clear_wide).
# Pinned to the fix from the still-open upstream PR until a release ships it:
# https://github.com/doy/vt100-rust/issues/28
# https://github.com/doy/vt100-rust/pull/30
[patch.crates-io]
vt100 = { git = "https://github.com/Junyi-99/vt100-rust", rev = "131a75e72d9da43d503772578dc1f1c332befbba" }

[profile.release]
opt-level = 3
lto = true
Expand Down
83 changes: 80 additions & 3 deletions src/pty/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ fn is_block_border(line: &str) -> bool {
/// Screen tracker with vt100 emulation
pub struct ScreenTracker {
parser: vt100::Parser,
// Current terminal dimensions, tracked independently of the parser so a
// panicked parser can be rebuilt from scratch at the right size (see
// `process`/`resize`).
rows: u16,
cols: u16,
ready_pattern: String,
waiting_approval: bool,
last_output: Instant,
Expand Down Expand Up @@ -138,6 +143,8 @@ impl ScreenTracker {

let mut tracker = Self {
parser: vt100::Parser::new(rows, cols, 0),
rows,
cols,
ready_pattern: String::from_utf8_lossy(ready_pattern).into_owned(),
waiting_approval: false,
last_output: Instant::now(),
Expand Down Expand Up @@ -207,8 +214,30 @@ impl ScreenTracker {
self.waiting_approval = title.contains(CODEX_ACTION_REQUIRED);
}

// Feed to vt100 parser
self.parser.process(data);
// Feed to vt100 parser. vt100 has known panics on malformed/edge-case
// terminal frames (e.g. https://github.com/doy/vt100-rust/issues/28 —
// a wide character orphaned by a resize, then erased). Catch rather
// than let it unwind and kill the PTY wrapper (hcom issue #73); the
// panic hook still logs the underlying panic, so this just contains
// the blast radius. A parser that panicked mid-mutation may be left
// in an inconsistent state, so rebuild it from scratch rather than
// keep using it — this drops the current screen contents, but the
// next output chunk repopulates it.
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.parser.process(data);
}))
.is_err()
{
crate::log::log_warn(
"pty",
"screen.parser_panic",
&format!(
"vt100 parser panicked processing output for {}; resetting screen state",
self.instance_name.as_deref().unwrap_or("unknown")
),
);
self.parser = vt100::Parser::new(self.rows, self.cols, 0);
}

// Track output timing
self.last_output = Instant::now();
Expand All @@ -223,7 +252,23 @@ impl ScreenTracker {

/// Resize the screen
pub fn resize(&mut self, rows: u16, cols: u16) {
self.parser.screen_mut().set_size(rows, cols);
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.parser.screen_mut().set_size(rows, cols);
}))
.is_err()
{
crate::log::log_warn(
"pty",
"screen.parser_panic",
&format!(
"vt100 parser panicked resizing screen for {}; resetting screen state",
self.instance_name.as_deref().unwrap_or("unknown")
),
);
self.parser = vt100::Parser::new(rows, cols, 0);
}
self.rows = rows;
self.cols = cols;
}

/// Clear approval state immediately when the user responds.
Expand Down Expand Up @@ -990,6 +1035,8 @@ mod tests {
fn make_tracker(rows: u16, cols: u16, ready_pattern: &str) -> ScreenTracker {
ScreenTracker {
parser: vt100::Parser::new(rows, cols, 0),
rows,
cols,
ready_pattern: ready_pattern.to_string(),
waiting_approval: false,
last_output: Instant::now(),
Expand All @@ -1005,6 +1052,36 @@ mod tests {
}
}

// ---- vt100 panic containment (issue #73) ----

#[test]
fn process_survives_vt100_wide_char_resize_panic() {
// A double-width (wide) character whose continuation cell gets
// truncated by a downward resize used to panic inside vt100
// (upstream doy/vt100-rust#28: `Row::clear_wide` indexes one past
// the row's new length) the next time that cell was erased. That
// panic used to unwind straight through `process`/`resize` and kill
// the PTY wrapper (hcom issue #73, observed as repeated
// `stopped by pty: closed` on real Codex sessions). It must now be
// contained: the tracker rebuilds its parser and stays usable.
let mut t = make_tracker(3, 10, "");

// Wide CJK char printed so it spans the last two columns (8, 9).
t.process(b"\x1b[1;9H");
t.process("\u{4e2d}".as_bytes());

// Shrink to 9 columns: the continuation cell (old col 9) is
// truncated away, orphaning the wide flag on the new last column.
t.resize(3, 9);

// Erase-in-line on that orphaned wide cell is what panicked upstream.
t.process(b"\x1b[1;9H\x1b[K");

// Tracker must have survived and still be fully usable.
assert_eq!(t.cols(), 9);
t.process(b"still alive\r\n");
}

// ---- is_ready ----

#[test]
Expand Down
Loading