Skip to content

FilePoll: support readable+writable on one poll (fixes c-ares TCP DNS assertion)#29955

Merged
Jarred-Sumner merged 6 commits into
mainfrom
jarred/filepoll-bidirectional
Apr 30, 2026
Merged

FilePoll: support readable+writable on one poll (fixes c-ares TCP DNS assertion)#29955
Jarred-Sumner merged 6 commits into
mainfrom
jarred/filepoll-bidirectional

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

c-ares can request both readable and writable on the same TCP DNS fd — writable while connecting or when a send hits EAGAIN, readable for the response. dns.zig:onDNSSocketState registered both directions on a single FilePoll, which set both .poll_readable and .poll_writable; on close, unregisterWithFd tripped bun.assert(!(poll_readable && poll_writable)). On epoll the second register was also a CTL_MOD that silently overwrote the first direction's mask.

Changes

src/async/posix_event_loop.zig:

  • registerWithFd (epoll): when the other direction is already registered on this poll, OR its event mask into the CTL_MOD so both stay armed. debugAssert against bidirectional + one-shot (EPOLLONESHOT disarms the whole fd on the first event in either direction — not hit by the DNS path, which passes one_shot=false).
  • registerWithFd (kqueue): unchanged. kqueue keys on (ident, filter), so the second register() call's EV_ADD creates a separate knote for the new filter without touching the existing one — there is no "already registered → modify" branch on kqueue.
  • unregisterWithFd (kqueue mac/freebsd): when both directions are set, submit two EV_DELETE changes (EVFILT_READ + EVFILT_WRITE) in one kevent call. epoll's CTL_DEL keys on fd alone so already removes both. Dropped the both-directions assertion.
    • macOS: changelist is [2]kevent64_s. The rc<0 global-error check now runs before per-entry EV_ERROR checks, which are guarded on rc >= N (with KEVENT_FLAG_ERROR_EVENTS error events are packed from index 0, not positionally matched to changes).
    • FreeBSD: nevents=0, so per-entry errors surface as rc=-1/errno for the first failing change; a silent miss on the second EV_DELETE (ENOENT) is harmless.
  • needs_rearm early-return now also clears .poll_writable (was only clearing readable/process/machport).

src/bun.js/api/bun/dns.zig:onDNSSocketState (POSIX): when c-ares's desired direction set differs from what's registered:

  • Adding only (e.g. W → R+W): just register() the new direction — one CTL_MOD on epoll, one EV_ADD on kqueue.
  • Dropping a direction (e.g. W → R): unregister() then re-register() the remaining direction(s). FilePoll has no per-direction unregister, and leaving writable armed on a level-triggered connected socket would busy-loop. c-ares DNS fds are short-lived so the extra syscall is acceptable.

activate/deactivate are already idempotent via has_incremented_poll_count, so registering twice keeps the loop refcount correct.

Tests

  • bun run zig:check-all: all platforms compile (Linux/macOS/Windows × debug/release, x64/arm64).
  • test/js/node/dns/ + test/js/bun/dns/: 137 pass / 2 fail (both pre-existing env-dependent: dns.prefetch cache-hit timing, dns.getServers resolv.conf — fail identically on parent of this commit).
  • test/js/node/async_hooks/: 110 pass / 3 todo / 1 fail (pre-existing http-clientrequest timeout, unrelated).
  • async-context-dns-resolveTxt.js fixture (original repro): exit 0.

Split out of #29897 per review.

…socket state to it

c-ares can request both readable and writable on a TCP DNS fd (writable
while connecting / send queue pending, readable for the response).
dns.zig registered both on one FilePoll, which set both .poll_readable
and .poll_writable. On unregister this tripped the
"!(poll_readable && poll_writable)" assertion; on epoll the second
register was a CTL_MOD that silently dropped the first direction.

posix_event_loop.zig:
- registerWithFd (epoll): when the other direction is already registered,
  OR its mask into the CTL_MOD events so both stay armed.
- unregisterWithFd (kqueue mac/freebsd): when both directions are set,
  submit two EV_DELETE changes (READ + WRITE). epoll's CTL_DEL already
  removes both. Drop the both-directions assertion.
- needs_rearm early-return: also clear .poll_writable.

dns.zig onDNSSocketState: when the desired direction set differs from
what's registered, unregister first then re-register each requested
direction.
@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator
Updated 7:04 PM PT - Apr 30th, 2026

@robobun, your commit 5eb10fc has 1 failures in Build #49521 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29955

That installs a local version of the PR into your bun-29955 executable, so you can run:

