fix(gui): close the window on the first click while hosting

Closing the window while a host/viewer child was running took two clicks:
the first only dropped the stream, the second actually closed the window.

eframe drops the app synchronously while it destroys the window, which ran
`ChildProc`'s teardown — SIGINT plus a up-to-2s grace-period wait — on the
event-loop thread. That wait froze the window mid-close, so the first click
looked like it only killed the stream and the window lingered until a second
close event. (The teardown runs from `Drop`, not from an `on_exit` /
`close_requested` hook, so it fires on every backend and close path; those
hooks don't fire at all under some winit backends.)

Make the teardown non-blocking: hold the child in an `Option`, and on drop
SIGINT it synchronously (so the host still runs its ctrl-c teardown even if
we exit immediately after) then reap it on a detached thread instead of
waiting inline. The app drops instantly, so the window closes on the first
click; the kicked-off SIGINT still tears the stream down cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:34:23 -04:00
parent f926dbea4e
commit e54d625f2a
+30 -24
View File
@@ -66,7 +66,9 @@ pub enum CaptureState {
const STDERR_TAIL_MAX: usize = 60;
pub struct ChildProc {
child: Child,
/// `Some` while the child is owned here; `Drop` takes it to hand off to a
/// detached reaper thread (see the `Drop` impl).
child: Option<Child>,
pub rx: Receiver<ChildEvent>,
stderr_tail: Arc<Mutex<Vec<String>>>,
/// Write end of the child's stdin, for the line-based command channel
@@ -123,7 +125,7 @@ impl ChildProc {
});
Ok(Self {
child,
child: Some(child),
rx,
stderr_tail,
stdin,
@@ -145,38 +147,42 @@ impl ChildProc {
/// Whether the child is still running.
pub fn is_alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
matches!(self.child.as_mut().map(Child::try_wait), Some(Ok(None)))
}
/// The last captured stderr lines, joined — for error display.
pub fn stderr_tail(&self) -> String {
self.stderr_tail.lock().unwrap().join("\n")
}
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
/// — tears down capture, closes the endpoint), with a ~2 s grace period
/// before a hard kill. Idempotent.
pub fn stop(&mut self) {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return; // already exited
}
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
for _ in 0..40 {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for ChildProc {
fn drop(&mut self) {
// Closing the window (dropping the app, hence the session) must not
// orphan a live host child streaming to viewers.
self.stop();
// Leaving a host/viewer screen, or closing the window, must not orphan
// a live child — but it must also not *block*. eframe runs this drop
// synchronously while it destroys the window, so a grace-period wait
// here freezes the window mid-close: the first click looks like it did
// nothing (the stream just drops) and the window only goes away on a
// second click. So SIGINT now — synchronously, so the host always gets
// its ctrl-c teardown (capture down, endpoint closed) even if we exit
// right after — then reap on a detached thread instead of waiting.
let Some(mut child) = self.child.take() else {
return;
};
if matches!(child.try_wait(), Ok(Some(_))) {
return; // already exited; nothing to signal or reap
}
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
std::thread::spawn(move || {
for _ in 0..40 {
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
});
}
}