Skip to content

Commit 0353a09

Browse files
miguelgilaclaude
andauthored
fix: implement dynamic PTY resize (ResizePty) (#57)
The shim's ResizePty was a no-op, causing interactive terminals to be stuck at 80x24. Implement file-based IPC between shim and runtime daemon: - Shim writes "width height" to /run/reaper/<id>/resize (or exec variant) - Runtime daemon polls the resize file every 100ms via a watcher thread - On change, daemon applies TIOCSWINSZ ioctl to the PTY master fd Supports both do_start() and exec_with_pty() PTY sessions. Closes #56 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad127ad commit 0353a09

File tree

6 files changed

+139
-7
lines changed

6 files changed

+139
-7
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ cargo clippy --target x86_64-unknown-linux-gnu --all-targets
366366

367367
### 🔄 Known Limitations
368368
- Multi-container pods not fully tested
369-
- ResizePty returns OK but is no-op (no dynamic PTY resize)
369+
- ResizePty polling interval is 100ms (resize may not feel instant)
370370
- No cgroup resource limits (by design)
371371
- No namespace isolation (by design)
372372
- Volume mounts are shared across all workloads (no per-container isolation)

docs/CURRENT_STATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for development setup and [TESTING.md](TEST
262262
### ⏳ Not Started
263263
- [ ] User/group ID management (currently disabled)
264264
- [ ] Signal handling robustness
265-
- [ ] Dynamic PTY resize (ResizePty)
265+
- [x] Dynamic PTY resize (ResizePty)
266266
- [ ] Resource monitoring (stats)
267267
- [ ] Performance optimization
268268

docs/SHIMV2_DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ service Task {
110110
| Pause/Resume | ⚠️ | Returns OK but no-op (no cgroup freezer) |
111111
| Checkpoint | ⚠️ | Not implemented (no CRIU) |
112112
| Exec || Implemented with PTY support |
113-
| ResizePty | ⚠️ | Returns OK but no-op (no dynamic resize) |
113+
| ResizePty | | Shim writes dimensions to resize file, runtime daemon applies via TIOCSWINSZ |
114114
| CloseIO | ⚠️ | Not implemented |
115115
| Update | ⚠️ | Not implemented (no cgroups) |
116116

src/bin/containerd-shim-reaper-v2/main.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,8 +1486,32 @@ impl Task for ReaperTask {
14861486
"resize_pty() called - container_id={}, exec_id={}, width={}, height={}",
14871487
req.id, req.exec_id, req.width, req.height
14881488
);
1489-
// TODO: Propagate window size to PTY master (requires IPC with runtime daemon)
1490-
// For now, return success - terminal works but won't resize dynamically
1489+
1490+
if req.width == 0 && req.height == 0 {
1491+
return Ok(api::Empty::new());
1492+
}
1493+
1494+
// Write resize dimensions to a file the runtime daemon polls.
1495+
// For exec processes, use exec-specific resize file.
1496+
let resize_file = if req.exec_id.is_empty() {
1497+
format!("{}/{}/resize", runtime_state_dir(), req.id)
1498+
} else {
1499+
format!(
1500+
"{}/{}/exec-{}-resize",
1501+
runtime_state_dir(),
1502+
req.id,
1503+
req.exec_id
1504+
)
1505+
};
1506+
1507+
let content = format!("{} {}\n", req.width, req.height);
1508+
if let Err(e) = std::fs::write(&resize_file, content) {
1509+
warn!(
1510+
"resize_pty() - failed to write resize file {}: {}",
1511+
resize_file, e
1512+
);
1513+
}
1514+
14911515
Ok(api::Empty::new())
14921516
}
14931517

src/bin/reaper-runtime/main.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use state::{
1313
delete as delete_state, load_exec_state, load_pid, load_state, save_exec_state, save_pid,
1414
save_state, ContainerState, OciUser,
1515
};
16+
#[cfg(target_os = "linux")]
17+
use state::{exec_resize_path, resize_path};
1618

1719
#[cfg(target_os = "linux")]
1820
mod overlay;
@@ -243,6 +245,58 @@ fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
243245
}
244246
}
245247

