packages feed

tricorder-0.1.0.0: src/Tricorder/Effects/BuildStore.hs

module Tricorder.Effects.BuildStore
    ( -- * Effect
      BuildStore (..)
    , getState
    , modifyPhase
    , waitUntilDone
    , waitForNext
    , waitForAnyChange
    , setPhase
    , markDirty
    , waitDirty
    , hasWaiters

      -- * Interpreters
    , runBuildStoreScripted
    , runBuildStore
    ) where

import Atelier.Effects.Input (Input, input)
import Effectful (Effect)
import Effectful.Concurrent (Concurrent)
import Effectful.Concurrent.STM (TChan, TVar, atomically, dupTChan, modifyTVar, newBroadcastTChan, newTVar, readTChan, readTVar, retry, writeTChan, writeTVar)
import Effectful.Dispatch.Dynamic (interpretWith_, reinterpret)
import Effectful.Exception (bracket_)
import Effectful.State.Static.Shared (State, evalState, get, put)
import Effectful.TH (makeEffect)

import Tricorder.BuildState
    ( BuildId
    , BuildPhase (..)
    , BuildState (..)
    , ChangeKind (..)
    , DaemonInfo (..)
    , initialBuildState
    )


data BuildStore :: Effect where
    -- | Read the current build state without blocking.
    GetState :: BuildStore m BuildState
    -- | Atomically update the phase of the current build, keeping its
    -- 'BuildId'. The function sees the live state, so a progress update can
    -- inspect the current phase and leave it untouched if it has moved on.
    ModifyPhase :: (BuildState -> BuildPhase) -> BuildStore m ()
    -- | Block until the current build cycle completes (phase transitions to Done).
    WaitUntilDone :: BuildStore m BuildState
    -- | Block until a completed build with a different 'BuildId' is available.
    WaitForNext :: BuildId -> BuildStore m BuildState
    -- | Block until the build state changes from the given state (any field).
    WaitForAnyChange :: BuildState -> BuildStore m BuildState
    -- | Update the build id and phase without touching other fields (e.g. daemonInfo).
    SetPhase :: BuildId -> BuildPhase -> BuildStore m ()
    -- | Signal that files have changed and a rebuild is needed.
    -- 'CabalChange' upgrades a pending 'SourceChange' but never downgrades.
    MarkDirty :: ChangeKind -> BuildStore m ()
    -- | Block until dirty, atomically clear the flag, and return the change kind.
    WaitDirty :: BuildStore m ChangeKind
    -- | Return True if any callers are currently blocked in 'waitUntilDone'.
    HasWaiters :: BuildStore m Bool


makeEffect ''BuildStore


-- | Mutable state shared between the production interpreters and writers
-- (e.g. 'GhciSession'). Internal to this module.
data BuildStateRef = BuildStateRef
    { stateRef :: TVar BuildState
    , dirtyRef :: TVar (Maybe ChangeKind)
    , waitersRef :: TVar Int
    , transitions :: TChan BuildState
    -- ^ Broadcast channel of every phase transition. Writers (setPhase /
    -- modifyPhase) atomically update 'stateRef' AND broadcast on this chan in
    -- the same STM transaction; waiters 'dupTChan' it on entry and consume
    -- every transition. A transient 'Done' followed immediately by
    -- 'Building (N+1)' is therefore observable as two messages on the
    -- channel — the waiter can't be woken on the 'Done' and then miss it
    -- because the 'Building' overwrote 'stateRef' before the waiter re-ran.
    }


-- | Allocate the shared STM state, seeding the build state from @di@. The
-- record's field types pin the otherwise-polymorphic 'newTVar' and
-- 'newBroadcastTChan' results.
newBuildStateRef :: (Concurrent :> es) => DaemonInfo -> Eff es BuildStateRef
newBuildStateRef di =
    atomically
        $ BuildStateRef
            <$> newTVar (initialBuildState di)
            <*> newTVar Nothing
            <*> newTVar 0
            <*> newBroadcastTChan


isBuilding :: BuildState -> Bool
isBuilding s = case s.phase of
    Building _ -> True
    Restarting -> True
    Testing _ -> True
    Done _ -> False
    BuildFailed _ -> False


-- | Block until the build state satisfies @predicate@, then return it.
--
-- Subscribes to 'transitions' before reading the current state, so every
-- subsequent state change is observable as a discrete message even if the
-- TVar value is overwritten before the waiter is rescheduled. This is what
-- prevents a transient 'Done' from being missed when 'Building (N+1)'
-- follows it within the scheduler's wake-up latency.
waitForState
    :: (Concurrent :> es)
    => TVar BuildState
    -> TChan BuildState
    -> (BuildState -> Bool)
    -> Eff es BuildState
