|
| 1 | +// Defensive Ctrl-C handling for stdin termios state. |
| 2 | +// |
| 3 | +// Background: when a Playwright session is interrupted with SIGINT (Ctrl-C), |
| 4 | +// the spawned Node driver and its chromium subprocesses can clobber the |
| 5 | +// controlling terminal's `termios` state in subtle ways and never restore |
| 6 | +// it, leaving the user's shell in non-canonical mode where arrow keys |
| 7 | +// echo as raw `^[[D` sequences instead of moving the cursor (issue #59). |
| 8 | +// |
| 9 | +// The exact subprocess responsible has not been pinpointed — the symptom |
| 10 | +// reproduces in some terminal environments but not others, and we have |
| 11 | +// not been able to recreate it inside an `expect`-allocated pty across |
| 12 | +// macOS/Linux ARM64/Linux x64 CI runners. The defensive fix here is |
| 13 | +// agnostic to which process is the offender: |
| 14 | +// |
| 15 | +// 1. At `Playwright::launch`, snapshot stdin's termios if stdin is a tty. |
| 16 | +// 2. Install a one-shot SIGINT handler that restores the snapshot before |
| 17 | +// letting the process die with the conventional 130 exit code. |
| 18 | +// 3. The `Drop` impl on `Playwright` also restores the snapshot so the |
| 19 | +// same protection applies to graceful exits and panics. |
| 20 | +// |
| 21 | +// The signal handler can be disabled by setting the |
| 22 | +// `PLAYWRIGHT_NO_SIGNAL_HANDLER` environment variable (any non-empty |
| 23 | +// value) — for users who manage their own SIGINT handlers and don't |
| 24 | +// want this library overriding them. |
| 25 | +// |
| 26 | +// Stub on Windows: the symptom in #59 is Unix-specific and Windows uses |
| 27 | +// a different console-mode model. The whole module compiles to no-ops |
| 28 | +// on Windows. |
| 29 | + |
| 30 | +#[cfg(unix)] |
| 31 | +mod imp { |
| 32 | + use parking_lot::Mutex; |
| 33 | + use std::sync::OnceLock; |
| 34 | + |
| 35 | + static SAVED: OnceLock<Mutex<Option<libc::termios>>> = OnceLock::new(); |
| 36 | + static HANDLER_INSTALLED: OnceLock<()> = OnceLock::new(); |
| 37 | + |
| 38 | + /// Snapshot stdin's termios if stdin is a tty. Idempotent — only the |
| 39 | + /// first call records a snapshot; later calls are no-ops so we never |
| 40 | + /// overwrite the original "clean" state with a possibly-already-clobbered |
| 41 | + /// one. |
| 42 | + pub(crate) fn save_if_tty() { |
| 43 | + let cell = SAVED.get_or_init(|| Mutex::new(None)); |
| 44 | + let mut guard = cell.lock(); |
| 45 | + if guard.is_some() { |
| 46 | + return; |
| 47 | + } |
| 48 | + // SAFETY: tcgetattr writes to the termios pointer on success and |
| 49 | + // ignores it on failure. Stdin (fd 0) is always a valid file |
| 50 | + // descriptor in a hosted environment. |
| 51 | + unsafe { |
| 52 | + if libc::isatty(0) != 1 { |
| 53 | + return; |
| 54 | + } |
| 55 | + let mut t: libc::termios = std::mem::zeroed(); |
| 56 | + if libc::tcgetattr(0, &mut t) == 0 { |
| 57 | + *guard = Some(t); |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + /// Restore the saved termios to stdin. No-op if nothing was saved |
| 63 | + /// (stdin wasn't a tty or save_if_tty was never called). |
| 64 | + pub(crate) fn restore() { |
| 65 | + let Some(cell) = SAVED.get() else { return }; |
| 66 | + let guard = cell.lock(); |
| 67 | + let Some(t) = *guard else { return }; |
| 68 | + // SAFETY: tcsetattr reads from the termios pointer and applies |
| 69 | + // it. Failure is acceptable (e.g. stdin no longer a tty) and we |
| 70 | + // ignore the return code. |
| 71 | + unsafe { |
| 72 | + let _ = libc::tcsetattr(0, libc::TCSANOW, &t); |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + /// Install a one-shot SIGINT handler that restores termios and exits |
| 77 | + /// with code 130. Returns immediately if already installed or if the |
| 78 | + /// `PLAYWRIGHT_NO_SIGNAL_HANDLER` env var is set. |
| 79 | + pub(crate) fn install_signal_handler() { |
| 80 | + if std::env::var_os("PLAYWRIGHT_NO_SIGNAL_HANDLER").is_some() { |
| 81 | + return; |
| 82 | + } |
| 83 | + if HANDLER_INSTALLED.set(()).is_err() { |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + tokio::spawn(async move { |
| 88 | + // tokio::signal::ctrl_c registers a SIGINT listener via the |
| 89 | + // tokio runtime; multiple listeners can coexist with user |
| 90 | + // handlers, but our task gets to act first and exit the |
| 91 | + // process cleanly. |
| 92 | + if tokio::signal::ctrl_c().await.is_ok() { |
| 93 | + restore(); |
| 94 | + // 128 + SIGINT(2) = 130, the conventional exit code for |
| 95 | + // Ctrl-C interrupted programs. |
| 96 | + std::process::exit(130); |
| 97 | + } |
| 98 | + }); |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +#[cfg(not(unix))] |
| 103 | +mod imp { |
| 104 | + pub(crate) fn save_if_tty() {} |
| 105 | + pub(crate) fn restore() {} |
| 106 | + pub(crate) fn install_signal_handler() {} |
| 107 | +} |
| 108 | + |
| 109 | +pub(crate) use imp::{install_signal_handler, restore, save_if_tty}; |
0 commit comments