diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/pqi-conformance.cabal b/pqi-conformance.cabal
--- a/pqi-conformance.cabal
+++ b/pqi-conformance.cabal
@@ -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
diff --git a/src/library/Pqi/Conformance.hs b/src/library/Pqi/Conformance.hs
--- a/src/library/Pqi/Conformance.hs
+++ b/src/library/Pqi/Conformance.hs
@@ -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
diff --git a/src/library/Pqi/Conformance/Operation/Exec.hs b/src/library/Pqi/Conformance/Operation/Exec.hs
--- a/src/library/Pqi/Conformance/Operation/Exec.hs
+++ b/src/library/Pqi/Conformance/Operation/Exec.hs
@@ -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 =
diff --git a/src/library/Pqi/Conformance/Operation/Exec/UninterruptibleMask.hs b/src/library/Pqi/Conformance/Operation/Exec/UninterruptibleMask.hs
new file mode 100644
--- /dev/null
+++ b/src/library/Pqi/Conformance/Operation/Exec/UninterruptibleMask.hs
@@ -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)}
