Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion unison-cli/src/Unison/Cli/Monad.hs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import Unison.Codebase.Editor.Output (NumberedArgs, NumberedOutput, Output)
import Unison.Codebase.Editor.UCMVersion (UCMVersion)
import Unison.Codebase.Path qualified as Path
import Unison.Codebase.ProjectPath qualified as PP
import Unison.Codebase.Watch qualified as Watch
import Unison.CommandLine.OutputMessages qualified as OutputMessages
import Unison.Core.Project (ProjectAndBranch (..))
import Unison.Debug qualified as Debug
Expand Down Expand Up @@ -178,7 +179,10 @@ data Env = Env
ucmVersion :: UCMVersion,
-- | Whether we're running in a transcript test or not.
-- Avoid using this except when absolutely necessary.
isTranscriptTest :: Bool
isTranscriptTest :: Bool,
-- | The file watch state, if file watching is enabled.
-- `Nothing` in contexts like transcripts or MCP where file watching is not supported.
watchState :: Maybe Watch.WatchState
}
deriving stock (Generic)

Expand Down
28 changes: 28 additions & 0 deletions unison-cli/src/Unison/Codebase/Editor/HandleInput.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time (UTCTime)
import Data.Tuple.Extra (uncurry3)
import System.Directory (makeAbsolute)
import Text.Megaparsec qualified as Megaparsec
import U.Codebase.Branch.Diff qualified as V2Branch.Diff
import U.Codebase.Causal qualified as V2Causal
Expand Down Expand Up @@ -115,6 +116,7 @@ import Unison.Codebase.Path qualified as Path
import Unison.Codebase.ProjectPath qualified as PP
import Unison.Codebase.Runtime qualified as Runtime
import Unison.Codebase.ShortCausalHash qualified as SCH
import Unison.Codebase.Watch qualified as Watch
import Unison.CommandLine.BranchRelativePath (BranchRelativePath (..))
import Unison.CommandLine.Completion qualified as Completion
import Unison.CommandLine.DisplayValues qualified as DisplayValues
Expand Down Expand Up @@ -721,6 +723,29 @@ loop e = do
UpgradeCommitI -> Cli.returnEarly (Output.Literal "The `upgrade.commit` command has been removed in favor of `update`.")
UpgradeI libs -> handleUpgrade libs
VersionI -> Cli.respond $ PrintVersion env.ucmVersion
WatchI path -> case env.watchState of
Nothing -> Cli.respond Output.WatchDisabled
Just ws -> do
result <- liftIO $ Watch.watchPath ws path
Cli.respond $ Output.WatchAddResult result path
UnwatchI paths -> case env.watchState of
Nothing -> Cli.respond Output.WatchDisabled
Just ws -> do
-- Process each path and collect results
results <- for paths \path -> do
canonPath <- liftIO $ makeAbsolute path
success <- liftIO $ Watch.unwatchPath ws canonPath
pure (path, canonPath, success)
let (removed, failed) = List.partition (\(_, _, success) -> success) results
let removedPaths = [canonPath | (_, canonPath, _) <- removed]
let failedPaths = [path | (path, _, _) <- failed]
remainingPaths <- liftIO $ Set.toList <$> Watch.getWatchedPaths ws
Cli.respondNumbered $ Output.WatchRemoved removedPaths failedPaths remainingPaths
WatchListI -> case env.watchState of
Nothing -> Cli.respond Output.WatchDisabled
Just ws -> do
watchedPaths <- liftIO $ Set.toList <$> Watch.getWatchedPaths ws
Cli.respondNumbered $ Output.WatchList watchedPaths

inputDescription :: Input -> Cli Text
inputDescription input =
Expand Down Expand Up @@ -868,6 +893,9 @@ inputDescription input =
UpgradeCommitI {} -> wat
UpgradeI {} -> wat
VersionI -> wat
WatchI {} -> wat
UnwatchI {} -> wat
WatchListI -> wat
CancelI -> wat
where
p' :: Path' -> Cli Text
Expand Down
6 changes: 6 additions & 0 deletions unison-cli/src/Unison/Codebase/Editor/Input.hs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ data Input
| UpgradeCommitI
| UpgradeI ![NameSegment]
| VersionI
| -- | Watch an external file or directory for changes
WatchI !FilePath
| -- | Stop watching one or more external files or directories
UnwatchI ![FilePath]
| -- | List currently watched external paths
WatchListI
deriving (Eq, Show)

-- | The source of a `branch` command: what to make the new branch from.
Expand Down
15 changes: 15 additions & 0 deletions unison-cli/src/Unison/Codebase/Editor/Output.hs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ data NumberedOutput
MoreEntriesThanShown
[ProjectReflog.Entry Project ProjectBranch (CausalHash, SCH.ShortCausalHash)]
| DeletedDefinitions (DefnsF Set Name Name)
| -- | List of currently watched paths (working dir is included)
WatchList ![FilePath]
| -- | Successfully removed paths from the watch list
-- (removed paths, failed paths, remaining paths)
WatchRemoved ![FilePath] ![FilePath] ![FilePath]

data TodoOutput = TodoOutput
{ defnsInLib :: !Bool,
Expand Down Expand Up @@ -491,6 +496,11 @@ data Output
| CommentAborted
| AuthorNameRequired
| ConfigValueGet ConfigKey (Maybe Text)
| WatchDisabled
| -- | Result of attempting to add a path to the watch list.
-- First FilePath is the canonical path on success (Nothing on failure),
-- second is the original path requested.
WatchAddResult !(Maybe FilePath) !FilePath

data MoreEntriesThanShown = MoreEntriesThanShown | AllEntriesShown
deriving (Eq, Show)
Expand Down Expand Up @@ -753,6 +763,9 @@ isFailure o = case o of
CommentAborted {} -> True
AuthorNameRequired {} -> True
ConfigValueGet {} -> False
WatchDisabled -> True
WatchAddResult Nothing _ -> True
WatchAddResult (Just _) _ -> False

isNumberedFailure :: NumberedOutput -> Bool
isNumberedFailure = \case
Expand All @@ -774,3 +787,5 @@ isNumberedFailure = \case
Output'Todo {} -> False
ShowProjectBranchReflog {} -> False
DeletedDefinitions {} -> False
WatchList {} -> False
WatchRemoved _ failedPaths _ -> not (null failedPaths)
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ data StructuredArgument
| HashQualifiedWithBranchPrefix AbsBranchId (HQ'.HashQualified Name)
| ShallowListEntry Path' (ShallowListEntry Symbol Ann)
| SearchResult (Maybe Path') SearchResult
| FilePath FilePath
deriving (Eq, Generic, Show)
4 changes: 3 additions & 1 deletion unison-cli/src/Unison/Codebase/Transcript/Runner.hs
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,9 @@ run isTest verbosity codebase runtime sbRuntime ucmVersion baseURL authenticated
sandboxedRuntime = sbRuntime,
serverBaseUrl = Nothing,
ucmVersion,
isTranscriptTest = isTest
isTranscriptTest = isTest,
-- Transcripts don't support file watching
watchState = Nothing
}

let loop :: Cli.LoopState -> IO (Seq Stanza)
Expand Down
202 changes: 136 additions & 66 deletions unison-cli/src/Unison/Codebase/Watch.hs
Original file line number Diff line number Diff line change
@@ -1,38 +1,122 @@
module Unison.Codebase.Watch
( watchDirectory,
( watchPath,
WatchState (..),
newWatchState,
awaitEvent,
unwatchPath,
getWatchedPaths,
)
where

import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (STM)
import Control.Concurrent.STM (STM, TVar)
import Control.Concurrent.STM qualified as STM
import Control.Exception (MaskingState (..))
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Map qualified as Map
import Data.Time.Clock (UTCTime, diffUTCTime)
import GHC.Conc (registerDelay)
import GHC.IO (unsafeUnmask)
import Ki qualified
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist)
import System.FSNotify (Event (Added, Modified))
import System.FSNotify qualified as FSNotify
import System.FilePath (splitFileName)
import Unison.Prelude
import UnliftIO.Exception (finally, tryAny)
import UnliftIO.Exception (tryAny)
import UnliftIO.STM (atomically)

watchDirectory :: Ki.Scope -> FSNotify.WatchManager -> FilePath -> (FilePath -> Bool) -> IO (IO (FilePath, Text))
watchDirectory scope mgr dir allow = do
readLatestEvent <- forkDirWatcherThread scope mgr dir allow
-- | State for managing multiple watched paths.
data WatchState = WatchState
{ -- | The FSNotify watch manager
watchManager :: FSNotify.WatchManager,
-- | TVar containing the latest event from any watcher
latestEventVar :: TVar (Maybe (FilePath, UTCTime)),
-- | Map from watched paths to their stop-watching actions
watchedPathsVar :: TVar (Map FilePath (IO ())),
-- | Predicate for filtering files (e.g., .u files only)
allowPredicate :: FilePath -> Bool,
-- | Cache for debouncing file contents
previousFilesRef :: IORef (Map FilePath (Text, UTCTime))
}

-- Await an event from the event queue with the following simple debounce logic, which is intended to work around the
-- tendency for modern editors to create a flurry of rapid filesystem events when a file is saved:
--
-- 1. Block until an event arrives that occurred within the last second (which allows us to ignore old filesystem
-- events that may have buffered during a long-running IO action).
-- 2. Keep consuming events until 50ms elapse without an event.
-- 3. Return only the last event.
--
-- Note we don't have any smarts here for a flurry of events that are related to more than one file; we just throw
-- everything away except the last event. In practice, this has seemed to work fine.
-- | Create a new watch state. The Ki scope is used for structured concurrency -
-- when the scope exits, all watcher threads are automatically cleaned up.
newWatchState :: FSNotify.WatchManager -> (FilePath -> Bool) -> IO WatchState
newWatchState mgr allow = do
latestEventVar <- STM.newTVarIO Nothing
watchedPathsVar <- STM.newTVarIO Map.empty
previousFilesRef <- newIORef Map.empty
pure
WatchState
{ watchManager = mgr,
latestEventVar = latestEventVar,
watchedPathsVar = watchedPathsVar,
allowPredicate = allow,
previousFilesRef = previousFilesRef
}

-- | Add a file or directory to be watched. Returns the canonical path if successful, Nothing otherwise.
--
-- Each watched path spawns a background thread via Ki that manages the FSNotify watcher.
-- When the Ki scope exits, all watcher threads are automatically cleaned up.
watchPath :: WatchState -> FilePath -> IO (Maybe FilePath)
watchPath ws path = do
canonPath <- canonicalizePath path
isDir <- doesDirectoryExist canonPath
isFile <- doesFileExist canonPath
if not (isDir || isFile)
then pure Nothing
else do
alreadyWatched <- atomically $ Map.member canonPath <$> STM.readTVar ws.watchedPathsVar
if alreadyWatched
then pure (Just canonPath) -- Already watching, consider it a success
else do
-- Create the handler that writes to our shared TVar
let handler :: Event -> IO ()
handler = \case
Added fp t FSNotify.IsFile | ws.allowPredicate fp -> atomically (STM.writeTVar ws.latestEventVar (Just (fp, t)))
Modified fp t FSNotify.IsFile | ws.allowPredicate fp -> atomically (STM.writeTVar ws.latestEventVar (Just (fp, t)))
_ -> pure ()

-- Determine what to watch
let watchAction =
if isDir
then FSNotify.watchDir ws.watchManager canonPath (const True) handler
else do
-- For a single file, we watch the parent directory and filter for our file
let (parentDir, _) = splitFileName canonPath
FSNotify.watchDir ws.watchManager parentDir (\e -> eventPath e == canonPath) handler

-- Start watching with FSNotify
stopListening <- watchAction

-- Record that we're watching this path, with the actual stop action
atomically $ STM.modifyTVar ws.watchedPathsVar (Map.insert canonPath stopListening)
pure (Just canonPath)
where
eventPath :: Event -> FilePath
eventPath = \case
Added p _time _isDir -> p
Modified p _time _isDir -> p
FSNotify.Removed p _time _isDir -> p
FSNotify.ModifiedAttributes p _time _isDir -> p
FSNotify.WatchedDirectoryRemoved p _time _isDir -> p
FSNotify.CloseWrite p _time _isDir -> p
FSNotify.Unknown p _time _isDir _eventString -> p

-- | Await an event from any watched source.
--
-- This function implements debouncing with the following logic, intended to work around the tendency
-- for modern editors to create a flurry of rapid filesystem events when a file is saved:
--
-- 1. Block until an event arrives.
-- 2. Keep consuming events until 50ms elapse without an event.
-- 3. Return only the last event.
--
-- Note we don't have any smarts here for a flurry of events that are related to more than one file;
-- we just throw everything away except the last event. In practice, this has seemed to work fine.
--
-- Additionally, we keep in memory the file contents of previously-saved files, so that we can avoid
-- emitting events for files that last changed less than 500ms ago, and whose contents haven't changed.
awaitEvent :: WatchState -> IO (FilePath, Text)
awaitEvent ws = do
let awaitEvent0 :: IO (FilePath, UTCTime)
awaitEvent0 = do
let go :: (FilePath, UTCTime) -> IO (FilePath, UTCTime)
Expand All @@ -49,62 +133,48 @@ watchDirectory scope mgr dir allow = do
event <- atomically readLatestEvent
go event

-- Enhance the previous "await event" action with a small file cache that serves as a second debounce implementation.
-- We keep in memory the file contents of previously-saved files, so that we can avoid emitting events for files that
-- last changed less than 500ms ago, and whose contents haven't changed.
previousFilesRef <- newIORef Map.empty
readLatestEvent :: STM (FilePath, UTCTime)
readLatestEvent =
STM.readTVar ws.latestEventVar >>= \case
Nothing -> STM.retry
Just event -> do
STM.writeTVar ws.latestEventVar Nothing
pure event

-- Apply debouncing based on file contents cache
let awaitEvent1 :: IO (FilePath, Text)
awaitEvent1 = do
(file, t) <- awaitEvent0
tryAny (readUtf8 file) >>= \case
-- Somewhat-expected read error from a file that was just written. Just ignore the event and try again.
Left _ -> awaitEvent1
Right contents -> do
previousFiles <- readIORef previousFilesRef
previousFiles <- readIORef ws.previousFilesRef
case Map.lookup file previousFiles of
Just (contents0, t0) | contents == contents0 && (t `diffUTCTime` t0) < 0.5 -> awaitEvent1
_ -> do
writeIORef previousFilesRef $! Map.insert file (contents, t) previousFiles
writeIORef ws.previousFilesRef $! Map.insert file (contents, t) previousFiles
pure (file, contents)

pure awaitEvent1

-- | `forkDirWatcherThread scope mgr dir allow` forks a background thread into `scope` that, using "file watcher
-- manager" `mgr` (just a boilerplate argument the caller is responsible for creating), watches directory `dir` for
-- all "added" and "modified" filesystem events that occur on files that pass the `allow` predicate. It returns an STM
-- action that reads (and clears) the latest event, blocking if one isn't available.
forkDirWatcherThread ::
Ki.Scope ->
FSNotify.WatchManager ->
FilePath ->
(FilePath -> Bool) ->
IO (STM (FilePath, UTCTime))
forkDirWatcherThread scope mgr dir allow = do
latestEventVar <- STM.newTVarIO Nothing

let handler :: Event -> IO ()
handler = \case
Added fp t FSNotify.IsFile | allow fp -> atomically (STM.writeTVar latestEventVar (Just (fp, t)))
Modified fp t FSNotify.IsFile | allow fp -> atomically (STM.writeTVar latestEventVar (Just (fp, t)))
_ -> pure ()
awaitEvent1

-- A bit of a "one too many threads" situation but there's not much we can easily do about it. The `fsnotify` API
-- doesn't expose any synchronous API; the only option is to fork a background thread with a callback. So, we spawn
-- a thread that spawns *that* thread, then waits forever. The purpose here is to simply leverage `ki` exception
-- propagation machinery to ensure that the `fsnotify` thread is properly cleaned up.
Ki.forkWith_ scope Ki.defaultThreadOptions {Ki.maskingState = MaskedUninterruptible} do
-- The goal here is to prevent spawning this background watching thread before installing an exception handler that
-- guarantees it's killed. Unfortunately the fsnotify API doesn't seem to make that possible (hence the first
-- `unsafeUnmask` here), since we do need the thread *it* spawns to be killable, and (at least as of version
-- 0.4.2.0) they don't take care to guarantee that; it just inherits the masking state.
stopListening <- unsafeUnmask (FSNotify.watchDir mgr dir (const True) handler) <|> pure (pure ())
unsafeUnmask (forever (threadDelay maxBound)) `finally` stopListening

let readLatestEvent =
STM.readTVar latestEventVar >>= \case
Nothing -> STM.retry
Just event -> do
STM.writeTVar latestEventVar Nothing
pure event
-- | Stop watching a path. Returns True if the path was being watched.
unwatchPath :: WatchState -> FilePath -> IO Bool
unwatchPath ws path = do
canonPath <- canonicalizePath path
maybeStop <- atomically $ do
paths <- STM.readTVar ws.watchedPathsVar
case Map.lookup canonPath paths of
Nothing -> pure Nothing
Just stopAction -> do
STM.writeTVar ws.watchedPathsVar (Map.delete canonPath paths)
pure (Just stopAction)
case maybeStop of
Nothing -> pure False
Just stopAction -> do
stopAction
pure True

pure readLatestEvent
-- | Get the list of currently watched paths.
getWatchedPaths :: WatchState -> IO (Set FilePath)
getWatchedPaths ws = atomically $ Map.keysSet <$> STM.readTVar ws.watchedPathsVar
Loading