Skip to content

Commit 65f03fe

Browse files
committed
Filter Windows PTY cursor sequences
Review finding: The Windows Python backend now emits raw ConPTY cursor-control sequences in normal replies, breaking an existing Python backend test and polluting user-visible output. Review comment: - [P1] Strip ConPTY cursor-control bytes from Python output C:\Users\kalin\Documents\GitHub\mcp-repl\src\worker_process.rs:7337-7339 When the Windows PTY path is used, this raw reader forwards VT control sequences emitted by ConPTY (e.g. \u001b[?25l\u001b[15;1H\u001b[?25h) directly into MCP replies; a simple Python reply now includes these bytes and python_backend_runs_inside_mcp_repl_worker fails on Windows. Please filter/suppress the ConPTY screen-control traffic before appending stdout, otherwise Windows Python sessions leak terminal escape codes to clients. Response: Added a Windows PTY output filter that strips ConPTY screen-control CSI sequences before append_raw_text, including split sequences, while preserving SGR sequences. Added regression assertions in python_backend_runs_inside_mcp_repl_worker and unit coverage for split ConPTY cursor sequences.
1 parent a100aaf commit 65f03fe

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

src/worker_process.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7323,6 +7323,86 @@ where
73237323
}))
73247324
}
73257325

7326+
#[cfg(target_family = "windows")]
7327+
#[derive(Default)]
7328+
struct WindowsPtyOutputFilter {
7329+
state: WindowsPtyOutputFilterState,
7330+
pending: Vec<u8>,
7331+
}
7332+
7333+
#[cfg(target_family = "windows")]
7334+
#[derive(Default)]
7335+
enum WindowsPtyOutputFilterState {
7336+
#[default]
7337+
Ground,
7338+
Escape,
7339+
Csi,
7340+
}
7341+
7342+
#[cfg(target_family = "windows")]
7343+
impl WindowsPtyOutputFilter {
7344+
fn filter(&mut self, bytes: &[u8]) -> Vec<u8> {
7345+
let mut output = Vec::with_capacity(bytes.len());
7346+
for &byte in bytes {
7347+
match self.state {
7348+
WindowsPtyOutputFilterState::Ground => {
7349+
if byte == 0x1b {
7350+
self.pending.clear();
7351+
self.pending.push(byte);
7352+
self.state = WindowsPtyOutputFilterState::Escape;
7353+
} else {
7354+
output.push(byte);
7355+
}
7356+
}
7357+
WindowsPtyOutputFilterState::Escape => {
7358+
self.pending.push(byte);
7359+
if byte == b'[' {
7360+
self.state = WindowsPtyOutputFilterState::Csi;
7361+
} else {
7362+
output.extend_from_slice(&self.pending);
7363+
self.pending.clear();
7364+
self.state = WindowsPtyOutputFilterState::Ground;
7365+
}
7366+
}
7367+
WindowsPtyOutputFilterState::Csi => {
7368+
self.pending.push(byte);
7369+
if is_csi_final_byte(byte) {
7370+
if !is_conpty_screen_control_csi(&self.pending) {
7371+
output.extend_from_slice(&self.pending);
7372+
}
7373+
self.pending.clear();
7374+
self.state = WindowsPtyOutputFilterState::Ground;
7375+
} else if self.pending.len() > 128 {
7376+
output.extend_from_slice(&self.pending);
7377+
self.pending.clear();
7378+
self.state = WindowsPtyOutputFilterState::Ground;
7379+
}
7380+
}
7381+
}
7382+
}
7383+
output
7384+
}
7385+
}
7386+
7387+
#[cfg(target_family = "windows")]
7388+
fn is_csi_final_byte(byte: u8) -> bool {
7389+
(0x40..=0x7e).contains(&byte)
7390+
}
7391+
7392+
#[cfg(target_family = "windows")]
7393+
fn is_conpty_screen_control_csi(sequence: &[u8]) -> bool {
7394+
if !sequence.starts_with(b"\x1b[") {
7395+
return false;
7396+
}
7397+
match sequence.last().copied() {
7398+
Some(b'@' | b'A'..=b'K' | b'P' | b'S' | b'T' | b'X' | b'f' | b'r' | b's' | b'u') => true,
7399+
Some(b'h' | b'l') => sequence
7400+
.get(2..sequence.len().saturating_sub(1))
7401+
.is_some_and(|params| params.starts_with(b"?")),
7402+
_ => false,
7403+
}
7404+
}
7405+
73267406
#[cfg(target_family = "windows")]
73277407
fn spawn_blocking_output_reader<R>(
73287408
stream: Option<R>,
@@ -7339,10 +7419,16 @@ where
73397419
let stop_requested = Arc::new(AtomicBool::new(false));
73407420
let handle = thread::spawn(move || {
73417421
let mut buffer = [0u8; 8192];
7422+
let mut filter = WindowsPtyOutputFilter::default();
73427423
loop {
73437424
match stream.read(&mut buffer) {
73447425
Ok(0) => break,
7345-
Ok(n) => live_output.append_raw_text(&buffer[..n], output_stream),
7426+
Ok(n) => {
7427+
let filtered = filter.filter(&buffer[..n]);
7428+
if !filtered.is_empty() {
7429+
live_output.append_raw_text(&filtered, output_stream);
7430+
}
7431+
}
73467432
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
73477433
Err(_) => break,
73487434
}
@@ -8124,6 +8210,25 @@ mod tests {
81248210
(result, kills)
81258211
}
81268212

8213+
#[cfg(target_family = "windows")]
8214+
#[test]
8215+
fn windows_pty_output_filter_strips_split_conpty_cursor_sequences() {
8216+
let mut filter = WindowsPtyOutputFilter::default();
8217+
let mut output = filter.filter(b"\r\nmcp-repl\n\x1b[?25");
8218+
output.extend(filter.filter(b"l\x1b[15;1H\x1b[?25h>>> "));
8219+
8220+
assert_eq!(String::from_utf8(output).unwrap(), "\r\nmcp-repl\n>>> ");
8221+
}
8222+
8223+
#[cfg(target_family = "windows")]
8224+
#[test]
8225+
fn windows_pty_output_filter_preserves_sgr_sequences() {
8226+
let mut filter = WindowsPtyOutputFilter::default();
8227+
let output = filter.filter(b"\x1b[31mred\x1b[0m\n");
8228+
8229+
assert_eq!(String::from_utf8(output).unwrap(), "\x1b[31mred\x1b[0m\n");
8230+
}
8231+
81278232
#[test]
81288233
fn trims_echo_prefix_across_text_chunks() {
81298234
let mut contents = vec![

tests/python_backend.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,10 @@ else:
779779
)
780780
.await?;
781781
let text = result_text(&result);
782+
assert!(
783+
!text.contains('\x1b'),
784+
"did not expect terminal control sequences in a simple Python reply, got: {text:?}"
785+
);
782786
assert!(
783787
text.lines().any(|line| line.trim() == "mcp-repl"),
784788
"expected Python worker process image to be mcp-repl, got: {text:?}"

0 commit comments

Comments
 (0)