Conversation
|
Claude finished @andypost's task in 6m 6s —— View job Review: port: drop a port's pending fd-event changes before it is freed
The diagnosis is right and the mechanism is the right shape: a pointer-keyed batch needs a way to un-queue an entry, the compaction is a correct stable in-place removal in all six batching engines, the I could not build or run: 1. The new test fails on kqueue platforms
Leg 2 of the test asserts the opposite, at if (nxt_slow_path(ev[0]->changing != 0 || ev[2]->changing != 0)) {
... "a committed change left changing set (%d, %d)" ...
goto done;
}That leg is compiled under 2. On poll/devpoll/pollset, dropping a pending DELETE is not the same as committing itThe header comment justifies dropping with "every facility this abstracts over removes a descriptor from its set when the descriptor is closed." That holds for epoll and kqueue kernel sets. It does not hold for The delete case is reachable for a port: The PR is right that the hash entry is already stale on the common path (a port never deletes its event), but this removes the one path that did clean it up, so on those engines it can turn a benign-in-practice read of 3. The same change can outlive the descriptor without any freeThe fix is anchored to So: arm write → close → next poll commits 4. The call-site guard is what forces the kqueue flag
if (port->engine != NULL && port->socket.changing) {
nxt_fd_event_cancel_changes(port->engine, &port->socket);
}Six of the seven engines test 5.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a2ee5c14a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
2a2ee5c to
17b892d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17b892d521
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
An fd-event change is queued into the engine's batch by a pointer to the event, and the pointer is dereferenced when the batch is committed -- at the top of the next poll at the latest. Nothing kept the struct that pointer names alive until then. port->socket lives inside the port, so nxt_port_release() frees it with port->mem_pool. nxt_port_rearm_now() arms the write event, and nxt_port_write_msgs() calls it immediately before the nxt_port_use() that can take the count to zero and release the port; nothing runs in between. The next nxt_epoll_poll() then writes ev->changing into freed memory and calls epoll_ctl() with a descriptor number the port had closed, which by then may name somebody else's file. A connection does not have this problem: nxt_conn_close_handler() asks nxt_fd_event_close() whether changes are pending and defers the rest of its teardown behind a zero timer, which fires after the next poll. A port has no such handler, never deletes its event from the engine, and is freed from a reference count drop that can happen anywhere. So add the missing engine operation instead: cancel_changes takes an event's pending changes out of the batch. The changes are dropped, not committed -- the descriptor is already closed (nxt_port_mp_cleanup() asserts pair[] is -1), and every facility here removes a descriptor from its set when the descriptor is closed. A caller that wants the kernel told deletes the event first, which is what nxt_fd_event_close() is for. nxt_port_release() calls it, guarded on ->changing so that a port that queued nothing pays nothing. kqueue now sets ->changing in nxt_kqueue_fd_set() so the guard means the same thing there; nothing else in that engine reads the flag. select batches nothing, so its implementation is empty. nxt_fd_event_change_test drives the platform's real engine, not a stub, and asserts on the engine's change count so a leg that stops exercising the path fails instead of passing quietly. Without the call in nxt_port_release() its third leg reports 1 change left and, under -fsanitize=address, a heap-use-after-free in nxt_epoll_commit_changes() on memory freed by nxt_port_release(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17b892d to
19e0b0b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 19e0b0baf8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| nxt_fd_event_enable_write(engine, ev); | ||
|
|
||
| if (nxt_slow_path(nxt_fd_event_change_test_nchanges(engine) != 1)) { |
There was a problem hiding this comment.
Flush fixture changes before asserting batch sizes
When epoll is built without NXT_HAVE_EVENTFD, or kqueue without NXT_HAVE_EVFILT_USER, nxt_event_engine_create() installs its fallback signal pipe through nxt_fd_event_enable_read(), leaving one change in this same batch before the test event is enabled. This assertion therefore observes 2 rather than 1 and the test suite fails immediately on those supported configurations; poll the new engine once or record/subtract its initial change count before running the legs.
Useful? React with 👍 / 👎.
Fixes #414.
What was wrong
An fd-event change is queued into the engine's batch by a pointer to the
event, and that pointer is dereferenced when the batch is committed -- at
the top of the next poll at the latest. Nothing kept the struct the
pointer names alive until then.
port->socketis embedded innxt_port_t, sonxt_port_release()freesit with
port->mem_pool.Reachability, reproduced
The issue traced a two-work-item interleaving. There is a tighter one, in a
single function:
nxt_port_write_msgs()callsnxt_port_rearm()(whichreaches
nxt_port_rearm_now()->nxt_fd_event_enable_write()->nxt_epoll_change()) and then, with nothing in between, callsnxt_port_use(task, port, use_delta)with a delta that can be negative(
src/nxt_port_socket.c, thecleanup:block). If that is the lastreference,
nxt_port_release()runs right there and frees the pool thepending change points into.
The new
nxt_fd_event_change_testdrives the platform's real event engineand does exactly that: arm the write event, close the descriptors the way
nxt_port_close()does, drop the last reference. Built with--tests --debug --openssl -fsanitize=address,undefined, with the fixremoved:
That write is
ev->changing = 0. Theepoll_ctl()two lines later readsev->fd, a descriptor the port has already closed, so on a busy process itcan name somebody else's file.
The fix
A new engine operation,
cancel_changes, takes an event's pending changesout of the batch.
nxt_port_release()calls it before releasing the pool.The changes are dropped rather than committed: the descriptor is closed by
then --
nxt_port_mp_cleanup()assertspair[0]andpair[1]are -1, soevery release path has run
nxt_port_close()first -- so committing wouldact on a descriptor number that may already name somebody else's file. A
caller that wants the kernel told deletes the event first, which is what
nxt_fd_event_close()is for.For epoll and kqueue that leaves nothing behind: the kernel drops a closed
descriptor from its set. The
poll,devpollandpollsetengines keeptheir set in user space instead, and only an applied delete removes an
entry -- see the second bullet under What this does not fix.
Why not the two shapes the issue sketched:
nxt_fd_event_t *and there is no generic way back to an owner, so thismeans a back-pointer and a release callback in a struct every connection
and listener embeds. Much larger, for the same result.
nxt_conn_close_handler()asks
nxt_fd_event_close()whether changes are pending and puts the restof its teardown behind a zero timer, which fires after the next poll. A
port has no close handler, no timer, and is freed from a reference-count
drop that can happen from anywhere; giving it one changes port lifetime
semantics far beyond this bug.
Implemented for all seven engines.
selectbatches nothing, so itsimplementation is empty.
kqueuenow sets->changinginnxt_kqueue_fd_set()so that the call-site guard means the same thingthere; nothing else in that engine reads the flag (
nxt_kqueue_close()scans by descriptor, and
nxt_kqueue_cancel_changes()scans by->udatawithout testing it).
A kqueue flush deliberately does not clear the flag. The batch mixes fd
events with the file events
nxt_kqueue_file_set()puts in the same->udatafield, and nothing in akeventsays which of the two an entryholds, so clearing the flag across a flushed batch would write through an
nxt_file_event_tas if it were annxt_fd_event_t. The flag thereforeonly ever says "maybe": it is set whenever a change is queued and cleared
only once the batch has been scanned, so it never skips a cancel that was
needed, and the cost of an over-report is a scan that finds nothing.
No other caller is added: connections, listeners and the signal pipe reach
the engine exactly as before.
Tests
nxt_fd_event_change_test, three legs, all asserting on the engine's ownchange count so a leg that stops exercising the path fails rather than
passing quietly:
->changinggoes 1 -> 0;poll commits them -- this is the compaction, which can silently go wrong
while the count stays right. On epoll the leg then asks the kernel which
descriptors actually reached the set (
EPOLL_CTL_MODfinds the two thatwere kept, and reports
ENOENTfor the cancelled one), an oracleindependent of the engine's own bookkeeping;
The
->changingassertions are epoll-only. The flag is not a cross-enginecontract: a kqueue flush leaves it set for the reason given above, so
asserting
changing == 0after a commit would fail on everyNXT_HAVE_KQUEUEbuild. The change count is the oracle that holds on both.Run green on
--tests --openssl --debugwith-fsanitize=address,undefined(50 tests passed, no ASan report) and on--tests --opensslrelease. Leg 3 was confirmed to fail without the fix,both by its own assertion and by the ASan report quoted above.
What this does not fix
nxt_epoll_commit_changes()queuesnxt_epoll_error_handlerwith thesame bare
evpointer on anepoll_ctl()failure; that work item hasthe same lifetime gap and is untouched here.
poll,devpollandpollsetkeep an fd -> event hash and set in userspace that nothing removes when a port is freed, because a port never
deletes its event. That dangling entry is pre-existing and is the common
case on those engines. This change narrows one sub-case and does not fix
it: where
nxt_port_fd_disable_write()had queued anNXT_POLL_DELETE(
nxt_poll_disable_write()does that whenev->readis alreadyinactive), that delete used to be applied and would have removed the
entry; it is now dropped with the rest. Note this is still a net
improvement in memory safety rather than a regression: committing the
batch runs
ev->changing = 0on the freed event before it even reachesthe op, so on those engines the old behaviour was itself a
use-after-free. Fixing the residual properly means deleting the event
before the port is freed on those engines, which is a wider change than
epoll/kqueue: a pending fd-event change can outlive the port it points into (UAF) #414; they are not on the Linux or BSD path and want their own look.
nxt_port_write_close()closespair[1]and leavessocket.fdat thenumber it had, and
nxt_port_close()does the same for both, whilecallers keep the port alive afterwards. So a change armed before such a
close can still be committed against a closed -- possibly reused --
descriptor number. That is not a use-after-free, and it is the narrower
half of the hazard epoll/kqueue: a pending fd-event change can outlive the port it points into (UAF) #414 describes. Moving the cancel into
nxt_port_close()would cover both with one call site, but it is not adrop-in:
nxt_router_thread_exit_handler()reassignsport->enginetothe router's engine immediately before calling
nxt_port_close(), so acancel there would scan the wrong engine's batch on a live path. Left for
its own change.
kqueue,devpoll,pollsetandeventportare reviewed, not built.🤖 Generated with Claude Code