packages feed

tail-0.2.0.0: src/System/Tail.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}

{- |
File tailing library with multi-subscriber support.

This library provides a Haskell API for @tail -f@ style file streaming
using 'Control.Concurrent.STM', 'Control.Concurrent.Async', and system processes.

Example usage:

@
import System.Tail

main :: IO ()
main = do
  tail <- tailFile 100 \"\/var\/log\/app.log\"
  subscriber <- tailSubscribe tail
  -- Read from subscriber...
  tailStop tail
@
-}
module System.Tail (
  -- * Core Types
  Tail,

  -- * Operations
  tailFile,
  tailStop,
  tailSubscribe,
) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (Async, async)
import Control.Concurrent.STM.CircularBuffer (CircularBuffer)
import Control.Concurrent.STM.CircularBuffer qualified as CB
import System.Directory (doesFileExist)
import System.IO (hGetLine)
import System.Process (CreateProcess (..), ProcessHandle, StdStream (..), createProcess, proc, terminateProcess, waitForProcess)
import System.Which (staticWhich)

-- | Represent running @tail -f@ on a file in Haskell
data Tail = Tail
  { filePath :: FilePath
  -- ^ The file being tailed
  , stop :: TMVar ()
  -- ^ Signal to stop tailing
  , tailProcess :: TVar (Maybe (ProcessHandle, Async ()))
  -- ^ The tail process handle and async reader
  , queues :: TVar [CircularBuffer Text]
  -- ^ Active subscriber queues
  , ringBuffer :: CircularBuffer Text
  -- ^ Ring buffer storing last N lines for new subscribers
  }
  deriving stock (Generic)

{- | Create a new 'Tail' handle for the given file path with specified buffer size.

The @tail@ process starts immediately and begins reading from the file.
New subscribers will receive a ring buffer containing the last @bufferSize@ lines.
-}

{- | Path to the @tail@ executable

This should be available in the PATH, thanks to Nix and 'System.Which.staticWhich'.
-}
tailBin :: FilePath
tailBin = $(staticWhich "tail")

tailFile :: (HasCallStack) => Int -> FilePath -> IO Tail
tailFile bufferSize filePath = do
  unlessM (doesFileExist filePath) $ error $ "File does not exist: " <> toText filePath
  queues <- newTVarIO mempty
  stop <- newEmptyTMVarIO
  tailProcess <- newTVarIO Nothing
  ringBuffer <- atomically $ CB.new bufferSize
  let t = Tail {..}
  -- Start the tail process immediately
  void $ async $ tailRun t
  pure t

{- | Signal the 'Tail' process to stop reading the file.

This will terminate the underlying @tail@ process and close all subscriber queues.
-}
tailStop :: Tail -> IO ()
tailStop t = do
  atomically $ putTMVar t.stop ()

tailRun :: Tail -> IO ()
tailRun t = do
  -- Start the tail -F process (show entire file from beginning)
  let createProc = (proc tailBin ["-F", "-n", "+1", t.filePath]) {std_out = CreatePipe}
  (_, Just hout, _, ph) <- createProcess createProc

  -- Start async reader that reads from tail process and distributes to queues
  readerAsync <- async $ readAndDistribute hout

  -- Store the process and reader
  atomically $ writeTVar t.tailProcess (Just (ph, readerAsync))

  -- Wait for stop signal
  atomically $ takeTMVar t.stop

  -- Clean up: terminate process and wait for reader to finish at EOF
  threadDelay 1_000_000 -- Give `tail -f` a second to flush any remaining lines
  terminateProcess ph
  void $ waitForProcess ph

  -- Reader will naturally stop when it reaches EOF

  -- Close all queues so readers can detect end of stream
  atomically $ do
    qs <- readTVar t.queues
    mapM_ CB.close qs

  atomically $ writeTVar t.tailProcess Nothing
  where
    readAndDistribute :: Handle -> IO ()
    readAndDistribute h = do
      let readLines = do
            hIsEOF h >>= \case
              True -> pass -- EOF reached, stop reading
              False -> do
                line <- toText <$> hGetLine h
                -- Add to ring buffer and distribute to all queues
                atomically $ do
                  -- Update ring buffer
                  CB.add line t.ringBuffer
                  -- Distribute to subscriber queues
                  qs <- readTVar t.queues
                  forM_ qs $ \q ->
                    CB.add line q
                readLines
      readLines

{- | Subscribe to 'Tail' output and receive a 'CircularBuffer' for reading lines.

The returned buffer will contain any previously read lines from the ring buffer,
plus all new lines as they are read from the file.

Use 'Control.Concurrent.STM.CircularBuffer.drain' to read from the buffer.
-}
tailSubscribe :: Tail -> IO (CircularBuffer Text)
tailSubscribe t = atomically $ do
  -- Clone ring buffer as CircularBuffer with buffered lines
  queue <- CB.clone t.ringBuffer
  -- Add to active subscribers
  modifyTVar' t.queues (queue :)
  pure queue