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 +7/−0
- LICENSE +27/−0
- spire-websocket.cabal +84/−0
- src/Spire/WebSocket.hs +26/−0
- src/Spire/WebSocket/Session.hs +172/−0
- test/Main.hs +359/−0
- test/Properties.hs +125/−0
+ 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"