bun-29955 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. DNS option use-vc in /etc/resolv.conf / RES_USEVC is not respected #18694 - DNS use-vc option forces TCP, which would hit the FilePoll bug where epoll CTL_MOD overwrites the direction mask, causing TCP DNS queries to stall and time out
  2. Bun DNS lookup not working for c-ares provider. #24970 - c-ares DNS provider fails with ECONNREFUSED, consistent with TCP socket events being lost due to the bidirectional FilePoll polling bug
  3. DNSException: querySrv ECONNREFUSED since Bun v1.3.5 with MongoDB@7.0.0 #25718 - SRV queries for MongoDB Atlas via c-ares fail with ECONNREFUSED since v1.3.5, consistent with c-ares TCP DNS socket events being dropped
  4. mongoose querySrv ECONNREFUSED #27180 - Same SRV/ECONNREFUSED root cause as DNSException: querySrv ECONNREFUSED since Bun v1.3.5 with MongoDB@7.0.0 #25718 for mongoose MongoDB Atlas connections
  5. Timeout error when resolving subdomain with many IPs #19933 - Domains with many IPs produce large responses that force TCP fallback, which would hit the FilePoll direction mask overwrite causing timeouts

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #18694
Fixes #24970
Fixes #25718
Fixes #27180
Fixes #19933

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 23972f9b-d4fb-4e7e-9ea7-2da0ef1713b3

📥 Commits

Reviewing files that changed from the base of the PR and between a62dc4d and a623339.

📒 Files selected for processing (2)
  • src/async/posix_event_loop.zig
  • src/bun.js/api/bun/dns.zig

Walkthrough

Epoll/kevent registration/unregistration logic was updated to preserve opposite-direction interests, fix flag removal bugs, and correct per-entry error detection. DNS socket poll state now fully resynchronizes with c-ares’ desired readable/writable set. A new UDP+TCP DNS test fixture and its test were added.

Changes

