diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-25 13:46:54 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-25 13:46:54 -0500 |
| commit | 66d2298f29f8e054687acc9e9615ddfa3cdb604a (patch) | |
| tree | 1029eb0e777b8df26f77e9cd1bec17cb5b635ec1 /Omni/Log | |
| parent | 9374b1955b32b49f77be3b5c84598922296e5b5c (diff) | |
Omni/Bild: improve concurrent build logging
- Add per-namespace log files at _/var/bild-logs/<namespace>.log
- Show log path on failure instead of dumping stderr
- Use efficient single-line updates instead of full redraws
- Use cursor save/restore for reliable positioning
- Simplify status symbols (no brackets): * + ~ . x _
- Remove OutputMode distinction (no longer needed)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'Omni/Log')
| -rw-r--r-- | Omni/Log/Concurrent.hs | 314 | ||||
| -rw-r--r-- | Omni/Log/Terminal.hs | 16 |
2 files changed, 140 insertions, 190 deletions
diff --git a/Omni/Log/Concurrent.hs b/Omni/Log/Concurrent.hs index 77131ef..e502e32 100644 --- a/Omni/Log/Concurrent.hs +++ b/Omni/Log/Concurrent.hs @@ -1,8 +1,9 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE NoImplicitPrelude #-} --- | Concurrent logging with multi-line output support +-- | Concurrent logging with efficient single-line updates module Omni.Log.Concurrent ( LineManager, BuildState (..), @@ -14,13 +15,13 @@ module Omni.Log.Concurrent where import Alpha -import Data.IORef (IORef, newIORef, readIORef, writeIORef) -import qualified Data.Map as Map +import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) +import qualified Data.Map.Strict as Map import qualified Data.Text as Text -import Omni.Log.Terminal (OutputMode (..), TerminalInfo (..), detectTerminal, truncateToWidth) +import Omni.Log.Terminal (TerminalInfo (..), detectTerminal, truncateToWidth) import Omni.Namespace (Namespace) import qualified Omni.Namespace as Namespace -import Rainbow (chunk, fore, green, red, white, yellow) +import Rainbow (chunk, fore, green, red, yellow) import qualified Rainbow import qualified System.Console.ANSI as ANSI import qualified System.IO as IO @@ -38,206 +39,169 @@ data LineManager = LineManager currentLineManager :: IORef (Maybe LineManager) currentLineManager = unsafePerformIO (newIORef Nothing) +-- | Current state of each namespace +{-# NOINLINE namespaceStates #-} +namespaceStates :: IORef (Map Namespace (BuildState, Text)) +namespaceStates = unsafePerformIO (newIORef Map.empty) + +-- | Namespace to line number mapping (0-indexed from top of our area) {-# NOINLINE namespaceLines #-} namespaceLines :: IORef (Map Namespace Int) namespaceLines = unsafePerformIO (newIORef Map.empty) --- | Tracks if the last output was transient (no newline printed) --- When True, cleanup should not add a newline since next manager will overwrite -{-# NOINLINE lastOutputTransient #-} -lastOutputTransient :: IORef Bool -lastOutputTransient = unsafePerformIO (newIORef False) - --- | Tracks if lines have been initialized (prevents duplicate initialization) +-- | Tracks if lines have been initialized {-# NOINLINE linesInitialized #-} linesInitialized :: IORef Bool linesInitialized = unsafePerformIO (newIORef False) -- | Global lock for all terminal operations --- ANSI terminal library (ncurses) is not thread-safe, so we must serialize all calls --- to prevent segfaults during concurrent builds {-# NOINLINE terminalLock #-} terminalLock :: MVar () terminalLock = unsafePerformIO (newMVar ()) withLineManager :: [Namespace] -> (LineManager -> IO a) -> IO a withLineManager nss action = do - -- Check if a manager is already active (reentrant call) existingMgr <- readIORef currentLineManager maybe createNewManager action existingMgr where createNewManager = do termInfo <- detectTerminal - case tiMode termInfo of - SingleLine -> do - -- Single-line mode: no reservations, updates in place - let mgr = LineManager {lmNamespaces = nss, lmTermInfo = termInfo} - writeIORef currentLineManager (Just mgr) - writeIORef lastOutputTransient False - writeIORef linesInitialized False - result <- action mgr - -- Only print final newline if last output wasn't transient - -- (transient outputs expect to be overwritten by next manager) - wasTransient <- readIORef lastOutputTransient - unless wasTransient (IO.hPutStrLn IO.stderr "") - writeIORef currentLineManager Nothing - writeIORef namespaceLines Map.empty - writeIORef linesInitialized False - pure result - MultiLine -> do - -- Multi-line mode: reserve lines for each namespace - let numLines = min (length nss) (tiHeight termInfo - 2) - replicateM_ numLines (IO.hPutStrLn IO.stderr "") - withMVar terminalLock <| \_ -> ANSI.hCursorUp IO.stderr numLines - - let mgr = LineManager {lmNamespaces = nss, lmTermInfo = termInfo} - writeIORef currentLineManager (Just mgr) - writeIORef linesInitialized False - - -- Initialize the namespace -> line mapping - writeIORef namespaceLines (Map.fromList <| zip nss [0 ..]) - - result <- action mgr - - IO.hPutStrLn IO.stderr "" - writeIORef currentLineManager Nothing - writeIORef namespaceLines Map.empty - writeIORef linesInitialized False - pure result - --- | Initialize all lines with pending status --- Only initializes once per manager session (prevents duplicate output on reentrant calls) + let mgr = LineManager {lmNamespaces = nss, lmTermInfo = termInfo} + writeIORef currentLineManager (Just mgr) + writeIORef linesInitialized False + -- Initialize all namespaces to Pending state + writeIORef namespaceStates (Map.fromList [(ns, (Pending, "")) | ns <- nss]) + -- Create line number mapping + writeIORef namespaceLines (Map.fromList (zip nss [0 ..])) + + -- Reserve lines for the display area + let numLines = length nss + when (numLines > 0 && tiSupportsANSI termInfo) <| do + -- Start fresh: clear from cursor to end of screen + IO.hPutStr IO.stderr "\r" + ANSI.hClearFromCursorToScreenEnd IO.stderr + -- Print N blank lines to reserve space + replicateM_ numLines (IO.hPutStrLn IO.stderr "") + -- Move back to the first line and save this position + ANSI.hCursorUp IO.stderr numLines + ANSI.hSaveCursor IO.stderr + IO.hFlush IO.stderr + + result <- action mgr + + -- Move to end and print final newline + when (numLines > 0 && tiSupportsANSI termInfo) <| do + -- Restore to top, then move down past our area + ANSI.hRestoreCursor IO.stderr + ANSI.hCursorDown IO.stderr numLines + IO.hPutStrLn IO.stderr "" + IO.hFlush IO.stderr + + writeIORef currentLineManager Nothing + writeIORef namespaceStates Map.empty + writeIORef namespaceLines Map.empty + writeIORef linesInitialized False + pure result + +-- | Initialize display with all namespaces initializeLines :: LineManager -> IO () -initializeLines LineManager {..} = do +initializeLines mgr = do alreadyInit <- readIORef linesInitialized - unless alreadyInit - <| case (tiMode lmTermInfo, tiSupportsANSI lmTermInfo) of - (_, False) -> pure () -- No ANSI support, skip initialization - (SingleLine, _) -> writeIORef linesInitialized True -- Mark as done even if no-op - (MultiLine, _) -> do - writeIORef linesInitialized True - nsMap <- readIORef namespaceLines - forM_ (Map.toList nsMap) <| \(ns, _) -> - withMVar terminalLock <| \_ -> do - ANSI.hSetCursorColumn IO.stderr 0 - ANSI.hClearLine IO.stderr - let nsText = Text.pack (Namespace.toPath ns) - let msg = "[.] " <> nsText -- Pending state before analysis starts - let truncated = truncateToWidth (tiWidth lmTermInfo - 1) msg - IO.hPutStrLn IO.stderr (Text.unpack truncated) - IO.hFlush IO.stderr + unless alreadyInit <| do + writeIORef linesInitialized True + -- Draw all lines once at initialization + redrawAll mgr +-- | Update progress text for a namespace updateLine :: Namespace -> Text -> IO () updateLine ns output = do mMgr <- readIORef currentLineManager case mMgr of - Nothing -> do - IO.hPutStr IO.stderr (Text.unpack <| output <> "\r") - IO.hFlush IO.stderr - Just LineManager {..} -> - case tiMode lmTermInfo of - SingleLine -> - -- Single line: update in place - -- Lock all terminal output to prevent interleaved writes - withMVar terminalLock <| \_ -> do - let nsText = Text.pack (Namespace.toPath ns) - let msg = - if Text.null output - then "[~] " <> nsText - else "[~] " <> nsText <> ": " <> output - let truncated = truncateToWidth (tiWidth lmTermInfo - 1) msg - -- Clear line and write - IO.hPutStr IO.stderr "\r" - IO.hPutStr IO.stderr (Text.unpack truncated) - -- Pad to clear previous longer text - let padding = replicate (tiWidth lmTermInfo - Text.length truncated - 1) ' ' - IO.hPutStr IO.stderr padding - IO.hPutStr IO.stderr "\r" - IO.hPutStr IO.stderr (Text.unpack truncated) - IO.hFlush IO.stderr - MultiLine -> - -- Multi-line: use reserved lines with truncation - -- Lock covers IORef read + all terminal operations to prevent races - withMVar terminalLock <| \_ -> do - nsMap <- readIORef namespaceLines - case Map.lookup ns nsMap of - Nothing -> pure () - Just lineNum -> do - let numLines = length lmNamespaces - -- Move to the target line from bottom - ANSI.hCursorUp IO.stderr (numLines - lineNum) - ANSI.hSetCursorColumn IO.stderr 0 - ANSI.hClearLine IO.stderr - let nsText = Text.pack (Namespace.toPath ns) - let msg = - if Text.null output - then "[~] " <> nsText - else "[~] " <> nsText <> ": " <> output - let truncated = truncateToWidth (tiWidth lmTermInfo - 1) msg - IO.hPutStr IO.stderr (Text.unpack truncated) - IO.hFlush IO.stderr - -- Move back to bottom - ANSI.hCursorDown IO.stderr (numLines - lineNum) - + Nothing -> pure () + Just mgr -> do + -- Update state and redraw just this line + atomicModifyIORef' namespaceStates <| \m -> + case Map.lookup ns m of + Just (bState, _) -> (Map.insert ns (bState, output) m, ()) + Nothing -> (m, ()) + redrawLine mgr ns + +-- | Update build state for a namespace updateLineState :: Namespace -> BuildState -> IO () updateLineState ns buildState = do mMgr <- readIORef currentLineManager case mMgr of Nothing -> pure () - Just LineManager {..} -> - case tiMode lmTermInfo of - SingleLine -> - -- Single line: show completion, keep visible for success/failure - -- Lock all terminal output to prevent interleaved writes - withMVar terminalLock <| \_ -> do - let nsText = Text.pack (Namespace.toPath ns) - let (symbol, color) = case buildState of - Success -> ("✓", green) - Failed -> ("x", red) - Skipped -> ("_", yellow) - Analyzing -> ("+", white) - Pending -> (".", white) - Building -> ("~", white) - let msg = "[" <> symbol <> "] " <> nsText - let truncated = truncateToWidth (tiWidth lmTermInfo - 1) msg - - IO.hPutStr IO.stderr "\r" - Rainbow.hPutChunks IO.stderr [fore color <| chunk truncated] - case buildState of - Success -> do - IO.hPutStrLn IO.stderr "" -- Keep successes visible - writeIORef lastOutputTransient False - Failed -> do - IO.hPutStrLn IO.stderr "" -- Keep failures visible - writeIORef lastOutputTransient False - Skipped -> do - IO.hPutStrLn IO.stderr "" -- Keep skipped visible - writeIORef lastOutputTransient False - _ -> writeIORef lastOutputTransient True -- Transient states overwrite - IO.hFlush IO.stderr - MultiLine -> - -- Multi-line: use reserved lines with truncation - -- Lock covers IORef read + all terminal operations to prevent races - withMVar terminalLock <| \_ -> do - nsMap <- readIORef namespaceLines - case Map.lookup ns nsMap of - Nothing -> pure () - Just lineNum -> do - let numLines = length lmNamespaces - ANSI.hCursorUp IO.stderr (numLines - lineNum) - ANSI.hSetCursorColumn IO.stderr 0 - ANSI.hClearLine IO.stderr - let nsText = Text.pack (Namespace.toPath ns) - let (symbol, colorFn) = case buildState of - Success -> ("✓", fore green) - Failed -> ("x", fore red) - Skipped -> ("_", fore yellow) - Analyzing -> ("+", identity) - Pending -> (".", identity) - Building -> ("~", identity) - let msg = "[" <> symbol <> "] " <> nsText - let truncated = truncateToWidth (tiWidth lmTermInfo - 1) msg - Rainbow.hPutChunks IO.stderr [colorFn <| chunk truncated] - IO.hFlush IO.stderr - ANSI.hCursorDown IO.stderr (numLines - lineNum) + Just mgr -> do + -- Update state and redraw just this line + atomicModifyIORef' namespaceStates <| \m -> + (Map.insert ns (buildState, "") m, ()) + redrawLine mgr ns + +-- | Redraw a single line efficiently +redrawLine :: LineManager -> Namespace -> IO () +redrawLine LineManager {..} ns = do + states <- readIORef namespaceStates + lineMap <- readIORef namespaceLines + + case Map.lookup ns lineMap of + Nothing -> pure () + Just lineNum -> do + let (bState, progress) = Map.findWithDefault (Pending, "") ns states + withMVar terminalLock <| \_ -> do + -- Restore to top of our area, then move down to the right line + ANSI.hRestoreCursor IO.stderr + when (lineNum > 0) <| ANSI.hCursorDown IO.stderr lineNum + -- Clear and redraw this line + ANSI.hClearLine IO.stderr + drawNamespaceLine lmTermInfo ns bState progress + IO.hFlush IO.stderr + +-- | Redraw all lines (used only at initialization) +redrawAll :: LineManager -> IO () +redrawAll LineManager {..} = do + states <- readIORef namespaceStates + + withMVar terminalLock <| \_ -> do + let numLines = length lmNamespaces + when (numLines > 0) <| do + -- Restore to saved position (top of our area) + ANSI.hRestoreCursor IO.stderr + -- Clear from here to end of screen + ANSI.hClearFromCursorToScreenEnd IO.stderr + + -- Redraw each line + forM_ lmNamespaces <| \ns -> do + let (bState, progress) = Map.findWithDefault (Pending, "") ns states + drawNamespaceLine lmTermInfo ns bState progress + IO.hPutStrLn IO.stderr "" + + IO.hFlush IO.stderr + +-- | Draw a single namespace line (without newline) +drawNamespaceLine :: TerminalInfo -> Namespace -> BuildState -> Text -> IO () +drawNamespaceLine termInfo ns bState progress = do + let nsText = Text.pack (Namespace.toPath ns) + let (symbol, mColor) = stateSymbol bState + let msg = case bState of + Success -> symbol <> " " <> nsText + Failed -> symbol <> " " <> nsText + Skipped -> symbol <> " " <> nsText + _ + | Text.null progress -> symbol <> " " <> nsText + | otherwise -> symbol <> " " <> nsText <> ": " <> progress + let truncated = truncateToWidth (tiWidth termInfo - 1) msg + + case mColor of + Just color -> Rainbow.hPutChunks IO.stderr [fore color <| chunk truncated] + Nothing -> IO.hPutStr IO.stderr (Text.unpack truncated) + +stateSymbol :: BuildState -> (Text, Maybe Rainbow.Radiant) +stateSymbol = \case + Success -> ("*", Just green) + Failed -> ("x", Just red) + Skipped -> ("_", Just yellow) + Analyzing -> ("+", Nothing) + Pending -> (".", Nothing) + Building -> ("~", Nothing) diff --git a/Omni/Log/Terminal.hs b/Omni/Log/Terminal.hs index 1a4c717..fd0a617 100644 --- a/Omni/Log/Terminal.hs +++ b/Omni/Log/Terminal.hs @@ -1,10 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE NoImplicitPrelude #-} --- | Terminal detection and output mode selection +-- | Terminal detection and utilities module Omni.Log.Terminal ( TerminalInfo (..), - OutputMode (..), detectTerminal, truncateToWidth, ) @@ -16,15 +15,9 @@ import qualified Data.Text as Text import qualified System.Console.ANSI as ANSI import qualified System.Environment as Env -data OutputMode - = MultiLine -- Wide terminals (≥80 cols) - reserved lines per namespace - | SingleLine -- Narrow terminals (<80 cols) - rotating single line - deriving (Eq, Show) - data TerminalInfo = TerminalInfo { tiWidth :: Int, tiHeight :: Int, - tiMode :: OutputMode, tiSupportsANSI :: Bool } deriving (Eq, Show) @@ -53,17 +46,10 @@ detectTerminal = do let (width, height) = case mSize of Just (h, w) -> (w, h) Nothing -> (80, 24) -- sensible default - - -- Determine mode based on ANSI support - let mode - | not supportsANSI = SingleLine -- Fallback to single line for dumb terminals - | otherwise = MultiLine - pure TerminalInfo { tiWidth = width, tiHeight = height, - tiMode = mode, tiSupportsANSI = supportsANSI } |
