Skip to content

Commit 9adc56c

Browse files
committed
test(e2e): harden PTY session cleanup
Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
1 parent 4ba48c0 commit 9adc56c

3 files changed

Lines changed: 71 additions & 33 deletions

File tree

tests/e2e-cucumber/tests/e2e.rs

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -192,29 +192,15 @@ impl Default for E2eWorld {
192192

193193
impl E2eWorld {
194194
/// The isolation/behaviour environment every spawned `rocm` gets, as owned
195-
/// `(key, value)` pairs. One source of truth for both the piped
196-
/// `std::process::Command` path (`isolate_cmd`) and the pseudo-terminal path
197-
/// (`tui_driver`, whose `portable_pty::CommandBuilder` has its own env API and
198-
/// can't take a `std::process::Command`) — so the interactive and
199-
/// non-interactive spawns can never drift.
195+
/// `(key, value)` pairs. Shared by the piped `std::process::Command` path and
196+
/// the pseudo-terminal path; PTY-only isolation is added by [`pty_env`].
200197
pub fn isolate_env(&self) -> Vec<(&'static str, std::ffi::OsString)> {
201198
let mut env = Vec::new();
202199
if let Some(root) = &self.isolated_root {
203200
let root = root.path();
204201
env.push(("ROCM_CLI_CONFIG_DIR", root.join("config").into_os_string()));
205202
env.push(("ROCM_CLI_DATA_DIR", root.join("data").into_os_string()));
206203
env.push(("ROCM_CLI_CACHE_DIR", root.join("cache").into_os_string()));
207-
// The dashboard socket default is derived from HOME/XDG rather than
208-
// AppPaths, so isolate it too; otherwise a host daemon can answer
209-
// the scenario and hide the planted service registry. Create both
210-
// directories because some PTY implementations use HOME as the
211-
// child's working directory.
212-
let home = root.join("home");
213-
let runtime = root.join("runtime");
214-
std::fs::create_dir_all(&home).expect("failed to create isolated HOME");
215-
std::fs::create_dir_all(&runtime).expect("failed to create isolated runtime dir");
216-
env.push(("HOME", home.into_os_string()));
217-
env.push(("XDG_RUNTIME_DIR", runtime.into_os_string()));
218204
}
219205
// Share only STATE-FREE, content-addressed caches across scenarios when
220206
// CI provides a persistent shared dir (see shared_cache_dir): HF model
@@ -253,6 +239,24 @@ impl E2eWorld {
253239
env
254240
}
255241

242+
/// Additional isolation for interactive sessions. The dashboard socket
243+
/// default is derived from HOME/XDG rather than the CLI AppPaths, so a PTY
244+
/// must not inherit the host daemon's socket location. Piped scenarios keep
245+
/// their historical HOME/XDG environment, including GPU/runtime defaults.
246+
pub fn pty_env(&self) -> Vec<(&'static str, std::ffi::OsString)> {
247+
let Some(root) = &self.isolated_root else {
248+
return Vec::new();
249+
};
250+
let home = root.path().join("home");
251+
let runtime = root.path().join("runtime");
252+
std::fs::create_dir_all(&home).expect("failed to create isolated HOME");
253+
std::fs::create_dir_all(&runtime).expect("failed to create isolated runtime dir");
254+
vec![
255+
("HOME", home.into_os_string()),
256+
("XDG_RUNTIME_DIR", runtime.into_os_string()),
257+
]
258+
}
259+
256260
pub fn isolate_cmd(&self, cmd: &mut std::process::Command) {
257261
for (key, value) in self.isolate_env() {
258262
cmd.env(key, value);

tests/e2e-cucumber/tests/e2e/dash_steps.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ async fn open_dashboard(world: &mut E2eWorld) {
103103

104104
#[when("the user opens the ROCm view")]
105105
async fn open_rocm_view(world: &mut E2eWorld) {
106+
// Dashboard tabs are currently ordered Home, ROCm, Serving, Observe; these
107+
// numeric shortcuts intentionally exercise that user-visible ordering.
106108
session(world)
107109
.send("2")
108110
.unwrap_or_else(|e| panic!("failed to switch to the ROCm tab: {e}"));
@@ -141,6 +143,8 @@ async fn open_command_palette(world: &mut E2eWorld) {
141143
#[when("the user chooses Serving")]
142144
async fn choose_serving(world: &mut E2eWorld) {
143145
let tui = session(world);
146+
// The palette initially selects Home; Serving is the third destination, so
147+
// two downward moves intentionally assert the current destination ordering.
144148
tui.send("jj")
145149
.unwrap_or_else(|e| panic!("failed to select Serving: {e}"));
146150
tui.send("\r")

tests/e2e-cucumber/tests/e2e/tui_driver.rs

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ const DETAIL_COLS: u16 = 120;
4545
/// poll cadence, not a fixed readiness sleep: every wait has a deadline and
4646
/// returns the instant its condition holds.
4747
const POLL_INTERVAL: Duration = Duration::from_millis(20);
48+
/// Maximum time to let the PTY reader consume the child's final frame after the
49+
/// process exits. This is bounded so a misbehaving PTY cannot stall a scenario.
50+
const DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
4851

4952
/// Default wall-clock budget for a single wait. Generous enough for a cold dash
5053
/// start plus the embedded-daemon connect, while still turning a genuine hang
@@ -108,7 +111,7 @@ impl TuiSession {
108111
for (key, value) in std::env::vars_os() {
109112
cmd.env(key, value);
110113
}
111-
for (key, value) in world.isolate_env() {
114+
for (key, value) in world.isolate_env().into_iter().chain(world.pty_env()) {
112115
cmd.env(key, value);
113116
}
114117
// Deterministic terminal type; the PTY ioctl size above is authoritative
@@ -157,12 +160,21 @@ impl TuiSession {
157160
/// already resolved escape sequences and styling into cells, so this is
158161
/// exactly what a user sees — color/attribute independent.
159162
pub fn screen_text(&self) -> String {
163+
self.screen_snapshot().0
164+
}
165+
166+
fn screen_snapshot(&self) -> (String, (u16, u16)) {
160167
self.parser
161168
.lock()
162-
.map(|p| p.screen().contents())
169+
.map(|p| (p.screen().contents(), p.screen().size()))
163170
.unwrap_or_default()
164171
}
165172

173+
fn framed_screen(&self) -> String {
174+
let (screen, (rows, cols)) = self.screen_snapshot();
175+
format!("--- last screen ({cols}x{rows}) ---\n{screen}\n--- end screen ---")
176+
}
177+
166178
/// Resize both the real PTY and the emulated screen. The application receives
167179
/// the normal terminal resize event; assertions continue to inspect exactly
168180
/// what a user would see at the new geometry.
@@ -201,24 +213,41 @@ impl TuiSession {
201213
if self.screen_text().contains(marker) {
202214
return Ok(());
203215
}
204-
// If the process is gone, give the reader a beat to drain any final
205-
// bytes, then check once more before declaring failure.
216+
// If the process is gone, let the reader drain the final frame for a
217+
// short bounded window. A single poll is not enough when a large frame
218+
// is still buffered behind the process exit notification.
206219
if let Ok(Some(status)) = self.child.try_wait() {
207220
self.finished = true;
208-
tokio::time::sleep(POLL_INTERVAL).await;
209-
let screen = self.screen_text();
210-
if screen.contains(marker) {
221+
self.record_once(i32::try_from(status.exit_code()).unwrap_or(-1));
222+
let drain_deadline = Instant::now() + DRAIN_TIMEOUT;
223+
while Instant::now() < drain_deadline {
224+
if self.screen_text().contains(marker) {
225+
return Ok(());
226+
}
227+
if self
228+
.reader
229+
.as_ref()
230+
.is_some_and(std::thread::JoinHandle::is_finished)
231+
{
232+
break;
233+
}
234+
tokio::time::sleep(POLL_INTERVAL).await;
235+
}
236+
// Final check after the drain window closes: the reader may have
237+
// committed the last frame between the loop's screen check and the
238+
// `is_finished`/deadline exit, so re-read before declaring failure.
239+
if self.screen_text().contains(marker) {
211240
return Ok(());
212241
}
213242
return Err(format!(
214243
"process exited ({status:?}) before {marker:?} appeared.\n{}",
215-
framed_screen(&screen)
244+
self.framed_screen()
216245
));
217246
}
218247
if Instant::now() >= deadline {
219248
return Err(format!(
220249
"timed out after {timeout:?} waiting for {marker:?}.\n{}",
221-
framed_screen(&self.screen_text())
250+
self.framed_screen()
222251
));
223252
}
224253
tokio::time::sleep(POLL_INTERVAL).await;
@@ -250,7 +279,7 @@ impl TuiSession {
250279
} else {
251280
Err(format!(
252281
"TUI exited unsuccessfully ({status:?}).\n{}",
253-
framed_screen(&self.screen_text())
282+
self.framed_screen()
254283
))
255284
};
256285
}
@@ -260,7 +289,7 @@ impl TuiSession {
260289
if Instant::now() >= deadline {
261290
return Err(format!(
262291
"timed out after {timeout:?} waiting for the TUI to exit.\n{}",
263-
framed_screen(&self.screen_text())
292+
self.framed_screen()
264293
));
265294
}
266295
tokio::time::sleep(POLL_INTERVAL).await;
@@ -288,8 +317,14 @@ impl Drop for TuiSession {
288317
// without an explicit quit (e.g. the consent-gate scenarios).
289318
if !self.finished {
290319
let _ = self.child.kill();
291-
let _ = self.child.wait();
320+
let rc = self
321+
.child
322+
.wait()
323+
.ok()
324+
.and_then(|status| i32::try_from(status.exit_code()).ok())
325+
.unwrap_or(-1);
292326
self.finished = true;
327+
self.record_once(rc);
293328
}
294329
self.reader_stop.store(true, Ordering::Relaxed);
295330
if let Some(handle) = self.reader.take() {
@@ -327,8 +362,3 @@ fn spawn_reader(
327362
}
328363
})
329364
}
330-
331-
/// Wrap a screen dump in delimiters so failure messages are easy to read.
332-
fn framed_screen(screen: &str) -> String {
333-
format!("--- last screen ({COLS}x{ROWS}) ---\n{screen}\n--- end screen ---")
334-
}

0 commit comments

Comments
 (0)