Cohort / File(s) Summary
Event Loop Registration Logic
src/async/posix_event_loop.zig
Linux: CTL_MOD now ORs opposite-direction EPOLL bits when adding one direction and asserts one-shot constraint. Fixed unregister early-return to remove .poll_writable correctly and track whether both directions were registered. macOS/FreeBSD: kevent unregister now emits combined delete entries when needed; macOS/FreeBSD error handling refined to check EV_ERROR via bitmask and distinguish per-entry EV_ERROR from global kevent failure.
DNS Socket Poll Synchronization
src/bun.js/api/bun/dns.zig
Non‑Windows path now resynchronizes FilePoll to exactly match c-ares’ desired (readable, writable) set by performing a full unregister when a direction must be removed, then re-registering remaining directions; unchanged logic only adds missing directions. Windows path minor parenthesis adjustment for computing uv_events.
DNS TCP Bidirectional Test Fixture
test/js/node/dns/dns-tcp-bidirectional-poll-fixture.ts
Adds UDP server that responds with DNS responses marked TC=1 (force TCP) and a TCP server that parses length-prefixed DNS frames, echoes question section, and returns a compressed TXT "hello" answer; prints JSON records or error and exits with status.
DNS TCP Bidirectional Test
test/js/node/dns/dns-tcp-bidirectional-poll.test.ts
Adds a new (skipped on Windows) test that runs the fixture subprocess, captures stdout/stderr, asserts no stderr, verifies stdout equals expected JSON ([["hello"]]), and checks exit code is 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding support for bidirectional (readable+writable) polling on a single FilePoll, which fixes a c-ares TCP DNS assertion.
Description check ✅ Passed The description comprehensively covers both required template sections: it explains what the PR does (the bidirectional polling issue and the three-file solution) and how it was verified (zig:check-all, DNS test suite, original repro fixture).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 0/5 reviews remaining, refill in 56 minutes and 7 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/async/posix_event_loop.zig`:
- Around line 836-848: The change in posix_event_loop.zig that updates the epoll
flag logic (variables: flags, one_shot_flag, linux.EPOLL, and checks
this.flags.contains(.poll_writable)/(.poll_readable)) affects Linux, macOS, and
FreeBSD branches — run the cross-platform Zig compile pass (bun run
zig:check-all) and fix any platform-specific compile failures by synchronizing
equivalent logic in the macOS/FreeBSD branches (preserve platform-specific
constants and guards or add proper conditional compilation), updating any
mismatched symbol names or flags so all platform builds succeed.

In `@src/bun.js/api/bun/dns.zig`:
- Around line 2563-2569: The resync branch that calls poll.unregister and
poll.register can fail partway and leave the polls map holding a FilePoll that
no longer matches kernel state; change the logic in the block that compares
have_readable/have_writable to readable/writable so that you check each
poll.unregister() and poll.register() return value and on the first error you
abort further register/unregister attempts, remove/deinit the corresponding
FilePoll entry from the polls map, and surface/return the error; specifically,
stop on any failing call to poll.unregister(vm.event_loop_handle.?, false) or
poll.register(vm.event_loop_handle.?, .readable/.writable, false), remove the
poll entry from polls (the FilePoll for this fd) and run its cleanup to avoid
leaving a stale entry.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 65eb24b6-c9da-4b6a-b91b-63183546f39b

📥 Commits

Reviewing files that changed from the base of the PR and between e257f2d and bce472d.

📒 Files selected for processing (2)
  • src/async/posix_event_loop.zig
  • src/bun.js/api/bun/dns.zig

Comment thread src/async/posix_event_loop.zig
Comment thread src/bun.js/api/bun/dns.zig Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this touches core FilePoll register/unregister logic across epoll, macOS kqueue, and FreeBSD kqueue and removes a long-standing assertion — worth a human pass to sanity-check the platform-specific paths.

Extended reasoning...

Overview

This PR enables a single FilePoll to be registered for both readable and writable events simultaneously, fixing an assertion failure when c-ares uses TCP DNS. It modifies registerWithFd (epoll: OR the other direction into the CTL_MOD mask) and unregisterWithFd (kqueue: submit two EV_DELETE changes; drop the both-directions assertion; fix the needs_rearm early-return to clear .poll_writable instead of double-removing .poll_process). dns.zig:onDNSSocketState now fully resyncs the poll (unregister + re-register) when c-ares's desired direction set changes.

Security risks

None identified. This is internal event-loop bookkeeping with no untrusted input parsing, auth, or boundary crossing.

Level of scrutiny

High. FilePoll underpins subprocess I/O, file sinks/readers, DNS, terminal, and process watching across all POSIX targets. The change has three separate platform code paths (Linux epoll, Darwin kevent64, FreeBSD kevent) that each need to be correct, and it removes a defensive assertion that previously guarded an unsupported state. Subtle questions a human should confirm: whether the asymmetric EPOLL.ERR handling between readable/writable masks is intentional when combined, whether the dns.zig unregister-then-register sequence can drop an edge-triggered event between syscalls, and whether the changelist[1] error check on macOS is reachable given KEVENT_FLAG_ERROR_EVENTS semantics.

Other factors

The PR description is thorough, tests pass locally (139 DNS + 111 async_hooks), and the needs_rearm fix is clearly a pre-existing copy-paste bug. The change was split out of #29897 per prior review feedback, so it's already been somewhat scoped down. Still, event-loop polling correctness is load-bearing enough that auto-approval isn't appropriate.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

@robobun resume this PR, its not correct.

y sound, but there are a few real problems and one design wrinkle worth flagging.

s correct

  - epoll: yes, you can CTL_MOD with EPOLLIN | EPOLLOUT in one mask; epoll keys on fd alone, so OR'ing the other direction in is exactly right. CTL_DEL removing both is also correct.
- kqueue: yes, kevent64/kevent accept nchanges > 1 and the changelist can mix filters, so two EV_DELETE entries (READ + WRITE) for the same ident in one call is valid. The same array for changelist/eventlist is fine.
- The needs_rearm typo fix (poll_process twice → poll_writable) is a real bugfix.
- Removing the assert(!(readable && writable)) is necessary for the feature.

Bugs

  1. kqueue register path is unhandled. You only patched unregister. With the new dns.zig flow:
    - unregister (clears both flags)
    - register(.readable) → kevent EV_ADD EVFILT_READ, sets poll_readable
    - register(.writable) → at this point poll_readable is set, so op = if poll_readable ... CTL_MOD on Linux, but on macOS/FreeBSD the register path isn't shown in the diff — does it submit a separate EV_ADD EVFILT_WRITE? If so, fine. But if it has a similar "already registered → modify the existing one" branch that picks ONE filter, the second register will replace rather than add. You need to verify the kqueue register path adds the second filter, not just the unregister path deleting it.
2. macOS changelist size. The diff doesn't show the macOS changelist declaration being grown from [1] to [2] (FreeBSD's is shown as [2]). If macOS still has var changelist = std.mem.zeroes([1]...), writing changelist[1] is OOB. Check the lines above the macOS hunk.
  3. both_directions error check ordering on macOS. You check changelist[1].flags == EV.ERROR before checking rc/errno. If kevent64 itself returns -1 (e.g. EBADF on the whole call), changelist[1] is uninitialized garbage from the syscall's perspective. Move the per-entry error check after the rc/errno success check, or at least guard on rc >= 1.
4. FreeBSD kevent nevents arg. The FreeBSD call passes nevents=0 (last numeric arg before null), so error events for the second change won't be returned in the eventlist — you'd only learn about a per-entry failure via the overall rc/errno, which kevent doesn't surface that way when nevents=0. Either pass nevents=nchanges and check entries like macOS, or accept silent failure on the second delete (probably fine for EV_DELETE).

Design wrinkle

5. dns.zig "unregister-then-re-register-both" is heavier than needed. c-ares calls the socket-state callback frequently (every state transition). For epoll you're doing CTL_DEL + CTL_ADD + CTL_MOD (3 syscalls) when one CTL_MOD would do. For kqueue: 2× EV_DELETE + 2× EV_ADD. It works, but consider: keep both registered while readable || writable is true, and only unregister when both go false. The c-ares fd is short-lived anyway, so this is minor.
6. One-shot interaction. If one_shot is ever true on a both-direction poll, epoll's EPOLLONESHOT disarms the whole fd after the first event in either direction — you'd need to re-arm both. dns.zig passes false for one_shot so it's not hit here, but the FilePoll API now allows it without handling that case.

Verdict

The epoll half is correct. The kqueue half needs: (a) the macOS changelist sized to [2], (b) the register path verified to ADD the second filter rather than replace, (c) the per-entry error check moved after the rc check. The dns.zig sync logic is correct but does more syscalls than necessary.

…yscalls

- unregisterWithFd (macOS): check rc<0 before reading per-entry EV_ERROR
  results, and guard changelist[i] on rc>=i+1 (error events are packed
  from index 0 with KEVENT_FLAG_ERROR_EVENTS, not positionally matched
  to changes).
- registerWithFd (epoll): debugAssert against one_shot + bidirectional
  (EPOLLONESHOT disarms the whole fd on the first event either way).
- dns.zig onDNSSocketState: only full-unregister when a direction is
  being dropped; when purely adding, register() alone suffices
  (CTL_MOD on epoll, separate knote on kqueue).
- FreeBSD unregister: note that nevents=0 surfaces the first failing
  change via errno; silent second EV_DELETE miss is harmless.
@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

No merge conflicts detected when merging into main.

Your branch is good to go!

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Addressed in 30af659:

  1. kqueue register path — verified correct as-is. The macOS/FreeBSD registerWithFd has no "already registered → modify" branch; each call submits one EV_ADD for the requested filter. kqueue keys on (ident, filter), so register(.readable) then register(.writable) creates two independent knotes. Noted in the PR body.
  2. macOS changelist size — already [2]std.posix.system.kevent64_s at the top of the macOS unregister block (line 1096).
  3. macOS per-entry error ordering — fixed. rc < 0 (global errno) is now checked first; per-entry EV_ERROR checks are guarded on rc >= 1 / rc >= 2 since KEVENT_FLAG_ERROR_EVENTS packs error events from index 0 rather than positionally. Also added the data != 0 guard to match the register path.
  4. FreeBSD nevents=0 — left as-is with a comment: first failing change comes back via errno; a silent ENOENT on the second EV_DELETE is harmless.
  5. dns.zig syscall count — reduced. When only adding a direction the unregister is skipped (so W→R+W is 1 syscall instead of 3). When dropping a direction we still full-resync: leaving writable armed on a level-triggered connected TCP socket would busy-loop, and FilePoll has no per-direction unregister. c-ares fds are short-lived so W→R at 2 syscalls is fine.
  6. one-shot × bidirectional — added bun.debugAssert(!one_shot) in the epoll bidirectional-merge branch to catch future misuse; dns.zig passes false.

zig:check-all passes on all targets.

robobun and others added 2 commits April 30, 2026 22:00
Exercises the FilePoll bidirectional path: ares_open_connection() for a
TCP DNS socket reports (readable=1, writable=1) via sock_state_cb, so
onDNSSocketState registers both directions on one poll. Without the
fix, the second register()'s CTL_MOD drops EPOLLIN on Linux (the
response is never read and the query busy-loops until c-ares gives
up), and unregisterWithFd asserts !(poll_readable && poll_writable)
on close in debug builds on all POSIX platforms.
Comment thread src/async/posix_event_loop.zig Outdated
Comment thread src/bun.js/api/bun/dns.zig
robobun added 2 commits April 30, 2026 22:14
…ts precedence

- registerWithFd/unregisterWithFd (macOS): xnu sets kev.flags |= EV_ERROR,
  so a failed EV_DELETE comes back as EV_DELETE|EV_ERROR (0x4002) and the
  previous equality test never matched. Use (flags & EV_ERROR) != 0 to
  match packages/bun-usockets/src/eventing/epoll_kqueue.c.
- dns.zig onDNSSocketState (Windows): Zig parses
  'if (r) A else 0 | if (w) B else 0' as 'if (r) A else (0 | ...)', so
  readable=writable=true yielded UV_READABLE only. Parenthesize each
  if-expression so uv_poll_start gets both bits for c-ares TCP DNS.
@Jarred-Sumner Jarred-Sumner merged commit 8503c59 into main Apr 30, 2026
73 of 75 checks passed
@Jarred-Sumner Jarred-Sumner deleted the jarred/filepoll-bidirectional branch April 30, 2026 23:53
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
… assertion) (oven-sh#29955)

c-ares can request both readable and writable on the same TCP DNS fd —
writable while connecting or when a send hits EAGAIN, readable for the
response. `dns.zig:onDNSSocketState` registered both directions on a
single `FilePoll`, which set both `.poll_readable` and `.poll_writable`;
on close, `unregisterWithFd` tripped `bun.assert(!(poll_readable &&
poll_writable))`. On epoll the second `register` was also a `CTL_MOD`
that silently overwrote the first direction's mask.

### Changes

`src/async/posix_event_loop.zig`:
- **`registerWithFd` (epoll):** when the other direction is already
registered on this poll, OR its event mask into the `CTL_MOD` so both
stay armed. `debugAssert` against bidirectional + one-shot (EPOLLONESHOT
disarms the whole fd on the first event in either direction — not hit by
the DNS path, which passes one_shot=false).
- **`registerWithFd` (kqueue):** unchanged. kqueue keys on `(ident,
filter)`, so the second `register()` call's `EV_ADD` creates a separate
knote for the new filter without touching the existing one — there is no
"already registered → modify" branch on kqueue.
- **`unregisterWithFd` (kqueue mac/freebsd):** when both directions are
set, submit two `EV_DELETE` changes (`EVFILT_READ` + `EVFILT_WRITE`) in
one kevent call. epoll's `CTL_DEL` keys on fd alone so already removes
both. Dropped the both-directions assertion.
- macOS: changelist is `[2]kevent64_s`. The rc<0 global-error check now
runs before per-entry `EV_ERROR` checks, which are guarded on `rc >= N`
(with `KEVENT_FLAG_ERROR_EVENTS` error events are packed from index 0,
not positionally matched to changes).
- FreeBSD: `nevents=0`, so per-entry errors surface as rc=-1/errno for
the first failing change; a silent miss on the second `EV_DELETE`
(ENOENT) is harmless.
- `needs_rearm` early-return now also clears `.poll_writable` (was only
clearing readable/process/machport).

`src/bun.js/api/bun/dns.zig:onDNSSocketState` (POSIX): when c-ares's
desired direction set differs from what's registered:
- **Adding only** (e.g. W → R+W): just `register()` the new direction —
one `CTL_MOD` on epoll, one `EV_ADD` on kqueue.
- **Dropping a direction** (e.g. W → R): `unregister()` then
re-`register()` the remaining direction(s). FilePoll has no
per-direction unregister, and leaving writable armed on a
level-triggered connected socket would busy-loop. c-ares DNS fds are
short-lived so the extra syscall is acceptable.

`activate`/`deactivate` are already idempotent via
`has_incremented_poll_count`, so registering twice keeps the loop
refcount correct.

### Tests

- `bun run zig:check-all`: all platforms compile (Linux/macOS/Windows ×
debug/release, x64/arm64).
- `test/js/node/dns/` + `test/js/bun/dns/`: 137 pass / 2 fail (both
pre-existing env-dependent: `dns.prefetch` cache-hit timing,
`dns.getServers` resolv.conf — fail identically on parent of this
commit).
- `test/js/node/async_hooks/`: 110 pass / 3 todo / 1 fail (pre-existing
`http-clientrequest` timeout, unrelated).
- `async-context-dns-resolveTxt.js` fixture (original repro): exit 0.

Split out of oven-sh#29897 per review.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.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.

2 participants