Skip to content

Commit 2e90d52

Browse files
authored
feat(pty): canonical-mode line editing (VKILL/VWERASE) + ECHOCTL caret echo (#166)
Ports the recovered kernel line-discipline work onto the converged terminal: - pty.rs: VKILL (Ctrl-U kill-line) + VWERASE (Ctrl-W word-erase) control chars and process_input branches; ECHOCTL caret-form control echo; column-accurate erase_sequence helper. - guest_pty.rs: wire the two control chars over tcsetattr/tcgetattr. - 8 new kernel pty unit tests. Double-print: investigated; the convergence model surfaces child stdout once (no PTY-master drain), so no guard is warranted — documented as a TODO.
1 parent a6b0a0f commit 2e90d52

4 files changed

Lines changed: 279 additions & 2 deletions

File tree

crates/kernel/src/pty.rs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ pub struct TermiosControlChars {
9898
pub vsusp: u8,
9999
pub veof: u8,
100100
pub verase: u8,
101+
pub vkill: u8,
102+
pub vwerase: u8,
101103
}
102104

103105
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -107,6 +109,8 @@ pub struct PartialTermiosControlChars {
107109
pub vsusp: Option<u8>,
108110
pub veof: Option<u8>,
109111
pub verase: Option<u8>,
112+
pub vkill: Option<u8>,
113+
pub vwerase: Option<u8>,
110114
}
111115

112116
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -139,6 +143,8 @@ impl Default for Termios {
139143
vsusp: 0x1a,
140144
veof: 0x04,
141145
verase: 0x7f,
146+
vkill: 0x15,
147+
vwerase: 0x17,
142148
},
143149
}
144150
}
@@ -187,6 +193,12 @@ impl TermiosControlChars {
187193
if let Some(verase) = update.verase {
188194
self.verase = verase;
189195
}
196+
if let Some(vkill) = update.vkill {
197+
self.vkill = vkill;
198+
}
199+
if let Some(vwerase) = update.vwerase {
200+
self.vwerase = vwerase;
201+
}
190202
}
191203
}
192204

@@ -972,11 +984,48 @@ fn process_input(
972984
}
973985

974986
if byte == pty.termios.cc.verase || byte == 0x08 {
987+
if let Some(&erased) = pty.line_buffer.last() {
988+
if pty.termios.echo {
989+
deliver_output(pty, waiters, &erase_sequence(erased), true)?;
990+
}
991+
pty.line_buffer.pop();
992+
}
993+
continue;
994+
}
995+
996+
if byte == pty.termios.cc.vkill {
975997
if !pty.line_buffer.is_empty() {
976998
if pty.termios.echo {
977-
deliver_output(pty, waiters, &[0x08, 0x20, 0x08], true)?;
999+
let erase: Vec<u8> = pty
1000+
.line_buffer
1001+
.iter()
1002+
.flat_map(|b| erase_sequence(*b))
1003+
.collect();
1004+
deliver_output(pty, waiters, &erase, true)?;
1005+
}
1006+
pty.line_buffer.clear();
1007+
}
1008+
continue;
1009+
}
1010+
1011+
if byte == pty.termios.cc.vwerase {
1012+
let mut erased: Vec<u8> = Vec::new();
1013+
while matches!(pty.line_buffer.last(), Some(b' ') | Some(b'\t')) {
1014+
if let Some(b) = pty.line_buffer.pop() {
1015+
erased.push(b);
1016+
}
1017+
}
1018+
while let Some(&b) = pty.line_buffer.last() {
1019+
if b == b' ' || b == b'\t' {
1020+
break;
9781021
}
9791022
pty.line_buffer.pop();
1023+
erased.push(b);
1024+
}
1025+
if pty.termios.echo && !erased.is_empty() {
1026+
let sequence: Vec<u8> =
1027+
erased.iter().flat_map(|b| erase_sequence(*b)).collect();
1028+
deliver_output(pty, waiters, &sequence, true)?;
9801029
}
9811030
continue;
9821031
}
@@ -996,7 +1045,9 @@ fn process_input(
9961045
continue;
9971046
}
9981047
if pty.termios.echo {
999-
deliver_output(pty, waiters, &[byte], true)?;
1048+
// ECHOCTL: echo control chars in caret form (e.g. 0x01 -> "^A")
1049+
// so they are visible; printable bytes echo verbatim.
1050+
deliver_output(pty, waiters, &echo_control_byte(byte), true)?;
10001051
}
10011052
pty.line_buffer.push(byte);
10021053
} else {
@@ -1132,6 +1183,16 @@ fn echo_control_byte(byte: u8) -> Vec<u8> {
11321183
}
11331184
}
11341185

