refactor(engine): track active connections symmetrically with idle (pre-P5)#22
refactor(engine): track active connections symmetrically with idle (pre-P5)#22andypost wants to merge 1 commit into
Conversation
…4.5 / pre-P5)
A separate prep PR for P5 (connection drain). The P5 audit
flagged that the engine has no enumerable list of active
connections -- nxt_conn_active() removed a conn from
engine->idle_connections but did not place it onto any other
queue, so c->link dangled while a request was in flight. The
graceful-drain force-close-on-timeout step in P5 needs an
enumerable list of active conns; counters cannot substitute
because closed_conns_cnt only fired on idle-conn closes.
This PR adds the missing primitive and makes idle/active state
symmetric. No drain logic, no timer logic -- those land in P5
on top of this.
What changes
------------
* src/nxt_event_engine.h adds:
nxt_queue_t active_connections;
nxt_atomic_uint_t active_conns_cnt;
alongside the pre-existing idle_connections / idle_conns_cnt.
* src/nxt_event_engine.c initialises the new queue and counter
in nxt_event_engine_create().
* src/nxt_conn.h:
- c->idle becomes a tri-state field (still uint8_t):
NXT_CONN_TRACK_NONE = 0 (e.g. controller, proxy peer --
c->link owned by another subsystem)
NXT_CONN_TRACK_IDLE = 1 (on engine->idle_connections)
NXT_CONN_TRACK_ACTIVE = 2 (on engine->active_connections)
The pre-P4.5 boolean coincided with TRACK_IDLE, and existing
callers that treated c->idle as boolean stay correct because
NXT_CONN_TRACK_IDLE == 1.
- nxt_conn_idle() now removes from active_connections (if
coming from TRACK_ACTIVE) before inserting onto idle_connections.
Counter accounting is symmetric: -active_conns_cnt then
+idle_conns_cnt.
- nxt_conn_active() now inserts onto active_connections after
removing from idle_connections. Counter symmetric.
- Both macros document their valid prior states.
* src/nxt_conn_close.c grows an explicit TRACK_ACTIVE branch in
both close handlers (sync and timer-driven). closed_conns_cnt
is now incremented for both idle and active closes -- the
pre-P4.5 behaviour effectively did this too because c->idle was
never reset to 0 by nxt_conn_active(), so every accepted-conn
close already incremented the counter (the gating condition
`if (c->idle)` was true for the residual TRACK_IDLE bit left
from the original idle insertion). The new explicit form
makes the contract auditable.
* src/nxt_runtime.c nxt_runtime_close_idle_connections() drops
its explicit nxt_queue_remove(link) before nxt_conn_close().
The close handler now performs the unlink itself; doing it
twice would corrupt the queue. Comment explains.
Call-site audit
---------------
git grep "nxt_conn_idle\|nxt_conn_active" src/
src/nxt_h1proto.c:485 nxt_conn_idle (initial idle on accept-side keep-alive)
src/nxt_h1proto.c:496 nxt_conn_active (request begin)
src/nxt_h1proto.c:1860 nxt_conn_idle (keep-alive return after response)
src/nxt_h1proto.c:1885 nxt_conn_idle (peer keep-alive)
src/nxt_h1proto.c:1985 nxt_conn_active (peer request begin)
src/nxt_conn_accept.c:264 nxt_conn_active (initial mark on first accept)
src/nxt_conn_close.c:122,160 c->idle gating (now tri-state branches)
Every call site is reachable only after the conn has been on
either idle_connections (most) or never on a tracked queue at
all (controller / proxy peer); no caller invokes nxt_conn_active
on a NONE-state link, so unconditional nxt_queue_remove inside
that macro remains safe.
Verified
--------
./configure --tests --modules=python && ./configure python \
--config=python3-config && make -j$(nproc) # clean
Plain build, regression slice (--restart):
test/test_python_application.py + test/test_idle_close_wait.py
=> 42 passed, 6 skipped
test/test_routing.py
=> 4 failed, 109 passed, 2 skipped, 4 errors -- ALL four
failures are [::1] IPv6 environmental on plain
freeunit/master too (verified by `git checkout
freeunit/master -- src/` rebuild + same suite).
Zero P4.5-attributable regressions.
ASan build (-fsanitize=address):
test/test_python_application.py + test/test_idle_close_wait.py
=> no queue corruption, no use-after-free.
Sole LeakSanitizer reports are 4x 136-byte
nxt_var_index_init / nxt_runtime_create entries -- one per
unitd process, pre-existing on master, separate issue.
Refs
----
- roadmap/plan-graceful-shutdown.md (P5 prerequisite; P5 itself
consumes engine->active_connections to enumerate in-flight
conns for drain-done detection and force-close-on-timeout)
- upstream nginx/unit PR nginx#334 (defensive queue-traversal
pattern; not directly applied here because no traversal is
added, but cited as the model for P5)
There was a problem hiding this comment.
Code Review
This pull request introduces a tri-state connection tracking system (NONE, IDLE, ACTIVE) to manage connection states and counts more effectively. It adds an active_connections queue and an active_conns_cnt counter, updating lifecycle macros and closing handlers accordingly. The review feedback suggests using atomic operations consistently for all shared counters to prevent data races and recommends refactoring the connection closing logic to reduce code duplication.
| nxt_queue_remove(&c->link); \ | ||
| e->idle_conns_cnt -= (c->idle == NXT_CONN_TRACK_IDLE); \ | ||
| \ | ||
| nxt_queue_insert_head(&e->active_connections, &c->link); \ | ||
| \ | ||
| e->idle_conns_cnt -= c->idle; \ | ||
| c->idle = NXT_CONN_TRACK_ACTIVE; \ | ||
| nxt_atomic_fetch_add(&e->active_conns_cnt, 1); \ |
There was a problem hiding this comment.
This macro has a potential correctness issue and an inconsistency:
- Double-counting: If the connection is already in the
NXT_CONN_TRACK_ACTIVEstate, calling this macro will incrementactive_conns_cntagain without a corresponding decrement, leading to an incorrect counter value. - Atomic Consistency:
idle_conns_cntis updated non-atomically whileactive_conns_cntusesnxt_atomic_fetch_add. Both should use atomic operations for consistency.
if (c->idle != NXT_CONN_TRACK_ACTIVE) {
nxt_queue_remove(&c->link);
if (c->idle == NXT_CONN_TRACK_IDLE) {
nxt_atomic_fetch_add(&e->idle_conns_cnt, -1);
}
nxt_queue_insert_head(&e->active_connections, &c->link);
c->idle = NXT_CONN_TRACK_ACTIVE;
nxt_atomic_fetch_add(&e->active_conns_cnt, 1);
}| if (c->idle == NXT_CONN_TRACK_ACTIVE) { \ | ||
| nxt_queue_remove(&c->link); \ | ||
| nxt_atomic_fetch_add(&e->active_conns_cnt, -1); \ | ||
| } \ | ||
| \ | ||
| nxt_queue_insert_head(&e->idle_connections, &c->link); \ | ||
| \ | ||
| c->idle = 1; \ | ||
| c->idle = NXT_CONN_TRACK_IDLE; \ | ||
| e->idle_conns_cnt++; \ |
There was a problem hiding this comment.
The update to idle_conns_cnt (line 338) uses a non-atomic increment, while the update to active_conns_cnt (line 332) uses nxt_atomic_fetch_add. Since both are defined as nxt_atomic_uint_t in nxt_event_engine_t, they should be updated consistently using atomic operations to avoid potential data races when these counters are read by other threads (e.g., for status monitoring).
if (c->idle == NXT_CONN_TRACK_ACTIVE) {
nxt_queue_remove(&c->link);
nxt_atomic_fetch_add(&e->active_conns_cnt, -1);
}
nxt_queue_insert_head(&e->idle_connections, &c->link);
c->idle = NXT_CONN_TRACK_IDLE;
nxt_atomic_fetch_add(&e->idle_conns_cnt, 1);| if (c->idle == NXT_CONN_TRACK_IDLE) { | ||
| nxt_queue_remove(&c->link); | ||
| engine->idle_conns_cnt--; | ||
| c->idle = NXT_CONN_TRACK_NONE; | ||
| engine->closed_conns_cnt++; | ||
|
|
||
| } else if (c->idle == NXT_CONN_TRACK_ACTIVE) { | ||
| nxt_queue_remove(&c->link); | ||
| nxt_atomic_fetch_add(&engine->active_conns_cnt, -1); | ||
| c->idle = NXT_CONN_TRACK_NONE; | ||
| engine->closed_conns_cnt++; | ||
| } |
There was a problem hiding this comment.
The logic for unlinking and updating counters can be refactored to be more concise and avoid duplication. Additionally, using atomic operations consistently for all nxt_atomic_uint_t fields (idle_conns_cnt, active_conns_cnt, and closed_conns_cnt) is recommended to avoid potential data races.
| if (c->idle == NXT_CONN_TRACK_IDLE) { | |
| nxt_queue_remove(&c->link); | |
| engine->idle_conns_cnt--; | |
| c->idle = NXT_CONN_TRACK_NONE; | |
| engine->closed_conns_cnt++; | |
| } else if (c->idle == NXT_CONN_TRACK_ACTIVE) { | |
| nxt_queue_remove(&c->link); | |
| nxt_atomic_fetch_add(&engine->active_conns_cnt, -1); | |
| c->idle = NXT_CONN_TRACK_NONE; | |
| engine->closed_conns_cnt++; | |
| } | |
| if (c->idle != NXT_CONN_TRACK_NONE) { | |
| nxt_queue_remove(&c->link); | |
| if (c->idle == NXT_CONN_TRACK_IDLE) { | |
| nxt_atomic_fetch_add(&engine->idle_conns_cnt, -1); | |
| } else { | |
| nxt_atomic_fetch_add(&engine->active_conns_cnt, -1); | |
| } | |
| c->idle = NXT_CONN_TRACK_NONE; | |
| nxt_atomic_fetch_add(&engine->closed_conns_cnt, 1); | |
| } |
| if (c->idle == NXT_CONN_TRACK_IDLE) { | ||
| nxt_queue_remove(&c->link); | ||
| engine->idle_conns_cnt--; | ||
| c->idle = NXT_CONN_TRACK_NONE; | ||
| engine->closed_conns_cnt++; | ||
|
|
||
| } else if (c->idle == NXT_CONN_TRACK_ACTIVE) { | ||
| nxt_queue_remove(&c->link); | ||
| nxt_atomic_fetch_add(&engine->active_conns_cnt, -1); | ||
| c->idle = NXT_CONN_TRACK_NONE; | ||
| engine->closed_conns_cnt++; | ||
| } |
There was a problem hiding this comment.
This block is identical to the one in nxt_conn_close_handler. Consolidating the logic and using atomic operations consistently for all tracking counters is recommended.
| if (c->idle == NXT_CONN_TRACK_IDLE) { | |
| nxt_queue_remove(&c->link); | |
| engine->idle_conns_cnt--; | |
| c->idle = NXT_CONN_TRACK_NONE; | |
| engine->closed_conns_cnt++; | |
| } else if (c->idle == NXT_CONN_TRACK_ACTIVE) { | |
| nxt_queue_remove(&c->link); | |
| nxt_atomic_fetch_add(&engine->active_conns_cnt, -1); | |
| c->idle = NXT_CONN_TRACK_NONE; | |
| engine->closed_conns_cnt++; | |
| } | |
| if (c->idle != NXT_CONN_TRACK_NONE) { | |
| nxt_queue_remove(&c->link); | |
| if (c->idle == NXT_CONN_TRACK_IDLE) { | |
| nxt_atomic_fetch_add(&engine->idle_conns_cnt, -1); | |
| } else { | |
| nxt_atomic_fetch_add(&engine->active_conns_cnt, -1); | |
| } | |
| c->idle = NXT_CONN_TRACK_NONE; | |
| nxt_atomic_fetch_add(&engine->closed_conns_cnt, 1); | |
| } |
Summary
Pre-P5 prep PR. Adds the missing
engine->active_connectionsqueue and makes idle/active state symmetric, so P5 (connection drain) can enumerate in-flight conns for drain-done detection and force-close-on-graceful_timeout.The P5 agent stopped with this finding:
This PR is purely the data-structure change. No drain logic, no timer logic — those land in P5 on top of this.
What changes
src/nxt_event_engine.halongside the pre-existing
idle_connections/idle_conns_cnt.src/nxt_conn.hc->idlebecomes a tri-state field (stilluint8_t):Pre-P4.5 was a boolean that coincided with
TRACK_IDLE. Existing callers that treatedc->idleas boolean stay correct becauseNXT_CONN_TRACK_IDLE == 1.The macros become symmetric —
c->linkis always on exactly one of(idle_connections, active_connections)between accept and close, withc->idleas the discriminator.src/nxt_conn_close.cBoth close handlers (sync and timer-driven) grow an explicit
TRACK_ACTIVEbranch.closed_conns_cntis now incremented for both idle and active closes — the pre-P4.5 behaviour effectively did this too becausec->idlewas never reset to 0 bynxt_conn_active(), so the counter already incremented for every accepted-conn close. The new explicit form makes the contract auditable.src/nxt_runtime.cnxt_runtime_close_idle_connections()drops its explicitnxt_queue_remove(link)beforenxt_conn_close(). The close handler now performs the unlink itself; doing it twice would corrupt the queue.Call-site audit
git grep "nxt_conn_idle\|nxt_conn_active" src/:src/nxt_h1proto.c:485src/nxt_h1proto.c:496src/nxt_h1proto.c:1860src/nxt_h1proto.c:1885src/nxt_h1proto.c:1985src/nxt_conn_accept.c:264src/nxt_conn_close.c:122,160c->idlegating (now tri-state branches)Every call site is reachable only after the conn has been on either
idle_connections(most) or never on a tracked queue at all (controller / proxy peer); no caller invokesnxt_conn_activeon aNONE-state link, so the unconditionalnxt_queue_removeinside that macro remains safe.Verification
Plain build
Regression slice with
--restart:test_python_application.py + test_idle_close_wait.pytest_routing.pyThe 4 routing failures are all
[::1]IPv6 environmental (socket("[::1]:8081") failed: Address family not supported by protocol). Verified by rebuildinggit checkout freeunit/master -- src/and re-running the same suite — identical 4-fail / 4-error pattern. Zero P4.5-attributable regressions.ASan build (
-fsanitize=address -fno-omit-frame-pointer -O1 -g)Sole LeakSanitizer reports are 4× 136-byte
nxt_var_index_init/nxt_runtime_createentries (one per unitd process), pre-existing on master. Separate issue.Out of scope
active_connectionsfor drain — that's P5.graceful_timeoutconfig wiring — also P5.roadmap/plan-graceful-shutdown.md.Refs
roadmap/plan-graceful-shutdown.md(P5 prerequisite)Generated by Claude Code