From d337de1a071ff643bb7b7acab61e895e97e5b6ee Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Mon, 15 Jun 2026 01:50:02 +0200 Subject: [PATCH 01/16] Isolate per-test caches via explicit args instead of XDG_CACHE_HOME --- .../Development/IDE/Session/Ghc.hs | 15 ++- hls-test-utils/src/Test/Hls.hs | 95 +++++++------------ hls-test-utils/src/Test/Hls/TestEnv.hs | 36 ++++++- 3 files changed, 84 insertions(+), 62 deletions(-) diff --git a/ghcide/session-loader/Development/IDE/Session/Ghc.hs b/ghcide/session-loader/Development/IDE/Session/Ghc.hs index 3b659a6bee..dbbb474f33 100644 --- a/ghcide/session-loader/Development/IDE/Session/Ghc.hs +++ b/ghcide/session-loader/Development/IDE/Session/Ghc.hs @@ -450,9 +450,20 @@ setCacheDirs recorder CacheDirs{..} dflags = do -- keeping the path short and clean. getCacheDirsDefault :: String -> Maybe B.ByteString -> [String] -> IO CacheDirs getCacheDirsDefault prefix mFirstHash opts = do - dir <- Just <$> getXdgDirectory XdgCache (cacheDir prefix' ++ "-" ++ opts_hash) - return $ CacheDirs dir dir dir + base <- getXdgDirectory XdgCache cacheDir + pure $ cacheDirsUnder base prefix mFirstHash opts + +-- | Like 'getCacheDirsDefault', but roots the cache under @base@ instead of +-- 'XdgCache', so callers can isolate a cache without touching @XDG_CACHE_HOME@. +getCacheDirsIn :: FilePath -> String -> Maybe B.ByteString -> [String] -> IO CacheDirs +getCacheDirsIn base prefix mFirstHash opts = + pure $ cacheDirsUnder (base cacheDir) prefix mFirstHash opts + +-- | The per-component cache folder under @base@, see Note [Avoiding bad interface files]. +cacheDirsUnder :: FilePath -> String -> Maybe B.ByteString -> [String] -> CacheDirs +cacheDirsUnder base prefix mFirstHash opts = CacheDirs dir dir dir where + dir = Just (base prefix' ++ "-" ++ opts_hash) -- Create a unique folder per set of different GHC options. prefix' = if isJust mFirstHash then "main" else prefix basectx = case mFirstHash of diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index e1155f1205..c06b00a906 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -106,6 +106,8 @@ import Development.IDE.Plugin.Completions.Types (PosPrefixInfo) import Development.IDE.Plugin.Test (TestRequest (GetBuildKeysBuilt, WaitForIdeRule, WaitForShakeQueue), WaitForIdeRuleResult (ideResultSuccess)) import qualified Development.IDE.Plugin.Test as Test +import Development.IDE.Session (SessionLoadingOptions (..)) +import Development.IDE.Session.Ghc (getCacheDirsIn) import Development.IDE.Types.Options import GHC.IO.Handle import GHC.TypeLits @@ -134,10 +136,9 @@ import Prelude hiding (log) import System.Directory (canonicalizePath, createDirectoryIfMissing, getCurrentDirectory, - getTemporaryDirectory, makeAbsolute, setCurrentDirectory) -import System.Environment (lookupEnv, setEnv) +import System.Environment (lookupEnv) import System.FilePath import System.IO.Extra (newTempDirWithin) import System.IO.Unsafe (unsafePerformIO) @@ -145,7 +146,8 @@ import System.Process.Extra (createPipe) import System.Time.Extra import qualified Test.Hls.FileSystem as FS import Test.Hls.FileSystem -import Test.Hls.TestEnv (hlsTestOptions, +import Test.Hls.TestEnv (getTestRootDir, + hlsTestOptions, wrapCliTestOptions) import Test.Hls.Util import Test.Tasty hiding (Timeout) @@ -533,16 +535,16 @@ runSessionWithServerInTmpDir config plugin tree act = {testLspConfig=config, testPluginDescriptor = plugin, testDirLocation=Right tree} (const act) --- | Same as 'withTemporaryDataAndCacheDirectory', but materialises the given --- 'VirtualFileTree' in the temporary directory. -withVfsTestDataDirectory :: VirtualFileTree -> (FileSystem -> IO a) -> IO a +-- | Like 'withTemporaryDataAndCacheDirectory', but first materialises the given +-- 'VirtualFileTree'. The continuation also receives the per-test cache directory. +withVfsTestDataDirectory :: VirtualFileTree -> (FileSystem -> FilePath -> IO a) -> IO a withVfsTestDataDirectory tree act = do - withTemporaryDataAndCacheDirectory $ \tmpRoot -> do + withTemporaryDataAndCacheDirectory $ \tmpRoot cacheDir -> do fs <- FS.materialiseVFT tmpRoot tree - act fs + act fs cacheDir --- | Run an action in a temporary directory. --- Sets the 'XDG_CACHE_HOME' environment variable to a temporary directory as well. +-- | Run an action in a temporary directory, passing it both the test root and a +-- fresh cache directory. -- -- This sets up a temporary directory for HLS tests to run. -- Typically, HLS tests copy their test data into the directory and then launch @@ -550,11 +552,11 @@ withVfsTestDataDirectory tree act = do -- This makes sure that the tests are run in isolation, which is good for correctness -- but also important to have fast tests. -- --- For improved isolation, we also make sure the 'XDG_CACHE_HOME' environment --- variable points to a temporary directory. So, we never share interface files --- or the 'hiedb' across tests. -withTemporaryDataAndCacheDirectory :: (FilePath -> IO a) -> IO a -withTemporaryDataAndCacheDirectory act = withLock lockForTempDirs $ do +-- For isolation, each test gets its own cache directory wired into the server +-- via 'argsGetHieDbLoc' and 'getCacheDirs', so we never share interface files or +-- the 'hiedb' and never mutate the process-global 'XDG_CACHE_HOME'. +withTemporaryDataAndCacheDirectory :: (FilePath -> FilePath -> IO a) -> IO a +withTemporaryDataAndCacheDirectory act = do testRoot <- setupTestEnvironment helperRecorder <- hlsHelperTestRecorder -- Do not clean up the temporary directory if this variable is set to anything but '0'. @@ -563,39 +565,23 @@ withTemporaryDataAndCacheDirectory act = withLock lockForTempDirs $ do let runTestInDir action = case cleanupTempDir of Just val | val /= "0" -> do (tempDir, cacheHome, _) <- setupTemporaryTestDirectories testRoot - a <- withTempCacheHome cacheHome (action tempDir) + a <- action tempDir cacheHome logWith helperRecorder Debug LogNoCleanup pure a _ -> do (tempDir, cacheHome, cleanup) <- setupTemporaryTestDirectories testRoot - a <- withTempCacheHome cacheHome (action tempDir) `finally` cleanup + a <- action tempDir cacheHome `finally` cleanup logWith helperRecorder Debug LogCleanup pure a - runTestInDir $ \tmpDir' -> do + runTestInDir $ \tmpDir' cacheHome -> do -- we canonicalize the path, so that we do not need to do -- canonicalization during the test when we compare two paths tmpDir <- canonicalizePath tmpDir' logWith helperRecorder Info $ LogTestDir tmpDir - act tmpDir + act tmpDir cacheHome where - cache_home_var = "XDG_CACHE_HOME" - -- Set the dir for "XDG_CACHE_HOME". - -- When the operation finished, make sure the old value is restored. - withTempCacheHome tempCacheHomeDir act = - bracket - (do - old_cache_home <- lookupEnv cache_home_var - setEnv cache_home_var tempCacheHomeDir - pure old_cache_home) - (\old_cache_home -> - maybe (pure ()) (setEnv cache_home_var) old_cache_home - ) - (\_ -> act) - - -- Set up a temporary directory for the test files and one for the 'XDG_CACHE_HOME'. - -- The 'XDG_CACHE_HOME' is important for independent test runs, i.e. completely empty - -- caches. + -- A fresh cache directory per test gives empty, independent caches. setupTemporaryTestDirectories testRoot = do (tempTestCaseDir, cleanup1) <- newTempDirWithin testRoot (tempCacheHomeDir, cleanup2) <- newTempDirWithin testRoot @@ -634,16 +620,9 @@ instance Default (TestConfig b) where -- However, it is totally safe to delete the directory between runs. setupTestEnvironment :: IO FilePath setupTestEnvironment = do - mRootDir <- lookupEnv "HLS_TEST_ROOTDIR" - case mRootDir of - Nothing -> do - tmpDirRoot <- getTemporaryDirectory - let testRoot = tmpDirRoot "hls-test-root" - createDirectoryIfMissing True testRoot - pure testRoot - Just rootDir -> do - createDirectoryIfMissing True rootDir - pure rootDir + testRoot <- getTestRootDir + createDirectoryIfMissing True testRoot + pure testRoot goldenWithHaskellDocFormatter :: Pretty b @@ -750,12 +729,6 @@ keepCurrentDirectory = bracket getCurrentDirectory setCurrentDirectory . const lock :: Lock lock = unsafePerformIO newLock - -{-# NOINLINE lockForTempDirs #-} --- | Never run in parallel -lockForTempDirs :: Lock -lockForTempDirs = unsafePerformIO newLock - data TestConfig b = TestConfig { testDirLocation :: Either FilePath VirtualFileTree @@ -830,7 +803,7 @@ wrapClientLogger logger = do -- For more detail of the test configuration, see 'TestConfig' runSessionWithTestConfig :: Pretty b => TestConfig b -> (FilePath -> Session a) -> IO a runSessionWithTestConfig TestConfig{..} session = - runSessionInVFS testDirLocation $ \root -> shiftRoot root $ do + runSessionInVFS testDirLocation $ \root cacheDir -> shiftRoot root $ do pipeIn@(inR, inW) <- createPipe pipeOut@(outR, outW) <- createPipe let serverRoot = fromMaybe root testServerRoot @@ -850,7 +823,7 @@ runSessionWithTestConfig TestConfig{..} session = let plugins = testPluginDescriptor recorder <> lspRecorderPlugin timeoutOverride <- fmap read <$> lookupEnv "LSP_TIMEOUT" let sconf' = testConfigSession { lspConfig = hlsConfigToClientConfig testLspConfig, messageTimeout = fromMaybe (messageTimeout defaultConfig) timeoutOverride} - arguments = testingArgs serverRoot recorderIde plugins + arguments = testingArgs serverRoot cacheDir recorderIde plugins -- Make an explicit call to keepAlive to protect both pipes from being GC'd. -- @@ -882,13 +855,13 @@ runSessionWithTestConfig TestConfig{..} session = else f runSessionInVFS (Left testConfigRoot) act = do root <- makeAbsolute testConfigRoot - withTemporaryDataAndCacheDirectory (const $ act root) + withTemporaryDataAndCacheDirectory (\_ cacheDir -> act root cacheDir) runSessionInVFS (Right vfs) act = - withVfsTestDataDirectory vfs $ \fs -> do - act (fsRoot fs) - testingArgs prjRoot recorderIde plugins = + withVfsTestDataDirectory vfs $ \fs cacheDir -> do + act (fsRoot fs) cacheDir + testingArgs prjRoot cacheDir recorderIde plugins = let - arguments@Arguments{ argsHlsPlugins, argsIdeOptions, argsLspOptions } = defaultArguments (cmapWithPrio LogIDEMain recorderIde) prjRoot plugins + arguments@Arguments{ argsHlsPlugins, argsIdeOptions, argsLspOptions, argsSessionLoadingOptions } = defaultArguments (cmapWithPrio LogIDEMain recorderIde) prjRoot plugins argsHlsPlugins' = if testDisableDefaultPlugin then plugins else argsHlsPlugins @@ -906,6 +879,10 @@ runSessionWithTestConfig TestConfig{..} session = , argsDefaultHlsConfig = testLspConfig , argsProjectRoot = prjRoot , argsDisableKick = testDisableKick + -- Keep interface files and the hiedb under this test's cache + -- directory instead of the shared 'XDG_CACHE_HOME'. + , argsGetHieDbLoc = const (pure (cacheDir "test.hiedb")) + , argsSessionLoadingOptions = argsSessionLoadingOptions { getCacheDirs = getCacheDirsIn cacheDir } } -- | Wait for the next progress begin step diff --git a/hls-test-utils/src/Test/Hls/TestEnv.hs b/hls-test-utils/src/Test/Hls/TestEnv.hs index f513d5886b..b7fb024dc4 100644 --- a/hls-test-utils/src/Test/Hls/TestEnv.hs +++ b/hls-test-utils/src/Test/Hls/TestEnv.hs @@ -7,13 +7,17 @@ module Test.Hls.TestEnv , LspTimeout(..) , hlsTestOptions , wrapCliTestOptions + , getTestRootDir ) where import Control.Monad (guard) import Data.Data (Proxy (..)) import Data.Foldable (traverse_) import Data.Maybe (catMaybes) +import System.Directory (createDirectoryIfMissing, + getTemporaryDirectory) import System.Environment (lookupEnv, setEnv, unsetEnv) +import System.FilePath (()) import Test.Tasty (TestTree, askOption, withResource) import Test.Tasty.Options (IsOption (defaultValue, optionCLParser, optionHelp, optionName, parseValue), OptionDescription (..), flagCLParser, @@ -92,7 +96,28 @@ wrapCliTestOptions tree = , ("HLS_TEST_HARNESS_NO_TESTDIR_CLEANUP", "1") <$ guard harnessNoTestdirCleanup , ("LSP_TIMEOUT",) . show <$> timeout ] - in withResource (setOverrides overrides) restoreEnvs (const tree) + in withResource (setupRunEnv overrides) restoreEnvs (const tree) + +-- | Root directory for all test files. Honours 'HLS_TEST_ROOTDIR', else the +-- system temp directory. +getTestRootDir :: IO FilePath +getTestRootDir = do + mRootDir <- lookupEnv "HLS_TEST_ROOTDIR" + case mRootDir of + Just rootDir -> pure rootDir + Nothing -> ( "hls-test-root") <$> getTemporaryDirectory + +-- | Apply the per-run environment once, before any test thread starts: the CLI +-- overrides plus 'HIE_BIOS_CACHE_DIR' under the test root to isolate the cradle +-- cache. Done once because mutating the process environment concurrently would +-- force a global lock. +setupRunEnv :: [(String, String)] -> IO [(String, Maybe String)] +setupRunEnv overrides = do + saved <- setOverrides overrides + hieBiosCacheDir <- ( "hie-bios") <$> getTestRootDir + createDirectoryIfMissing True hieBiosCacheDir + savedHieBios <- setEnvIfUnset "HIE_BIOS_CACHE_DIR" hieBiosCacheDir + pure (saved ++ savedHieBios) setOverrides :: [(String, String)] -> IO [(String, Maybe String)] setOverrides = traverse $ \(k, v) -> do @@ -100,5 +125,14 @@ setOverrides = traverse $ \(k, v) -> do setEnv k v pure (k, old) +-- | Set @k@ only if unset, so a value the developer exported wins. Returns the +-- restore entry, empty when we left it untouched. +setEnvIfUnset :: String -> String -> IO [(String, Maybe String)] +setEnvIfUnset k v = do + old <- lookupEnv k + case old of + Just _ -> pure [] + Nothing -> setEnv k v >> pure [(k, Nothing)] + restoreEnvs :: [(String, Maybe String)] -> IO () restoreEnvs = traverse_ $ \(k, mv) -> maybe (unsetEnv k) (setEnv k) mv From 20bbdc202e5b7ba7f3d2ae22d8d5428695f0ce56 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Mon, 15 Jun 2026 01:51:10 +0200 Subject: [PATCH 02/16] Gate the server CWD shift behind an opt-in --- ghcide-test/exe/Config.hs | 4 +- ghcide-test/exe/CradleTests.hs | 5 ++- ghcide-test/exe/DependentFileTest.hs | 16 +++++--- .../exe/FindDefinitionAndHoverTests.hs | 2 +- ghcide-test/exe/RootUriTests.hs | 4 +- ghcide-test/exe/WatchedFileTests.hs | 4 +- .../Development/IDE/Session/Ghc.hs | 9 +++- .../src/Development/IDE/LSP/LanguageServer.hs | 12 +++++- ghcide/src/Development/IDE/Main.hs | 36 ++++++++-------- hls-test-utils/src/Test/Hls.hs | 41 ++++++++++++++----- hls-test-utils/src/Test/Hls/FileSystem.hs | 4 +- plugins/hls-hlint-plugin/test/Main.hs | 6 +-- plugins/hls-stan-plugin/test/Main.hs | 2 +- test/functional/Config.hs | 2 +- 14 files changed, 96 insertions(+), 51 deletions(-) diff --git a/ghcide-test/exe/Config.hs b/ghcide-test/exe/Config.hs index c98023e90e..058bd63591 100644 --- a/ghcide-test/exe/Config.hs +++ b/ghcide-test/exe/Config.hs @@ -60,7 +60,7 @@ testSessionWithPlugin fs plugin = runSessionWithTestConfig def { testPluginDescriptor = plugin , testDirLocation = Right fs , testConfigCaps = lspTestCaps - , testShiftRoot = True + , testCwdHandling = NoCwdShift } -- * A dummy plugin for testing ghcIde @@ -78,7 +78,7 @@ runWithDummyPlugin' fs = runSessionWithTestConfig def { testPluginDescriptor = dummyPlugin , testDirLocation = Right fs , testConfigCaps = lspTestCaps - , testShiftRoot = True + , testCwdHandling = NoCwdShift } testWithDummyPlugin :: String -> FS.VirtualFileTree -> Session () -> TestTree diff --git a/ghcide-test/exe/CradleTests.hs b/ghcide-test/exe/CradleTests.hs index ac9d42c483..304519a328 100644 --- a/ghcide-test/exe/CradleTests.hs +++ b/ghcide-test/exe/CradleTests.hs @@ -35,7 +35,8 @@ import Language.LSP.Protocol.Types hiding mkRange) import Language.LSP.Test import System.FilePath -import Test.Hls (TestConfig (..), def, +import Test.Hls (CwdHandling (..), + TestConfig (..), def, runSessionWithTestConfig, waitForBuildQueue) import Test.Hls.FileSystem @@ -284,7 +285,7 @@ runWithExtraFilesMultiComponent dirName action = do { testPluginDescriptor = dummyPlugin , testDirLocation = Right vfs , testConfigCaps = lspTestCaps - , testShiftRoot = True + , testCwdHandling = NoCwdShift , testDisableKick = True , testLspConfig = lspConfig } diff --git a/ghcide-test/exe/DependentFileTest.hs b/ghcide-test/exe/DependentFileTest.hs index dd2cb2a046..e0c410905f 100644 --- a/ghcide-test/exe/DependentFileTest.hs +++ b/ghcide-test/exe/DependentFileTest.hs @@ -14,6 +14,7 @@ import Language.LSP.Protocol.Types hiding SemanticTokensEdit (..), mkRange) import Language.LSP.Test +import System.FilePath (()) import Test.Hls import Test.Hls.FileSystem @@ -21,17 +22,22 @@ import Test.Hls.FileSystem tests :: TestTree tests = testGroup "addDependentFile" [testGroup "file-changed" [testCase "test" $ runSessionWithTestConfig def - { testShiftRoot = True + { testCwdHandling = NoCwdShift , testDirLocation = Right (mkIdeTestFs []) , testPluginDescriptor = dummyPlugin } test] ] where test :: FilePath -> Session () - test _ = do + test sessionDir = do -- If the file contains B then no type error -- otherwise type error - let depFilePath = "dep-file.txt" + -- Absolute path so the splice's qRunIO/readFile and the watched-file + -- notification resolve identically regardless of the process CWD. + let depFilePath = sessionDir "dep-file.txt" + -- show gives a properly escaped Haskell string literal, so a Windows + -- path's backslashes survive the splice into Foo's source. + let depFileLit = T.pack (show depFilePath) liftIO $ atomicFileWriteString depFilePath "A" let fooContent = T.unlines [ "{-# LANGUAGE TemplateHaskell #-}" @@ -39,8 +45,8 @@ tests = testGroup "addDependentFile" , "import Language.Haskell.TH.Syntax" , "foo :: Int" , "foo = 1 + $(do" - , " qAddDependentFile \"" <> T.pack depFilePath <> "\"" - , " f <- qRunIO (readFile \"" <> T.pack depFilePath <> "\")" + , " qAddDependentFile " <> depFileLit + , " f <- qRunIO (readFile " <> depFileLit <> ")" , " if f == \"B\" then [| 1 |] else lift f)" ] let bazContent = T.unlines ["module Baz where", "import Foo ()"] diff --git a/ghcide-test/exe/FindDefinitionAndHoverTests.hs b/ghcide-test/exe/FindDefinitionAndHoverTests.hs index d97d340029..cf3f5eb46f 100644 --- a/ghcide-test/exe/FindDefinitionAndHoverTests.hs +++ b/ghcide-test/exe/FindDefinitionAndHoverTests.hs @@ -307,7 +307,7 @@ linkToTests = { testPluginDescriptor = dummyPlugin , testDirLocation = Right (mkIdeTestFs [copyDir "hover"]) , testConfigCaps = lspTestCaps - , testShiftRoot = True + , testCwdHandling = NoCwdShift , testLspConfig = lspConf } hoverCheck pos fp expects = do diff --git a/ghcide-test/exe/RootUriTests.hs b/ghcide-test/exe/RootUriTests.hs index 2a9cb19ab1..866cf16370 100644 --- a/ghcide-test/exe/RootUriTests.hs +++ b/ghcide-test/exe/RootUriTests.hs @@ -9,7 +9,7 @@ import System.FilePath -- import Test.QuickCheck.Instances () import Config import Data.Default (def) -import Test.Hls (TestConfig (..), +import Test.Hls (CwdHandling (..), TestConfig (..), runSessionWithTestConfig) import Test.Hls.FileSystem (copyDir) import Test.Tasty @@ -33,7 +33,7 @@ tests = testCase "use rootUri" . runTest "dirA" "dirB" $ \dir -> do , testDirLocation = Right $ mkIdeTestFs [copyDir "rootUri"] , testServerRoot = Just dir1 , testClientRoot = Just dir2 - , testShiftRoot = True + , testCwdHandling = NoCwdShift } diff --git a/ghcide-test/exe/WatchedFileTests.hs b/ghcide-test/exe/WatchedFileTests.hs index f00e4bfffe..53170c8676 100644 --- a/ghcide-test/exe/WatchedFileTests.hs +++ b/ghcide-test/exe/WatchedFileTests.hs @@ -78,9 +78,9 @@ tests = testGroup "watched files" _ <- openDoc hsFile "haskell" expectDiagnostics [(hsFile, [(DiagnosticSeverity_Error, (2, 7), "Could not load module \8216Data.List.Split\8217", Nothing)])] let cabalFile = "reload.cabal" - cabalContent <- liftIO $ T.readFile cabalFile + cabalContent <- liftIO $ T.readFile (sessionDir cabalFile) let fix = T.replace "build-depends: base" "build-depends: base, split" - liftIO $ atomicFileWriteText cabalFile (fix cabalContent) + liftIO $ atomicFileWriteText (sessionDir cabalFile) (fix cabalContent) sendNotification SMethod_WorkspaceDidChangeWatchedFiles $ DidChangeWatchedFilesParams [ FileEvent (filePathToUri $ sessionDir cabalFile) FileChangeType_Changed ] expectDiagnostics [(hsFile, [])] diff --git a/ghcide/session-loader/Development/IDE/Session/Ghc.hs b/ghcide/session-loader/Development/IDE/Session/Ghc.hs index dbbb474f33..790943adc2 100644 --- a/ghcide/session-loader/Development/IDE/Session/Ghc.hs +++ b/ghcide/session-loader/Development/IDE/Session/Ghc.hs @@ -253,7 +253,7 @@ setOptions haddockOpt cfp (ComponentOptions theOpts compRoot _) dflags rootDir = where initMulti unitArgFiles = forM unitArgFiles $ \f -> do - args <- liftIO $ expandResponse [f] + args <- liftIO $ expandResponse [rebaseResponseFile compRoot f] -- The reponse files may contain arguments like "+RTS", -- and hie-bios doesn't expand the response files of @-unit@ arguments. -- Thus, we need to do the stripping here. @@ -296,6 +296,13 @@ setOptions haddockOpt cfp (ComponentOptions theOpts compRoot _) dflags rootDir = dflags'' return (HomeUnitConfig dflags''' targets mHash) +-- | Rebase a relative @file response-file arg onto the component root, since +-- 'expandResponse' would otherwise resolve it against the process CWD. +rebaseResponseFile :: FilePath -> String -> String +rebaseResponseFile root arg = case arg of + '@' : path -> '@' : toAbsolute root path + _ -> arg + addComponentInfo :: MonadUnliftIO m => Recorder (WithPriority Log) -> diff --git a/ghcide/src/Development/IDE/LSP/LanguageServer.hs b/ghcide/src/Development/IDE/LSP/LanguageServer.hs index 7ccc4ac369..9869b73908 100644 --- a/ghcide/src/Development/IDE/LSP/LanguageServer.hs +++ b/ghcide/src/Development/IDE/LSP/LanguageServer.hs @@ -112,6 +112,8 @@ data ServerLifecycleContext config = ServerLifecycleContext -- ^ Logger for recording server events and diagnostics , ctxDefaultRoot :: FilePath -- ^ Default root directory for the workspace, see Note [Root Directory] + , ctxDisableInitialCwdShift :: Bool + -- ^ Skip the init-time setCurrentDirectory so in-process test servers can run in parallel, see Note [Root Directory] , ctxGetHieDbLoc :: FilePath -> IO FilePath -- ^ Function to determine the HIE database location for a given root path , ctxGetIdeState :: LSP.LanguageContextEnv config -> FilePath -> WithHieDb -> ThreadQueue -> IO IdeState @@ -191,12 +193,13 @@ setupLSP :: forall config. Recorder (WithPriority Log) -> FilePath -- ^ root directory, see Note [Root Directory] + -> Bool -- ^ disable the initial setCurrentDirectory to the rootUri (for parallel in-process tests) -> (FilePath -> IO FilePath) -- ^ Map root paths to the location of the hiedb for the project -> LSP.Handlers (ServerM config) -> (LSP.LanguageContextEnv config -> FilePath -> WithHieDb -> ThreadQueue -> IO IdeState) -> MVar () -> IO (Setup config (ServerM config) IdeState) -setupLSP recorder defaultRoot getHieDbLoc userHandlers getIdeState clientMsgVar = do +setupLSP recorder defaultRoot disableInitialCwdShift getHieDbLoc userHandlers getIdeState clientMsgVar = do -- Send everything over a channel, since you need to wait until after initialise before -- LspFuncs is available clientMsgChan :: Chan ReactorMessage <- newChan @@ -254,6 +257,7 @@ setupLSP recorder defaultRoot getHieDbLoc userHandlers getIdeState clientMsgVar let lifecycleCtx = ServerLifecycleContext { ctxRecorder = recorder , ctxDefaultRoot = defaultRoot + , ctxDisableInitialCwdShift = disableInitialCwdShift , ctxGetHieDbLoc = getHieDbLoc , ctxGetIdeState = getIdeState , ctxUntilReactorStopSignal = untilReactorStopSignal @@ -285,7 +289,11 @@ handleInit lifecycleCtx env (TRequestMessage _ _ m params) = otTracedHandler "In untilReactorStopSignal = ctxUntilReactorStopSignal lifecycleCtx lifetimeConfirm = ctxConfirmReactorShutdown lifecycleCtx root <- case LSP.resRootPath env of - Just lspRoot | lspRoot /= defaultRoot -> setCurrentDirectory lspRoot >> return lspRoot + -- Skip the CWD shift under the test harness so in-process servers can run + -- in parallel. See Note [Root Directory]. + Just lspRoot | lspRoot /= defaultRoot -> do + unless (ctxDisableInitialCwdShift lifecycleCtx) $ setCurrentDirectory lspRoot + return lspRoot _ -> pure defaultRoot dbLoc <- ctxGetHieDbLoc lifecycleCtx root let initConfig = parseConfiguration params diff --git a/ghcide/src/Development/IDE/Main.hs b/ghcide/src/Development/IDE/Main.hs index aad5fba3c2..cc70fbae56 100644 --- a/ghcide/src/Development/IDE/Main.hs +++ b/ghcide/src/Development/IDE/Main.hs @@ -206,22 +206,23 @@ commandP plugins = data Arguments = Arguments - { argsProjectRoot :: FilePath - , argCommand :: Command - , argsRules :: Rules () - , argsHlsPlugins :: IdePlugins IdeState - , argsGhcidePlugin :: Plugin Config -- ^ Deprecated - , argsSessionLoadingOptions :: SessionLoadingOptions - , argsIdeOptions :: Config -> Action IdeGhcSession -> IdeOptions - , argsLspOptions :: LSP.Options - , argsDefaultHlsConfig :: Config - , argsGetHieDbLoc :: FilePath -> IO FilePath -- ^ Map project roots to the location of the hiedb for the project - , argsDebouncer :: IO (Debouncer NormalizedUri) -- ^ Debouncer used for diagnostics - , argsHandleIn :: IO Handle - , argsHandleOut :: IO Handle - , argsThreads :: Maybe Natural - , argsMonitoring :: IO Monitoring - , argsDisableKick :: Bool -- ^ flag to disable kick used for testing + { argsProjectRoot :: FilePath + , argCommand :: Command + , argsRules :: Rules () + , argsHlsPlugins :: IdePlugins IdeState + , argsGhcidePlugin :: Plugin Config -- ^ Deprecated + , argsSessionLoadingOptions :: SessionLoadingOptions + , argsIdeOptions :: Config -> Action IdeGhcSession -> IdeOptions + , argsLspOptions :: LSP.Options + , argsDefaultHlsConfig :: Config + , argsGetHieDbLoc :: FilePath -> IO FilePath -- ^ Map project roots to the location of the hiedb for the project + , argsDebouncer :: IO (Debouncer NormalizedUri) -- ^ Debouncer used for diagnostics + , argsHandleIn :: IO Handle + , argsHandleOut :: IO Handle + , argsThreads :: Maybe Natural + , argsMonitoring :: IO Monitoring + , argsDisableKick :: Bool -- ^ flag to disable kick used for testing + , argsDisableInitialCwdShift :: Bool -- ^ skip the init-time setCurrentDirectory, for in-process parallel tests } defaultArguments :: Recorder (WithPriority Log) -> FilePath -> IdePlugins IdeState -> Arguments @@ -266,6 +267,7 @@ defaultArguments recorder projectRoot plugins = Arguments return newStdout , argsMonitoring = OpenTelemetry.monitoring , argsDisableKick = False + , argsDisableInitialCwdShift = False } @@ -379,7 +381,7 @@ defaultMain recorder Arguments{..} = withHeapStats (cmapWithPrio LogHeapStats re putMVar ideStateVar ide pure ide - let setup ideStateVar = setupLSP (cmapWithPrio LogLanguageServer recorder) argsProjectRoot argsGetHieDbLoc (pluginHandlers plugins) (getIdeState ideStateVar) + let setup ideStateVar = setupLSP (cmapWithPrio LogLanguageServer recorder) argsProjectRoot argsDisableInitialCwdShift argsGetHieDbLoc (pluginHandlers plugins) (getIdeState ideStateVar) -- See Note [Client configuration in Rules] onConfigChange ideStateVar cfg = do -- TODO: this is nuts, we're converting back to JSON just to get a fingerprint diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index c06b00a906..c7f4e463f4 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -69,7 +69,8 @@ module Test.Hls Priority(..), captureKickDiagnostics, kick, - TestConfig(..) + TestConfig(..), + CwdHandling(..) ) where @@ -601,7 +602,7 @@ instance Default (TestConfig b) where testDirLocation = Right $ VirtualFileTree [] "", testClientRoot = Nothing, testServerRoot = Nothing, - testShiftRoot = False, + testCwdHandling = ServerCwdShift, testDisableKick = False, testDisableDefaultPlugin = False, testPluginDescriptor = mempty, @@ -725,10 +726,22 @@ keepCurrentDirectory :: IO a -> IO a keepCurrentDirectory = bracket getCurrentDirectory setCurrentDirectory . const {-# NOINLINE lock #-} --- | Never run in parallel +-- | Serialises tests that shift the global CWD ('HarnessCwdShift'). See Note [Root Directory]. lock :: Lock lock = unsafePerformIO newLock +-- | How a test drives the process-global current working directory. See Note [Root Directory]. +data CwdHandling + = ServerCwdShift + -- ^ The server shifts the CWD to the rootUri on init. The historical default. + | HarnessCwdShift + -- ^ The harness shifts the CWD to the test root under 'lock', for CWD-coupled + -- suites (stan, hlint) that cannot run in parallel. + | NoCwdShift + -- ^ Neither the harness nor the server shifts the CWD, so in-process servers + -- can run in parallel. + deriving (Eq) + data TestConfig b = TestConfig { testDirLocation :: Either FilePath VirtualFileTree @@ -750,8 +763,8 @@ data TestConfig b = TestConfig -- Don't forget to use 'TASTY_PATTERN' to debug only a subset of tests. -- -- For plugin test logs, look at the documentation of 'mkPluginTestDescriptor'. - , testShiftRoot :: Bool - -- ^ Whether to shift the current directory to the root of the project + , testCwdHandling :: CwdHandling + -- ^ How the test drives the process-global CWD. See Note [Root Directory]. , testClientRoot :: Maybe FilePath -- ^ Specify the root of (the client or LSP context), -- if Nothing it is the same as the testDirLocation @@ -806,8 +819,8 @@ runSessionWithTestConfig TestConfig{..} session = runSessionInVFS testDirLocation $ \root cacheDir -> shiftRoot root $ do pipeIn@(inR, inW) <- createPipe pipeOut@(outR, outW) <- createPipe - let serverRoot = fromMaybe root testServerRoot - let clientRoot = fromMaybe root testClientRoot + let serverRoot = maybe root (root ) testServerRoot + let clientRoot = maybe root (root ) testClientRoot (recorder, cb1) <- wrapClientLogger =<< hlsPluginTestRecorder (recorderIde, cb2) <- wrapClientLogger =<< hlsHelperTestRecorder @@ -849,10 +862,13 @@ runSessionWithTestConfig TestConfig{..} session = pure result where - shiftRoot shiftTarget f = - if testShiftRoot - then withLock lock $ keepCurrentDirectory $ setCurrentDirectory shiftTarget >> f - else f + -- NoCwdShift suites avoid the global CWD entirely (server root via + -- 'argsProjectRoot', client via rootUri), so they run in parallel. + -- HarnessCwdShift suites shift under 'lock', which serialises them. + -- See Note [Root Directory]. + shiftRoot shiftTarget f + | testCwdHandling == HarnessCwdShift = withLock lock $ keepCurrentDirectory $ setCurrentDirectory shiftTarget >> f + | otherwise = f runSessionInVFS (Left testConfigRoot) act = do root <- makeAbsolute testConfigRoot withTemporaryDataAndCacheDirectory (\_ cacheDir -> act root cacheDir) @@ -879,6 +895,9 @@ runSessionWithTestConfig TestConfig{..} session = , argsDefaultHlsConfig = testLspConfig , argsProjectRoot = prjRoot , argsDisableKick = testDisableKick + -- NoCwdShift suites skip the server's setCurrentDirectory so their + -- in-process servers do not race on the global CWD. + , argsDisableInitialCwdShift = testCwdHandling == NoCwdShift -- Keep interface files and the hiedb under this test's cache -- directory instead of the shared 'XDG_CACHE_HOME'. , argsGetHieDbLoc = const (pure (cacheDir "test.hiedb")) diff --git a/hls-test-utils/src/Test/Hls/FileSystem.hs b/hls-test-utils/src/Test/Hls/FileSystem.hs index e349dbad3b..b34a07367c 100644 --- a/hls-test-utils/src/Test/Hls/FileSystem.hs +++ b/hls-test-utils/src/Test/Hls/FileSystem.hs @@ -118,7 +118,9 @@ materialise rootDir' fileTree testDataDir' = do copyDir' :: FilePath -> FilePath -> IO () copyDir' root dir = do - files <- fmap FP.normalise . lines <$> withCurrentDirectory (testDataDir dir) (readProcess "git" ["ls-files", "--cached", "--modified", "--others"] "") + -- Use `git -C` rather than withCurrentDirectory: mutating the global CWD + -- here races with parallel tests. See Note [Root Directory]. + files <- fmap FP.normalise . lines <$> readProcess "git" ["-C", testDataDir dir, "ls-files", "--cached", "--modified", "--others"] "" mapM_ (createDirectoryIfMissing True . ((root ) . takeDirectory)) files mapM_ (\f -> copyFile (testDataDir dir f) (root f)) files return () diff --git a/plugins/hls-hlint-plugin/test/Main.hs b/plugins/hls-hlint-plugin/test/Main.hs index 39944ca3f8..59b8ee9500 100644 --- a/plugins/hls-hlint-plugin/test/Main.hs +++ b/plugins/hls-hlint-plugin/test/Main.hs @@ -120,7 +120,7 @@ suggestionsTests = { testConfigCaps = noLiteralCaps , testDirLocation = Left testDir , testPluginDescriptor = hlintPlugin - , testShiftRoot = True} $ const $ do + , testCwdHandling = HarnessCwdShift} $ const $ do doc <- openDoc "Base.hs" "haskell" _ <- hlintCaptureKick @@ -350,7 +350,7 @@ runHlintSession :: FilePath -> Session a -> IO a runHlintSession subdir = failIfSessionTimeout . runSessionWithTestConfig def { testConfigCaps = codeActionNoResolveCaps - , testShiftRoot = True + , testCwdHandling = HarnessCwdShift , testDirLocation = Left (testDir subdir) , testPluginDescriptor = hlintPlugin } @@ -466,7 +466,7 @@ setupGoldenHlintTest :: TestName -> FilePath -> ClientCapabilities -> (TextDocum setupGoldenHlintTest testName path config = goldenWithTestConfig def { testConfigCaps = config - , testShiftRoot = True + , testCwdHandling = HarnessCwdShift , testPluginDescriptor = hlintPlugin , testDirLocation = Right tree } testName tree path "expected" "hs" diff --git a/plugins/hls-stan-plugin/test/Main.hs b/plugins/hls-stan-plugin/test/Main.hs index 231707d142..378d53aedc 100644 --- a/plugins/hls-stan-plugin/test/Main.hs +++ b/plugins/hls-stan-plugin/test/Main.hs @@ -78,7 +78,7 @@ runStanSession subdir = failIfSessionTimeout . runSessionWithTestConfig def{ testConfigCaps=codeActionNoResolveCaps - , testShiftRoot=True + , testCwdHandling=HarnessCwdShift , testPluginDescriptor=stanPlugin , testDirLocation=Left (testDir subdir) } diff --git a/test/functional/Config.hs b/test/functional/Config.hs index 874792784f..e35e2ea27e 100644 --- a/test/functional/Config.hs +++ b/test/functional/Config.hs @@ -71,7 +71,7 @@ genericConfigTests = testGroup "generic plugin config" runConfigSession subdir session = do failIfSessionTimeout $ runSessionWithTestConfig def - { testConfigSession=def {ignoreConfigurationRequests=False}, testShiftRoot=True + { testConfigSession=def {ignoreConfigurationRequests=False}, testCwdHandling=HarnessCwdShift , testPluginDescriptor=plugin, testDirLocation=Left ("test/testdata" subdir) } (const session) From 28c17ec4a2414dbed82bdade5b72e908a70887a9 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Mon, 15 Jun 2026 01:51:24 +0200 Subject: [PATCH 03/16] Check path-completion existence against the working dir --- .../src/Ide/Plugin/Cabal/Completion/Completer/FilePath.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completer/FilePath.hs b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completer/FilePath.hs index 9faa98bddd..0281bc13e6 100644 --- a/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completer/FilePath.hs +++ b/plugins/hls-cabal-plugin/src/Ide/Plugin/Cabal/Completion/Completer/FilePath.hs @@ -167,7 +167,10 @@ mkPathCompletionDir complInfo completion = mkFilePathCompletion :: PathCompletionInfo -> T.Text -> IO T.Text mkFilePathCompletion complInfo completion = do let combinedPath = mkPathCompletionDir complInfo completion - isFilePath <- doesFileExist $ T.unpack combinedPath + -- combinedPath is relative to the cabal file (it becomes completion text), so + -- the existence check must prepend the absolute working directory rather than + -- rely on the process CWD. See Note [Root Directory]. + isFilePath <- doesFileExist $ workingDirectory complInfo FP. T.unpack combinedPath let completedPath = if isFilePath then applyStringNotation (isStringNotationPath complInfo) combinedPath else combinedPath pure completedPath From 1e10142440241854788c602627e360f0a24ced47 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 10:33:46 +0200 Subject: [PATCH 04/16] Match test thread count to the server capability count tasty defaulted to `numProcessors` while each in-process server set its RTS capabilities to `numProcessors / 2`. Drive both from a single variable so test concurrency and capabilities agree. --- hls-test-utils/src/Test/Hls.hs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index c7f4e463f4..d3d9ce5067 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -110,6 +110,7 @@ import qualified Development.IDE.Plugin.Test as Test import Development.IDE.Session (SessionLoadingOptions (..)) import Development.IDE.Session.Ghc (getCacheDirsIn) import Development.IDE.Types.Options +import GHC.Conc (getNumProcessors) import GHC.IO.Handle import GHC.TypeLits import Ide.Logger (Pretty (pretty), @@ -156,6 +157,7 @@ import Test.Tasty.ExpectedFailure import Test.Tasty.Golden import Test.Tasty.HUnit import Test.Tasty.Ingredients.Rerun +import Test.Tasty.Runners (NumThreads (..)) data Log = LogIDEMain IDEMain.Log @@ -200,11 +202,21 @@ unCurrent (BrokenCurrent a) = a -- | Run main with rerun, limiting each single test case running at most 10 minutes defaultTestRunner :: TestTree -> IO () -defaultTestRunner = defaultMainWithIngredients ingredientsWithRerun . wrapCliTestOptions . adjustOption (const $ mkTimeout 600000000) +defaultTestRunner = defaultMainWithIngredients ingredientsWithRerun . wrapCliTestOptions . adjustOption (const $ mkTimeout 600000000) . localOption (NumThreads testNumThreads) where ingredients = includingOptions hlsTestOptions : defaultIngredients ingredientsWithRerun = [rerunningTests ingredients] +-- | Thread count shared by tasty (concurrent tests) and the in-process servers' +-- RTS capabilities ('argsThreads' -> 'withNumCapabilities'), so the two match. +-- Defaults to @numProcessors@, overridable with @GHCIDE_TEST_THREADS@. +{-# NOINLINE testNumThreads #-} +testNumThreads :: Int +testNumThreads = unsafePerformIO $ do + override <- lookupEnv "GHCIDE_TEST_THREADS" + np <- getNumProcessors + pure $ maybe np read override + gitDiff :: FilePath -> FilePath -> [String] gitDiff fRef fNew = ["git", "-c", "core.fileMode=false", "diff", "--no-index", "--text", "--exit-code", fRef, fNew] @@ -836,7 +848,10 @@ runSessionWithTestConfig TestConfig{..} session = let plugins = testPluginDescriptor recorder <> lspRecorderPlugin timeoutOverride <- fmap read <$> lookupEnv "LSP_TIMEOUT" let sconf' = testConfigSession { lspConfig = hlsConfigToClientConfig testLspConfig, messageTimeout = fromMaybe (messageTimeout defaultConfig) timeoutOverride} - arguments = testingArgs serverRoot cacheDir recorderIde plugins + -- Pin the server's RTS capability count to 'testNumThreads', the same value + -- tasty runs with (see 'defaultTestRunner'), so concurrency and capabilities match. + arguments = (testingArgs serverRoot cacheDir recorderIde plugins) + { argsThreads = Just (fromIntegral testNumThreads) } -- Make an explicit call to keepAlive to protect both pipes from being GC'd. -- From 630df1e1a90712ca985c49f15dbbfa422fd322e9 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 10:34:08 +0200 Subject: [PATCH 05/16] Sync completion and resolve tests on typecheck, not progress --- ghcide-test/exe/CompletionTests.hs | 5 ++--- ghcide-test/exe/ResolveTests.hs | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ghcide-test/exe/CompletionTests.hs b/ghcide-test/exe/CompletionTests.hs index 8c44173bd6..a5771460cf 100644 --- a/ghcide-test/exe/CompletionTests.hs +++ b/ghcide-test/exe/CompletionTests.hs @@ -59,7 +59,7 @@ testSessionSingleFile testName fp txt session = completionTest :: HasCallStack => String -> [T.Text] -> Position -> [(T.Text, CompletionItemKind, T.Text, Bool, Bool, Maybe [TextEdit])] -> TestTree completionTest name src pos expected = testSessionSingleFile name "A.hs" (T.unlines src) $ do docId <- openDoc "A.hs" "haskell" - _ <- waitForDiagnostics + _ <- waitForTypecheck docId compls <- getAndResolveCompletions docId pos let compls' = [ (_label, _kind, _insertText, _additionalTextEdits) | CompletionItem{..} <- compls] @@ -220,8 +220,7 @@ localCompletionTests = [ , " { field1 :: Int" , " , field2 :: Int" , " }" - , -- Without the following, this file doesn't trigger any diagnostics, so completionTest waits forever - "triggerDiag :: UnknownType" + , "triggerDiag :: UnknownType" , "foo record = record.f" ] (Position 7 21) diff --git a/ghcide-test/exe/ResolveTests.hs b/ghcide-test/exe/ResolveTests.hs index 4fc917c56b..771a44397c 100644 --- a/ghcide-test/exe/ResolveTests.hs +++ b/ghcide-test/exe/ResolveTests.hs @@ -24,7 +24,7 @@ import Language.LSP.Test hiding (resolveCompletion) import Test.Hls (IdeState, SMethod (..), liftIO, mkPluginTestDescriptor, someMethodToMethodString, - waitForAllProgressDone) + waitForTypecheck) import qualified Test.Hls.FileSystem as FS import Test.Tasty import Test.Tasty.HUnit @@ -100,7 +100,7 @@ resolveRequests = , "data Foo = Foo { foo :: Int }" , "bar = Foo 4" ] - waitForAllProgressDone + _ <- waitForTypecheck doc items <- getCompletions doc (Position 2 7) let resolveCompItems = filter (\i -> "test item" `T.isPrefixOf` (i ^. J.label)) items liftIO $ assertEqual "There must be exactly two results" 2 (length resolveCompItems) @@ -113,7 +113,7 @@ resolveRequests = , "data Foo = Foo { foo :: Int }" , "bar = Foo 4" ] - waitForAllProgressDone + _ <- waitForTypecheck doc -- Cant use 'getAllCodeActions', as this lsp-test function queries the diagnostic -- locations and we don't have diagnostics in these tests. cas <- Maybe.mapMaybe (preview _R) <$> getCodeActions doc (Range (Position 0 0) (Position 1 0)) @@ -128,7 +128,7 @@ resolveRequests = , "data Foo = Foo { foo :: Int }" , "bar = Foo 4" ] - waitForAllProgressDone + _ <- waitForTypecheck doc cd <- getCodeLenses doc let resolveCodeLenses = filter (\i -> case i ^. J.command of Just cmd -> "test item" `T.isPrefixOf` (cmd ^. J.title) From fb86eb20149c6052346f0f1bfadde884b1375f75 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 10:34:35 +0200 Subject: [PATCH 06/16] Stop th-linking-test racing the relink-transient diagnostic --- ghcide-test/exe/THTests.hs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ghcide-test/exe/THTests.hs b/ghcide-test/exe/THTests.hs index dd0491a842..94d4917992 100644 --- a/ghcide-test/exe/THTests.hs +++ b/ghcide-test/exe/THTests.hs @@ -2,6 +2,8 @@ module THTests (tests) where import Config +import Control.Applicative ((<|>)) +import Control.Lens ((^.)) import Control.Monad.IO.Class (liftIO) import qualified Data.Text as T import Development.IDE.GHC.Compat (GhcVersion (..), ghcVersion) @@ -9,6 +11,7 @@ import Development.IDE.GHC.Util import Development.IDE.Test (expectCurrentDiagnostics, expectDiagnostics, expectNoMoreDiagnostics) +import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Types hiding (SemanticTokenAbsolute (..), SemanticTokenRelative (..), SemanticTokensEdit (..), mkRange) @@ -288,7 +291,23 @@ thLinkingTest unboxed = testCase name $ runWithExtraFiles dir $ \dir -> do -- modify b too let bSource' = T.unlines $ init (T.lines bSource) ++ ["$th"] changeDoc bdoc [TextDocumentContentChangeEvent . InR $ TextDocumentContentChangeWholeDocument bSource'] - _ <- waitForDiagnostics + + -- The reload renames THA's splice (th_a -> th) and re-splices it in THB. + -- While THA relinks, THB transiently reports "th_a not in scope", and a single + -- 'waitForDiagnostics' could catch that transient (see the note in Main.hs). + -- Wait for THB's own settled "Top-level binding" warning, matched on THB's uri. + let bUri = bdoc ^. L.uri + settledTHB params = + params ^. L.uri == bUri && case params ^. L.diagnostics of + [d] -> d ^. L.severity == Just DiagnosticSeverity_Warning + && "Top-level binding" `T.isInfixOf` (d ^. L.message) + _ -> False + -- next PublishDiagnostics, skipping any non-diagnostic messages + nextPublishDiagnostics = publishDiagnosticsNotification <|> (anyMessage *> nextPublishDiagnostics) + waitForSettledTHB = do + notif <- nextPublishDiagnostics + if settledTHB (notif ^. L.params) then pure () else waitForSettledTHB + waitForSettledTHB expectCurrentDiagnostics bdoc [(DiagnosticSeverity_Warning, (4,1), "Top-level binding", Just "GHC-38417")] From 1ff2491a1f8378da96ce8937b3efcaff3223e128 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 13:15:37 +0200 Subject: [PATCH 07/16] Remove -j1 from default test-options via cabal.project --- cabal.project | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cabal.project b/cabal.project index d9085d43ca..98fa88d82d 100644 --- a/cabal.project +++ b/cabal.project @@ -15,11 +15,6 @@ benchmarks: True write-ghc-environment-files: never --- Many of our tests only work single-threaded, and the only way to --- ensure tasty runs everything purely single-threaded is to pass --- this at the top-level -test-options: -j1 - -- Make sure dependencies are build with haddock so we get -- haddock shown on hover package * From b4c6ca70a81381f74b276d519442f44dbb207e1e Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 16:20:13 +0200 Subject: [PATCH 08/16] Shrink eval TIO delay so its stdout capture stops racing the reporter --- plugins/hls-eval-plugin/test/testdata/TIO.expected.hs | 5 +++-- plugins/hls-eval-plugin/test/testdata/TIO.hs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/hls-eval-plugin/test/testdata/TIO.expected.hs b/plugins/hls-eval-plugin/test/testdata/TIO.expected.hs index 0be985ae3c..4081d467d0 100644 --- a/plugins/hls-eval-plugin/test/testdata/TIO.expected.hs +++ b/plugins/hls-eval-plugin/test/testdata/TIO.expected.hs @@ -7,9 +7,10 @@ import Control.Concurrent (threadDelay) {- Capture stdout, returns value. -Has a delay in order to show progress reporting. +Small delay only: a large one widens the stdout capture window so the test +reporter's output for concurrent tests races into it under parallel runs. ->>> threadDelay 2000000 >> print "ABC" >> return "XYZ" +>>> threadDelay 1000 >> print "ABC" >> return "XYZ" "ABC" "XYZ" -} diff --git a/plugins/hls-eval-plugin/test/testdata/TIO.hs b/plugins/hls-eval-plugin/test/testdata/TIO.hs index 455c19d5b8..5961492fcf 100644 --- a/plugins/hls-eval-plugin/test/testdata/TIO.hs +++ b/plugins/hls-eval-plugin/test/testdata/TIO.hs @@ -7,7 +7,8 @@ import Control.Concurrent (threadDelay) {- Capture stdout, returns value. -Has a delay in order to show progress reporting. +Small delay only: a large one widens the stdout capture window so the test +reporter's output for concurrent tests races into it under parallel runs. ->>> threadDelay 2000000 >> print "ABC" >> return "XYZ" +>>> threadDelay 1000 >> print "ABC" >> return "XYZ" -} From 19ade3a97f58249e8db3ece93f480271e28936ba Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 21:40:47 +0200 Subject: [PATCH 09/16] Cap tasty's thread pool and pre-init the RTS linker in the harness Pre-initialise the RTS object linker once before tasty forks workers, so concurrent in-process sessions don't race their initialization. --- hls-test-utils/hls-test-utils.cabal | 1 + hls-test-utils/src/Test/Hls.hs | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/hls-test-utils/hls-test-utils.cabal b/hls-test-utils/hls-test-utils.cabal index d2823f25bc..d6a0ea9f55 100644 --- a/hls-test-utils/hls-test-utils.cabal +++ b/hls-test-utils/hls-test-utils.cabal @@ -44,6 +44,7 @@ library , directory , extra , filepath + , ghci , ghcide == 2.14.0.0 , hls-plugin-api == 2.14.0.0 , lens diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index d3d9ce5067..1eae142eaf 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -18,6 +18,8 @@ module Test.Hls module Control.Monad.IO.Class, module Control.Applicative.Combinators, defaultTestRunner, + defaultTestRunnerWithThreads, + NumThreads (..), goldenGitDiff, goldenWithHaskellDoc, goldenWithHaskellDocInTmpDir, @@ -113,6 +115,8 @@ import Development.IDE.Types.Options import GHC.Conc (getNumProcessors) import GHC.IO.Handle import GHC.TypeLits +import GHCi.ObjLink (ShouldRetainCAFs (..), + initObjLinker) import Ide.Logger (Pretty (pretty), Priority (..), Recorder, @@ -202,7 +206,18 @@ unCurrent (BrokenCurrent a) = a -- | Run main with rerun, limiting each single test case running at most 10 minutes defaultTestRunner :: TestTree -> IO () -defaultTestRunner = defaultMainWithIngredients ingredientsWithRerun . wrapCliTestOptions . adjustOption (const $ mkTimeout 600000000) . localOption (NumThreads testNumThreads) +defaultTestRunner = defaultTestRunnerWithThreads (NumThreads testNumThreads) + +-- | Like 'defaultTestRunner' but caps tasty's worker pool at @n@ threads. +-- Suites whose sessions shift the global working directory pass @NumThreads 1@, +-- since concurrent shifts race the CWD. +defaultTestRunnerWithThreads :: NumThreads -> TestTree -> IO () +defaultTestRunnerWithThreads n tree = do + -- Pre-initialise the RTS object linker once, single-threaded, before tasty + -- forks workers, so concurrent sessions do not race linker initialization. + initObjLinker RetainCAFs + defaultMainWithIngredients ingredientsWithRerun + (localOption n (wrapCliTestOptions (adjustOption (const $ mkTimeout 600000000) tree))) where ingredients = includingOptions hlsTestOptions : defaultIngredients ingredientsWithRerun = [rerunningTests ingredients] From c0da0407aa222ca7e2749283d154fb9c891ebe65 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 21:40:48 +0200 Subject: [PATCH 10/16] Run testsuites serially to avoid the parallel cwd race --- hls-graph/test/Main.hs | 3 ++- hls-test-utils/src/Test/Hls.hs | 11 +++++++++-- plugins/hls-hlint-plugin/test/Main.hs | 2 +- plugins/hls-stan-plugin/test/Main.hs | 2 +- test/functional/Main.hs | 2 +- test/wrapper/Main.hs | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/hls-graph/test/Main.hs b/hls-graph/test/Main.hs index 553982775f..881debcc40 100644 --- a/hls-graph/test/Main.hs +++ b/hls-graph/test/Main.hs @@ -2,6 +2,7 @@ import qualified Spec import Test.Tasty import Test.Tasty.Hspec import Test.Tasty.Ingredients.Rerun (defaultMainWithRerun) +import Test.Tasty.Runners (NumThreads (..)) main :: IO () -main = testSpecs Spec.spec >>= defaultMainWithRerun . testGroup "tactics" +main = testSpecs Spec.spec >>= defaultMainWithRerun . localOption (NumThreads 1) . testGroup "tactics" diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index 1eae142eaf..95e379b10f 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -142,7 +142,6 @@ import Prelude hiding (log) import System.Directory (canonicalizePath, createDirectoryIfMissing, getCurrentDirectory, - makeAbsolute, setCurrentDirectory) import System.Environment (lookupEnv) import System.FilePath @@ -216,6 +215,7 @@ defaultTestRunnerWithThreads n tree = do -- Pre-initialise the RTS object linker once, single-threaded, before tasty -- forks workers, so concurrent sessions do not race linker initialization. initObjLinker RetainCAFs + _ <- pure $! length originalWorkingDirectory -- force before tasty forks workers defaultMainWithIngredients ingredientsWithRerun (localOption n (wrapCliTestOptions (adjustOption (const $ mkTimeout 600000000) tree))) where @@ -757,6 +757,13 @@ keepCurrentDirectory = bracket getCurrentDirectory setCurrentDirectory . const lock :: Lock lock = unsafePerformIO newLock +{-# NOINLINE originalWorkingDirectory #-} +-- | Working directory captured at process start, before any 'shiftRoot' runs. +-- Relative test roots resolve against this, not the live (concurrently shifted) +-- CWD, so parallel suites cannot double the path. +originalWorkingDirectory :: FilePath +originalWorkingDirectory = unsafePerformIO getCurrentDirectory + -- | How a test drives the process-global current working directory. See Note [Root Directory]. data CwdHandling = ServerCwdShift @@ -900,7 +907,7 @@ runSessionWithTestConfig TestConfig{..} session = | testCwdHandling == HarnessCwdShift = withLock lock $ keepCurrentDirectory $ setCurrentDirectory shiftTarget >> f | otherwise = f runSessionInVFS (Left testConfigRoot) act = do - root <- makeAbsolute testConfigRoot + let root = normalise (originalWorkingDirectory testConfigRoot) withTemporaryDataAndCacheDirectory (\_ cacheDir -> act root cacheDir) runSessionInVFS (Right vfs) act = withVfsTestDataDirectory vfs $ \fs cacheDir -> do diff --git a/plugins/hls-hlint-plugin/test/Main.hs b/plugins/hls-hlint-plugin/test/Main.hs index 59b8ee9500..63ea297bfb 100644 --- a/plugins/hls-hlint-plugin/test/Main.hs +++ b/plugins/hls-hlint-plugin/test/Main.hs @@ -24,7 +24,7 @@ import Test.Hls import Test.Hls.FileSystem main :: IO () -main = defaultTestRunner tests +main = defaultTestRunnerWithThreads (NumThreads 1) tests hlintPlugin :: PluginTestDescriptor HLint.Log hlintPlugin = mkPluginTestDescriptor HLint.descriptor "hlint" diff --git a/plugins/hls-stan-plugin/test/Main.hs b/plugins/hls-stan-plugin/test/Main.hs index 378d53aedc..977974c911 100644 --- a/plugins/hls-stan-plugin/test/Main.hs +++ b/plugins/hls-stan-plugin/test/Main.hs @@ -12,7 +12,7 @@ import System.FilePath import Test.Hls main :: IO () -main = defaultTestRunner tests +main = defaultTestRunnerWithThreads (NumThreads 1) tests tests :: TestTree tests = diff --git a/test/functional/Main.hs b/test/functional/Main.hs index daa342f694..28f517b57a 100644 --- a/test/functional/Main.hs +++ b/test/functional/Main.hs @@ -9,7 +9,7 @@ import Progress import Test.Hls main :: IO () -main = defaultTestRunner $ testGroup "haskell-language-server" +main = defaultTestRunnerWithThreads (NumThreads 1) $ testGroup "haskell-language-server" [ Config.tests , ConfigSchema.tests , ignoreInEnv [HostOS Windows] "Tests gets stuck in ci" Format.tests diff --git a/test/wrapper/Main.hs b/test/wrapper/Main.hs index 0fbfa76b7a..61fea16ce5 100644 --- a/test/wrapper/Main.hs +++ b/test/wrapper/Main.hs @@ -5,7 +5,7 @@ import System.Process import Test.Hls main :: IO () -main = defaultTestRunner $ testGroup "haskell-language-server-wrapper" [projectGhcVersionTests] +main = defaultTestRunnerWithThreads (NumThreads 1) $ testGroup "haskell-language-server-wrapper" [projectGhcVersionTests] projectGhcVersionTests :: TestTree projectGhcVersionTests = testGroup "--project-ghc-version" From 3749df1b9a8187864b18e118e0ebf505c12829e3 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Tue, 16 Jun 2026 23:53:10 +0200 Subject: [PATCH 11/16] Run ghcide tests serially on Windows in CI --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac585b459b..5d4c970e78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -119,6 +119,12 @@ jobs: run: | cabal configure --test-options="--rerun-update --rerun-filter failures,exceptions,new" --max-backjumps 10000 + - if: matrix.os == 'windows-latest' + name: Run tests serially on Windows + # Windows runners are too slow to parallelise: concurrent in-process + # sessions starve each other into lsp-test message timeouts. + run: echo "GHCIDE_TEST_THREADS=1" >> "$GITHUB_ENV" + - if: matrix.test name: Test hls-graph run: cabal test ${CABAL_ARGS} hls-graph From 7c38270f46add0b786540c7bb81de539f6f780ad Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Wed, 17 Jun 2026 00:02:14 +0200 Subject: [PATCH 12/16] Default test threads to `numProcessors / 2` --- hls-test-utils/src/Test/Hls.hs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index 95e379b10f..d3292b2d01 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -224,13 +224,14 @@ defaultTestRunnerWithThreads n tree = do -- | Thread count shared by tasty (concurrent tests) and the in-process servers' -- RTS capabilities ('argsThreads' -> 'withNumCapabilities'), so the two match. --- Defaults to @numProcessors@, overridable with @GHCIDE_TEST_THREADS@. +-- Defaults to @numProcessors `div` 2@ (full @numProcessors@ over-subscribes, +-- since each test is a GHC session), overridable with @GHCIDE_TEST_THREADS@. {-# NOINLINE testNumThreads #-} testNumThreads :: Int testNumThreads = unsafePerformIO $ do override <- lookupEnv "GHCIDE_TEST_THREADS" np <- getNumProcessors - pure $ maybe np read override + pure $ maybe (max 1 (np `div` 2)) read override gitDiff :: FilePath -> FilePath -> [String] gitDiff fRef fNew = ["git", "-c", "core.fileMode=false", "diff", "--no-index", "--text", "--exit-code", fRef, fNew] From 443c6e89419947a37eafa3bc300005a993fad8d6 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Wed, 17 Jun 2026 00:24:12 +0200 Subject: [PATCH 13/16] Give the test exes a 32M nursery and drop the redundant -N --- haskell-language-server.cabal | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/haskell-language-server.cabal b/haskell-language-server.cabal index ea2bbbcc64..6013df7fd0 100644 --- a/haskell-language-server.cabal +++ b/haskell-language-server.cabal @@ -52,7 +52,9 @@ common defaults , base >=4.12 && <5 common test-defaults - ghc-options: -threaded -rtsopts -with-rtsopts=-N + -- Capabilities come from 'withNumCapabilities' ('testNumThreads'), not -N. + -- -A32M cuts minor-GC frequency so parallel sessions stop the world less. + ghc-options: -threaded -rtsopts -with-rtsopts=-A32M if impl(ghc >= 9.8) -- We allow using partial functions in tests ghc-options: -Wno-x-partial @@ -2090,7 +2092,7 @@ executable ghcide-test-preprocessor buildable: False test-suite ghcide-tests - import: warnings, defaults + import: warnings, defaults, test-defaults type: exitcode-stdio-1.0 default-language: GHC2021 build-tool-depends: @@ -2137,7 +2139,7 @@ test-suite ghcide-tests build-depends: ghc-typelits-knownnat hs-source-dirs: ghcide-test/exe - ghc-options: -threaded -O0 + ghc-options: -O0 main-is: Main.hs other-modules: From 386b726a3a82a7173769788c9d6ffae44d1654d3 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Wed, 17 Jun 2026 01:04:22 +0200 Subject: [PATCH 14/16] Build CI with --jobs and --semaphore --- .github/workflows/flags.yml | 3 ++- .github/workflows/test.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flags.yml b/.github/workflows/flags.yml index cf2ad4d787..4dbb93b6e0 100644 --- a/.github/workflows/flags.yml +++ b/.github/workflows/flags.yml @@ -82,7 +82,8 @@ jobs: cat cabal.project.local - name: Build everything with non-default flags - run: cabal build all + # --semaphore (GHC -jsem) needs GHC >= 9.8, so skip it on 9.6. + run: cabal build --jobs ${{ matrix.ghc != '9.6' && '--semaphore' || '' }} all flags_post_job: if: always() diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d4c970e78..41031a6c09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -110,7 +110,8 @@ jobs: os: ${{ runner.os }} - name: Build - run: cabal build --max-backjumps 10000 ${CABAL_ARGS} all + # --semaphore (GHC -jsem) needs GHC >= 9.8, so skip it on 9.6. + run: cabal build --max-backjumps 10000 ${CABAL_ARGS} --jobs ${{ matrix.ghc != '9.6' && '--semaphore' || '' }} all - name: Set test options # See https://github.com/ocharles/tasty-rerun/issues/22 for why we need From f847ca4e3a2c2172477bf2c0922352a8e41af009 Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Wed, 17 Jun 2026 22:23:53 +0200 Subject: [PATCH 15/16] Decouple tasty test concurrency from server build capabilities --- .github/workflows/test.yml | 5 +++-- hls-test-utils/src/Test/Hls.hs | 23 ++++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41031a6c09..1407e19853 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,8 +123,9 @@ jobs: - if: matrix.os == 'windows-latest' name: Run tests serially on Windows # Windows runners are too slow to parallelise: concurrent in-process - # sessions starve each other into lsp-test message timeouts. - run: echo "GHCIDE_TEST_THREADS=1" >> "$GITHUB_ENV" + # sessions starve each other into lsp-test message timeouts. Serialise + # the tests only -- builds keep their numProcessors/2 capabilities. + run: echo "GHCIDE_TEST_TASTY_THREADS=1" >> "$GITHUB_ENV" - if: matrix.test name: Test hls-graph diff --git a/hls-test-utils/src/Test/Hls.hs b/hls-test-utils/src/Test/Hls.hs index d3292b2d01..4400fc9f77 100644 --- a/hls-test-utils/src/Test/Hls.hs +++ b/hls-test-utils/src/Test/Hls.hs @@ -205,7 +205,7 @@ unCurrent (BrokenCurrent a) = a -- | Run main with rerun, limiting each single test case running at most 10 minutes defaultTestRunner :: TestTree -> IO () -defaultTestRunner = defaultTestRunnerWithThreads (NumThreads testNumThreads) +defaultTestRunner = defaultTestRunnerWithThreads (NumThreads testTastyThreads) -- | Like 'defaultTestRunner' but caps tasty's worker pool at @n@ threads. -- Suites whose sessions shift the global working directory pass @NumThreads 1@, @@ -222,10 +222,9 @@ defaultTestRunnerWithThreads n tree = do ingredients = includingOptions hlsTestOptions : defaultIngredients ingredientsWithRerun = [rerunningTests ingredients] --- | Thread count shared by tasty (concurrent tests) and the in-process servers' --- RTS capabilities ('argsThreads' -> 'withNumCapabilities'), so the two match. --- Defaults to @numProcessors `div` 2@ (full @numProcessors@ over-subscribes, --- since each test is a GHC session), overridable with @GHCIDE_TEST_THREADS@. +-- | The in-process servers' RTS capability count ('argsThreads' -> +-- 'withNumCapabilities'), i.e. how parallel each GHC build is. Defaults to +-- @numProcessors `div` 2@, overridable with @GHCIDE_TEST_THREADS@. {-# NOINLINE testNumThreads #-} testNumThreads :: Int testNumThreads = unsafePerformIO $ do @@ -233,6 +232,15 @@ testNumThreads = unsafePerformIO $ do np <- getNumProcessors pure $ maybe (max 1 (np `div` 2)) read override +-- | tasty's concurrent-test pool. Defaults to 'testNumThreads' (so +-- @GHCIDE_TEST_THREADS@ drives both, matching test concurrency to build +-- capabilities), but @GHCIDE_TEST_TASTY_THREADS@ overrides it alone: CI uses +-- that to serialise the Windows tests without single-threading their builds. +{-# NOINLINE testTastyThreads #-} +testTastyThreads :: Int +testTastyThreads = unsafePerformIO $ + maybe testNumThreads read <$> lookupEnv "GHCIDE_TEST_TASTY_THREADS" + gitDiff :: FilePath -> FilePath -> [String] gitDiff fRef fNew = ["git", "-c", "core.fileMode=false", "diff", "--no-index", "--text", "--exit-code", fRef, fNew] @@ -871,8 +879,9 @@ runSessionWithTestConfig TestConfig{..} session = let plugins = testPluginDescriptor recorder <> lspRecorderPlugin timeoutOverride <- fmap read <$> lookupEnv "LSP_TIMEOUT" let sconf' = testConfigSession { lspConfig = hlsConfigToClientConfig testLspConfig, messageTimeout = fromMaybe (messageTimeout defaultConfig) timeoutOverride} - -- Pin the server's RTS capability count to 'testNumThreads', the same value - -- tasty runs with (see 'defaultTestRunner'), so concurrency and capabilities match. + -- Each server builds GHC with 'testNumThreads' capabilities. This is the + -- build parallelism, independent of tasty's test concurrency + -- ('testTastyThreads'), so serialising tests does not single-thread builds. arguments = (testingArgs serverRoot cacheDir recorderIde plugins) { argsThreads = Just (fromIntegral testNumThreads) } From f75d7378c8c33fccfd56f1cba6e4e9079f9cc22b Mon Sep 17 00:00:00 2001 From: Curtis Chin Jen Sem Date: Wed, 17 Jun 2026 22:23:54 +0200 Subject: [PATCH 16/16] Run the refactor extend-import tests serially --- plugins/hls-refactor-plugin/test/Main.hs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/hls-refactor-plugin/test/Main.hs b/plugins/hls-refactor-plugin/test/Main.hs index d26eb8349b..22b48bfe20 100644 --- a/plugins/hls-refactor-plugin/test/Main.hs +++ b/plugins/hls-refactor-plugin/test/Main.hs @@ -1098,9 +1098,9 @@ removeImportTests = testGroup "remove import actions" ] extendImportTests :: TestTree -extendImportTests = testGroup "extend import actions" - [ testGroup "with checkAll" $ tests True - , testGroup "without checkAll" $ tests False +extendImportTests = dependentTestGroup "extend import actions" AllFinish + [ dependentTestGroup "with checkAll" AllFinish $ tests True + , dependentTestGroup "without checkAll" AllFinish $ tests False ] where tests overrideCheckProject =