From 70170802e3f7f5cc58d0fa413b0289b158ff5195 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 30 Apr 2026 10:06:07 +0200 Subject: [PATCH] Cygwin: pty: detect pcon-backed pty for non-Cygwin-spawned children When a Cygwin process (e.g. `bash` under MinTTY) spawns a native Win32 child (e.g. `git.exe`) with pseudo console support enabled, the child gets a pseudo console that bridges the pty. If that native child then spawns a Cygwin grandchild (e.g. `vim`, `less`), the grandchild inherits the pseudo console's console handles. In `init_std_file_from_handle()`, the grandchild's msys2-runtime sees `GetConsoleScreenBufferInfo()` succeed on those handles and, with no valid `ctty` set, falls back to `FH_CONSOLE` and gives the process `cons0` instead of connecting to the pty. This causes scrollback clobbering in MinTTY because alternate screen sequences (`ESC[?1049h` / `ESC[?1049l`) are handled by `fhandler_console`'s `save_restore()` against the pseudo console's buffer, which has no correspondence to MinTTY's scrollback. Fix this in the existing console branch of `init_std_file_from_handle()`: when there is no valid `ctty` and we are about to fall back to `FH_CONSOLE`, first scan the shared tty table for an entry whose `pcon_activated` is set and whose `nat_pipe_owner_pid` is in our console's process list (via `GetConsoleProcessList`). If found, parse the device as that pty slave instead of as a real console. The handle is closed in either fallback path, matching the existing `FH_CONSOLE` behavior. `myself->ctty` is left untouched; the regular `fhandler_pty_slave::open_setup()` path will set it via `myself->set_ctty()` when the pty slave is opened. The structure of `find_pcon_pty()` matters and is easy to get wrong in case a keen developer would like to refactor this code in the future. This code runs on every Cygwin process startup whose parent is non-Cygwin, so the common path (no pty with an active pseudo console) must remain free of expensive operations. Two pitfalls to avoid: filtering tty entries with `tty::exists()` looks correct but creates and destroys a named pipe per entry (128 entries on every call), and hoisting the `GetConsoleProcessList()` call out of the loop pays the cross-process cost even when no candidate exists. The current shape, a cheap shared-memory boolean check first and a lazily fetched process list only on the first candidate, keeps the common case at a handful of pointer reads. This was reported in https://github.com/git-for-windows/git/issues/5303 and bisected to Git for Windows v2.41.0, where the msys2-runtime was upgraded from 3.3.6 (no pcon) to 3.4.6 (pcon architecture introduced). Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Johannes Schindelin --- winsup/cygwin/dtable.cc | 12 +++++++++- winsup/cygwin/local_includes/tty.h | 5 ++++ winsup/cygwin/tty.cc | 37 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/winsup/cygwin/dtable.cc b/winsup/cygwin/dtable.cc index 44ce75642d..3312de9373 100644 --- a/winsup/cygwin/dtable.cc +++ b/winsup/cygwin/dtable.cc @@ -327,7 +327,17 @@ dtable::init_std_file_from_handle (int fd, HANDLE handle) dev.parse (myself->ctty); else { - dev.parse (FH_CONSOLE); + /* Check whether the inherited console is actually a pseudo + console bridging a pty. This happens when our non-Cygwin + parent was itself spawned by a Cygwin process from a pty + (e.g. bash spawning git.exe which then spawns vim). In + that case, connect to the pty slave instead of treating + the handle as a real console. */ + int pcon_minor = cygwin_shared->tty.find_pcon_pty (); + if (pcon_minor >= 0) + dev.parse (FHDEV (DEV_PTYS_MAJOR, pcon_minor)); + else + dev.parse (FH_CONSOLE); CloseHandle (handle); handle = INVALID_HANDLE_VALUE; } diff --git a/winsup/cygwin/local_includes/tty.h b/winsup/cygwin/local_includes/tty.h index 6e70a74cd7..f980599425 100644 --- a/winsup/cygwin/local_includes/tty.h +++ b/winsup/cygwin/local_includes/tty.h @@ -175,6 +175,10 @@ class tty: public tty_min void wait_fwd (); bool pty_input_state_eq (xfer_dir x) { return pty_input_state == x; } bool nat_fg (pid_t pgid); + bool has_active_pcon () const + { return pcon_activated && switch_to_nat_pipe; } + bool has_pcon_and_owner (DWORD pid) const + { return pcon_activated && switch_to_nat_pipe && nat_pipe_owner_pid == pid; } friend class fhandler_pty_common; friend class fhandler_pty_master; friend class fhandler_pty_slave; @@ -193,6 +197,7 @@ class tty_list int connect (int); void init (); tty_min *get_cttyp (); + int find_pcon_pty (); int attach (int n); static void init_session (); friend class lock_ttys; diff --git a/winsup/cygwin/tty.cc b/winsup/cygwin/tty.cc index c8730e81c5..5cce05de34 100644 --- a/winsup/cygwin/tty.cc +++ b/winsup/cygwin/tty.cc @@ -123,6 +123,43 @@ tty_list::init () } } +/* Search for a pty whose pseudo console owns our console. + Return tty minor number or -1 if not found. + Called from init_std_file_from_handle() for processes started by + non-Cygwin parents to detect that inherited console handles are + from a pcon-backed pty. + + The cheap precondition (any tty with pcon active in shared memory) + short-circuits the common case where no pty has a pseudo console + active, avoiding the GetConsoleProcessList() LPC call entirely. */ +int +tty_list::find_pcon_pty () +{ + DWORD pids[128]; + DWORD count = 0; + bool got_pids = false; + + for (int i = 0; i < NTTYS; i++) + { + if (!ttys[i].has_active_pcon ()) + continue; + + /* Fetch the console process list lazily, only on first candidate. */ + if (!got_pids) + { + count = GetConsoleProcessList (pids, 128); + if (!count) + return -1; + got_pids = true; + } + + for (DWORD j = 0; j < count; j++) + if (ttys[i].has_pcon_and_owner (pids[j])) + return i; + } + return -1; +} + /* Search for a free tty and allocate it. Return tty number or -1 if error. */