packages feed

pqi-conformance 1.0.17.0 → 1.0.18.0

raw patch · 5 files changed

+128/−1 lines, 5 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Pqi.Conformance: nonThreadedSpecs :: Adapter -> Spec

Files

CHANGELOG.md view
@@ -1,3 +1,9 @@+# v1.0.18.0++## Non-breaking++- Added an `exec` spec covering a call under `uninterruptibleMask_` (`Pqi.Conformance.Operation.Exec.UninterruptibleMask`), as `safe-exceptions`' `bracket` runs its release action. `libpq` sends the query and returns the result. `pqi-native` 1.0.1.16 hung forever: on the non-threaded RTS its send loop arms the socket's readable and writable waits with base's `threadWaitReadSTM`/`threadWaitWriteSTM` and cancels the losing one, and those fork a bare `forkIO` helper whose `killThread` cancellation inherits the caller's masking state, so from under an `uninterruptibleMask` the readable wait's cancellation cannot be delivered while the socket stays writable-but-not-readable - the steady state of an idle connection being sent a query. On the threaded RTS the waits are event-manager registrations and there is no helper thread, so the spec is meaningful only on the non-threaded RTS. Because `timeout` cannot deliver its exception into the very masking state the hang is about, the scenario runs the call in a forked thread under a watchdog that folds the hang into the compared observation as `TIMED OUT`; on a hang the connection is deliberately left open and unfinished, since a candidate thread is stuck inside its send. The spec also requires the new `nonThreadedSpecs` entry point, which adapter test suites built without `-threaded` should run beside `specs`. Found via `pqi-native` issue #17.+ # v1.0.17.0  ## Non-breaking
pqi-conformance.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: pqi-conformance-version: 1.0.17.0+version: 1.0.18.0 category: Database, PostgreSQL, Testing synopsis: Differential conformance tests for pqi adapters description:@@ -120,6 +120,7 @@     Pqi.Conformance.Operation.Exec.ConnectionLostMidQuery     Pqi.Conformance.Operation.Exec.Interrupted     Pqi.Conformance.Operation.Exec.LostConnection+    Pqi.Conformance.Operation.Exec.UninterruptibleMask     Pqi.Conformance.Operation.ExecParams     Pqi.Conformance.Operation.ExecParams.Interrupted     Pqi.Conformance.Operation.ExecParams.LargeField
src/library/Pqi/Conformance.hs view
@@ -9,8 +9,13 @@ -- don't have to know about @testcontainers@ at all. The SCRAM-SHA-256 -- authentication spec is part of the @connectdb@ group and boots its own -- password-auth container.+--+-- Some specs only reproduce on the non-threaded RTS; 'nonThreadedSpecs'+-- collects them. A test suite built without @-threaded@ should run it+-- beside 'specs'. module Pqi.Conformance   ( specs,+    nonThreadedSpecs,   ) where @@ -38,6 +43,7 @@ import qualified Pqi.Conformance.Operation.EscapeIdentifier as EscapeIdentifier import qualified Pqi.Conformance.Operation.EscapeStringConn as EscapeStringConn import qualified Pqi.Conformance.Operation.Exec as Exec+import qualified Pqi.Conformance.Operation.Exec.UninterruptibleMask as Exec.UninterruptibleMask import qualified Pqi.Conformance.Operation.ExecParams as ExecParams import qualified Pqi.Conformance.Operation.ExecPrepared as ExecPrepared import qualified Pqi.Conformance.Operation.ExitPipelineMode as ExitPipelineMode@@ -119,6 +125,14 @@ import qualified Pqi.Conformance.Operation.UnsafeFreeResult as UnsafeFreeResult import qualified Pqi.Conformance.Operation.User as User import Test.Hspec++-- | The specs that only reproduce on the non-threaded RTS, under one shared+-- trust-auth container. On the threaded RTS they pass vacuously, so they+-- must be run from a test suite built without @-threaded@.+nonThreadedSpecs :: Pqi.Adapter -> Spec+nonThreadedSpecs adapter = parallel do+  containerHook do+    Exec.UninterruptibleMask.spec adapter  -- | The full conformance battery: every per-operation spec under one shared -- trust-auth container, plus SCRAM-SHA-256 authentication (which boots its own
src/library/Pqi/Conformance/Operation/Exec.hs view
@@ -10,6 +10,7 @@ import qualified Pqi.Conformance.Operation.Exec.ConnectionLostMidQuery as ConnectionLostMidQuery import qualified Pqi.Conformance.Operation.Exec.Interrupted as Interrupted import qualified Pqi.Conformance.Operation.Exec.LostConnection as LostConnection+import qualified Pqi.Conformance.Operation.Exec.UninterruptibleMask as UninterruptibleMask import Pqi.Conformance.Prelude import Pqi.Conformance.Scenario import Test.Hspec@@ -19,6 +20,7 @@   ConnectionLostMidQuery.spec adapter   Interrupted.spec adapter   LostConnection.spec adapter+  UninterruptibleMask.spec adapter    describe "exec" do     let forCase title sql =
+ src/library/Pqi/Conformance/Operation/Exec/UninterruptibleMask.hs view
@@ -0,0 +1,104 @@+-- | Coverage for 'Pqi.exec' called under+-- 'Control.Exception.uninterruptibleMask_', as @safe-exceptions@' 'bracket'+-- runs its release action.+--+-- The divergence: @pqi-native@'s send loop waits for the socket to become+-- writable or readable at once and then cancels the losing wait+-- (@awaitWritableOrReadable@ in @Pqi.Native.Transport@). On the non-threaded+-- RTS those waits come from base's @threadWaitReadSTM@\/@threadWaitWriteSTM@,+-- which fork a bare @forkIO@ helper and cancel it with @killThread@. The+-- helper inherits the caller's masking state, so from under an+-- @uninterruptibleMask@ the cancellation of the readable wait cannot be+-- delivered while the socket stays writable-but-not-readable - the steady+-- state of an idle connection being sent a query - and the send, and with it+-- the whole query, blocks forever. libpq has no Haskell masking state to+-- inherit and sends as usual. On the threaded RTS the waits are+-- event-manager registrations with no helper thread, so the divergence only+-- manifests in binaries built without @-threaded@.+--+-- Why this observation: 'System.Timeout.timeout' cannot break the hang (it+-- delivers its exception by @throwTo@, which the same masking state defers),+-- so the call runs in a forked thread under a watchdog that folds a true+-- hang into the compared observation, the way+-- "Pqi.Conformance.Scenario.LostConnection" folds a timeout. The observation+-- is the 'ResultObservation' of the query, or @TIMED OUT@ when the watchdog+-- fires. A hung call never unblocks - the query never reaches the server, so+-- nothing ever makes the cancelled wait's socket readable - which makes the+-- 5 s deadline unambiguous. On a hang the connection is deliberately left+-- open and unfinished: a thread of the candidate is stuck inside its send,+-- and closing the socket it is blocked on is territory this spec does not+-- need to enter. The leaked connection dies with the test process.+--+-- Where it surfaced: @pqi-native@ issue #17.+module Pqi.Conformance.Operation.Exec.UninterruptibleMask+  ( spec,+  )+where++import Control.Concurrent (forkIO, newEmptyMVar, takeMVar, tryPutMVar)+import Control.Exception (SomeException, try, uninterruptibleMask_)+import qualified Pqi+import Pqi.Conformance.Harness+import Pqi.Conformance.Observation+import Pqi.Conformance.Prelude+import Test.Hspec++spec :: Pqi.Adapter -> SpecWith ByteString+spec adapter =+  describe "exec" do+    describe "under uninterruptibleMask" do+      it "a query completes the same way the reference's does" \conninfo ->+        differentialConnect adapter conninfo \adapter' conninfo' -> do+          connection <- Pqi.connectdb adapter' conninfo'+          outcome <- bounded (Pqi.exec connection "select 1" >>= traverse observeResult)+          case outcome of+            TimedOut -> pure ()+            _ -> Pqi.finish connection+          pure (renderCall renderOutcome outcome)++-- | How long the call is allowed to take. The hang it detects is permanent -+-- the query never reaches the server, so nothing ever wakes the stuck wait -+-- so the deadline only needs to clear normal latency by a wide margin.+callDeadlineMicros :: Int+callDeadlineMicros = 5_000_000++-- | The outcome of one bounded call: the value, a synchronous exception (an+-- asynchronous one cannot surface - the call is under the mask the hang is+-- about), or a hang.+data Call a+  = Returned a+  | Threw String+  | TimedOut++-- | Run the action under 'uninterruptibleMask_' in a forked thread, with a+-- watchdog folding a hang into 'TimedOut'. The mask is the scenario, and+-- this is why 'System.Timeout.timeout' is no good here: it delivers its+-- exception by @throwTo@ to this very thread, which the mask defers. A hung+-- worker is left behind - blocked in the @throwTo@ of its cancellation,+-- inert and unkillable - and dies with the process.+bounded :: IO a -> IO (Call a)+bounded action = do+  outcome <- newEmptyMVar+  void+    $ forkIO do+      caught <- try @SomeException (uninterruptibleMask_ action)+      void $ tryPutMVar outcome (either (Threw . show) Returned caught)+  void+    $ forkIO do+      threadDelay callDeadlineMicros+      void $ tryPutMVar outcome TimedOut+  takeMVar outcome++renderCall :: (a -> String) -> Call a -> String+renderCall render = \case+  Returned value -> render value+  Threw exception -> "THREW " <> exception+  TimedOut -> "TIMED OUT after " <> show callDeadlineMicros <> "us"++renderOutcome :: Maybe ResultObservation -> String+renderOutcome = \case+  Nothing -> "Nothing"+  Just observation ->+    -- Only the error fields that are set, as everywhere else a result is+    -- rendered: all of them are always observed and nearly all are absent.+    "Just " <> show observation {errorFields = filter (isJust . snd) (errorFields observation)}