Skip to content

fix: bound plugin shutdown so an orphaned process can't hang forever - #156

Merged
aesslinger merged 2 commits into
mainfrom
fix/plugin-hang-on-quit-155
Oct 15, 2026
Merged

aesslinger merged 2 commits into
mainfrom
fix/plugin-hang-on-quit-155

Conversation

@aesslinger

@aesslinger aesslinger commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

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.rs awaited every worker with no upper bound, and none of the awaits a worker can block on has a time limit:

run_reader(req_tx).await;          // returns on stdin EOF
let _ = shutdown_tx.send(true);
for handle in worker_handles {
    let _ = handle.await;          // no upper bound
}

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:

let deadline = Instant::now() + SHUTDOWN_GRACE;
for mut handle in worker_handles {
    match tokio::time::timeout_at(deadline, &mut handle).await {
        Ok(Ok(())) => {}
        Ok(Err(_join_err)) => {}
        Err(_elapsed) => {
            handle.abort();
            let _ = handle.await;
        }
    }
}

Each JoinHandle is polled by reference (&mut handle) — JoinHandle: Unpin — so timeout_at borrows the handle rather than consuming it. That keeps ownership with us so, on elapsed, we can still 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 detach the task (it keeps running) — reproducing #155 rather than fixing it. The abort() is load-bearing.

Why not connect_timeout?

tokio-postgres's connect_timeout bounds 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 of connect_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 the deadpool::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 cancel notification that triggers server-side pg_cancel_backend (src/cancel.rs, #126) — that is the primary bound on long queries, not this grace. SHUTDOWN_GRACE is 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-test DB:

Check Result
cargo build clean
cargo clippy --all-targets -- -D warnings clean
cargo fmt --check clean
#155 repro (in-flight connect → stdin close) exit 0 in ~6s (was exit 124 / hung)
Control (plain EOF, no request) exit 0 instant (preserved)
tests/shutdown.rs (3 new tests) 3/3 pass
tests/live_db.rs (37 tests) 37/0 — no regressions
unit tests (--lib) 410/0

The new tests/shutdown.rs covers: 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

  • Fix is scoped to main.rs shutdown path only
  • No behavior change during normal operation (grace only fires on stdin EOF / orphan path)
  • Regression tests added and passing
  • Live DB suite green — no regressions
  • Clippy + fmt clean

…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.
@aesslinger aesslinger self-assigned this Oct 15, 2026
@aesslinger aesslinger added the prerelease:rc Version suggestion targets a release candidate label Oct 15, 2026
@github-actions

Copy link
Copy Markdown

Version suggestion

Based on this PR's title (fix) and the prerelease:rc label:

Current 1.0.0-rc.9
Suggested next tag v1.0.0-rc.10

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.
@aesslinger
aesslinger merged commit 925baf3 into main Oct 15, 2026
7 checks passed
@aesslinger
aesslinger deleted the fix/plugin-hang-on-quit-155 branch October 15, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working prerelease:rc Version suggestion targets a release candidate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Plugin process never exits after Tabularis quits if an RPC is in flight

1 participant