Re: [PATCH v2] Cygwin: pty: Do not transfer input to nat-pipe while masked
Takashi Yano <[email protected]> Mon, 6 Jul 2026 12:21:20 +0900
| Newsgroups | gmane.os.cygwin.patches |
|---|---|
| Message-ID | <[email protected]> |
Hi Johannes,
On Sun, 5 Jul 2026 10:10:51 +0200 (CEST)
Johannes Schindelin wrote:
> Hi Takashi,
>
> Thank you for v2. The overall direction (blocking the nat-pipe transfer
> while a cygwin reader is active on the cyg-pipe) is correct, and the new
> cleanup hook is a good defensive addition against a slave that exits
> mid-read wedging the pipe state. I do want to flag one hazard, though,
> which I think v2 introduces by moving `slave_reading` and `num_reader`
> from the fhandler into the shared `tty` struct.
>
> On Tue, 30 Jun 2026, Takashi Yano wrote:
>
> > On the command "cat | non-cygwin-app", `cat` sometimes fails to read
> > key input. This happens when `cat` starts to read input before `non-
> > cygwin-app` configures pseudo console. This is because pipe state is
> > switched to nat-pipe when pseudo console is configured.
> >
> > This patch prevent the pipe state from changing to nat-pipe state if
> > some cygwin process is reading input from the cyg-pipe.
> >
> > Fixes: f20641789427 ("Cygwin: pty: Reduce unecessary input transfer.")
> > Signed-off-by: Takashi Yano <[email protected]>
> > Reviewed-by:
> > ---
> > v2: Release all masks owned by myself on cleanup()
> >
> > winsup/cygwin/fhandler/pty.cc | 33 +++++++++++++++++++++----
> > winsup/cygwin/local_includes/fhandler.h | 3 +--
> > winsup/cygwin/local_includes/tty.h | 2 ++
> > 3 files changed, 31 insertions(+), 7 deletions(-)
> >
> > diff --git a/winsup/cygwin/fhandler/pty.cc b/winsup/cygwin/fhandler/pty.cc
> > index 35e320507..54cd64a47 100644
> > --- a/winsup/cygwin/fhandler/pty.cc
> > +++ b/winsup/cygwin/fhandler/pty.cc
> > @@ -951,7 +951,7 @@ out:
> >
> > fhandler_pty_slave::fhandler_pty_slave (int unit, dev_t via)
> > : fhandler_pty_common (), inuse (NULL), output_handle_nat (NULL),
> > - io_handle_nat (NULL), slave_reading (NULL), num_reader (0)
> > + io_handle_nat (NULL), masked_cnt (0)
> > {
> > dev_referred_via = via;
> > if (unit >= 0)
> > @@ -1230,6 +1230,10 @@ fhandler_pty_slave::open_setup (int flags)
> > void
> > fhandler_pty_slave::cleanup ()
> > {
> > + fhandler_pty_slave *arch = (fhandler_pty_slave *) archetype ? : this;
> > + while (arch->masked_cnt)
> > + mask_switch_to_nat_pipe (false, false);
> > +
> > if (get_ttyp ()->pcon_activated && get_ttyp ()->getpgid () == myself->pgid)
> > req_fixup_pcon_state ();
> >
> > @@ -1499,11 +1503,18 @@ fhandler_pty_slave::mask_switch_to_nat_pipe (bool mask, bool xfer)
> > WaitForSingleObject (input_mutex, mutex_timeout);
> > if (mask)
> > {
> > - if (InterlockedIncrement (&num_reader) == 1)
> > - slave_reading = CreateEvent (&sec_none_nih, TRUE, FALSE, name);
> > + if (InterlockedIncrement (&get_ttyp ()->num_reader) == 1)
> > + get_ttyp ()->slave_reading =
> > + CreateEvent (&sec_none_nih, TRUE, FALSE, name);
> > }
> > - else if (InterlockedDecrement (&num_reader) == 0)
> > - CloseHandle (slave_reading);
> > + else if (InterlockedDecrement (&get_ttyp ()->num_reader) == 0)
> > + CloseHandle (get_ttyp ()->slave_reading);
>
> `num_reader` is fine to share (it is just a counter, and the interlocked
> increment/decrement pair does the right thing across processes).
> `slave_reading`, however, is a `HANDLE`, and HANDLE values are
> per-process: they index into the owning process's handle table and are not
> portable to another process without `DuplicateHandle`. Storing the raw
> value in shared memory and letting an arbitrary process call `CloseHandle`
> on it later is therefore hazardous.
Ouch, that's my fatal mistake!
> Concretely, consider two cygwin readers on the same pty:
>
> Process A takes the first mask. `InterlockedIncrement` returns 1, so A
> calls `CreateEvent` and writes A's handle value into `get_ttyp
> ()->slave_reading`. Process B then takes a second mask; the counter goes
> to 2, so B does not create or store anything. A releases first: the
> counter goes to 1, so A does not close. B releases last: the counter goes
> to 0, and B executes `CloseHandle (get_ttyp ()->slave_reading)`. But that
> HANDLE value lives in A's handle table, not B's. In the benign case B gets
> `ERROR_INVALID_HANDLE` and the event object leaks (A already lost its slot
> in this API, so nobody will ever close it). In the malignant case, that
> same numeric HANDLE value happens to be live in B's own handle table
> pointing at an unrelated object, which B then closes out from under
> itself.
>
> In v1 both fields were per-fhandler, so this could not arise: whichever
> fhandler created the event also closed it, in the same process. For the
> specific same-process teardown path that v2's `cleanup ()` adds ("release
> all masks I still own"), the hazard also does not trigger by construction,
> since the process draining `masked_cnt` is the same one that took those
> masks. So the concern is purely about the general mask/unmask API contract
> now that the storage is shared.
>
> Two questions, then:
>
> First, is cross-process mask ownership actually reachable via the current
> call sites (read paths, `cleanup ()`, `close ()`, exec/spawn transitions)?
> If every mask is guaranteed to be released by the same process that took
> it, the hazard is theoretical and it would suffice to document that
> invariant near the field. I have not fully traced this myself and would
> trust your reading here.
This happens when,
1) Start `cat` and suspend it by Ctrl-Z
2) Start another `cat` and suspend it by Ctrl-Z
3) Foreground the first `cat` and press Ctrl-D
4) Foreground the second `cat` and press Ctrl-D
> Second, if it _is_ reachable, would it make sense to mirror the by-name
> lookup you already do in `transfer_input ()` on the release side as well,
> that is, `OpenEvent` the named event by name inside
> `mask_switch_to_nat_pipe (false, ...)` when the counter hits zero and
> close the freshly opened handle, so `CloseHandle` always operates on a
> handle native to the closing process? Then `slave_reading` in the shared
> struct would only serve as the "an event with this name exists" flag, and
> no cross-process HANDLE ever gets closed.
Looks good. Thanks! You can find the similar in v3 patch.
It reverts the change that made num_reader and slave_reading shared.
Even if a process close slave_reading, the other process keeps
slave_reading opened. Therefore, checking `masked` by OpenEvent() returns
true. Therefore, there was no need to make slave_reading and num_reader
shared.
> One much smaller, non-blocking observation on the check you added at the
> top of `transfer_input ()`:
>
> > +
> > + fhandler_pty_slave *arch = (fhandler_pty_slave *) archetype ? : this;
> > + if (mask)
> > + InterlockedIncrement (&arch->masked_cnt);
> > + else
> > + InterlockedDecrement (&arch->masked_cnt);
> >
> > if (!!masked != mask && xfer && get_ttyp ()->switch_to_nat_pipe)
> > {
> > @@ -4401,6 +4412,18 @@ fhandler_pty_slave::transfer_input (tty::xfer_dir dir, HANDLE from, tty *ttyp,
> > HANDLE input_available_event,
> > HANDLE input_transferred_to_cyg)
> > {
> > + if (dir == tty::to_nat)
> > + {
> > + char name[MAX_PATH];
> > + shared_name (name, TTY_SLAVE_READING, ttyp->get_minor ());
> > + HANDLE masked = OpenEvent (READ_CONTROL, FALSE, name);
> > + CloseHandle (masked);
> > + if (masked)
> > + /* Cygwin process is reading cyg-pipe.
> > + Do not transfer input to nat-pipe. */
> > + return;
> > + }
>
> There is a small TOCTOU window here: another thread can take the first
> mask (and create the event) in between our `OpenEvent` returning NULL and
> our `to_nat` transfer actually starting. The bounded worst case is one
> spurious transfer, not corruption, so I do not consider this a blocker;
> just noting it for the record in case a tighter interlock via
> `input_mutex` is cheap here.
Indeed. The caller of transfer_input() has already acquired the input
mutex. So, guarding mask_switch_to_nat_pipe() by input_mutex is needed
in addition. Please have a look v3 patch.
Thanks!
--
Takashi Yano <[email protected]>