----------------------------------------------------------------------------- {-# LANGUAGE CPP #-} ----------------------------------------------------------------------------- -- | -- Module : Miso.Subscription.Util -- Copyright : (C) 2016-2026 David M. Johnson -- License : BSD3-style (see the file LICENSE) -- Maintainer : David M. Johnson <code@dmj.io> -- Stability : experimental -- Portability : non-portable -- -- = Overview -- -- "Miso.Subscription.Util" provides 'createSub', the building block used -- by every subscription in "Miso.Subscription". It handles the -- acquire-use-release lifecycle of an external resource (an event -- listener, an animation-frame callback, etc.) using -- 'Control.Exception.bracket' so the resource is always cleaned up when -- the t'Miso.Types.Component' unmounts, even if an exception is thrown. -- -- It also provides 'rAFLoop', a @requestAnimationFrame@ loop built on -- 'createSub' whose callback re-schedules itself every frame. It is the -- basis of 'Miso.Subscription.RAF.rAFSub', -- 'Miso.Subscription.RAF.rAFSubElapsed' and -- 'Miso.Subscription.Canvas.canvasSub'. See Note [rAFLoop teardown]. -- -- = Quick start -- -- Use 'createSub' to build a custom subscription from any pair of -- acquire\/release actions. The subscription sleeps between polls and -- relies entirely on the acquire step to register whatever callbacks -- deliver events to the sink: -- -- @ -- import "Miso.Subscription.Util" ('createSub') -- import "Miso.Effect" ('Sub') -- import qualified "Miso.Runtime.Internal" as FFI -- -- -- Custom subscription: fire an action whenever the window is resized -- resizeSub :: (Int -> action) -> 'Sub' action -- resizeSub toAction sink = 'createSub' acquire release sink -- where -- acquire = -- FFI.'Miso.Runtime.Internal.windowAddEventListener' \"resize\" $ \\_ -> do -- w <- FFI.'Miso.FFI.windowInnerWidth' -- sink (toAction w) -- release cb = -- FFI.'Miso.Runtime.Internal.windowRemoveEventListener' \"resize\" cb -- @ -- -- = How it works -- -- @'createSub' acquire release sink@ runs: -- -- @ -- 'Control.Exception.bracket' acquire release (\\_ -> forever (threadDelay 1000_000_000)) -- @ -- -- The @forever@ loop keeps the subscription thread alive by sleeping in -- very long increments. All actual work is done inside callbacks -- registered during @acquire@, which call @sink@ directly. @release@ is -- guaranteed to run (via 'Control.Exception.bracket') when the component -- teardown kills the thread. -- -- = See also -- -- * "Miso.Effect" — t'Miso.Effect.Sub', t'Miso.Effect.Sink' -- * "Miso.Runtime.Internal" — 'Miso.Runtime.Internal.windowAddEventListener', -- 'Miso.Runtime.Internal.windowRemoveEventListener' -- * "Miso.Subscription" — all built-in subscriptions ---------------------------------------------------------------------------- module Miso.Subscription.Util ( -- ** Utilities createSub , rAFLoop ) where ---------------------------------------------------------------------------- import Control.Concurrent (threadDelay) import Control.Monad (forever, void) import Control.Exception (bracket) import Data.IORef ----------------------------------------------------------------------------- import Miso.DSL import Miso.Effect ----------------------------------------------------------------------------- -- | Utility function to allow resource finalization on 'Sub'. createSub :: IO a -- ^ Acquire resource -> (a -> IO b) -- ^ Release resource -> Sub model action createSub :: forall a b model action. IO a -> (a -> IO b) -> Sub model action createSub IO a acquire a -> IO b release = \Sink action _ IO model _ -> IO a -> (a -> IO b) -> (a -> IO ()) -> IO () forall a b c. IO a -> (a -> IO b) -> (a -> IO c) -> IO c bracket IO a acquire a -> IO b release (\a _ -> IO () -> IO () forall (f :: * -> *) a b. Applicative f => f a -> f b forever (Int -> IO () threadDelay (Int -> Int secs Int 1000))) where -- 'Int' is 32 bits on wasm32 (GHC and MicroHs), so the delay must stay -- below 2^31 microseconds (about 2147 seconds); MicroHs throws -- 'Overflow' on a larger product, which would kill the 'Sub' thread and -- release the resource. secs :: Int -> Int secs :: Int -> Int secs = (Int -> Int -> Int forall a. Num a => a -> a -> a *Int 1000000) ---------------------------------------------------------------------------- -- | A self re-scheduling @requestAnimationFrame@ loop, as a 'Sub'. -- -- The 'IO' action runs once, when the 'Sub' starts, and builds the -- per-frame handler; it is the place to acquire anything the handler -- needs (a canvas context, t'Data.IORef.IORef's for timing state, ...). -- The handler then receives the frame's @DOMHighResTimeStamp@ in -- milliseconds, every frame, until the 'Sub' is stopped. -- -- A single callback is used for the whole loop; it is freed when the -- 'Sub' stops. See Note [rAFLoop teardown]. -- -- @since 1.15.0.0 rAFLoop :: IO (Double -> IO ()) -- ^ Builds the per-frame handler; runs once at start -> Sub model action rAFLoop :: forall model action. IO (Double -> IO ()) -> Sub model action rAFLoop IO (Double -> IO ()) mkOnFrame = IO (IORef Bool) -> (IORef Bool -> IO ()) -> Sub model action forall a b model action. IO a -> (a -> IO b) -> Sub model action createSub IO (IORef Bool) acquire IORef Bool -> IO () release where acquire :: IO (IORef Bool) acquire = do onFrame <- IO (Double -> IO ()) mkOnFrame stopRef <- newIORef False cbRef <- newIORef (error "rAFLoop: uninitialized, impossible") callback <- syncCallback1 $ \JSVal jsval -> do stopped <- IORef Bool -> IO Bool forall a. IORef a -> IO a readIORef IORef Bool stopRef if stopped then freeFunction . Function =<< readIORef cbRef else do onFrame =<< fromJSValUnchecked jsval void . requestAnimationFrame =<< readIORef cbRef writeIORef cbRef callback void (requestAnimationFrame callback) pure stopRef release :: IORef Bool -> IO () release IORef Bool stopRef = IORef Bool -> Bool -> IO () forall a. IORef a -> a -> IO () writeIORef IORef Bool stopRef Bool True ---------------------------------------------------------------------------- -- Note [rAFLoop teardown] -- ~~~~~~~~~~~~~~~~~~~~~~~ -- The frame callback owns its own lifetime: it is the only thing that ever -- frees itself. 'release' just sets a flag, and the next frame the callback -- sees it, frees itself and does not re-schedule. So a stopped loop runs -- one more (silent) frame before the callback is gone. -- -- Why not cancel the queued frame and free the callback in 'release'? The -- callback and 'release' run on different Haskell threads, and the wasm RTS -- has no timer, so it context-switches at every heap-block boundary -- whenever another thread is runnable (-C0; see Note [No timer on wasm32] -- in GHC's rts). Sinking an action wakes the scheduler thread, so the -- callback can be descheduled right after it, and if the action stops this -- 'Sub' then 'release' frees the callback before the callback resumes and -- calls @requestAnimationFrame@ on its own, now freed, handle: the JSFFI -- glue throws (@getJSVal@) and the runtime is dead. Any scheme where -- 'release' frees needs the two threads to agree on who frees, i.e. a lock. -- With one owner there is nothing to agree on: the flag is checked at the -- start of every invocation, and a rAF callback never runs concurrently -- with itself, so no interleaving reaches a freed handle. -- -- Freeing the callback from inside itself is fine: the trampoline has -- already dereferenced the Haskell closure by the time the body runs, and -- the body does nothing with the handle afterwards. ----------------------------------------------------------------------------