1186+
/// Backspace-erase sequence for a single buffered input byte, accounting for how
1187+
/// wide it was echoed: a control char echoed in caret form (`^X`, ECHOCTL)
1188+
/// occupies two columns and needs two `BS SP BS` triples, while a printable byte
1189+
/// occupies one. Used by VERASE / VKILL / VWERASE erase echo so the erased echo
1190+
/// width matches the displayed width.
1191+
fn erase_sequence(byte: u8) -> Vec<u8> {
1192+
let columns = echo_control_byte(byte).len();
1193+
(0..columns).flat_map(|_| [0x08, 0x20, 0x08]).collect()
1194+
}
1195+
11351196
fn buffer_size(buffer: &VecDeque<Vec<u8>>) -> usize {
11361197
buffer.iter().map(Vec::len).sum()
11371198
}

crates/kernel/tests/pty.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,186 @@ fn set_termios_only_updates_requested_fields() {
561561
assert_eq!(termios.cc.verase, 0x08);
562562
assert_eq!(termios.cc.vintr, 0x03);
563563
}
564+
565+
#[test]
566+
fn canonical_mode_kill_erases_the_whole_line() {
567+
let manager = PtyManager::new();
568+
let pty = manager.create_pty();
569+
570+
// Type "abc", then VKILL (Ctrl-U, 0x15) to wipe the line, then "xy\n".
571+
manager
572+
.write(pty.master.description.id(), b"abc\x15xy\n")
573+
.expect("write canonical input with kill");
574+
575+
let line = manager
576+
.read(pty.slave.description.id(), 64)
577+
.expect("read canonical line")
578+
.expect("line should be available");
579+
assert_eq!(String::from_utf8(line).expect("valid utf8"), "xy\n");
580+
581+
let echo = manager
582+
.read(pty.master.description.id(), 64)
583+
.expect("read echo")
584+
.expect("echo should be available");
585+
// "abc" echoed, then three BS-SP-BS to erase them, then "xy", then CRLF.
586+
assert_eq!(
587+
String::from_utf8(echo).expect("valid utf8"),
588+
"abc\x08 \x08\x08 \x08\x08 \x08xy\r\n"
589+
);
590+
}
591+
592+
#[test]
593+
fn canonical_mode_kill_on_empty_line_is_noop() {
594+
let manager = PtyManager::new();
595+
let pty = manager.create_pty();
596+
597+
manager
598+
.write(pty.master.description.id(), b"\x15hi\n")
599+
.expect("write kill on empty line");
600+
601+
let line = manager
602+
.read(pty.slave.description.id(), 64)
603+
.expect("read canonical line")
604+
.expect("line should be available");
605+
assert_eq!(String::from_utf8(line).expect("valid utf8"), "hi\n");
606+
607+
let echo = manager
608+
.read(pty.master.description.id(), 64)
609+
.expect("read echo")
610+
.expect("echo should be available");
611+
// No erase output for the empty-line kill; just the "hi" echo + CRLF.
612+
assert_eq!(String::from_utf8(echo).expect("valid utf8"), "hi\r\n");
613+
}
614+
615+
#[test]
616+
fn canonical_mode_werase_erases_preceding_word_and_trailing_space() {
617+
let manager = PtyManager::new();
618+
let pty = manager.create_pty();
619+
620+
// "foo bar " then VWERASE (Ctrl-W, 0x17) erases the trailing space and
621+
// "bar", leaving "foo ", then "baz\n".
622+
manager
623+
.write(pty.master.description.id(), b"foo bar \x17baz\n")
624+
.expect("write canonical input with werase");
625+
626+
let line = manager
627+
.read(pty.slave.description.id(), 64)
628+
.expect("read canonical line")
629+
.expect("line should be available");
630+
assert_eq!(String::from_utf8(line).expect("valid utf8"), "foo baz\n");
631+
632+
let echo = manager
633+
.read(pty.master.description.id(), 64)
634+
.expect("read echo")
635+
.expect("echo should be available");
636+
// "foo bar " echoed (8 chars), then 4 BS-SP-BS erases (space + "bar"),
637+
// then "baz", then CRLF.
638+
let mut expected = String::from("foo bar ");
639+
expected.push_str(&"\x08 \x08".repeat(4));
640+
expected.push_str("baz\r\n");
641+
assert_eq!(String::from_utf8(echo).expect("valid utf8"), expected);
642+
}
643+
644+
#[test]
645+
fn canonical_mode_werase_on_leading_whitespace_only_erases_it() {
646+
let manager = PtyManager::new();
647+
let pty = manager.create_pty();
648+
649+
// Only whitespace before VWERASE: erase all of it, nothing else.
650+
manager
651+
.write(pty.master.description.id(), b" \x17done\n")
652+
.expect("write whitespace then werase");
653+
654+
let line = manager
655+
.read(pty.slave.description.id(), 64)
656+
.expect("read canonical line")
657+
.expect("line should be available");
658+
assert_eq!(String::from_utf8(line).expect("valid utf8"), "done\n");
659+
}
660+
661+
#[test]
662+
fn canonical_mode_echoctl_echoes_control_char_in_caret_form() {
663+
let manager = PtyManager::new();
664+
let pty = manager.create_pty();
665+
666+
// A control char that is neither a signal, VEOF, VERASE, VKILL, VWERASE,
667+
// nor newline (0x01 = Ctrl-A) is buffered and echoed as caret form "^A".
668+
manager
669+
.write(pty.master.description.id(), b"a\x01b\n")
670+
.expect("write control char in canonical mode");
671+
672+
let line = manager
673+
.read(pty.slave.description.id(), 64)
674+
.expect("read canonical line")
675+
.expect("line should be available");
676+
// The raw control byte is delivered to the slave verbatim.
677+
assert_eq!(line, b"a\x01b\n");
678+
679+
let echo = manager
680+
.read(pty.master.description.id(), 64)
681+
.expect("read echo")
682+
.expect("echo should be available");
683+
assert_eq!(String::from_utf8(echo).expect("valid utf8"), "a^Ab\r\n");
684+
}
685+
686+
#[test]
687+
fn canonical_mode_erase_after_echoctl_removes_both_caret_columns() {
688+
let manager = PtyManager::new();
689+
let pty = manager.create_pty();
690+
691+
// Type Ctrl-A (echoed "^A", two columns), then VERASE (0x7f): the erase
692+
// must remove both caret columns, then "z\n".
693+
manager
694+
.write(pty.master.description.id(), b"\x01\x7fz\n")
695+
.expect("write control char then erase");
696+
697+
let line = manager
698+
.read(pty.slave.description.id(), 64)
699+
.expect("read canonical line")
700+
.expect("line should be available");
701+
assert_eq!(line, b"z\n");
702+
703+
let echo = manager
704+
.read(pty.master.description.id(), 64)
705+
.expect("read echo")
706+
.expect("echo should be available");
707+
// "^A" echoed, then two BS-SP-BS to erase both columns, then "z", then CRLF.
708+
let mut expected = String::from("^A");
709+
expected.push_str(&"\x08 \x08".repeat(2));
710+
expected.push_str("z\r\n");
711+
assert_eq!(String::from_utf8(echo).expect("valid utf8"), expected);
712+
}
713+
714+
#[test]
715+
fn set_termios_updates_kill_and_werase_control_chars() {
716+
let manager = PtyManager::new();
717+
let pty = manager.create_pty();
718+
719+
let termios = manager
720+
.get_termios(pty.master.description.id())
721+
.expect("read default termios");
722+
assert_eq!(termios.cc.vkill, 0x15);
723+
assert_eq!(termios.cc.vwerase, 0x17);
724+
725+
manager
726+
.set_termios(
727+
pty.master.description.id(),
728+
PartialTermios {
729+
cc: Some(PartialTermiosControlChars {
730+
vkill: Some(0x18),
731+
vwerase: Some(0x1a),
732+
..PartialTermiosControlChars::default()
733+
}),
734+
..PartialTermios::default()
735+
},
736+
)
737+
.expect("merge termios update");
738+
739+
let termios = manager
740+
.get_termios(pty.master.description.id())
741+
.expect("read merged termios");
742+
assert_eq!(termios.cc.vkill, 0x18);
743+
assert_eq!(termios.cc.vwerase, 0x1a);
744+
// Unspecified control chars keep their defaults.
745+
assert_eq!(termios.cc.verase, 0x7f);
746+
}

crates/sidecar-core/src/guest_pty.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ where
190190
"vsusp": termios.cc.vsusp,
191191
"veof": termios.cc.veof,
192192
"verase": termios.cc.verase,
193+
"vkill": termios.cc.vkill,
194+
"vwerase": termios.cc.vwerase,
193195
},
194196
}))
195197
}
@@ -221,6 +223,8 @@ fn parse_partial_termios(request: &Value) -> PartialTermios {
221223
vsusp: optional_u8(cc, "vsusp"),
222224
veof: optional_u8(cc, "veof"),
223225
verase: optional_u8(cc, "verase"),
226+
vkill: optional_u8(cc, "vkill"),
227+
vwerase: optional_u8(cc, "vwerase"),
224228
});
225229
PartialTermios {
226230
icrnl: optional_bool(request, "icrnl"),

crates/sidecar/src/execution.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17621,6 +17621,35 @@ fn service_javascript_kernel_tty_size_sync_rpc(
1762117621
}))
1762217622
}
1762317623

17624+
// TODO(double-print): The recovered pre-convergence work (94e935d0) added a
17625+
// guard here because in THAT model the sidecar owned a per-process
17626+
// `tty_master_fd`, drained the PTY master into `Stdout`/`ProcessOutput` events,
17627+
// and therefore had to suppress a TTY child's own `process_output` event to
17628+
// avoid surfacing the same bytes twice (once via the child's Stdout event, once
17629+
// via the drained master).
17630+
//
17631+
// The current "real terminal client" model on main is different and does NOT
17632+
// exhibit that bug as far as I can determine:
17633+
// * `tty_master_fd` here is only a LOCAL used to derive
17634+
// `kernel_stdin_writer_fd`; there is no `ActiveProcess::tty_master_fd`
17635+
// field and no `drain_tty_master_output` — the sidecar never surfaces the
17636+
// PTY master as host `ProcessOutput` at all. The in-guest terminal client
17637+
// reads the master itself.
17638+
// * A wasm/python CHILD of a cooked-TTY JS shell is spawned WITHOUT the
17639+
// parent's PTY slave dup2'd onto its fds (the child spawn path does no
17640+
// `open_pty`/slave dup2), so the child's stdout does not flow through the
17641+
// parent's master. It is surfaced exactly once, via the `Stdout` event
17642+
// queued below.
17643+
// So there is no second surfacing path to suppress, and porting 94e935d0's
17644+
// master-drain guard would guard against a drain that no longer exists.
17645+
//
17646+
// UNRESOLVED: I could not exercise the live JS-shell-spawns-wasm-child scenario
17647+
// end-to-end to positively confirm the host sees exactly one copy; the analysis
17648+
// above is from the code paths only. If a real double-print is later observed
17649+
// for a TTY child, the fix belongs here (or at the `ProcessOutput` emission in
17650+
// `event_frame_for_execution_event`): suppress this child `Stdout` event when
17651+
// the same bytes already reach the host through a PTY master owned by an
17652+
// ancestor. Deliberately NOT fabricating that guard now, per instructions.
1762417653
fn service_javascript_kernel_stdio_write_sync_rpc(
1762517654
kernel: &mut SidecarKernel,
1762617655
process: &mut ActiveProcess,

0 commit comments

Comments
 (0)