Skip to content

fix(ext/node): allow adopting inherited extra stdio TCP fds - #35805

Merged
bartlomieju merged 2 commits into
mainfrom
fix/tcp-wrap-inherited-extra-stdio
Jul 7, 2026
Merged

bartlomieju merged 2 commits into
mainfrom
fix/tcp-wrap-inherited-extra-stdio

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Follow-up to #34133, which registers inherited extra stdio fds in the child's
FdTable so node:fs fd APIs can use them. That registration broke adopting an
inherited fd that is a TCP socket: net.Socket({ fd }) routes through
TCPWrap::open, whose duplicate-fd check was not taught about
InheritedExtraStdio entries and rejected the fd with EEXIST (PipeWrap
got the exemption, TCPWrap did not). This mirrors the PipeWrap logic in
TCPWrap::open: inherited extra stdio fds are allowed, and their entry (with
the node:fs dup it holds) is consumed only once uv_tcp_open actually claims
the fd. The new regression test fails with EEXIST without this change.

The startup dup is now also created with F_DUPFD_CLOEXEC instead of
dup(). It exists purely for in-process node:fs use, but without
close-on-exec it leaked into spawned grandchildren, holding pipe ends open
and delaying EOF for the parent past what Node exhibits.

AI assistance was used to prepare this PR.

Follow-up to #34133, which registered inherited extra stdio fds in the
FdTable so node:fs can use them. That registration broke adopting an
inherited fd that is a TCP socket: net.Socket({ fd }) routes through
TCPWrap::open, whose duplicate-fd check was not taught about
InheritedExtraStdio entries and rejected the fd with EEXIST (PipeWrap
got the exemption, TCPWrap did not).

Mirror the PipeWrap logic in TCPWrap::open: allow inherited extra stdio
fds and consume their entry (dropping the node:fs dup) only once
uv_tcp_open actually claims the fd.

Also create the startup dup with F_DUPFD_CLOEXEC instead of dup(). The
dup exists purely for in-process node:fs use; without close-on-exec it
leaked into spawned grandchildren, holding pipe ends open and delaying
EOF for the parent.
@bartlomieju

Copy link
Copy Markdown
Member Author

Reviewed β€” the fix itself looks correct: the exemption is a faithful mirror of PipeWrap::open (same snapshot-then-check, same consume-only-on-successful-open), the if ret == 0 block is the single convergence point so both the client and listener open variants are covered, and the F_DUPFD_CLOEXEC change is unix-gated and strictly Node-ward. A few things to address:

  1. Add test coverage for the listener path. The PR description mentions server.listen({ fd }), and SocketType::Server routes through the same new FdTable handling via uv_tcp_open_listener β€” but the regression test only exercises the client path (new Socket({ fd: 3 })). Please add a companion test that passes an inherited listening TCP fd through an extra stdio slot and has the child adopt it with net.createServer().listen({ fd: 3 }), then accept one connection through it. That pins down the uv_tcp_open_listener + entry-consumption interaction, which is not covered anywhere today.

  2. Optional: extract the duplicated check into a shared FdTable helper. TCPWrap::open and PipeWrap::open now carry an identical, somewhat dense block (the is_inherited_extra_stdio snapshot + EEXIST rejection, and the matching remove+register_uv_owned on success). A small helper pair on FdTable (e.g. check_adoptable(fd) -> Result<bool, i32> and consume_for_uv(fd, was_inherited)) would keep the two wraps from drifting the way they did in the first place β€” this bug existed precisely because the exemption was added to one copy and not the other. Fine as a follow-up if you'd rather keep this PR minimal.

  3. Non-blocking observation, no change needed: remove(fd) drops an Rc<dyn File>, so if node:fs holds a clone at adoption time the dup's close is deferred until that Rc drops. Harmless, but worth a short note in the comment above the remove call so nobody later assumes the close is immediate.

- Add a regression test for the SocketType::Server path: the parent
  passes a listening TCP fd through stdio slot 3 and the child adopts
  it with net.createServer().listen({ fd: 3 }), accepting a connection
  queued by the kernel while the parent's copy of the socket is closed.
- Extract the duplicate-fd policy shared by PipeWrap::open and
  TCPWrap::open into FdTable::begin_uv_adopt / finish_uv_adopt so the
  two wraps can't drift again (the original bug existed because the
  exemption was added to one copy and not the other).
- finish_uv_adopt's doc comment notes that dropping the inherited entry
  releases an Rc, so the dup's close is deferred while an in-flight
  node:fs stream still holds a clone.
@bartlomieju

Copy link
Copy Markdown
Member Author

Addressed in a006a44:

  1. Listener-path test added β€” [node/child_process spawn] Deno child adopts a listening TCP fd passed as extra stdio: the parent creates a listener, hands a raw dup of it through stdio slot 3, closes its own copy, and connects a client (queued by the kernel); the child adopts the fd with net.createServer().listen({ fd: 3 }) and serves the connection. This exercises SocketType::Server β†’ uv_tcp_open_listener β†’ finish_uv_adopt.
  2. Shared helper extracted β€” the duplicate-fd policy now lives in FdTable::begin_uv_adopt / finish_uv_adopt (ext/io/fd_table.rs), and both PipeWrap::open and TCPWrap::open reduce to the same two calls, so the exemption can't drift between copies again.
  3. Rc note added β€” finish_uv_adopt's doc comment states that dropping the inherited entry releases an Rc<dyn File>, so the dup's close is deferred while an in-flight node:fs stream holds a clone.

Full unit_node::child_process_test suite is green locally with the refactor.

@bartlomieju
bartlomieju merged commit b7d5a7d into main Jul 7, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/tcp-wrap-inherited-extra-stdio branch July 7, 2026 10:22
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