Repository navigation
fix: bound plugin shutdown so an orphaned process can't hang forever - #156
Merged
Merged
Conversation
…155) When Tabularis quits while an RPC is in flight, the plugin process never exited — it reparented to PID 1 and held its DB connections open until killed manually. Root cause: after stdin EOF, main.rs awaited every worker with no upper bound, and none of the awaits a worker can block on has a time limit. A pooler that accepted the TCP connection but never answers the startup handshake (the exact case in #155) wedges the worker forever, so stdin closing does not let the process exit. Fix: drain in-flight RPCs for at most SHUTDOWN_GRACE (5s) after stdin EOF, then force-cancel any worker still blocked. Each JoinHandle is polled by reference (JoinHandle: Unpin) so on elapsed we still own it and can call handle.abort(), which cancels the stuck future and drops the held deadpool::Object — closing the DB socket server-side instead of detaching the task and leaking it. The naive timeout(handle).await that drops the handle on elapsed would reproduce #155, not fix it. tokio-postgres connect_timeout does not cover this case: it bounds only socket-level connect, not the startup handshake, so it cannot bound the pooler-accepts-but-never-responds repro. Only this shutdown deadline covers every hang mode (connect, handshake, query, pool wait) uniformly. Long-running queries in flight at quit-time are force-cancelled at the 5s boundary — acceptable because the result is undeliverable (the host that issued it is gone) and identical to the builtin's behavior on host process death. During normal operation, long queries are bounded by the host's per-call timeout via the cancel notification -> pg_cancel_backend path (src/cancel.rs, #126); SHUTDOWN_GRACE is only the orphan fallback. Adds tests/shutdown.rs with three regression tests: the in-flight hang case, the plain-EOF control, and a live-DB happy path proving a fast in-flight query completes and returns before the deadline. Fixes #155.
Version suggestionBased on this PR's title (
This is informational only — no tag or release is created automatically yet. |
The ensemble review (boundary, docs-vs-code-drift, correctness-logic) plus /code-review all independently found the same defect: the hanging-listener test helper dropped the accepted TcpStream immediately, closing the socket and letting the plugin's connect fail fast — so the primary regression test passed on unfixed code and never exercised the SHUTDOWN_GRACE abort path. Fixes from review: - spawn_hanging_listener now collects accepted streams into a Vec, keeping the socket open with no bytes sent, so the plugin's handshake read blocks forever (the real #155 hang). Verified: the test now FAILS on unfixed code (hangs past its own deadline) and passes on fixed (~6s exit). - Use an ephemeral port (bind 127.0.0.1:0) instead of hardcoded 54331 to avoid collision under parallel test runs. - Tighten the control test to assert status.success() (clean exit), not just any exit code — a non-zero exit on plain EOF would be a regression. - Gate the live-DB test on a reachability check so plain `cargo test` skips it instead of spuriously failing on a machine without the DB. - Read stdout on a bounded thread instead of blocking read_to_string, so a regressed hang surfaces as a test timeout, not a hung test. - Fix the SHUTDOWN_GRACE doc comment: abort() drops the held deadpool::Object only in the hung-query case; in the stuck-handshake and saturated-pool-wait cases the worker is blocked inside pool.get() and abort drops the in-flight connect future instead. The socket is released in all cases, but the mechanism differs — the comment now says so accurately. - Fix "assertss" typo in the test module doc comment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #155 — the plugin process never exits when Tabularis quits while an RPC is in flight, instead reparenting to PID 1 and holding its DB connections open until killed manually.
Root cause
After stdin EOF,
main.rsawaited every worker with no upper bound, and none of the awaits a worker can block on has a time limit:The only timeout in the whole codebase is the startup-script preflight (
STARTUP_SCRIPT_TIMEOUT_MS). So a worker blocked on an unbounded await — a pooler that accepted the TCP connection but never answers the startup handshake (the exact repro in #155), a hung query, or a saturated pool wait — keeps the process, and every DB connection it holds, alive forever after stdin closes.Fix
Drain in-flight RPCs for at most
SHUTDOWN_GRACE(5s) after stdin EOF, then force-cancel any worker still blocked:Each
JoinHandleis polled by reference (&mut handle) —JoinHandle: Unpin— sotimeout_atborrows the handle rather than consuming it. That keeps ownership with us so, on elapsed, we can still callhandle.abort(), which cancels the stuck future and drops the helddeadpool::Object, closing the DB socket server-side instead of detaching the task and leaking it. The naivetimeout(handle).awaitthat drops the handle on elapsed would detach the task (it keeps running) — reproducing #155 rather than fixing it. Theabort()is load-bearing.Why not
connect_timeout?tokio-postgres'sconnect_timeoutbounds only the socket-level connect, not the startup handshake. The #155 repro listener accepts the TCP connection (connect succeeds instantly) then never answers the PG handshake, so the handshake read blocks forever regardless ofconnect_timeout. Only this shutdown deadline covers every hang mode (connect, handshake, query, pool wait) uniformly. Pool-config timeouts (wait_timeout/connect_timeout) are a complementary fail-fast improvement worth a separate follow-up, but none of them bound the handshake, so they can't be the fix for this issue.Long-running queries
A query still in flight when the grace elapses is force-cancelled: the
abort()drops thedeadpool::Object, closing the DB socket, so PostgreSQL cancels the running statement. This is the same outcome the builtin produces when the host process dies and takes its pool with it — not a regression. The result is undeliverable anyway (the host that issued it is gone).During normal operation (host alive), long queries are bounded by the host's per-call timeout, which sends a
cancelnotification that triggers server-sidepg_cancel_backend(src/cancel.rs, #126) — that is the primary bound on long queries, not this grace.SHUTDOWN_GRACEis only the orphan fallback for when no cancel is coming because the host itself is gone.Verification
All against the built binary + the live podman
pg-tabularis-testDB:cargo buildcargo clippy --all-targets -- -D warningscargo fmt --checktests/shutdown.rs(3 new tests)tests/live_db.rs(37 tests)--lib)The new
tests/shutdown.rscovers: the in-flight hang case (a hanging TCP listener that accepts but never answers), the plain-EOF control, and a live-DB happy path proving a fast in-flight query completes and returns its result before the deadline (guards against the fix aborting legitimate in-flight work).Checklist
main.rsshutdown path only