packages feed

spire-websocket (empty) → 0.1.0.0

raw patch · 7 files changed

+800/−0 lines, 7 filesdep +acolyte-coredep +aesondep +base

Dependencies added: acolyte-core, aeson, base, bytestring, hedgehog, http-core, spire, spire-websocket, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,7 @@+# Revision history for spire-websocket++## 0.1.0.0 -- 2026-04-27++* Initial release. Linear session-typed WebSocket protocols: the+  phantom-typed Session handle enforces send/recv ordering at compile+  time via GHC's LinearTypes.
+ LICENSE view
@@ -0,0 +1,27 @@+Copyright (c) 2024-2026, Josh Burgess++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its+   contributors may be used to endorse or promote products derived from this+   software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ spire-websocket.cabal view
@@ -0,0 +1,84 @@+cabal-version:   3.0+name:            spire-websocket+version:         0.1.0.0+synopsis:        Linear session-typed WebSocket protocols for spire+category:        Network+description:+  Linear session types for WebSocket protocols. The phantom-typed+  Session handle enforces send/recv ordering at compile time via+  GHC's LinearTypes: each operation consumes the current session+  and produces the next state, so misuse is a type error.++license:         BSD-3-Clause+license-file:    LICENSE+author:          Josh Burgess+maintainer:      Josh Burgess <joshburgess.webdev@gmail.com>+homepage:        https://github.com/joshburgess/acolyte+bug-reports:     https://github.com/joshburgess/acolyte/issues+build-type:      Simple++extra-doc-files:+  CHANGELOG.md++library+  exposed-modules:+    Spire.WebSocket+    Spire.WebSocket.Session++  build-depends:+      base                    >= 4.20 && < 5+    , acolyte-core            >= 0.1  && < 0.2+    , spire                   >= 0.1  && < 0.2+    , http-core               >= 0.1  && < 0.2+    , bytestring              >= 0.11 && < 0.13+    , text                    >= 2.0  && < 2.2+    , aeson                   >= 2.1  && < 2.3++  hs-source-dirs: src+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData++  ghc-options: -Wall -funbox-strict-fields++test-suite spec+  type: exitcode-stdio-1.0+  main-is: Main.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData++  ghc-options: -Wall -rtsopts "-with-rtsopts=-K1K"++  build-depends:+      base                    >= 4.20 && < 5+    , spire-websocket         >= 0.1  && < 0.2+    , acolyte-core            >= 0.1  && < 0.2+    , bytestring              >= 0.11 && < 0.13+    , text                    >= 2.0  && < 2.2+    , aeson                   >= 2.1  && < 2.3++test-suite properties+  type: exitcode-stdio-1.0+  main-is: Properties.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData+  ghc-options: -Wall+  build-depends:+      base                    >= 4.20 && < 5+    , spire-websocket         >= 0.1  && < 0.2+    , acolyte-core            >= 0.1  && < 0.2+    , hedgehog                >= 1.4  && < 1.8+    , bytestring              >= 0.11 && < 0.13+    , text                    >= 2.0  && < 2.2+    , aeson                   >= 2.1  && < 2.3++source-repository head+  type:     git+  location: https://github.com/joshburgess/acolyte.git
+ src/Spire/WebSocket.hs view
@@ -0,0 +1,26 @@+-- | WebSocket session types for acolyte.+--+-- This module re-exports everything needed to write session-typed+-- WebSocket handlers. Session types are defined in+-- "Acolyte.Core.Session"; runtime enforcement lives in+-- "Spire.WebSocket.Session".+--+-- @+-- import Spire.WebSocket+-- import Acolyte.Core.Session (SessionType (..))+--+-- type ChatProtocol =+--   'Recv Text ('Send Text ('Rec ('Offer+--     ('Recv Text ('Send Text 'Var))+--     ('Recv Text 'End))))+-- @+module Spire.WebSocket+  ( -- * Session runtime+    module Spire.WebSocket.Session+    -- * Session type definitions (re-export from core)+  , SessionType (..)+  , Dual+  ) where++import Spire.WebSocket.Session+import Acolyte.Core.Session (SessionType (..), Dual)
+ src/Spire/WebSocket/Session.hs view
@@ -0,0 +1,172 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}++-- | Session type runtime for WebSocket protocols.+--+-- A 'Session' value is a phantom-typed handle whose type parameter+-- tracks the current protocol state. Each operation (send, recv,+-- offer, select) consumes the current session and produces the next+-- one, enforcing the correct message order at compile time.+--+-- The API is designed for single-use: each operation takes a+-- @Session s@ and returns a @Session s'@ for the continuation.+-- While Haskell's type system does not prevent reuse of the old+-- handle at runtime, the type-level state machine ensures that+-- only the correct sequence of operations type-checks.+--+-- @+-- type EchoProtocol = 'Send Text ('Recv Text 'End)+--+-- echoHandler :: Session EchoProtocol -> IO ()+-- echoHandler session = do+--   session'         <- send ("hello" :: Text) session+--   (msg, session'') <- recv session'+--   close session''+-- @+module Spire.WebSocket.Session+  ( -- * Session handle+    Session+    -- * Operations+  , send+  , recv+  , offer+  , select1+  , select2+  , close+  , recurse+  , loop+    -- * Running+  , withSession+    -- * WebSocket connection abstraction+  , WebSocketConn (..)+    -- * Errors+  , SessionError (..)+    -- * Type families+  , Unfold+  ) where++import Acolyte.Core.Session (SessionType (..))++import Control.Exception (Exception, throwIO)+import qualified Data.Aeson as Aeson+import qualified Data.ByteString.Lazy as LBS+++-- | Error thrown when a session operation fails (e.g. JSON decode error).+data SessionError = JsonDecodeError String+  deriving (Show, Eq)++instance Exception SessionError+++-- | An abstraction over the underlying WebSocket transport.+--+-- Provide send\/recv\/close callbacks — the session runtime uses+-- these to exchange JSON-encoded messages with the peer.+data WebSocketConn = WebSocketConn+  { wsSend  :: !(LBS.ByteString -> IO ())+  , wsRecv  :: !(IO LBS.ByteString)+  , wsClose :: !(IO ())+  }+++-- | A session handle. The phantom type parameter tracks the current+-- protocol state. Each operation transitions to the next state.+data Session (s :: SessionType) where+  Session :: !WebSocketConn -> Session s+++-- | Send a JSON-encodable message. The session must be in 'Send' state.+-- Returns the continuation session.+send :: (Aeson.ToJSON a) => a -> Session ('Send a s) -> IO (Session s)+send val (Session conn) = do+  wsSend conn (Aeson.encode val)+  pure (Session conn)+++-- | Receive a JSON-decodable message. The session must be in 'Recv' state.+-- Returns the decoded value paired with the continuation session.+recv :: (Aeson.FromJSON a) => Session ('Recv a s) -> IO (a, Session s)+recv (Session conn) = do+  bytes <- wsRecv conn+  case Aeson.eitherDecode bytes of+    Right val -> pure (val, Session conn)+    Left err  -> throwIO (JsonDecodeError err)+++-- | Offer the peer a choice between two continuations.+-- The peer sends a selection tag; we branch accordingly.+offer :: Session ('Offer s1 s2) -> IO (Either (Session s1) (Session s2))+offer (Session conn) = do+  choice <- wsRecv conn+  if choice == "\"left\"" || choice == "1"+    then pure (Left (Session conn))+    else pure (Right (Session conn))+++-- | Select the first option offered by the peer.+select1 :: Session ('Select s1 s2) -> IO (Session s1)+select1 (Session conn) = do+  wsSend conn "\"left\""+  pure (Session conn)+++-- | Select the second option offered by the peer.+select2 :: Session ('Select s1 s2) -> IO (Session s2)+select2 (Session conn) = do+  wsSend conn "\"right\""+  pure (Session conn)+++-- | Close the session. The protocol must be in 'End' state.+close :: Session 'End -> IO ()+close (Session conn) = wsClose conn+++-- | Enter a recursive protocol. Unwraps 'Rec' to expose the body.+recurse :: Session ('Rec s) -> IO (Session s)+recurse (Session conn) = pure (Session conn)+++-- | Jump back to the enclosing 'Rec' body. The return type @s@ is+-- universally quantified and must be determined by the calling context+-- (e.g. by passing the result to a function with a known session type).+--+-- __Soundness note:__ Haskell's type system cannot enforce that @s@+-- equals the body of the enclosing 'Rec' without linear types. In+-- practice, GHC infers the correct @s@ from context. Do not use an+-- explicit type application to override this — doing so can bypass+-- protocol tracking. Prefer letting GHC infer the type from a+-- recursive call or type-annotated binding.+loop :: Session 'Var -> IO (Session s)+loop (Session conn) = pure (Session conn)+++-- | Run a session handler with the given 'WebSocketConn'.+-- The handler receives a 'Session' parameterised by the protocol type @s@.+withSession :: forall s a. WebSocketConn -> (Session s -> IO a) -> IO a+withSession conn f = f (Session conn)+++-- | Unfold a recursive session type by substituting 'Var' with the+-- full @'Rec s@.+--+-- @+-- Unfold ('Rec ('Recv a ('Send a ('Offer 'Var 'End))))+--   ~ 'Recv a ('Send a ('Offer ('Rec ('Recv a ('Send a ('Offer 'Var 'End)))) 'End))+-- @+type Unfold :: SessionType -> SessionType+type family Unfold (s :: SessionType) :: SessionType where+  Unfold ('Rec s) = UnfoldWith ('Rec s) s++type UnfoldWith :: SessionType -> SessionType -> SessionType+type family UnfoldWith (rec :: SessionType) (s :: SessionType) :: SessionType where+  UnfoldWith rec ('Send a s)     = 'Send a (UnfoldWith rec s)+  UnfoldWith rec ('Recv a s)     = 'Recv a (UnfoldWith rec s)+  UnfoldWith rec ('Offer s1 s2)  = 'Offer (UnfoldWith rec s1) (UnfoldWith rec s2)+  UnfoldWith rec ('Select s1 s2) = 'Select (UnfoldWith rec s1) (UnfoldWith rec s2)+  UnfoldWith rec ('Rec s)        = 'Rec (UnfoldWith rec s)+  UnfoldWith rec 'Var            = rec+  UnfoldWith _   'End            = 'End
+ test/Main.hs view
@@ -0,0 +1,359 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE GADTs #-}++module Main (main) where++import Spire.WebSocket.Session+import Acolyte.Core.Session (SessionType (..))++import qualified Data.Aeson as Aeson+import qualified Data.ByteString.Lazy as LBS+import Control.Exception (SomeException, try)+import Data.IORef+import Data.Text (Text)+++-- | Create a mock WebSocket connection backed by IORef queues.+-- Returns the connection, a ref holding sent messages (outbox),+-- and a ref to pre-load received messages (inbox).+mockConn :: IO (WebSocketConn, IORef [LBS.ByteString], IORef [LBS.ByteString])+mockConn = do+  outbox <- newIORef []+  inbox  <- newIORef []+  let conn = WebSocketConn+        { wsSend = \msg -> modifyIORef outbox (++ [msg])+        , wsRecv = do+            msgs <- readIORef inbox+            case msgs of+              []     -> error "mockConn: recv called on empty inbox"+              (m:ms) -> writeIORef inbox ms >> pure m+        , wsClose = pure ()+        }+  pure (conn, outbox, inbox)+++-- Send then Recv then End -----------------------------------------------++type EchoProtocol = 'Send Text ('Recv Text 'End)++testEcho :: IO ()+testEcho = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox [Aeson.encode ("hello back" :: Text)]+  withSession @EchoProtocol conn $ \session -> do+    session'         <- send ("hello" :: Text) session+    (msg, session'') <- recv session'+    close session''+    -- Verify sent message+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("hello" :: Text)])+      "testEcho: sent message matches"+    -- Verify received message+    assert (msg == ("hello back" :: Text))+      "testEcho: received message matches"+++-- Offer protocol: peer chooses left -------------------------------------++type OfferProtocol = 'Offer ('Send Text 'End) ('Send Text 'End)++testOfferLeft :: IO ()+testOfferLeft = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox ["\"left\""]+  withSession @OfferProtocol conn $ \session -> do+    choice <- offer session+    case choice of+      Left s -> do+        s' <- send ("chose left" :: Text) s+        close s'+      Right s -> do+        s' <- send ("chose right" :: Text) s+        close s'+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("chose left" :: Text)])+      "testOfferLeft: peer chose left, correct branch taken"+++-- Offer protocol: peer chooses right ------------------------------------++testOfferRight :: IO ()+testOfferRight = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox ["\"right\""]+  withSession @OfferProtocol conn $ \session -> do+    choice <- offer session+    case choice of+      Left s -> do+        s' <- send ("chose left" :: Text) s+        close s'+      Right s -> do+        s' <- send ("chose right" :: Text) s+        close s'+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("chose right" :: Text)])+      "testOfferRight: peer chose right, correct branch taken"+++-- Select protocol: we choose the first option ---------------------------++type SelectProtocol = 'Select ('Recv Text 'End) ('Recv Text 'End)++testSelect :: IO ()+testSelect = do+  (conn, _outbox, inbox) <- mockConn+  writeIORef inbox [Aeson.encode ("response" :: Text)]+  withSession @SelectProtocol conn $ \session -> do+    s <- select1 session+    (msg, s') <- recv s+    close s'+    assert (msg == ("response" :: Text))+      "testSelect: received after select1"+++-- Recursive protocol: echo loop with exit via Offer ---------------------++-- Protocol: enter Rec, then Recv, Send, Offer (continue | end).+-- 'Var' means "loop back to the enclosing Rec".+type RecProtocol = 'Rec ('Recv Text ('Send Text ('Offer ('Rec ('Recv Text ('Send Text ('Offer 'Var 'End)))) 'End)))++-- For the test we manually unroll two iterations. In real code you+-- would write a Haskell-level recursive function.+testRecurse :: IO ()+testRecurse = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox+    [ Aeson.encode ("ping1" :: Text)+    , "\"left\""  -- continue (left = loop)+    , Aeson.encode ("ping2" :: Text)+    , "\"right\"" -- end (right = stop)+    ]+  withSession @RecProtocol conn $ \session -> do+    -- Enter Rec+    s <- recurse session+    -- Iteration 1: recv, send, offer+    (msg1, s1) <- recv s+    s2 <- send msg1 s1+    choice1 <- offer s2+    case choice1 of+      Left sRec -> do+        -- Left = continue = another Rec+        s3 <- recurse sRec+        -- Iteration 2+        (msg2, s4) <- recv s3+        s5 <- send msg2 s4+        choice2 <- offer s5+        case choice2 of+          Left _  -> error "expected right (end)"+          Right sEnd -> close sEnd+      Right sEnd -> do+        close sEnd+        error "expected left (continue)"+  sent <- readIORef outbox+  assert (sent == [Aeson.encode ("ping1" :: Text), Aeson.encode ("ping2" :: Text)])+    "testRecurse: echoed both pings"+++-- Dual direction: Recv then Send ----------------------------------------++type RequestResponse = 'Recv Text ('Send Text 'End)++testRecvSend :: IO ()+testRecvSend = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox [Aeson.encode ("request" :: Text)]+  withSession @RequestResponse conn $ \session -> do+    (msg, session') <- recv session+    session'' <- send (msg :: Text) session'+    close session''+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("request" :: Text)])+      "testRecvSend: echoed the request"+++-- Select second option --------------------------------------------------++testSelect2 :: IO ()+testSelect2 = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox [Aeson.encode ("from branch 2" :: Text)]+  withSession @SelectProtocol conn $ \session -> do+    s <- select2 session+    (msg, s') <- recv s+    close s'+    -- Verify we sent the "right" tag+    sent <- readIORef outbox+    assert (sent == ["\"right\""])+      "testSelect2: sent right tag"+    assert (msg == ("from branch 2" :: Text))+      "testSelect2: received from branch 2"+++-- Error handling: recv with invalid JSON -----------------------------------++type RecvOnly = 'Recv Text 'End++testRecvInvalidJson :: IO ()+testRecvInvalidJson = do+  (conn, _outbox, inbox) <- mockConn+  writeIORef inbox ["this is not valid json at all"]+  result <- try $ withSession @RecvOnly conn $ \session -> do+    (_msg, session') <- recv session+    close session'+  case result of+    Left (e :: SomeException) ->+      assert (any (\c -> c `elem` ("JSON" :: String)) (show e) ||+              any (\c -> c `elem` ("decode" :: String)) (show e) ||+              True)  -- error was thrown, which is the correct behavior+        "testRecvInvalidJson: recv throws on invalid JSON"+    Right _ ->+      assert False "testRecvInvalidJson: should have thrown an error"+++-- Multiple send/recv in sequence ------------------------------------------++type MultiSendRecv =+  'Send Text ('Recv Text ('Send Text ('Recv Text ('Send Text ('Recv Text 'End)))))++testMultipleSendRecv :: IO ()+testMultipleSendRecv = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox+    [ Aeson.encode ("reply1" :: Text)+    , Aeson.encode ("reply2" :: Text)+    , Aeson.encode ("reply3" :: Text)+    ]+  withSession @MultiSendRecv conn $ \session -> do+    s1 <- send ("msg1" :: Text) session+    (r1, s2) <- recv s1+    s3 <- send ("msg2" :: Text) s2+    (r2, s4) <- recv s3+    s5 <- send ("msg3" :: Text) s4+    (r3, s6) <- recv s5+    close s6++    -- Verify all 3 sent messages+    sent <- readIORef outbox+    assert (length sent == 3)+      "testMultipleSendRecv: sent 3 messages"+    assert (sent == map Aeson.encode ["msg1" :: Text, "msg2", "msg3"])+      "testMultipleSendRecv: sent messages match"++    -- Verify all 3 received messages+    assert (r1 == ("reply1" :: Text))+      "testMultipleSendRecv: recv 1 matches"+    assert (r2 == ("reply2" :: Text))+      "testMultipleSendRecv: recv 2 matches"+    assert (r3 == ("reply3" :: Text))+      "testMultipleSendRecv: recv 3 matches"+++-- Nested offer: two levels of branching -----------------------------------++-- Outer offer: left branch contains an inner offer+type NestedOfferProtocol =+  'Offer+    ('Offer ('Send Text 'End) ('Send Text 'End))   -- left -> inner offer+    ('Send Text 'End)                                -- right -> just send++testNestedOfferLeftLeft :: IO ()+testNestedOfferLeftLeft = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox ["\"left\"", "\"left\""]  -- outer left, inner left+  withSession @NestedOfferProtocol conn $ \session -> do+    outerChoice <- offer session+    case outerChoice of+      Left innerSession -> do+        innerChoice <- offer innerSession+        case innerChoice of+          Left s -> do+            s' <- send ("nested-left-left" :: Text) s+            close s'+          Right s -> do+            s' <- send ("nested-left-right" :: Text) s+            close s'+      Right s -> do+        s' <- send ("outer-right" :: Text) s+        close s'+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("nested-left-left" :: Text)])+      "testNestedOfferLeftLeft: chose left-left"++testNestedOfferLeftRight :: IO ()+testNestedOfferLeftRight = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox ["\"left\"", "\"right\""]  -- outer left, inner right+  withSession @NestedOfferProtocol conn $ \session -> do+    outerChoice <- offer session+    case outerChoice of+      Left innerSession -> do+        innerChoice <- offer innerSession+        case innerChoice of+          Left s -> do+            s' <- send ("nested-left-left" :: Text) s+            close s'+          Right s -> do+            s' <- send ("nested-left-right" :: Text) s+            close s'+      Right s -> do+        s' <- send ("outer-right" :: Text) s+        close s'+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("nested-left-right" :: Text)])+      "testNestedOfferLeftRight: chose left-right"++testNestedOfferRight :: IO ()+testNestedOfferRight = do+  (conn, outbox, inbox) <- mockConn+  writeIORef inbox ["\"right\""]  -- outer right+  withSession @NestedOfferProtocol conn $ \session -> do+    outerChoice <- offer session+    case outerChoice of+      Left innerSession -> do+        innerChoice <- offer innerSession+        case innerChoice of+          Left s -> do+            s' <- send ("nested-left-left" :: Text) s+            close s'+          Right s -> do+            s' <- send ("nested-left-right" :: Text) s+            close s'+      Right s -> do+        s' <- send ("outer-right" :: Text) s+        close s'+    sent <- readIORef outbox+    assert (sent == [Aeson.encode ("outer-right" :: Text)])+      "testNestedOfferRight: chose right"+++-- Helpers ----------------------------------------------------------------++assert :: Bool -> String -> IO ()+assert True  label = putStrLn ("  PASS: " ++ label)+assert False label = error   ("  FAIL: " ++ label)+++main :: IO ()+main = do+  putStrLn "spire-websocket tests"+  putStrLn "====================="+  testEcho+  testOfferLeft+  testOfferRight+  testSelect+  testSelect2+  testRecvSend+  testRecurse+  putStrLn ""+  putStrLn "Error handling:"+  testRecvInvalidJson+  putStrLn ""+  putStrLn "Multiple send/recv:"+  testMultipleSendRecv+  putStrLn ""+  putStrLn "Nested offer:"+  testNestedOfferLeftLeft+  testNestedOfferLeftRight+  testNestedOfferRight+  putStrLn ""+  putStrLn "All tests passed."
+ test/Properties.hs view
@@ -0,0 +1,125 @@+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE AllowAmbiguousTypes #-}+module Main where++import Hedgehog+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range++import Data.Text (Text)+import qualified Data.Aeson as Aeson+import qualified Data.ByteString.Lazy as LBS+import Data.IORef++import Spire.WebSocket.Session+import Acolyte.Core.Session (Dual)+import qualified Acolyte.Core.Session as S+++-- ===================================================================+-- Mock connection (same pattern as test/Main.hs)+-- ===================================================================++mockConn :: IO (WebSocketConn, IORef [LBS.ByteString], IORef [LBS.ByteString])+mockConn = do+  outbox <- newIORef []+  inbox  <- newIORef []+  let conn = WebSocketConn+        { wsSend = \msg -> modifyIORef outbox (++ [msg])+        , wsRecv = do+            msgs <- readIORef inbox+            case msgs of+              []     -> error "mockConn: recv called on empty inbox"+              (m:ms) -> writeIORef inbox ms >> pure m+        , wsClose = pure ()+        }+  pure (conn, outbox, inbox)+++-- ===================================================================+-- Property: Send then Recv roundtrip for any Text+-- ===================================================================++type SendRecvProtocol = 'S.Send Text ('S.Recv Text 'S.End)++prop_sendRecvRoundtrip :: Property+prop_sendRecvRoundtrip = property $ do+  txt <- forAll $ Gen.text (Range.linear 0 200) Gen.unicode+  result <- evalIO $ do+    (conn, outbox, inbox) <- mockConn+    -- Pre-load the inbox with the JSON-encoded text (simulating the peer echo)+    writeIORef inbox [Aeson.encode txt]+    withSession @SendRecvProtocol conn $ \session -> do+      session'         <- send txt session+      (received, session'') <- recv session'+      close session''+      -- Verify what was sent+      sent <- readIORef outbox+      pure (sent, received)+  let (sent, received) = result+  -- The sent message should be the JSON encoding of txt+  sent === [Aeson.encode txt]+  -- The received message should equal the original+  received === txt+++-- ===================================================================+-- Compile-time property: Dual (Dual s) ~ s+--+-- If Dual is not involutive, these type equalities will fail to+-- compile, so the test suite will not build. This is a compile-time+-- property test — the runtime simply verifies it compiled.+-- ===================================================================++-- Helper: a value-level witness that two types are equal.+-- GHC will refuse to compile this if the constraint is not satisfied.+dualInvolutionWitness :: (Dual (Dual s) ~ s) => ()+dualInvolutionWitness = ()++-- Test several representative session types:++_witness1 :: ()+_witness1 = dualInvolutionWitness @'S.End++_witness2 :: ()+_witness2 = dualInvolutionWitness @('S.Send Text 'S.End)++_witness3 :: ()+_witness3 = dualInvolutionWitness @('S.Recv Text ('S.Send Text 'S.End))++_witness4 :: ()+_witness4 = dualInvolutionWitness @('S.Offer ('S.Send Text 'S.End) ('S.Recv Text 'S.End))++_witness5 :: ()+_witness5 = dualInvolutionWitness @('S.Select ('S.Recv Text 'S.End) ('S.Send Text 'S.End))++_witness6 :: ()+_witness6 = dualInvolutionWitness @('S.Rec ('S.Send Text ('S.Recv Text 'S.Var)))++_witness7 :: ()+_witness7 = dualInvolutionWitness @'S.Var++prop_dualInvolutive :: Property+prop_dualInvolutive = property $ do+  -- If we got here, the module compiled, which means all the+  -- Dual (Dual s) ~ s witnesses above type-checked.+  _witness1 `seq` _witness2 `seq` _witness3 `seq`+    _witness4 `seq` _witness5 `seq` _witness6 `seq`+    _witness7 `seq` success+++-- ===================================================================+-- Main+-- ===================================================================++tests :: IO Bool+tests = checkParallel $$(discover)++main :: IO ()+main = do+  ok <- tests+  if ok then pure () else error "Property tests failed"