waitForState ref transitions predicate = do
    myChan <- atomically (dupTChan transitions)
    -- Snapshot AFTER subscribing so we don't race past a transition: any
    -- state change that happens between subscribing and reading 'ref' also
    -- lands on 'myChan', so the loop will pick it up.
    s0 <- atomically (readTVar ref)
    if predicate s0 then pure s0 else drainUntilMatch myChan
  where
    drainUntilMatch ch = do
        s <- atomically (readTChan ch)
        if predicate s then pure s else drainUntilMatch ch


-- | Atomically take the dirty marker, blocking until one is set.
takeDirty :: (Concurrent :> es) => TVar (Maybe ChangeKind) -> Eff es ChangeKind
takeDirty dirtyRef = atomically do
    readTVar dirtyRef >>= \case
        Just ck -> writeTVar dirtyRef Nothing >> pure ck
        Nothing -> retry


-- | Scripted interpreter for testing.
--
-- Advances through a pre-loaded list of 'BuildState' values for blocking
-- operations. Useful for testing components that read build state without
-- needing a real 'TVar' or concurrency.
--
-- * 'getState' peeks at the head of the list without consuming it.
-- * 'modifyPhase' rewrites the head's phase in place.
-- * 'waitUntilDone' pops states until it finds one where @phase /= Building@.
-- * 'waitForNext' pops states until it finds a Done state with a different 'BuildId'.
runBuildStoreScripted :: [BuildState] -> Eff (BuildStore : es) a -> Eff es a
runBuildStoreScripted states = reinterpret (evalState states) $ \_ -> \case
    GetState ->
        get >>= \case
            [] -> error "BuildStoreScripted: getState called on empty state list"
            s : _ -> pure s
    ModifyPhase f ->
        get >>= \case
            [] -> pure ()
            s : rest -> put (s {phase = f s} : rest)
    WaitUntilDone -> advance (not . isBuilding)
    WaitForNext bid -> advance \s -> not (isBuilding s) && s.buildId /= bid
    WaitForAnyChange prev -> advance (/= prev)
    SetPhase bid phase -> do
        get >>= \case
            [] -> pure ()
            s : rest -> put (s {buildId = bid, phase = phase} : rest)
    MarkDirty _ -> pure ()
    WaitDirty -> pure SourceChange
    HasWaiters -> pure False
  where
    advance :: (BuildState -> Bool) -> Eff (State [BuildState] : es) BuildState
    advance predicate =
        get >>= \case
            [] -> error "BuildStoreScripted: no matching state in list"
            s : rest
                | predicate s -> put rest >> pure s
                | otherwise -> put rest >> advance predicate


-- | Production interpreter backed by a 'TVar', sharing its STM state with
-- writers (e.g. 'GhciSession'). Seeds the state with 'initialBuildState' for
-- the current 'DaemonInfo' and refreshes it on every phase change.
--
-- Blocking operations use STM @retry@ rather than polling, so a transient
-- 'Done' state cannot be missed by a poll cycle landing on the surrounding
-- 'Building' phases. 'atomically' is interruptible, so async exceptions
-- (e.g. Ki's @ScopeClosing@) propagate during daemon shutdown.
runBuildStore
    :: ( Concurrent :> es
       , Input DaemonInfo :> es
       )
    => Eff (BuildStore : es) a -> Eff es a
runBuildStore eff = do
    di <- input
    refs <- newBuildStateRef di
    interpretWith_ eff \case
        GetState -> atomically (readTVar refs.stateRef)
        ModifyPhase f -> do
            daemonInfo <- input
            atomically do
                modifyTVar refs.stateRef \bs -> bs {phase = f bs, daemonInfo}
                readTVar refs.stateRef >>= writeTChan refs.transitions
        WaitUntilDone ->
            bracket_
                (atomically (modifyTVar refs.waitersRef (+ 1)))
                (atomically (modifyTVar refs.waitersRef (subtract 1)))
                (waitForState refs.stateRef refs.transitions (not . isBuilding))
        WaitForNext bid ->
            waitForState refs.stateRef refs.transitions \s -> not (isBuilding s) && s.buildId /= bid
        WaitForAnyChange prev -> waitForState refs.stateRef refs.transitions (/= prev)
        SetPhase bid phase -> do
            daemonInfo <- input
            atomically do
                modifyTVar refs.stateRef \bs -> bs {buildId = bid, phase = phase, daemonInfo}
                readTVar refs.stateRef >>= writeTChan refs.transitions
        MarkDirty ck -> atomically (modifyTVar refs.dirtyRef (max (Just ck)))
        WaitDirty -> takeDirty refs.dirtyRef
        HasWaiters -> fmap (> 0) $ atomically (readTVar refs.waitersRef)