Skip to content

feat(lifecycle): two-phase listener close for graceful drain (P2)#11

Open
andypost wants to merge 1 commit into
masterfrom
p2-listener-drain
Open

feat(lifecycle): two-phase listener close for graceful drain (P2)#11
andypost wants to merge 1 commit into
masterfrom
p2-listener-drain

Conversation

@andypost
Copy link
Copy Markdown
Owner

@andypost andypost commented May 8, 2026

Summary

Phase 2 of the graceful-shutdown / graceful-reload plan documented in roadmap/plan-graceful-shutdown.md on the roadmap branch.

Splits nxt_router_listen_socket_close() into a two-phase state machine so in-flight accepted connections complete cleanly when a listener is reconfigured under load. Pre-P2, the listener FD was released the same tick the close timer fired and any unfinished TLS handshake or accepted-but-not-yet-handled connection was RST.

State machine

Accepting -> Draining: nxt_router_listen_socket_close() (phase 1)
                       disarms accept(2), sets lev->draining = 1.
Draining  -> Closed:   nxt_router_listen_socket_close_finish() (phase 2)
                       runs once lev->count == 1 (no in-flight refs),
                       releases the FD, posts the close_job back to
                       the configuration thread.

Phase 2 re-enters from nxt_router_listen_event_release() when the last accepted-conn ref is dropped. finish() then drops the listener's own ref (count: 1 → 0) and frees lev via the nxt_router_listen_event_release() call at its tail.

Mirrors the engine->shutdown + nxt_queue_is_empty() pattern already used in nxt_router_worker_thread_quit().

What changes

  • src/nxt_conn.h — new uint8_t draining bit on nxt_listen_event_t.
  • src/nxt_router.cnxt_router_listen_socket_close() now guards the disarm with nxt_fd_event_is_active(), returns deferred when lev->count > 1, and falls through to the new nxt_router_listen_socket_close_finish() when count is 1. The release path re-enters finish() when --lev->count == 1 && lev->draining.
  • test/test_listener_drain.py — new functional tests.

Scope note

This is accepted-connection drain only. TCP connections completed by the kernel but still in the kernel listen queue waiting for userspace accept(2) are not preserved and will be RST when the FD is released. Kernel-accept-queue drain is P5 territory and is explicitly out of scope here.

Upstream lessons applied

Tests

test/test_listener_drain.py adds:

  • test_listener_reconfigure_drains_inflight_tls_handshake — opens raw TCP, gives the router a beat to accept(2), then drops TLS via reconfigure before driving the handshake. Must terminate with clean TLS error or EOF, NOT ECONNRESET (the pre-P2 regression).
  • test_listener_close_releases_fd_eventually — regression guard that the old listener FD actually closes after drain.
  • test_listener_drain_no_dropped_accepted_connection — explicitly skipped with a clear reason. Per-connection drain is P5 territory; the test is staged here so P5 can flip it on by removing the skip marker.

Verification

./configure --tests --modules=python --openssl
./configure python --config=python3-config
make -j$(nproc)                                          # clean
python3 -m pytest test/test_listener_drain.py            # 2 pass, 1 skip
python3 -m pytest test/test_tls.py                       # 20 pass, 4 skip
python3 -m pytest test/test_configuration.py             # 46 pass, 9 skip,
                                                         # 2 pre-existing
                                                         # IPv6 errors on
                                                         # plain master,
                                                         # not P2-related.

Independence

This PR does not depend on PR #7 (P1 graceful signal plumbing). It branches off master and the two changes touch disjoint files. Either order of merge works.

Out of scope

  • Kernel-accept-queue drain (P5).
  • Per-connection drain with graceful_timeout escalation (P5).
  • The X3 POST /reload endpoint that consumes this primitive (P6).

Generated by Claude Code

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a two-phase close mechanism for router listen events to ensure graceful reconfiguration. It introduces a "draining" state in nxt_listen_event_t that disarms accept(2) while allowing in-flight connections to complete before the file descriptor is released. Functional tests have been added to verify that TLS handshakes are preserved during reconfiguration and that listener FDs are correctly closed. I have no feedback to provide as there were no review comments to assess.

Disarm listener accepts before releasing the old listener FD, keep accepted-connection refs alive until the drain completes, and post config readiness once accept is disarmed so reconfiguration does not block on idle client timeouts.

Treat draining idle HTTP/TLS accepted connections like closed listeners while preserving already-started requests on their existing configuration. Add listener-drain functional coverage, including the deferred-accept timing needed for the TLS handshake case.
@andypost andypost force-pushed the p2-listener-drain branch from 8e592f8 to afce757 Compare May 8, 2026 14:48
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