Skip to content

Complete PgSqlHandle operations only once the connection is idle - #75

Open
danog wants to merge 1 commit into
amphp:2.xfrom
danog:fix-pgsql-result-race
Open

danog wants to merge 1 commit into
amphp:2.xfrom
danog:fix-pgsql-result-race

Conversation

@danog

@danog danog commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

The ext-pgsql handle can lose a query's result and hang its caller forever.

Mechanism. The poll callback completes an operation as soon as its first result is available ($deferred->complete(\pg_get_result($connection)); $deferred = null;), and the caller then fetches the remaining results with the blocking pg_get_result() (createResult() → fetchNextResult(), and the drain loop in the error path). Between the two, the handle has no pending operation, so another fiber waiting in send() can send its own query. When the first caller then calls pg_get_result(), libpq blocks on the new query, reads its response off the socket and returns it as the first query's next result. The second query's pending operation never completes: its response was consumed, so the socket never becomes readable again.

This is easy to hit through statement deallocation, which runs DEALLOCATE in its own fiber (statementDeallocate()) while the pool considers the connection idle: if a query is sent from {main} while the DEALLOCATE is in flight, {main}'s suspension is resumed before the queued resumption of the deallocating fiber, so {main} sends its query first, and the deallocating fiber's fetchNextResult() steals its result. In a traced reproduction (a PostgresConnectionPool whose pop() sends DISCARD ALL):

TRACE 253 send DEALLOCATE amp_41187917db97f352a054dab3dae8852f341
TRACE 253 readable: complete
TRACE 253 send DISCARD ALL
TRACE 253 fetchNextResult after DEALLOCATE amp_41187917db97f35: busy_before=1 got=DISCARD ALL

after which the connection is not busy, its socket is not readable, and the DISCARD ALL caller waits forever. We found it through intermittent failures in danog/AsyncOrm's CI, where a write issued from a destructor was lost.

Fix. The poll callback collects all results of the operation, until pg_get_result() returns false (the connection is idle again), before completing it, and createResult() builds the result chain from that list, without blocking calls on the connection (which also removes the blocking pg_get_result() calls from the event loop). COPY results, which libpq would return repeatedly, end the collection as before (they're unsupported and close the connection).

Test. PgSqlStatementDeallocateTest is a plain TestCase so that it runs on {main}: it fails on the first iteration without the fix (the query's result is lost, a timer fails the test instead of hanging), and passes with it. The rest of the suite passes (ext-pq tests skipped locally); psalm reports the same issues as before.

PqHandle::fetchNextResult() checks busy before getResult(), so it doesn't block, but it may have a milder form of the same race (returning another operation's already-buffered result); I couldn't test ext-pq here.

The poll callback completed an operation after its first result, and the
caller then fetched the remaining results with the blocking pg_get_result.
In between, another fiber could see no pending operation and send its own
query on the same handle; pg_get_result then blocked on that query and
returned its result as the previous query's next result, so the other
query's result was lost and its caller waited forever.

This happens with statement deallocation, which runs a DEALLOCATE query in
its own fiber while the pool considers the connection idle: a query sent
from {main} (whose suspension resumes before queued fiber resumptions)
while the DEALLOCATE is in flight hangs.

The poll callback now collects all results of an operation, until
pg_get_result returns false, before completing it, and the results are
built from that list, without blocking calls on the connection.
Hjwad pushed a commit to Hjwad/MadelineProto that referenced this pull request Oct 20, 2026
1.1.6 uses unnamed statements for Postgres queries: deallocating the named
statement prepared for every query raced with the next queries on the
connection in amphp/postgres (amphp/postgres#75), losing their results, so
a cache flush issued from a destructor was lost or a query hung forever.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant