Skip to content

Commit b7f37ad

Browse files
authored
Estimate file versions safely (#2753)
* applyWithoutDependency * delete dead code * estimateFileVersionUnsafely For a long time, defineEarlyCutoff has been accessing the Values store directly to compute GetModificationTime values instead of calling use, breaking the invariant. The values are used to associate the rule result to a file version, which gets recorded in the Value as well as used as the key in the Diagnostics store. The problem here is that the GetModificationTime rule computes a new version and mutates the Values store, so if defineEarlyCutoff peeks in the store before GetModificationTime has run, it will grab the old version. This leads to lost diagnostics and potentially to misversioned Values Fixing the problem is tricky, because we cannot simply use GetModificationTime inside defineEarlyCutoff for all rules. There are three issues: 1. Creating a dependency on GetModificationTime. If everything depends on it, then we lose the ability to do early cutoff 2. Creating cycles in the build graph, since GetModificationTime has dependencies itself. Because hls-graph doesn't implement cycle detection (Shake did), it is a nightmare to debug these cycles. 3. Creating overhead, since GetModification time calls the file system for non FOIs and in the past this was very expensive for projects with large cartesian product of module paths and source folders To work around these I had to introduce a new hls-graph primitive, applyWithoutDependency, as well as do a bunch of fragile type tests on the key type to decide on whether to use GetModificationTime or peek into the values store. The type casts could be cleaned up by introducing a type class, but I'm not sure the end result would be any better. To understand the issue and debug the implementation of the fix, I added a number of opentelemety traces which I'm leaving in place in case they could be useful in the future. * Traces for diagnostics * handle the empty file path * return Nothing instead of peeking the store
1 parent 5afb077 commit b7f37ad

File tree

3 files changed

+80
-109
lines changed

3 files changed

+80
-109
lines changed

ghcide/src/Development/IDE/Core/Shake.hs

Lines changed: 71 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ module Development.IDE.Core.Shake(
4242
RuleBody(..),
4343
define, defineNoDiagnostics,
4444
defineEarlyCutoff,
45-
defineOnDisk, needOnDisk, needOnDisks,
4645
defineNoFile, defineEarlyCutOffNoFile,
4746
getDiagnostics,
4847
mRunLspT, mRunLspTCallback,
@@ -63,7 +62,6 @@ module Development.IDE.Core.Shake(
6362
Priority(..),
6463
updatePositionMapping,
6564
deleteValue, recordDirtyKeys,
66-
OnDiskRule(..),
6765
WithProgressFunc, WithIndefiniteProgressFunc,
6866
ProgressEvent(..),
6967
DelayedAction, mkDelayedAction,
@@ -168,6 +166,7 @@ import qualified "list-t" ListT
168166
import OpenTelemetry.Eventlog
169167
import qualified StmContainers.Map as STM
170168
import System.FilePath hiding (makeRelative)
169+
import System.IO.Unsafe (unsafePerformIO)
171170
import System.Time.Extra
172171

173172
data Log
@@ -1026,6 +1025,10 @@ usesWithStale key files = do
10261025
-- whether the rule succeeded or not.
10271026
mapM (lastValue key) files
10281027

1028+
useWithoutDependency :: IdeRule k v
1029+
=> k -> NormalizedFilePath -> Action (Maybe v)
1030+
useWithoutDependency key file =
1031+
(\[A value] -> currentValue value) <$> applyWithoutDependency [Q (key, file)]
10291032

10301033
data RuleBody k v
10311034
= Rule (k -> NormalizedFilePath -> Action (Maybe BS.ByteString, IdeResult v))
@@ -1044,28 +1047,28 @@ defineEarlyCutoff
10441047
-> Rules ()
10451048
defineEarlyCutoff recorder (Rule op) = addRule $ \(Q (key, file)) (old :: Maybe BS.ByteString) mode -> otTracedAction key file mode traceA $ \traceDiagnostics -> do
10461049
extras <- getShakeExtras
1047-
let diagnostics diags = do
1050+
let diagnostics ver diags = do
10481051
traceDiagnostics diags
1049-
updateFileDiagnostics recorder file (Key key) extras . map (\(_,y,z) -> (y,z)) $ diags
1052+
updateFileDiagnostics recorder file ver (Key key) extras . map (\(_,y,z) -> (y,z)) $ diags
10501053
defineEarlyCutoff' diagnostics (==) key file old mode $ const $ op key file
10511054
defineEarlyCutoff recorder (RuleNoDiagnostics op) = addRule $ \(Q (key, file)) (old :: Maybe BS.ByteString) mode -> otTracedAction key file mode traceA $ \traceDiagnostics -> do
1052-
let diagnostics diags = do
1055+
let diagnostics _ver diags = do
10531056
traceDiagnostics diags
10541057
mapM_ (logWith recorder Warning . LogDefineEarlyCutoffRuleNoDiagHasDiag) diags
10551058
defineEarlyCutoff' diagnostics (==) key file old mode $ const $ second (mempty,) <$> op key file
10561059
defineEarlyCutoff recorder RuleWithCustomNewnessCheck{..} =
10571060
addRule $ \(Q (key, file)) (old :: Maybe BS.ByteString) mode ->
10581061
otTracedAction key file mode traceA $ \ traceDiagnostics -> do
1059-
let diagnostics diags = do
1062+
let diagnostics _ver diags = do
10601063
traceDiagnostics diags
10611064
mapM_ (logWith recorder Warning . LogDefineEarlyCutoffRuleCustomNewnessHasDiag) diags
10621065
defineEarlyCutoff' diagnostics newnessCheck key file old mode $
10631066
const $ second (mempty,) <$> build key file
10641067
defineEarlyCutoff recorder (RuleWithOldValue op) = addRule $ \(Q (key, file)) (old :: Maybe BS.ByteString) mode -> otTracedAction key file mode traceA $ \traceDiagnostics -> do
10651068
extras <- getShakeExtras
1066-
let diagnostics diags = do
1069+
let diagnostics ver diags = do
10671070
traceDiagnostics diags
1068-
updateFileDiagnostics recorder file (Key key) extras . map (\(_,y,z) -> (y,z)) $ diags
1071+
updateFileDiagnostics recorder file ver (Key key) extras . map (\(_,y,z) -> (y,z)) $ diags
10691072
defineEarlyCutoff' diagnostics (==) key file old mode $ op key file
10701073

10711074
defineNoFile :: IdeRule k v => Recorder (WithPriority Log) -> (k -> Action v) -> Rules ()
@@ -1080,7 +1083,7 @@ defineEarlyCutOffNoFile recorder f = defineEarlyCutoff recorder $ RuleNoDiagnost
10801083

10811084
defineEarlyCutoff'
10821085
:: forall k v. IdeRule k v
1083-
=> ([FileDiagnostic] -> Action ()) -- ^ update diagnostics
1086+
=> (TextDocumentVersion -> [FileDiagnostic] -> Action ()) -- ^ update diagnostics
10841087
-- | compare current and previous for freshness
10851088
-> (BS.ByteString -> BS.ByteString -> Bool)
10861089
-> k
@@ -1099,8 +1102,9 @@ defineEarlyCutoff' doDiagnostics cmp key file old mode action = do
10991102
case v of
11001103
-- No changes in the dependencies and we have
11011104
-- an existing successful result.
1102-
Just (v@Succeeded{}, diags) -> do
1103-
doDiagnostics $ Vector.toList diags
1105+
Just (v@(Succeeded _ x), diags) -> do
1106+
ver <- estimateFileVersionUnsafely state key (Just x) file
1107+
doDiagnostics (vfsVersion =<< ver) $ Vector.toList diags
11041108
return $ Just $ RunResult ChangedNothing old $ A v
11051109
_ -> return Nothing
11061110
_ ->
@@ -1120,18 +1124,13 @@ defineEarlyCutoff' doDiagnostics cmp key file old mode action = do
11201124
\(e :: SomeException) -> do
11211125
pure (Nothing, ([ideErrorText file $ T.pack $ show e | not $ isBadDependency e],Nothing))
11221126

1123-
modTime <- case eqT @k @GetModificationTime of
1124-
Just Refl -> pure res
1125-
Nothing
1126-
| file == emptyFilePath -> pure Nothing
1127-
| otherwise -> liftIO $ (currentValue . fst =<<) <$> atomicallyNamed "define - read 2" (getValues state GetModificationTime file)
1128-
1127+
ver <- estimateFileVersionUnsafely state key res file
11291128
(bs, res) <- case res of
11301129
Nothing -> do
11311130
pure (toShakeValue ShakeStale bs, staleV)
1132-
Just v -> pure (maybe ShakeNoCutoff ShakeResult bs, Succeeded modTime v)
1131+
Just v -> pure (maybe ShakeNoCutoff ShakeResult bs, Succeeded ver v)
11331132
liftIO $ atomicallyNamed "define - write" $ setValues state key file res (Vector.fromList diags)
1134-
doDiagnostics diags
1133+
doDiagnostics (vfsVersion =<< ver) diags
11351134
let eq = case (bs, fmap decodeShakeValue old) of
11361135
(ShakeResult a, Just (ShakeResult b)) -> cmp a b
11371136
(ShakeStale a, Just (ShakeStale b)) -> cmp a b
@@ -1144,117 +1143,74 @@ defineEarlyCutoff' doDiagnostics cmp key file old mode action = do
11441143
A res
11451144
liftIO $ atomicallyNamed "define - dirtyKeys" $ modifyTVar' dirtyKeys (HSet.delete $ toKey key file)
11461145
return res
1146+
where
1147+
-- Highly unsafe helper to compute the version of a file
1148+
-- without creating a dependency on the GetModificationTime rule
1149+
-- (and without creating cycles in the build graph).
1150+
estimateFileVersionUnsafely
1151+
:: forall k v
1152+
. IdeRule k v
1153+
=> Values
1154+
-> k
1155+
-> Maybe v
1156+
-> NormalizedFilePath
1157+
-> Action (Maybe FileVersion)
1158+
estimateFileVersionUnsafely state _k v fp
1159+
| fp == emptyFilePath = pure Nothing
1160+
| Just Refl <- eqT @k @GetModificationTime = pure v
1161+
-- GetModificationTime depends on these rules, so avoid creating a cycle
1162+
| Just Refl <- eqT @k @AddWatchedFile = pure Nothing
1163+
| Just Refl <- eqT @k @IsFileOfInterest = pure Nothing
1164+
-- GetFileExists gets called for missing files
1165+
| Just Refl <- eqT @k @GetFileExists = pure Nothing
1166+
-- For all other rules - compute the version properly without:
1167+
-- * creating a dependency: If everything depends on GetModificationTime, we lose early cutoff
1168+
-- * creating bogus "file does not exists" diagnostics
1169+
| otherwise = useWithoutDependency (GetModificationTime_ False) fp
11471170

11481171
traceA :: A v -> String
11491172
traceA (A Failed{}) = "Failed"
11501173
traceA (A Stale{}) = "Stale"
11511174
traceA (A Succeeded{}) = "Success"
11521175

1153-
-- | Rule type, input file
1154-
data QDisk k = QDisk k NormalizedFilePath
1155-
deriving (Eq, Generic)
1156-
1157-
instance Hashable k => Hashable (QDisk k)
1158-
1159-
instance NFData k => NFData (QDisk k)
1160-
1161-
instance Show k => Show (QDisk k) where
1162-
show (QDisk k file) =
1163-
show k ++ "; " ++ fromNormalizedFilePath file
1164-
1165-
type instance RuleResult (QDisk k) = Bool
1166-
1167-
data OnDiskRule = OnDiskRule
1168-
{ getHash :: Action BS.ByteString
1169-
-- This is used to figure out if the state on disk corresponds to the state in the Shake
1170-
-- database and we can therefore avoid rerunning. Often this can just be the file hash but
1171-
-- in some cases we can be more aggressive, e.g., for GHC interface files this can be the ABI hash which
1172-
-- is more stable than the hash of the interface file.
1173-
-- An empty bytestring indicates that the state on disk is invalid, e.g., files are missing.
1174-
-- We do not use a Maybe since we have to deal with encoding things into a ByteString anyway in the Shake DB.
1175-
, runRule :: Action (IdeResult BS.ByteString)
1176-
-- The actual rule code which produces the new hash (or Nothing if the rule failed) and the diagnostics.
1177-
}
1178-
1179-
-- This is used by the DAML compiler for incremental builds. Right now this is not used by
1180-
-- ghcide itself but that might change in the future.
1181-
-- The reason why this code lives in ghcide and in particular in this module is that it depends quite heavily on
1182-
-- the internals of this module that we do not want to expose.
1183-
defineOnDisk
1184-
:: (Shake.ShakeValue k, RuleResult k ~ ())
1185-
=> Recorder (WithPriority Log)
1186-
-> (k -> NormalizedFilePath -> OnDiskRule)
1187-
-> Rules ()
1188-
defineOnDisk recorder act = addRule $
1189-
\(QDisk key file) (mbOld :: Maybe BS.ByteString) mode -> do
1190-
extras <- getShakeExtras
1191-
let OnDiskRule{..} = act key file
1192-
let validateHash h
1193-
| BS.null h = Nothing
1194-
| otherwise = Just h
1195-
let runAct = actionCatch runRule $
1196-
\(e :: SomeException) -> pure ([ideErrorText file $ T.pack $ displayException e | not $ isBadDependency e], Nothing)
1197-
case mbOld of
1198-
Nothing -> do
1199-
(diags, mbHash) <- runAct
1200-
updateFileDiagnostics recorder file (Key key) extras $ map (\(_,y,z) -> (y,z)) diags
1201-
pure $ RunResult ChangedRecomputeDiff (fromMaybe "" mbHash) (isJust mbHash)
1202-
Just old -> do
1203-
current <- validateHash <$> (actionCatch getHash $ \(_ :: SomeException) -> pure "")
1204-
if mode == RunDependenciesSame && Just old == current && not (BS.null old)
1205-
then
1206-
-- None of our dependencies changed, we’ve had a successful run before and
1207-
-- the state on disk matches the state in the Shake database.
1208-
pure $ RunResult ChangedNothing (fromMaybe "" current) (isJust current)
1209-
else do
1210-
(diags, mbHash) <- runAct
1211-
updateFileDiagnostics recorder file (Key key) extras $ map (\(_,y,z) -> (y,z)) diags
1212-
let change
1213-
| mbHash == Just old = ChangedRecomputeSame
1214-
| otherwise = ChangedRecomputeDiff
1215-
pure $ RunResult change (fromMaybe "" mbHash) (isJust mbHash)
1216-
1217-
needOnDisk :: (Shake.ShakeValue k, RuleResult k ~ ()) => k -> NormalizedFilePath -> Action ()
1218-
needOnDisk k file = do
1219-
successfull <- apply1 (QDisk k file)
1220-
liftIO $ unless successfull $ throwIO $ BadDependency (show k)
1221-
1222-
needOnDisks :: (Shake.ShakeValue k, RuleResult k ~ ()) => k -> [NormalizedFilePath] -> Action ()
1223-
needOnDisks k files = do
1224-
successfulls <- apply $ map (QDisk k) files
1225-
liftIO $ unless (and successfulls) $ throwIO $ BadDependency (show k)
1226-
12271176
updateFileDiagnostics :: MonadIO m
12281177
=> Recorder (WithPriority Log)
12291178
-> NormalizedFilePath
1179+
-> TextDocumentVersion
12301180
-> Key
12311181
-> ShakeExtras
12321182
-> [(ShowDiagnostic,Diagnostic)] -- ^ current results
12331183
-> m ()
1234-
updateFileDiagnostics recorder fp k ShakeExtras{diagnostics, hiddenDiagnostics, publishedDiagnostics, state, debouncer, lspEnv} current = liftIO $ do
1235-
modTime <- (currentValue . fst =<<) <$> atomicallyNamed "diagnostics - read" (getValues state GetModificationTime fp)
1184+
updateFileDiagnostics recorder fp ver k ShakeExtras{diagnostics, hiddenDiagnostics, publishedDiagnostics, debouncer, lspEnv} current =
1185+
liftIO $ withTrace ("update diagnostics " <> fromString(fromNormalizedFilePath fp)) $ \ addTag -> do
1186+
addTag "key" (show k)
12361187
let (currentShown, currentHidden) = partition ((== ShowDiag) . fst) current
12371188
uri = filePathToUri' fp
1238-
ver = vfsVersion =<< modTime
1239-
update new store = setStageDiagnostics uri ver (T.pack $ show k) new store
1189+
addTagUnsafe :: String -> String -> String -> a -> a
1190+
addTagUnsafe msg t x v = unsafePerformIO(addTag (msg <> t) x) `seq` v
1191+
update :: (forall a. String -> String -> a -> a) -> [Diagnostic] -> STMDiagnosticStore -> STM [Diagnostic]
1192+
update addTagUnsafe new store = addTagUnsafe "count" (show $ Prelude.length new) $ setStageDiagnostics addTagUnsafe uri ver (T.pack $ show k) new store
1193+
addTag "version" (show ver)
12401194
mask_ $ do
12411195
-- Mask async exceptions to ensure that updated diagnostics are always
12421196
-- published. Otherwise, we might never publish certain diagnostics if
12431197
-- an exception strikes between modifyVar but before
12441198
-- publishDiagnosticsNotification.
1245-
newDiags <- liftIO $ atomicallyNamed "diagnostics - update" $ update (map snd currentShown) diagnostics
1246-
_ <- liftIO $ atomicallyNamed "diagnostics - hidden" $ update (map snd currentHidden) hiddenDiagnostics
1199+
newDiags <- liftIO $ atomicallyNamed "diagnostics - update" $ update (addTagUnsafe "shown ") (map snd currentShown) diagnostics
1200+
_ <- liftIO $ atomicallyNamed "diagnostics - hidden" $ update (addTagUnsafe "hidden ") (map snd currentHidden) hiddenDiagnostics
12471201
let uri = filePathToUri' fp
12481202
let delay = if null newDiags then 0.1 else 0
1249-
registerEvent debouncer delay uri $ do
1203+
registerEvent debouncer delay uri $ withTrace ("report diagnostics " <> fromString (fromNormalizedFilePath fp)) $ \tag -> do
12501204
join $ mask_ $ do
12511205
lastPublish <- atomicallyNamed "diagnostics - publish" $ STM.focus (Focus.lookupWithDefault [] <* Focus.insert newDiags) uri publishedDiagnostics
12521206
let action = when (lastPublish /= newDiags) $ case lspEnv of
12531207
Nothing -> -- Print an LSP event.
12541208
logWith recorder Info $ LogDiagsDiffButNoLspEnv (map (fp, ShowDiag,) newDiags)
1255-
Just env -> LSP.runLspT env $
1209+
Just env -> LSP.runLspT env $ do
1210+
liftIO $ tag "count" (show $ Prelude.length newDiags)
1211+
liftIO $ tag "key" (show k)
12561212
LSP.sendNotification LSP.STextDocumentPublishDiagnostics $
1257-
LSP.PublishDiagnosticsParams (fromNormalizedUri uri) (fmap fromIntegral ver) (List newDiags)
1213+
LSP.PublishDiagnosticsParams (fromNormalizedUri uri) (fmap fromIntegral ver) (List newDiags)
12581214
return action
12591215

12601216
newtype Priority = Priority Double
@@ -1276,26 +1232,33 @@ type STMDiagnosticStore = STM.Map NormalizedUri StoreItem
12761232
getDiagnosticsFromStore :: StoreItem -> [Diagnostic]
12771233
getDiagnosticsFromStore (StoreItem _ diags) = concatMap SL.fromSortedList $ Map.elems diags
12781234

1279-
updateSTMDiagnostics :: STMDiagnosticStore
1280-
-> NormalizedUri -> TextDocumentVersion -> DiagnosticsBySource
1281-
-> STM [LSP.Diagnostic]
1282-
updateSTMDiagnostics store uri mv newDiagsBySource =
1235+
updateSTMDiagnostics ::
1236+
(forall a. String -> String -> a -> a) ->
1237+
STMDiagnosticStore ->
1238+
NormalizedUri ->
1239+
TextDocumentVersion ->
1240+
DiagnosticsBySource ->
1241+
STM [LSP.Diagnostic]
1242+
updateSTMDiagnostics addTag store uri mv newDiagsBySource =
12831243
getDiagnosticsFromStore . fromJust <$> STM.focus (Focus.alter update *> Focus.lookup) uri store
12841244
where
12851245
update (Just(StoreItem mvs dbs))
1246+
| addTag "previous version" (show mvs) $
1247+
addTag "previous count" (show $ Prelude.length $ filter (not.null) $ Map.elems dbs) False = undefined
12861248
| mvs == mv = Just (StoreItem mv (newDiagsBySource <> dbs))
12871249
update _ = Just (StoreItem mv newDiagsBySource)
12881250

12891251
-- | Sets the diagnostics for a file and compilation step
12901252
-- if you want to clear the diagnostics call this with an empty list
12911253
setStageDiagnostics
1292-
:: NormalizedUri
1254+
:: (forall a. String -> String -> a -> a)
1255+
-> NormalizedUri
12931256
-> TextDocumentVersion -- ^ the time that the file these diagnostics originate from was last edited
12941257
-> T.Text
12951258
-> [LSP.Diagnostic]
12961259
-> STMDiagnosticStore
12971260
-> STM [LSP.Diagnostic]
1298-
setStageDiagnostics uri ver stage diags ds = updateSTMDiagnostics ds uri ver updatedDiags
1261+
setStageDiagnostics addTag uri ver stage diags ds = updateSTMDiagnostics addTag ds uri ver updatedDiags
12991262
where
13001263
!updatedDiags = Map.singleton (Just stage) $! SL.toSortedList diags
13011264

hls-graph/src/Development/IDE/Graph/Internal/Action.hs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module Development.IDE.Graph.Internal.Action
1111
, alwaysRerun
1212
, apply1
1313
, apply
14+
, applyWithoutDependency
1415
, parallel
1516
, reschedule
1617
, runActions
@@ -120,6 +121,13 @@ apply ks = do
120121
liftIO $ modifyIORef ref (ResultDeps is <>)
121122
pure vs
122123

124+
-- | Evaluate a list of keys without recording any dependencies.
125+
applyWithoutDependency :: (RuleResult key ~ value, ShakeValue key, Typeable value) => [key] -> Action [value]
126+
applyWithoutDependency ks = do
127+
db <- Action $ asks actionDatabase
128+
(_, vs) <- liftIO $ build db ks
129+
pure vs
130+
123131
runActions :: Database -> [Action a] -> IO [a]
124132
runActions db xs = do
125133
deps <- newIORef mempty

hls-graph/src/Development/IDE/Graph/Rule.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module Development.IDE.Graph.Rule(
77
RunMode(..), RunChanged(..), RunResult(..),
88
-- * Calling builtin rules
99
-- | Wrappers around calling Shake rules. In general these should be specialised to a builtin rule.
10-
apply, apply1,
10+
apply, apply1, applyWithoutDependency
1111
) where
1212

1313
import Development.IDE.Graph.Internal.Action

0 commit comments

Comments
 (0)