gbnet-hs-0.2.2.0: src/GBNet/TestNet.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- |
-- Module : GBNet.TestNet
-- Description : Pure deterministic network for testing
--
-- A simulated network that runs entirely in pure code.
-- Useful for:
-- - Unit testing connection logic
-- - Property-based testing
-- - Deterministic replay
-- - Network condition simulation
module GBNet.TestNet
( -- * Test network monad
TestNet (..),
runTestNet,
-- * Test network state
InFlightPacket (..),
TestNetConfig (..),
TestNetState (..),
initialTestNetState,
defaultTestNetConfig,
-- * Operations
advanceTime,
simulateLatency,
simulateLoss,
getPendingPackets,
-- * Multi-peer world simulation
TestWorld (..),
newTestWorld,
runPeerInWorld,
deliverPackets,
worldAdvanceTime,
)
where
import Control.Monad.State.Strict (MonadState, State, get, runState)
import Data.ByteString (ByteString)
import Data.Foldable (toList)
import Data.List (partition)
import qualified Data.Map.Strict as Map
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import Data.Word (Word64)
import GBNet.Class (MonadNetwork (..), MonadTime (..), MonoTime (..), NetError (..))
import GBNet.Security (validateAndStripCrc32)
import GBNet.Util (nextRandom, randomDouble)
import Network.Socket (SockAddr)
import Optics ((%), (%~), (&), (.~))
import Optics.State (use)
import Optics.State.Operators ((%=), (.=))
import Optics.TH (makeFieldLabelsNoPrefix)
-- | A packet in transit.
data InFlightPacket = InFlightPacket
{ ifpFrom :: !SockAddr,
ifpTo :: !SockAddr,
ifpData :: !ByteString,
ifpDeliverAt :: !MonoTime -- When this packet should be delivered
}
deriving (Show)
makeFieldLabelsNoPrefix ''InFlightPacket
-- | Nanoseconds per millisecond.
nsPerMs :: Word64
nsPerMs = 1000000
-- | Maximum extra delay for out-of-order simulation (50ms in nanoseconds).
testNetOutOfOrderMaxDelayNs :: Word64
testNetOutOfOrderMaxDelayNs = 50000000
-- | Extra jitter for duplicate packet copies (10ms in nanoseconds).
testNetDuplicateJitterNs :: Word64
testNetDuplicateJitterNs = 10000000
-- | Test network configuration.
data TestNetConfig = TestNetConfig
{ tncLatencyNs :: !Word64, -- Simulated one-way latency in nanoseconds
tncLossRate :: !Double, -- Packet loss probability (0.0 - 1.0)
tncJitterNs :: !Word64, -- Random jitter range in nanoseconds
tncDuplicateChance :: !Double, -- Probability of duplicating a packet (0.0 - 1.0)
tncOutOfOrderChance :: !Double -- Probability of reordering a packet (0.0 - 1.0)
}
deriving (Show)
makeFieldLabelsNoPrefix ''TestNetConfig
-- | Default test config (no latency, no loss).
defaultTestNetConfig :: TestNetConfig
defaultTestNetConfig =
TestNetConfig
{ tncLatencyNs = 0,
tncLossRate = 0.0,
tncJitterNs = 0,
tncDuplicateChance = 0.0,
tncOutOfOrderChance = 0.0
}
-- | State of the test network.
data TestNetState = TestNetState
{ tnsCurrentTime :: !MonoTime,
tnsLocalAddr :: !SockAddr,
tnsInFlight :: !(Seq InFlightPacket), -- Packets in transit
tnsInbox :: !(Seq (ByteString, SockAddr)), -- Delivered packets
tnsConfig :: !TestNetConfig,
tnsRng :: !Word64,
tnsClosed :: !Bool
}
deriving (Show)
makeFieldLabelsNoPrefix ''TestNetState
-- | Create initial test network state.
initialTestNetState :: SockAddr -> TestNetState
initialTestNetState localAddr =
TestNetState
{ tnsCurrentTime = 0,
tnsLocalAddr = localAddr,
tnsInFlight = Seq.empty,
tnsInbox = Seq.empty,
tnsConfig = defaultTestNetConfig,
tnsRng = 42, -- Deterministic seed
tnsClosed = False
}
-- | The test network monad.
newtype TestNet a = TestNet {unTestNet :: State TestNetState a}
deriving (Functor, Applicative, Monad, MonadState TestNetState)
-- | Run a test network computation.
runTestNet :: TestNet a -> TestNetState -> (a, TestNetState)
runTestNet (TestNet m) = runState m
instance MonadTime TestNet where
getMonoTime = use #tnsCurrentTime
instance MonadNetwork TestNet where
netSend toAddr bytes = do
st <- get
if tnsClosed st
then pure (Left NetSocketClosed)
else do
let cfg = tnsConfig st
(r1, rng1) = nextRandom (tnsRng st)
if randomDouble r1 < tncLossRate cfg
then do
#tnsRng .= rng1
pure (Right ())
else do
let (r2, rng2) = nextRandom rng1
jitterRange = tncJitterNs cfg
jitter = if jitterRange == 0 then 0 else r2 `mod` (jitterRange + 1)
-- Out-of-order: add extra random delay
(r3, rng3) = nextRandom rng2
oooDelay =
if randomDouble r3 < tncOutOfOrderChance cfg
then MonoTime (r3 `mod` (testNetOutOfOrderMaxDelayNs + 1))
else 0
deliverAt = tnsCurrentTime st + MonoTime (tncLatencyNs cfg) + MonoTime jitter + oooDelay
pkt =
InFlightPacket
{ ifpFrom = tnsLocalAddr st,
ifpTo = toAddr,
ifpData = bytes,
ifpDeliverAt = deliverAt
}
#tnsInFlight %= (Seq.|> pkt)
-- Duplicate: clone packet with extra jitter
let (r4, rng4) = nextRandom rng3
if randomDouble r4 < tncDuplicateChance cfg
then do
let (r5, rng5) = nextRandom rng4
dupJitter = MonoTime (r5 `mod` (testNetDuplicateJitterNs + 1))
dupPkt = pkt {ifpDeliverAt = deliverAt + dupJitter}
#tnsInFlight %= (Seq.|> dupPkt)
#tnsRng .= rng5
else #tnsRng .= rng4
pure (Right ())
netRecv = do
inbox <- use #tnsInbox
case Seq.viewl inbox of
Seq.EmptyL -> pure Nothing
(bytes, from) Seq.:< rest -> do
#tnsInbox .= rest
-- Validate and strip CRC, matching the IO backend behavior
case validateAndStripCrc32 bytes of
Nothing -> pure Nothing -- Drop corrupt packets
Just validated -> pure (Just (validated, from))
netClose = #tnsClosed .= True
-- | Advance time and deliver packets that are ready.
advanceTime :: MonoTime -> TestNet ()
advanceTime newTime = do
st <- get
let (ready, stillInFlight) =
Seq.partition (\p -> ifpDeliverAt p <= newTime) (tnsInFlight st)
-- Only deliver packets addressed to us
delivered =
(\p -> (ifpData p, ifpFrom p))
<$> Seq.filter (\p -> ifpTo p == tnsLocalAddr st) ready
#tnsCurrentTime .= newTime
#tnsInFlight .= stillInFlight
#tnsInbox %= (Seq.>< delivered)
-- | Configure simulated one-way latency (in milliseconds).
simulateLatency :: Word64 -> TestNet ()
simulateLatency ms = #tnsConfig % #tncLatencyNs .= ms * nsPerMs
-- | Configure simulated packet loss.
simulateLoss :: Double -> TestNet ()
simulateLoss rate = #tnsConfig % #tncLossRate .= rate
-- | Get all packets currently in flight (for debugging/testing).
getPendingPackets :: TestNet [InFlightPacket]
getPendingPackets = toList <$> use #tnsInFlight
-- -----------------------------------------------------------------------------
-- Multi-peer world simulation
-- -----------------------------------------------------------------------------
-- | A world containing multiple peers for integration testing.
-- Allows deterministic simulation of multi-peer scenarios.
data TestWorld = TestWorld
{ -- | State for each peer, keyed by their address
twPeers :: !(Map.Map SockAddr TestNetState),
-- | Global simulation time
twGlobalTime :: !MonoTime
}
deriving (Show)
makeFieldLabelsNoPrefix ''TestWorld
-- | Create a new test world with no peers.
newTestWorld :: TestWorld
newTestWorld =
TestWorld
{ twPeers = Map.empty,
twGlobalTime = 0
}
-- | Run a TestNet operation for a specific peer in the world.
-- If the peer doesn't exist, it's created with the given address.
runPeerInWorld :: SockAddr -> TestNet a -> TestWorld -> (a, TestWorld)
runPeerInWorld addr action world =
let peerState =
Map.findWithDefault
(initialTestNetState addr) {tnsCurrentTime = twGlobalTime world}
addr
(twPeers world)
(result, updated) = runTestNet action peerState
in (result, world & #twPeers %~ Map.insert addr updated)
-- | Deliver all ready packets between peers.
-- Packets are moved from sender's outFlight to receiver's inbox
-- if the delivery time has passed.
deliverPackets :: TestWorld -> TestWorld
deliverPackets world =
let time = twGlobalTime world
-- Collect all packets from all peers
allPackets =
concatMap
(toList . tnsInFlight)
(Map.elems (twPeers world))
-- Partition into ready and not ready
(ready, notReady) = partition (\p -> ifpDeliverAt p <= time) allPackets
-- Clear all peer outFlight, then redistribute
clearedPeers = Map.map (\ps -> ps & #tnsInFlight .~ Seq.empty) (twPeers world)
-- Put not-ready packets back to their senders
peersWithPending = foldr putBackPending clearedPeers notReady
-- Deliver ready packets to their destinations
peersWithDelivered = foldr deliverOne peersWithPending ready
in world & #twPeers .~ peersWithDelivered
where
putBackPending pkt =
Map.adjust
(\ps -> ps & #tnsInFlight %~ (Seq.|> pkt))
(ifpFrom pkt)
deliverOne pkt =
Map.adjust
(\ps -> ps & #tnsInbox %~ (Seq.|> (ifpData pkt, ifpFrom pkt)))
(ifpTo pkt)
-- | Advance time for all peers in the world.
worldAdvanceTime :: MonoTime -> TestWorld -> TestWorld
worldAdvanceTime newTime world =
let timed = world & #twGlobalTime .~ newTime
updatedPeers =
Map.map
(\ps -> ps & #tnsCurrentTime .~ newTime)
(twPeers timed)
in deliverPackets (timed & #twPeers .~ updatedPeers)