Re: [PATCH v2] Cygwin: open: Unlock fdtab before open_with_arch()

Johannes Schindelin <[email protected]> Wed, 22 Jul 2026 22:29:44 +0200 (CEST)
Newsgroups gmane.os.cygwin.patches
Message-ID <[email protected]>
Hi Takashi,

Thank you for trying the experiment so quickly. To answer your closing
question first: yes, both outputs are exactly what a correct fix should
produce. Let me walk through why, and then flag two caveats about the
reserved-marker design before we build it out.

On Wed, 22 Jul 2026, Takashi Yano wrote:

> On Wed, 22 Jul 2026 20:10:12 +0900
> Takashi Yano wrote:
> > Hi Johannes,
> >=20
> > Thanks for reviewing!
> >=20
> > On Tue, 21 Jul 2026 19:16:11 +0200 (CEST)
> > Johannes Schindelin wrote:
> > > This is the first point where the slot and its reference count agree
> > > again; Too late for anything that looked in between. And the cleanup=
 only
> > > runs for non-negative descriptors, so it never covers the case above=
.
> > >=20
> > > Since it is already in `master`, a follow-up patch probably makes mo=
st
> > > sense. Two things to fix: release the lock when no descriptor is
> > > available, and stop a reserved-but-not-yet-open descriptor from look=
ing
> > > like a fully open one to the rest of the fdtable. Reviving the old i=
nteger
> > > marker would mean teaching every consumer of the table about it, so =
it is
> > > not a drop-in.
> >=20
> > Ah, I got it. The user program cannot know that, but cygwin1.dll can
> > refere fdtab inside it. What about adding reserved flag to fdtab?

Adding a reserved marker is the right approach, and it is what I had in
mind too. Keeping the reserved slot hidden from the rest of the table lets
`open()` remain the sole owner of the handler and delete it on any error
path, including the `FH_PROCESSFD` reopen, without any reference-count
juggling.

The first caveat, and the bigger one: this is not a drop-in change. Every
place that treats a non-NULL slot as a live handler has to learn to skip
the reserved marker. Besides `cygheap_fdget`, that includes
`cygheap_fdenum::next`, `not_open`, and the five fork/exec fixup loops
(`set_file_pointers_for_exec`, `fixup_after_exec`, `fixup_after_fork`,
`fixup_before_fork`, and `fixup_before_exec`). If a fork or exec runs on
another thread while a FIFO open is still holding the reservation, those
loops would dereference the marker.

To keep that safe, I would encode the marker as a single distinguished
pointer value stored in the slot itself, rather than as a separate flag.
Then the lock-free `cygheap_fdget` keeps working with one added
comparison, and there is no risk of a torn read.

>=20
> I tried it as quick experiment.
>=20
> The result of the first reproducer is:
> baseline close(-1): result=3D-1 errno=3D9
> descriptor allocation stopped after 3197 opens: errno=3D24
> second-thread close(-1): result=3D-1 errno=3D9

The decisive line here is the second thread's `close(-1)` returning
`EBADF` (errno 9) instead of blocking. That is the proof that the table
lock is no longer held when no descriptor is available; before the fix,
the second thread would have waited forever on the process-wide lock. The
allocation stopping after 3197 opens with `EMFILE` (errno 24) is right as
well: that is `OPEN_MAX` (3200, spelled `__OPEN_MAX` in the headers) minus
the three standard descriptors, so the ceiling is reached cleanly.

> The result of the second reproducer is:
> expected FIFO writer descriptor: 4
> provisional descriptor query: result=3D-1 writer_done=3D0

This one is exactly right too. The writer taking descriptor 4 is just the
expected setup; the line that matters is the provisional-descriptor query,
which returns -1 (`EBADF`) with `writer_done` still 0 while the FIFO open
is still blocked waiting for the other end. The reserved descriptor now
reports a bad descriptor instead of triggering the access violation
(status 0xC0000005) I saw before, where `cygheap_fdget` raised the
reference count from zero to one and its destructor then lowered it back
to zero and deleted the handler while `open()` was still blocked inside
`open_with_arch()`.

> Are these results as you expected?

Yes, as detailed above. The second caveat is about coverage: the two
reproducers drive `close()` and the descriptor lookup on a single known
number, but not the paths that walk the whole table. `close_range` is the
clearest of those. It iterates every descriptor in the range and looks
each one up, so it reaches a reserved slot without ever being told its
number. That, incidentally, answers the question you raised earlier about
how the racing thread could reference a descriptor that `open()` has not
returned yet: it does not _have_ to. The internal iterators reach the slot
regardless. A `close_range` call and a fork or exec, each racing an
in-flight open, would exercise the paths the current reproducers miss.

Independently of all that, the lock-leak fix stands on its own and is
trivial, namely releasing the table lock when no descriptor is available,
so it could land first as a separate commit while we work out the marker
design.

Ciao,
Johannes