Thank you for investigating! After digging further, I think I found the problem. It is not technically a bug, but I do have a suggestion to prevent others from running into the same problem.
First, I forgot to mention that I am sending async queries (PQsendQuery). libpq documentation states that PQgetResult() must be called repeatedly until it returns a null pointer. Unfortunately, there is nothing about this requirement in the official docs for PHP's libpq wrapper extension. If I don't call PQgetResult() one more time than I really need, the transaction and connection statuses remain active/busy. Even though PG reports its status as "busy", subsequent queries succeed normally.
I suggest that the connection and transaction states should be updated when all queued async queries are completed, without the extra call to PQgetResult(). What do you think?