packages feed

pqi-conformance-0.0.1.0: src/library/Pqi/Conformance/Operation/CancelCleanup.hs

-- | Regression coverage for the stale-cancel bug that corrupts a connection
-- after a pipelined query completes.
--
-- Root cause: in pqi-native, 'Pqi.getResult' returns 'Nothing' (the pipeline
-- separator) __before__ reading the trailing 'ReadyForQuery' message, leaving
-- @asyncPending = True@.  If 'Pqi.cancel' is called while @asyncPending@ is
-- still @True@ — as 'Hasql.Comms.Session.cleanUpAfterInterruption' does after
-- draining results — a cancel request is sent to the server even though the
-- query has already finished.  The server receives the signal, sets
-- @QueryCancelPending@, and the __next__ command (e.g. @ABORT@) is cancelled
-- with SQLSTATE @57014@, leaving the connection unusable.
module Pqi.Conformance.Operation.CancelCleanup
  ( spec,
  )
where

import Control.Exception (bracket)
import Pqi (ExecStatus (..), IsCancel (..), IsConnection (..), IsResult (..))
import qualified Pqi as Lq
import Pqi.Conformance.Prelude
import Test.Hspec

spec :: forall c. (IsConnection c) => Proxy c -> SpecWith ByteString
spec proxy =
  describe "cancel cleanup" do
    -- Reproduces the state that hasql's cleanUpAfterInterruption reaches after
    -- a timeout fires mid-pipeline:
    --
    --   1. drainResults reads CommandComplete → separator, exits loop.
    --      In pqi-native asyncPending is still True here (ReadyForQuery not
    --      yet consumed); in libpq the ReadyForQuery has already been
    --      processed internally.
    --   2. cancel is called.  pqi-native sends a cancel because
    --      asyncPending=True; the query has long since finished so this is
    --      a stale cancel.
    --   3. drainResults reads the remaining ReadyForQuery.
    --   4. The connection re-enters serial mode.
    --   5. exec runs a follow-up command — it must NOT be cancelled by the
    --      stale signal that arrived in step 2.
    it "does not corrupt subsequent commands when cancel is called after pipeline results are drained" \conninfo ->
      bracket (connectdb conninfo :: IO c) finish \connection -> do
        -- Enter pipeline mode and dispatch a fast query.
        _ <- enterPipelineMode connection
        _ <- sendQueryParams connection "select 1" [] Lq.Text
        _ <- pipelineSync connection

        -- Wait for the server to process the query so both messages
        -- (CommandComplete + ReadyForQuery) are already in the socket by the
        -- time we start reading.
        threadDelay 10_000 -- 10 ms

        -- Drain the command result then the pipeline separator (Nothing).
        -- After this loop exits, asyncPending=True in pqi-native because
        -- ReadyForQuery has not been read yet.
        let drainAll = do
              mr <- getResult connection
              case mr of
                Nothing -> pure ()
                Just _ -> drainAll
        drainAll

        -- Send cancel.  Because asyncPending=True in pqi-native, a cancel
        -- request is dispatched to the server despite the query being done.
        handle <- getCancel connection
        for_ handle cancel

        -- Give the stale cancel enough time to reach the server and set
        -- QueryCancelPending before the next command arrives.
        threadDelay 10_000 -- 10 ms

        -- Read the ReadyForQuery that was still pending.
        drainAll

        -- Exit pipeline mode (sends an implicit Sync in the reference impl).
        _ <- exitPipelineMode connection

        -- A follow-up command must succeed; 57014 here means the stale cancel
        -- corrupted the connection.
        mResult <- exec connection "select 1"
        case mResult of
          Nothing -> expectationFailure "exec returned no result after pipeline cleanup"
          Just (result :: ResultOf c) -> do
            status <- resultStatus result
            status `shouldBe` TuplesOk