Skip to content

Backport musl's fix for the popen/pclose descriptor race - #17

Open
buyemune-sys wants to merge 2 commits into
pg83:mainfrom
buyemune-sys:fix/musl-popen-race
Open

buyemune-sys wants to merge 2 commits into
pg83:mainfrom
buyemune-sys:fix/musl-popen-race

Conversation

@buyemune-sys

Copy link
Copy Markdown

The problem

popen from several threads at once sometimes fails with EBADF:

$ solo ./popen_race        # 8 threads x 150 popen("true")
failures=12 of 1200

This is musl 1.2.5 itself; plain musl on Alpine 3.24 fails the same way. It is not the bridge.

  1. popen collects the descriptors of the other popen streams from the open file list under its lock (ext/musl/src/stdio/popen.c:38) and gives the child a close action for each of them.
  2. But fclose closes the descriptor first (ext/musl/src/stdio/fclose.c:13) and leaves the list only afterwards (:28).
  3. So a concurrent popen can still see the dead stream. If its own pipe2 just got that descriptor number back, the child closes its own pipe end before the dup2 (popen.c:40), and posix_spawn reports EBADF.

In practice: Tarantool with app_threads starts eight threads that load the metrics module at once. Each runs io.popen("getconf PAGESIZE"); one of them gets nil and aborts the instance.

The fix

This is musl's own fix, upstream commit f21a96538f78 ("fix toctou race in popen children's closing of other popen pipes", 2026-07-28), applied unchanged to the vendored tree. fclose of a popen stream now holds the open file list lock across the close.

The README's vendored-versions list records the backport.

Test

The conformance battery (concurrentPipes()) runs popen("true") from 8 threads, 150 times each, and expects no failure. On real glibc it passes in about 0.3 s.

On main it fails on every run (8 to 15 failures out of 1200 in six runs). With the fix: ./build test passes.

buyemune-sys and others added 2 commits October 20, 2026 15:42
Security updates replaced libapparmor1 4.0.1really4.0.1-0ubuntu0.24.04.7
with .9 and libexpat1 2.6.1-2ubuntu0.4 with ubuntu0.6, and the pool kept
only the new files, so the cold-cache Fetch x86_64 job answers 404 and
every x86-64 job behind it is skipped. Both pins move to the versions
noble-updates ships now; their SHA256s match the archive's Packages index.
The other twelve pins are still served.
popen gives every child a file action closing the descriptors of the
other popen streams, collected from the open file list under its lock.
musl 1.2.5's fclose closes the descriptor first and leaves the list
afterwards, so a concurrent popen can still see the dead stream. If its
own pipe2 just got that descriptor number back, its child closes its own
pipe end before the dup2, and popen fails with EBADF.

Tarantool's application threads hit this on startup: eight threads load
the metrics module at once, each runs io.popen("getconf PAGESIZE"), and
one of them gets nil and aborts the instance. Glibc guests share musl's
stdio, so this is not a bridge problem but the libc underneath it; plain
musl on Alpine fails the same way.

musl fixed it after 1.2.5 in f21a965 ("fix toctou race in popen
children's closing of other popen pipes"): fclose of a popen stream now
holds the open file list lock across the close. The commit applies
unchanged to the vendored tree.

The conformance battery runs popen from eight threads at once. Before
the fix, every run fails some of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant