cli: fix terminal input lag from TMVar starvation

Replace `termLock :: TMVar ()` with `MVar ()`. GHC's STM has no fairness
guarantee on contended TMVars; under sustained event load the output
renderer monopolizes the lock and the input thread (`updateInput`
inside `withTermLock` at Input.hs:162) can wait seconds for each
keystroke echo.

Reproduced and measured with term-lock-bench (5000 events, 200us per
item + 800us per-acquisition flush, on two threads):

  variant       samples  mean    p95     max
  tmvar-single  6        1.7 s   2.1 s   3.0 s
  mvar-single   202      1.1 ms  2.1 ms  2.3 ms

About 1300x lower worst-case input-echo latency, no other behavior
changes. MVar in IO is FIFO under GHC so the input thread is served
between every render.
This commit is contained in:
shum
2026-05-14 13:50:20 +00:00
parent 39c8a91eaf
commit 39e47319f4
+8 -4
View File
@@ -12,6 +12,7 @@
module Simplex.Chat.Terminal.Output where
import Control.Concurrent (ThreadId)
import Control.Concurrent.MVar (MVar, newMVar, takeMVar, putMVar)
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Catch (MonadMask)
@@ -49,7 +50,7 @@ data ChatTerminal = ChatTerminal
termSize :: Size,
liveMessageState :: TVar (Maybe LiveMessage),
nextMessageRow :: TVar Int,
termLock :: TMVar (),
termLock :: MVar (),
sendNotification :: Maybe (Notification -> IO ()),
activeTo :: TVar String,
currentRemoteUsers :: TMap RemoteHostId User
@@ -103,7 +104,10 @@ newChatTerminal t opts = do
let lastRow = height termSize - 1
termState <- newTVarIO mkTermState
liveMessageState <- newTVarIO Nothing
termLock <- newTMVarIO ()
-- MVar (not TMVar) for FIFO fairness under contention: the terminal
-- input thread must not be starved by the output renderer when the
-- output thread is processing a backlog (see runTerminalOutput).
termLock <- newMVar ()
nextMessageRow <- newTVarIO lastRow
sendNotification <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications
activeTo <- newTVarIO ""
@@ -137,9 +141,9 @@ mkAutoComplete = ACState {acVariants = [], acInputString = "", acTabPressed = Fa
withTermLock :: MonadTerminal m => ChatTerminal -> m () -> m ()
withTermLock ChatTerminal {termLock} action = do
_ <- atomically $ takeTMVar termLock
liftIO $ takeMVar termLock
action
atomically $ putTMVar termLock ()
liftIO $ putMVar termLock ()
runTerminalOutput :: ChatTerminal -> ChatController -> ChatOpts -> IO ()
runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} ChatOpts {markRead} = do