Conversation
port->messages is unbounded. Before a queued message owned the descriptors it names, a peer that stopped reading cost the sender memory. Now each queued message holds up to two descriptors of this process open for as long as it waits, so the same peer applies RLIMIT_NOFILE pressure as well. Bound that, and only that. A message with no descriptor still costs only memory, which is not what changed, and bounding the queue as a whole would change behaviour on every ordinary reply and every body fragment. So NXT_PORT_MAX_FD_MSGS caps the descriptor-carrying entries of one port, and the queue itself stays unbounded. A send that would go past the bound is refused with NXT_ERROR rather than dropped. That answer already means "nothing was consumed, the message is still yours" -- it is what nxt_port_msg_chk_insert() answers when the copy cannot be allocated -- so every caller that sends a descriptor already closes it on a failed send. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @andypost's task in 5m 30s —— View job Review: bound the queued messages that hold a descriptor
I could not run VerdictThe invariant holds as described. I traced every path that puts an entry into
Comments are thorough and the "refuse, don't drop" choice is right: Findings below, most actionable first. None of them is a correctness hole in the happy path. 1. The alert fires on every refused send, not on entering the refusing state
That matters most for Logging on the 0→refusing transition (or every N-th refusal) would keep the signal and drop the flood. The test's "logs once, and exactly once" observation is a property of the test's single refusal, not of the code. 2.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aafca410e0
ℹ️ 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".
| for (i = 0; i < NXT_PORT_MAX_FD_MSGS; i++) { | ||
| if (nxt_port_socket_write2(task, port, NXT_PORT_MSG_NEW_PORT, fd, -1, | ||
| i, 0, NULL) | ||
| != NXT_OK) |
There was a problem hiding this comment.
Skip the bound test when the fd limit is too low
When the test runs under a low RLIMIT_NOFILE (for example, 128, or any limit with fewer than 128 descriptors remaining after the harness opens its existing files), one of these sends fails because nxt_port_msg_alloc() cannot duplicate fd, before the queue reaches the configured bound. The test then reports that the implementation refused a message below the bound even though descriptor exhaustion is unrelated to the behavior under test. The later getrlimit() call only limits descriptor counting; this loop should first verify that at least NXT_PORT_MAX_FD_MSGS slots are available and skip or adapt the test otherwise.
Useful? React with 👍 / 👎.
A receiving port keeps every fragment of a message until the last one arrives, and the sender decides how many streams it opens and how long each runs. Nothing bounded either: a peer that never sends the last fragment, or opens a new stream id per message, grew the receiver without limit (fmsg->size accumulated unchecked, port->frags grew with every new (stream, pid)). libunit never fragments, so the legitimate senders are Unit's own processes, one message at a time per port. Now, per port (src/nxt_port.h): NXT_PORT_FRAG_STREAMS_MAX 64 open fragment streams NXT_PORT_FRAG_SIZE_MAX 128 MB per stream NXT_PORT_FRAG_TOTAL_MAX 256 MB across the open streams A stream that would pass a limit is dropped whole with an alert: it is taken out of ->frags, its buffers are released (mmap ones completed, plain ones returned to the port's free list), the first fragment's descriptors are closed, and its later fragments take the existing "frag stream not found" path. The counters live at the back of nxt_port_s (process-local; two uint32_t, 448 -> 456 bytes, no SHM struct changes) and are kept by nxt_port_frag_start(), the last-fragment lookup and the drop. The sender side of #394 (port->messages holding descriptors) is left to the open PR #409, which bounds exactly that. Test: src/test/nxt_port_frag_test.c (./build/tests), fragments sized over a PROT_NONE reservation so 128 MB streams cost no memory: a stream at the size limit completes; past it on the last or on a middle fragment it is dropped; stream 65 is refused while 64 are open, and the 64 still complete; a stream that would push the port past 256 MB is refused while the two within it complete; after all that a fresh stream at the limit passes (counters return to zero). Red before the fix: "past the size limit, last: delivered". tools/perf baselines regenerated: nxt_port_read_msg_process codegen (the new checks on the fragment path; the unfragmented path is unchanged) and the process-local nxt_port_s layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSMMShDBn9zs2gFfTrLqwA
Closes #394.
Follows #388 (a queued message dups the descriptors it names) and #389, both
merged. This branch is on
master, not on #393.What the bound is, and what it is not
port->messagesstays unbounded. This bounds only the entries that carry afile descriptor.
The cost #388 introduced is descriptor pressure, not memory pressure. A
message with no descriptor costs the sender memory exactly as it did before
#388, and that is not what #394 is about — bounding the queue as a whole
would change behaviour on paths that never hold a descriptor at all: every
ordinary reply, every request body fragment. So
NXT_PORT_MAX_FD_MSGS(
src/nxt_port.h) caps the descriptor-carrying entries of one port at 128,which is at most 256 held descriptors, and the queue itself is untouched.
128 is a constant with the reasoning next to it. The traffic being bounded is
control-plane and one message per event — a new port, a process start, a
listening socket, a certificate, a script, a shared memory segment. A port
with 128 of them outstanding is a peer that has stopped reading, not a busy
port.
The count is
port->fd_messages, maintained underport->write_mutexon oneinvariant: an entry is counted exactly while it is in
port->messagesandcarries a descriptor. It is decremented at the two moments that end it — the
message leaves the queue (
nxt_port_socket_cancel(),nxt_port_error_handler()), or it stays queued but its descriptors have justgone out (
nxt_port_write_msgs(), beforenxt_port_msg_close_fd()clearsmsg->fd[]).The refusal, and the caller audit
A send that would pass the bound is refused with
NXT_ERROR, not droppedsilently. That answer already carries a defined meaning — nothing was
consumed, the message is still the caller's (
src/nxt_port.h) — and it isalready what
nxt_port_msg_chk_insert()answers when the heap copy cannot beallocated. So the refusal is not a new code path for any caller; it is an
existing one reached for a new reason.
Every caller that can reach it, and what it already does:
NXT_OKreturnnxt_port_send_port(),src/nxt_port.c:487(NEW_PORT, two borrowed fds)NXT_ERRORto its own caller. The descriptors are borrowed fromnew_portand stay with it, as on the success path.nxt_router_start_app_process(),src/nxt_router.c:616(START_PROCESS)nxt_port_rpc_cancel()on the stream, then the failure path.nxt_router_app_prefork(),src/nxt_router.c:4074(START_PROCESS)nxt_port_rpc_cancel(), thenfail:.nxt_cert_store_get()reply,src/nxt_cert.c:1306file.fdexplicitly, with a comment saying the port layer never took it.nxt_script_store_get()reply,src/nxt_script.c:604nxt_main_port_socket_handler(),src/nxt_main_process.c:1399(listening socket)ls.socketand queues the buffer's completion.nxt_main_port_access_log_handler(),src/nxt_main_process.c:2225nxt_port_change_log_file(),src/nxt_port.c:1075(CHANGE_FILE)nxt_router_get_mmap_handler(),src/nxt_router.c:7669(MMAP)NXT_PORT_MSG_CLOSE_FD, so the descriptor stays withmmap_handlereither way. The peer does not get the segment — degraded, not leaked.No caller loses a descriptor or a buffer to the refusal, and none of them
treats
NXT_ERRORas fatal to the process. That is the result that made thisshape safe to ship; had one of them been unable to survive a refusal the
answer would have had to be different.
Tradeoff
A refused send is a behaviour change. Before this, a send to a stalled peer
always succeeded and the queue grew. Now, past 128 descriptor-carrying
entries on one port, a NEW_PORT or a START_PROCESS or a certificate reply can
fail where it used to be accepted, and the caller takes its failure path —
typically an RPC cancel, which surfaces as a failed process start rather than
a silent stall. That is the intended trade: a bounded, reported failure
instead of an unbounded descriptor hold.
Tests
Third leg in
src/test/nxt_port_queued_fd_test.c, the file that alreadycovers #388's ownership change, on the same stub-engine fixture with a real
socketpair. It fills a port to the bound, then asserts:
NXT_ERROR;still open, the queue is unchanged;
descriptor over the socketpair and it names the right file;
before the fill and after the drain and must match;
The leg reads no internal counter; it observes the bound the way a caller
does.
Proof the leg ran, rather than a green suite: the refusal logs once, and
exactly once, in both configurations —
Fail-before / pass-after, measured, not inferred:
./build/testsdf9843b0+ this test file only (with the constant declared so it compiles)port queued fd test: a message past the bound of 128 was accepted--tests --openssl --debugport queued fd test passed--tests --openssl(release)nxt_queue_remove()nulls links only in debugMutation check: with both bound checks neutered to
has_fd && 0, thesuite goes to exit 1 on this same line; restored, back to exit 0.
NXT_HAVE_EPOLL_EDGEforced to 0 atsrc/nxt_unix.h:193:make testsbuilds clean, exit 0 (then restored). No new static is inside an
#if (NXT_HAVE_EPOLL_EDGE)block, so-Wunused-functionhas nothing tocatch, but the build was run rather than reasoned about.
What this does not do
unnoticed; this only caps how many descriptors can be pinned that way.
port->messagesitself, so an unbounded queue ofdescriptor-free messages is still possible. That is the pre-port: a queued NEW_PORT can transmit a closed or reused descriptor #388 memory
cost and is out of scope here.
peer can hold a queue open long enough to matter, and against which port.
The bound makes the ceiling finite without needing that answer.
path, and the C suite covers the change directly.
🤖 Generated with Claude Code