packages feed

pqi-conformance-0.1.0.1: src/library/Pqi/Conformance/Operation/Cancel/Stale.hs

-- | Deterministic regression coverage for the stale-cancel bug: a cancel
-- dispatched during pipeline clean-up must not corrupt the next command run on
-- the connection.
--
-- Root cause: 'Pqi.cancel' opens a TCP connection to the postmaster, sends the
-- @CancelRequest@, and — in the buggy implementation — immediately closes its
-- end.  The request can then sit unread in the postmaster's socket buffer while
-- the client races ahead and issues its next query.  The postmaster eventually
-- reads the request and delivers @SIGINT@ to the backend, which by then is
-- executing the /next/ statement, cancelling it with SQLSTATE @57014@.
--
-- The race is reliably opened by the pipeline clean-up sequence that
-- @hasql@ runs after a timeout fires mid-read: once the pipeline is aborted,
-- 'Pqi.getResult' returns synthetic results for the outstanding commands
-- __without blocking on the wire__ (see @getNextResult@ in
-- @Pqi.Native.Query@).  That lets the client reach its follow-up query before
-- the postmaster has processed the cancel.
--
-- The fix mirrors libpq's @PQcancel@: after sending the request, drain the
-- cancel socket until the server closes its end (which it only does after the
-- signal has been dispatched), so the cancel can no longer land on a later
-- command.
--
-- A single run hits the race only intermittently (the postmaster usually
-- processes the cancel in time), so this spec replays the scenario many times.
-- On the buggy implementation at least one iteration reliably loses the race
-- and reports @57014@ on the victim query; the fixed implementation passes
-- every iteration.
module Pqi.Conformance.Operation.Cancel.Stale
  ( spec,
  )
where

import Control.Exception (bracket)
import qualified Pqi
import qualified Pqi as Lq
import Pqi.Conformance.Observation (ResultObservation (..))
import Pqi.Conformance.Prelude
import Pqi.Conformance.Scenario (drainResults, execScenario, float8Oid)
import System.Timeout (timeout)
import Test.Hspec

-- | How many times to replay the racy scenario.  Each iteration that loses the
-- race on buggy code surfaces SQLSTATE @57014@ on the victim query; enough
-- iterations make at least one failure overwhelmingly likely (the underlying
-- per-run failure rate on buggy code is roughly 30%).
iterations :: Int
iterations = 30

spec :: Pqi.Adapter -> SpecWith ByteString
spec adapter =
  describe "stale cancel" do
    it "does not corrupt the next command when a cancel is sent during pipeline clean-up" \conninfo -> do
      outcomes <- for [1 .. iterations] \i -> do
        outcome <- runScenario adapter conninfo
        pure (i, outcome)
      -- Every iteration's victim query must have succeeded.  Any iteration that
      -- did not means a stale cancel corrupted the connection (57014).
      let corrupted = [(i, outcome) | (i, Just outcome) <- outcomes]
      corrupted `shouldBe` []

-- | Run one stale-cancel scenario on a fresh connection.  Returns 'Nothing' if
-- the victim query succeeded, or @Just (status, sqlstate)@ describing how it
-- failed (e.g. @(FatalError, Just "57014")@).
runScenario ::
  Pqi.Adapter ->
  ByteString ->
  IO (Maybe (Lq.ExecStatus, Maybe ByteString))
runScenario adapter conninfo =
  bracket (Lq.connectdb adapter conninfo) Lq.finish \connection -> do
    -- Build a pipeline with a fast query followed by a slow one, mirroring the
    -- sequence hasql issues.  Two prepared statements keep pendingParses in
    -- play, matching the real-world reproduction.
    _ <- Lq.enterPipelineMode connection
    _ <- Lq.sendPrepare connection "s1" "select $1::int" Nothing
    _ <- Lq.sendQueryPrepared connection "s1" [Just ("42", Lq.Text)] Lq.Text
    _ <- Lq.sendPrepare connection "s2" "select pg_sleep($1)" (Just [float8Oid])
    _ <- Lq.sendQueryPrepared connection "s2" [Just ("0.1", Lq.Text)] Lq.Text
    _ <- Lq.pipelineSync connection

    -- Interrupt the read mid-pipeline, exactly as hasql's timeout does: the
    -- slow pg_sleep is still running when the read is abandoned.
    _ <- timeout 50_000 (drainResults connection)

    -- cleanUpAfterInterruption: drain, cancel, drain.  The cancel is sent while
    -- the pipeline is mid-flight; on buggy code its SIGINT can arrive late.
    _ <- drainResults connection
    mHandle <- Lq.getCancel connection
    _ <- for mHandle Lq.cancel
    _ <- drainResults connection

    -- leavePipeline: the exact restore sequence hasql performs, including the
    -- retry it falls back to.
    statusBefore <- Lq.pipelineStatus connection
    when (statusBefore == Lq.PipelineOn) do
      _ <- Lq.pipelineSync connection
      _ <- drainResults connection
      _ <- Lq.sendFlushRequest connection
      _ <- drainResults connection
      ok <- Lq.exitPipelineMode connection
      unless ok do
        _ <- drainResults connection
        void (Lq.exitPipelineMode connection)

    -- The victim.  It must not be cancelled by the stale signal.
    execScenario "select 99" connection >>= \case
      Nothing -> pure (Just (Lq.FatalError, Just "no-result"))
      Just observation ->
        case status observation of
          Lq.TuplesOk -> pure Nothing
          status -> pure (Just (status, fromMaybe Nothing (lookup Lq.DiagSqlstate (errorFields observation))))