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 +6/−0
- pqi-conformance.cabal +2/−1
- src/library/Pqi/Conformance.hs +14/−0
- src/library/Pqi/Conformance/Operation/Exec.hs +2/−0
- src/library/Pqi/Conformance/Operation/Exec/UninterruptibleMask.hs +104/−0
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)}