248+
/// Spawn a thread that polls a resize file and applies `TIOCSWINSZ` to the PTY master.
249+
///
250+
/// The shim writes `"width height\n"` to `resize_file`. This thread polls every 100ms,
251+
/// reads the dimensions, applies them via ioctl, then deletes the file. The thread exits
252+
/// when `stop` is set to `true` (signaled by the caller after the child process exits).
253+
#[cfg(target_os = "linux")]
254+
fn spawn_resize_watcher(
255+
master_raw_fd: i32,
256+
resize_file: PathBuf,
257+
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
258+
) {
259+
std::thread::spawn(move || {
260+
use std::sync::atomic::Ordering;
261+
while !stop.load(Ordering::Relaxed) {
262+
if resize_file.exists() {
263+
if let Ok(content) = fs::read_to_string(&resize_file) {
264+
let _ = fs::remove_file(&resize_file);
265+
let parts: Vec<&str> = content.split_whitespace().collect();
266+
if parts.len() == 2 {
267+
if let (Ok(width), Ok(height)) =
268+
(parts[0].parse::<u16>(), parts[1].parse::<u16>())
269+
{
270+
let ws = nix::libc::winsize {
271+
ws_row: height,
272+
ws_col: width,
273+
ws_xpixel: 0,
274+
ws_ypixel: 0,
275+
};
276+
let ret = unsafe {
277+
nix::libc::ioctl(
278+
master_raw_fd,
279+
nix::libc::TIOCSWINSZ as _,
280+
&ws as *const nix::libc::winsize,
281+
)
282+
};
283+
if ret < 0 {
284+
tracing::warn!(
285+
"TIOCSWINSZ failed: {}",
286+
std::io::Error::last_os_error()
287+
);
288+
} else {
289+
tracing::debug!("PTY resized to {}x{}", width, height);
290+
}
291+
}
292+
}
293+
}
294+
}
295+
std::thread::sleep(std::time::Duration::from_millis(100));
296+
}
297+
});
298+
}
299+
246300
#[allow(clippy::too_many_arguments)]
247301
fn do_create(
248302
id: &str,
@@ -658,13 +712,31 @@ fn do_start(id: &str, bundle: &Path) -> Result<()> {
658712
// Close slave in parent - child has it via dup2
659713
drop(pty.slave);
660714

715+
// Capture master raw fd for resize ioctl before converting to File
716+
#[cfg(target_os = "linux")]
717+
let master_raw_fd = {
718+
use std::os::unix::io::AsRawFd;
719+
pty.master.as_raw_fd()
720+
};
721+
661722
// Convert PTY master OwnedFd to File for I/O
662723
let master_file: std::fs::File = pty.master.into();
663724
let master_clone = master_file.try_clone().unwrap_or_else(|e| {
664725
tracing::error!("failed to clone master fd: {}", e);
665726
std::process::exit(1);
666727
});
667728

729+
// Start PTY resize watcher thread
730+
#[cfg(target_os = "linux")]
731+
let resize_stop =
732+
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
733+
#[cfg(target_os = "linux")]
734+
spawn_resize_watcher(
735+
master_raw_fd,
736+
resize_path(&container_id),
737+
resize_stop.clone(),
738+
);
739+
668740
// Relay: stdin FIFO → PTY master (user input to process)
669741
if let Some(ref state) = io_state {
670742
if let Some(ref stdin_path) = state.stdin {
@@ -746,6 +818,8 @@ fn do_start(id: &str, bundle: &Path) -> Result<()> {
746818

747819
match child.wait() {
748820
Ok(exit_status) => {
821+
#[cfg(target_os = "linux")]
822+
resize_stop.store(true, std::sync::atomic::Ordering::Relaxed);
749823
let exit_code = exit_code_from_status(exit_status);
750824
if let Ok(mut state) = load_state(&container_id) {
751825
state.status = "stopped".into();
@@ -754,6 +828,8 @@ fn do_start(id: &str, bundle: &Path) -> Result<()> {
754828
}
755829
}
756830
Err(_e) => {
831+
#[cfg(target_os = "linux")]
832+
resize_stop.store(true, std::sync::atomic::Ordering::Relaxed);
757833
if let Ok(mut state) = load_state(&container_id) {
758834
state.status = "stopped".into();
759835
state.exit_code = Some(1);
@@ -1167,13 +1243,30 @@ fn exec_with_pty(
11671243
// Close slave in parent - child has it via dup2
11681244
drop(pty.slave);
11691245

1246+
// Capture master raw fd for resize ioctl before converting to File
1247+
#[cfg(target_os = "linux")]
1248+
let master_raw_fd = {
1249+
use std::os::unix::io::AsRawFd;
1250+
pty.master.as_raw_fd()
1251+
};
1252+
11701253
// Convert PTY master OwnedFd to File for I/O
11711254
let master_file: std::fs::File = pty.master.into();
11721255
let master_clone = master_file.try_clone().unwrap_or_else(|e| {
11731256
tracing::error!("failed to clone master fd: {}", e);
11741257
std::process::exit(1);
11751258
});
11761259

1260+
// Start PTY resize watcher thread
1261+
#[cfg(target_os = "linux")]
1262+
let resize_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1263+
#[cfg(target_os = "linux")]
1264+
spawn_resize_watcher(
1265+
master_raw_fd,
1266+
exec_resize_path(container_id, exec_id),
1267+
resize_stop.clone(),
1268+
);
1269+
11771270
// Start relay threads
11781271
// stdin FIFO → PTY master (user input to process)
11791272
if let Some(ref stdin_p) = stdin_path {
@@ -1226,10 +1319,13 @@ fn exec_with_pty(
12261319
}
12271320

12281321
// Wait for child
1229-
match child.wait() {
1322+
let exit = match child.wait() {
12301323
Ok(status) => exit_code_from_status(status),
12311324
Err(_) => 1,
1232-
}
1325+
};
1326+
#[cfg(target_os = "linux")]
1327+
resize_stop.store(true, std::sync::atomic::Ordering::Relaxed);
1328+
exit
12331329
}
12341330

12351331
#[allow(clippy::too_many_arguments)]

src/bin/reaper-runtime/state.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ pub fn pid_path(id: &str) -> PathBuf {
102102
container_dir(id).join("pid")
103103
}
104104

105+
/// Path for PTY resize signaling (shim writes width/height, runtime reads)
106+
#[cfg(target_os = "linux")]
107+
pub fn resize_path(id: &str) -> PathBuf {
108+
container_dir(id).join("resize")
109+
}
110+
111+
/// Path for exec PTY resize signaling
112+
#[cfg(target_os = "linux")]
113+
pub fn exec_resize_path(container_id: &str, exec_id: &str) -> PathBuf {
114+
container_dir(container_id).join(format!("exec-{}-resize", exec_id))
115+
}
116+
105117
pub fn save_state(state: &ContainerState) -> anyhow::Result<()> {
106118
validate_id(&state.id)?;
107119
let dir = container_dir(&state.id);

0 commit comments

Comments
 (0)