Repository navigation
Send Terminate before closing a connection - #42
Conversation
DBInterface.close!(conn) closed the socket without sending the protocol's Terminate message, so the server could not tell an orderly close from a dropped client: closing inside a transaction logged "unexpected EOF on client connection with an open transaction". libpq sends Terminate first (closePGconn -> sendTerminateConn). close! now writes Terminate under the connection lock before closing the socket, as a best effort: skipped when the socket is already closed, and a write error is ignored, so close! still never throws. It is also skipped on a reentrant close from a callback inside an operation, where the operation still owns the stream and an executemany writer may be blocked on the socket. Internal closes on error paths are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
[review generated via AI]
I compared this with libpq's closePGconn in fe-connect.c (REL_16): sendTerminateConn writes Terminate only when conn->sock != PGINVALID_SOCKET && conn->status == CONNECTION_OK, ignores any error, and pqDropConnection follows. close! now does the same: Terminate only on an open socket, write errors swallowed, then the socket closes. A Connection here never exists during startup, so the CONNECTION_OK half has no equivalent.
What I ran on 24fb06d:
- Full suite with Docker integration (PostgreSQL 16, Julia 1.12.6, macOS arm64): 9304 of 9304 pass.
- Against a local PostgreSQL 14 with its log open.
BEGINthenclose(conn): main logsunexpected EOF on client connection with an open transaction, this branch logs nothing. Close on a half-closed socket (another session ranpg_terminate_backend, the FATAL sat unread): no throw and 0.1 ms on both. A double close after anInterruptExceptionclosed the socket: a no-op on both. fieldnames(ReentrantLock)includesreentrancy_cnton 1.10.11 and 1.12.6 here, and thepreCI job passed.
Cases I traced in the code and found fine:
close!during a running query or COPY from another task waits in@lock conn.lockfor the operation, which ends at ReadyForQuery, so the Terminate follows a drained stream.close!racing anotherclose!orisvalid: the lock serializes them, and the second close seesconn.closed.cancel_query!only takestrylock, so no deadlock.- A broken socket: Reseau's
_write_ptr!throwsSystemErrororEOFErroron EPIPE or a reset instead of blocking, and thetryswallows it. Julia ignores SIGPIPE. - Write deadlines: the Terminate write has none of its own, so it could block only on an open, idle, unlocked socket whose send buffer is full. No code path leaves a connection in that state, because every writer holds the lock until ReadyForQuery or closes the socket on failure, and libpq's
pqFlushblocks in the same situation. - Finalizers:
Connectionhas none, so nothing changes. A user-registeredfinalizer(close, conn)already takesconn.locktoday, and a 5-byte write to an idle socket does not wait. --trim: the field read is concrete, and the trim lane passed.
On conn.lock.reentrancy_cnt: it is Base-internal, so a Julia release could rename it, but it has been stable since 1.0 and the alternatives are no better. conn.lock.locked_by === current_task() before the @lock is the exact test Base.assert_havelock uses, and it is just as internal. Tracking "inside an operation" in the Connection would touch every @lock site. I would keep it as is.
Interaction with #44: #45 removes the socket check in cancel_query!. After an interrupt the socket is already closed, so close! skips Terminate there, as libpq would, and the cancel still goes out with the held pid and key. After an orderly close with Terminate, a later cancel reaches a backend that has already exited and the server ignores it. The two changes touch different functions and git merge-tree reports a clean merge in either order.
Nothing to change. I recommend merging; the merge itself is Jacob's call.
Co-authored-by: Jacob Quinn <quinn.jacobd@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #41.
DBInterface.close!(conn)closed the socket without first sending the protocol's Terminate message. libpq sends it before dropping the connection (closePGconn→sendTerminateConninfe-connect.c). Without it the server cannot tell an orderly close from a dropped client, so closing a connection inside a transaction logsunexpected EOF on client connection with an open transaction.close!now writes Terminate under the connection lock, before closing the socket, as a best effort. It is skipped when the socket is already closed, and a write error is ignored, soclose!still never throws. It is also skipped whenclose!is entered re-entrantly, from a notice or notification callback inside an operation. There the operation still owns the stream, and duringexecutemanythe writer task may be blocked on the socket: only closing the socket wakes it. The re-entrancy check readsconn.lock.reentrancy_cnt, a Base-internal field (present on Julia 1.10 through 1.13). I'm happy to switch to another check if you prefer one.The internal socket closes on error paths are unchanged: those sockets are already broken, and libpq does not send Terminate there either.
No public API or runtime dependency changes.
Validation:
test/isvalid_fake_server.jl:'X'with an empty body, then EOF; this fails on the previous code;isvalidunder the lock) sends nothing; this fails with the re-entrancy check removed. Theexecutemanyhang itself is not reproduced in a test.POSTGRES_REQUIRE_INTEGRATION=true(Linux x64, local Docker): 9304 checks pass on Julia 1.13.2 and 9275 on Julia 1.10.12 against PostgreSQL 16; 9304 on 1.13.2 against PostgreSQL 14, 15, 17 and 18 with SCRAM and PostgreSQL 16 with MD5; and 9304 withjulia -t 2. Docs build passes.close!(conn)closes the socket without sending the Terminate message #41 against PostgreSQL 16 withlog_disconnections = on, over plain TCP and TLS: before the change, closing afterBEGINlogsunexpected EOF on client connection with an open transaction; after it, every connection logs onlydisconnection:.Drafted with Claude Code (Claude Opus 5.5) and reviewed by me before posting.
🤖 Generated with Claude Code