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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ ctrlc = "3"
# adding it directly here so the feature gate is explicit.
# `SetConsoleCtrlHandler` for `Win32_System_Console`: clearing the inherited
# ignore-Ctrl-C flag so panes get an interrupt, not just the 0x03 byte.
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
4 changes: 2 additions & 2 deletions docs/architecture/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ trait TerminalBackend {
}
```

`PtyBackend`는 `portable-pty`와 reader/waiter thread로 로컬 child를 소유하고, `HubBackend`는 daemon hub에 요청만 보낸다. pane id·title·resize·reorder는 즉시 로컬 상태로 확정하지 않고 `Created`, `Resized`, `Reordered`, `Exited` 같은 backend event를 따른다. `drain_events`는 보고만 하며 `Exited`를 받은 owner가 `destroy_pane`을 호출해 자원을 회수한다. VT parsing은 두 backend 모두 client-side `PaneEmulator`가 담당한다. pane child의 환경은 daemon이 상속한 값이 아니라 pane이 실제로 렌더되는 emulator를 기준으로 맞춘다: `TERM=xterm-256color`, `COLORTERM=truecolor`를 강제하고 `NO_COLOR`는 제거한다. daemon은 agent shell이나 service manager처럼 터미널이 아닌 곳에서 시작될 수 있고, 그런 부모는 자기 자식용으로 `NO_COLOR=1`, `TERM=dumb`를 내보내는 일이 흔하기 때문이다.
`PtyBackend`는 `portable-pty`와 reader/waiter thread로 로컬 child를 소유하고, `HubBackend`는 daemon hub에 요청만 보낸다. 각 pane은 생성 직후 Unix session 또는 Windows Job Object를 종료 경계로 삼아 `destroy_pane`과 hub 종료가 그 경계 안의 subprocess를 함께 종료한다. 단순 detach·quit·browser disconnect는 pane destroy 경로를 호출하지 않으므로 프로세스를 유지한다. pane id·title·resize·reorder는 즉시 로컬 상태로 확정하지 않고 `Created`, `Resized`, `Reordered`, `Exited` 같은 backend event를 따른다. `drain_events`는 보고만 하며 `Exited`를 받은 owner가 `destroy_pane`을 호출해 자원을 회수한다. VT parsing은 두 backend 모두 client-side `PaneEmulator`가 담당한다. pane child의 환경은 daemon이 상속한 값이 아니라 pane이 실제로 렌더되는 emulator를 기준으로 맞춘다: `TERM=xterm-256color`, `COLORTERM=truecolor`를 강제하고 `NO_COLOR`는 제거한다. daemon은 agent shell이나 service manager처럼 터미널이 아닌 곳에서 시작될 수 있고, 그런 부모는 자기 자식용으로 `NO_COLOR=1`, `TERM=dumb`를 내보내는 일이 흔하기 때문이다.

세션 상한은 repository당 PTY 8개, pane 크기 1–500행 × 1–1100열, pane당 reconnect scrollback 256 KiB다. 명령 queue가 가득 찼다는 이유로 close/resize의 성공을 가정하지 않는다.
세션 상한은 repository당 PTY 8개, pane 크기 1–500행 × 1–1100열, pane당 reconnect scrollback 256 KiB다. close와 resize는 bounded input queue 밖의 전용 latest-state 경로로 보내 queue 포화에도 마지막 요청을 잃지 않는다.

## Shared state

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ TUI workspace state는 `~/.nightcrow/workspace.json`에 저장한다. 열린 pro

`ui::chrome::chrome_areas`가 project tabs, body, notice, hint 네 영역을 항상 만든다. notice와 hint는 배치와 무관하게 화면 아래 두 행이고(`bottom_rows`), project tabs는 `[layout] tabs`에 따라 그 위의 첫 행(`top`) 또는 좌측 `STRIP_WIDTH`(20) 열(`left`)이며 body는 남은 영역이다. body의 upper/lower split은 TUI layout config에서 계산하고, terminal pane rect는 [terminal.md](terminal.md)의 단일 기하 출처를 사용한다. notice나 dialog 때문에 행을 추가·삭제하지 않는다.

입력·PTY output·snapshot/load 결과·tree watch·resize·recovery·title 변화는 dirty frame을 요청한다. event loop는 16 ms마다 queue를 poll하지만 변경 없는 tick에는 `Terminal::draw`를 호출하지 않는다. `<leader> r`만 front buffer를 비우는 명시적 full repaint다. status의 hot-file fade와 attention/search caret 경계도 timer event로 dirty를 만든다.
입력·PTY output·snapshot/load 결과·tree watch·resize·recovery·title 변화는 dirty frame을 요청한다. event loop는 16 ms마다 queue를 poll하지만 변경 없는 tick에는 `Terminal::draw`를 호출하지 않는다. `<leader> r`만 front buffer를 비우는 명시적 full repaint다. status의 hot-file fade와 attention/search caret 경계도 timer event로 dirty를 만든다. repo 입력을 열면 notice row의 좌우 border가 고정된 채 accent/dim 두 pulse를 보여 주고, 두 pulse가 끝나면 dim으로 유지한다. 이 focus cue는 phase 경계에서만 repaint하며 완료 뒤 timer를 더 예약하지 않는다.

## Notice row

Expand Down
5 changes: 4 additions & 1 deletion src/application/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::application::terminal_guard::TuiTerminal;
use crate::workspace::Workspace;
use crossterm::event::{self, Event};
use ratatui::layout::Rect;
use std::time::{Duration, SystemTime};
use std::time::{Duration, Instant, SystemTime};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;

Expand Down Expand Up @@ -123,6 +123,9 @@ pub(crate) fn main_loop(
.active()
.is_some_and(crate::app::App::search_overlay_active);
redraw.observe_caret(caret_active, crate::ui::current_caret_lit());
if ws.advance_repo_input_focus_flash(Instant::now()) {
redraw.request(RedrawCause::RepoInputFocus);
}
let active_tab = ws.active_index();
let empty_notice = ws.empty_notice().cloned();
let prefix_armed = ws.prefix_armed();
Expand Down
2 changes: 2 additions & 0 deletions src/application/redraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub(crate) enum RedrawCause {
Log,
AttentionBlink,
CaretBlink,
RepoInputFocus,
HotFile,
Session,
Redraw,
Expand Down Expand Up @@ -124,6 +125,7 @@ mod tests {
RedrawCause::Git,
RedrawCause::Log,
RedrawCause::HotFile,
RedrawCause::RepoInputFocus,
RedrawCause::Session,
RedrawCause::Redraw,
];
Expand Down
4 changes: 2 additions & 2 deletions src/application/tests/prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ fn leader_x_asks_the_workspace_to_close_the_project() {
let mut app = app_with_files(vec!["a.rs"]);
let _ = handle_key(&mut app, leader());

let outcome = handle_key(&mut app, press(KeyCode::Char('x'), KeyModifiers::NONE));
let outcome = handle_key(&mut app, press(KeyCode::Char('x'), KeyModifiers::CONTROL));

assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Close));
assert!(
Expand Down Expand Up @@ -208,7 +208,7 @@ fn handle_key_leader_w_closes_pane_with_terminal_focus() {
app.terminal.create_pane_now().unwrap();
let before = app.terminal.panes.len();
let _ = handle_key(&mut app, leader());
let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE));
let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::CONTROL));
// Closing is a request; the pane goes when its exit arrives.
app.poll_terminal();
assert_eq!(app.terminal.panes.len(), before - 1);
Expand Down
10 changes: 9 additions & 1 deletion src/backend/pty.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::slot::{PaneSlot, PaneSlots};
use super::{BackendEvent, PaneId, ResizeOutcome, TerminalBackend};
use crate::config::ShellConfig;
use crate::platform::process_tree::ProcessTree;
use crate::platform::threading::try_timed_join;
use anyhow::Result;
use portable_pty::PtySize;
Expand Down Expand Up @@ -56,6 +57,7 @@ pub(super) struct PtyPane {
pub(super) master: Option<Box<dyn portable_pty::MasterPty>>,
pub(super) writer: Option<Box<dyn Write + Send>>,
pub(super) killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
pub(super) process_tree: ProcessTree,
pub(super) rx: Receiver<PtyEvent>,
pub(super) reader_handle: Option<thread::JoinHandle<()>>,
pub(super) wait_handle: Option<thread::JoinHandle<()>>,
Expand All @@ -64,7 +66,13 @@ pub(super) struct PtyPane {

impl Drop for PtyPane {
fn drop(&mut self) {
// Best-effort kill: the child may already be gone.
// Terminate the pane's process boundary before the PTY handles and
// waiter are reaped, so child jobs cannot outlive an explicit close.
if let Err(error) = self.process_tree.terminate() {
tracing::warn!(%error, "could not terminate pane process tree");
}
// Best-effort direct kill covers a child that exited before its tree
// termination raced with teardown.
let _ = self.killer.kill();
// Drop writer/master so the reader's blocked `read()` returns EOF;
// without this, joining the reader would hang.
Expand Down
12 changes: 12 additions & 0 deletions src/backend/pty_spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::{ExitPhase, PtyBackend, PtyEvent, PtyPane};
use crate::backend::PaneId;
use crate::backend::identity::{PANE_TOKEN_ENV, PLUGIN_RUNTIME_DIR_ENV, PaneIdentity};
use crate::backend::slot::{PaneLaunch, resume_command_line};
use crate::platform::process_tree::ProcessTree;
use anyhow::Result;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::io::Read;
Expand Down Expand Up @@ -111,6 +112,16 @@ impl PtyBackend {
cmd.cwd(canonical);
}
let mut child = pair.slave.spawn_command(cmd)?;
let process_tree = match ProcessTree::attach(&*child, &*pair.master) {
Ok(tree) => tree,
Err(error) => {
// Do not leave a process behind when establishing the tree
// boundary fails before the pane is inserted in the backend.
let _ = child.kill();
let _ = child.wait();
return Err(error.context("establishing pane process tree"));
}
};
let killer = child.clone_killer();
drop(pair.slave);

Expand Down Expand Up @@ -154,6 +165,7 @@ impl PtyBackend {
master: Some(pair.master),
writer: Some(writer),
killer,
process_tree,
rx,
reader_handle: Some(reader_handle),
wait_handle: Some(wait_handle),
Expand Down
104 changes: 104 additions & 0 deletions src/backend/pty_tests/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,110 @@ fn pty_backend_create_and_destroy_pane() {
assert!(!backend.panes.contains_key(&id));
}

#[test]
fn destroying_a_pane_terminates_its_subprocess() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("child.pid");
let (shell, command) = descendant_command(&marker);
let mut backend = PtyBackend::new(".", shell);
let id = backend
.open_pane(24, 80, Some(&command))
.expect("open pane with descendant");
let pid = wait_for_pid(&mut backend, id, &marker);

backend.destroy_pane(id);

let deadline = Instant::now() + Duration::from_secs(5);
while process_is_alive(pid) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(20));
}
assert!(
!process_is_alive(pid),
"pane descendant {pid} survived close"
);
}

#[cfg(unix)]
fn descendant_command(marker: &std::path::Path) -> (ShellConfig, String) {
(
ShellConfig::default(),
format!("sh -c 'echo $$ > \"{}\"; exec sleep 60'", marker.display()),
)
}

#[cfg(windows)]
fn descendant_command(marker: &std::path::Path) -> (ShellConfig, String) {
let shell = ShellConfig {
program: Some("powershell.exe".to_string()),
command_args: vec!["-NoProfile".to_string(), "-Command".to_string()],
};
let command = format!(
"$p = Start-Process cmd.exe -WindowStyle Hidden -ArgumentList '/C','ping -t 127.0.0.1' -PassThru; [IO.File]::WriteAllText('{}', [string]$p.Id); $p.WaitForExit()",
marker.display()
);
(shell, command)
}

fn wait_for_pid(backend: &mut PtyBackend, id: PaneId, marker: &std::path::Path) -> u32 {
let deadline = Instant::now() + Duration::from_secs(10);
let mut output = Vec::new();
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(marker)
&& let Ok(pid) = text.trim().parse()
{
return pid;
}
for event in backend.drain_events() {
if let BackendEvent::Output { pane, data } = event
&& pane == id
{
if data.windows(4).any(|window| window == b"\x1b[6n") {
let _ = backend.send_input(id, b"\x1b[1;1R");
}
output.extend(data);
}
}
thread::sleep(Duration::from_millis(20));
}
panic!(
"subprocess did not write {}: {}",
marker.display(),
String::from_utf8_lossy(&output)
);
}

#[cfg(any(target_os = "linux", target_os = "android"))]
fn process_is_alive(pid: u32) -> bool {
if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat"))
&& stat.rfind(')').and_then(|end| stat.as_bytes().get(end + 2)) == Some(&b'Z')
{
return false;
}
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn process_is_alive(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

#[cfg(windows)]
fn process_is_alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{OpenProcess, WaitForSingleObject};

const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
let process = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
if process.is_null() {
return false;
}
let alive = unsafe { WaitForSingleObject(process, 0) } == WAIT_TIMEOUT;
unsafe { CloseHandle(process) };
alive
}

#[test]
fn resizing_an_unknown_pane_is_reported() {
let mut backend = PtyBackend::new(".", ShellConfig::default());
Expand Down
1 change: 1 addition & 0 deletions src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod link_target_paths;
pub(crate) mod links;
pub(crate) mod logging;
pub(crate) mod paths;
pub(crate) mod process_tree;
pub(crate) mod self_replace;
pub(crate) mod signals;
pub(crate) mod threading;
Loading