Skip to content

Lock store creation against concurrent processes#12114

Open
crtschin wants to merge 5 commits into
haskell:masterfrom
crtschin:crtschin/fix-concurrent-store-create
Open

Lock store creation against concurrent processes#12114
crtschin wants to merge 5 commits into
haskell:masterfrom
crtschin:crtschin/fix-concurrent-store-create

Conversation

@crtschin

@crtschin crtschin commented Jul 14, 2026

Copy link
Copy Markdown

Fixes #11329.

Used the hypothesis in #12111 (comment) to create a reproducer. Extracted a helper from the code that used fd locks to make concurrent store additions safe, and use that when initializing the store as well.

Template Α: This PR modifies behaviour or interface

Include the following checklist in your PR:

crtschin added 2 commits July 14, 2026 21:55
When several cabal processes share one `--store-dir` and that store is
cold, they all race `createPackageDBIfMissing`. Precisely hitting the
warning above it, noting that it is not thread-safe.

Fix this by using a fd-based lock, prior to attempting to create the
index.
@Bodigrim

Copy link
Copy Markdown
Collaborator

Thanks a lot for working on this!

I was thinking about the issue in the background and came up with a different approach. Namely,

  • Distribution.Simple.Program.HcPkg.init should read stderr of ghc-pkg and raise alreadyExistsErrorType if ghc-pkg says so.
  • Then createPackageDBIfMissing can catch the error from createPackageDB. If the error isAlreadyExistsError then return success, otherwise rethrow.

Theoretically this is slightly better in a sense that it works regardless on who else is trying to create package DB (which could be an older Cabal version, Stack, Shake, anyone) and there is no need to agree on the name of the lock file, because there is no locking.

But I understand if you prefer your implementation, which is already written :)

@crtschin

Copy link
Copy Markdown
Author

Appreciate you taking a look! Nice idea, taking more of a optimistic approach to avoid locking.

It's probably benign, but I'm not too big of a fan of the idea of logic depending on reading stderr. So I do prefer this as is 😄, but I'm open to changing the PR to your approach though if that's generally preferred.


-- | Hold an exclusive cross-process lock on @lockPath@ for the duration of
-- @action@, creating the lock file if it does not exist.
withFileLock :: Verbosity -> FilePath -> String -> IO a -> IO a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think withFileLock should go into Distribution.Simple.Utils or another utility module.

Should not we remove the lock file afterwards?

@crtschin crtschin Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think withFileLock should go into Distribution.Simple.Utils or another utility module.

Will do.

Should not we remove the lock file afterwards?

I think in this case of the store-init, it would be fine. As the check falls back to the existence of the store directory. I haven't taken a good look into withIncomingUnitIdLock to judge if that's safe as well.

I don't think this is safe in the general sense. Deleting the lockfile would disjoint visibility of the lock. Consider the following sequence of events.

- A creates a lockfile and acquires lock 1.
- B sees lockfile and tries to acquire lock 1 and gets blocked.
- A finishes, unlocks lock 1 and deletes the lockfile.
- B proceeds with acquiring lock 1.
- C sees no file, creates a new lockfile and acquires lock 2.
* B and C run concurrently

@crtschin
crtschin marked this pull request as ready for review July 14, 2026 21:13
@ulysses4ever

Copy link
Copy Markdown
Collaborator

Will this fix #11298 as well?

@crtschin

crtschin commented Jul 15, 2026

Copy link
Copy Markdown
Author

Will this fix #11298 as well?

I think so. The test doesn't cover it, but from what I can tell they use the same createPackageDBIfMissing function.


Edit: Nevermind, I was only looking at the comments, and not the OP. The issue in this comment looks like the same packagedb creation function this PR fixes.

The original message, however, references package.conf.inplace. I don't think this PR touches that codepath.

@ulysses4ever

Copy link
Copy Markdown
Collaborator

Could you please try to reproduce that issue locally and see if that's any difference with the cabal from this patch?

@Mikolaj

Mikolaj commented Jul 16, 2026

Copy link
Copy Markdown
Member

There's also https://gitlab.haskell.org/ghc/ghc/-/merge_requests/15498, which maybe fixes the same issue. Does it? Is this PR a cabal workaround for an underlying GHC problem maybe?

(i, r) <- takeMVar mv
either throwIO (\res -> pure (i, res)) r

liftIO $ forM_ results $ \(i, r) -> do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this test deterministic? Do we really need 10 threads to make it reliable? Anyway to make it deterministic with for example 2 threads?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's possible to make this entirely deterministic, after all we're testing for a race condition that depends on the timing of filesystem operations. The best we can do is make the timing as tight as we possibly can, and use a sufficiently large number of concurrent threads to make the race condition very likely to occur.
It's probably worth adding a comment to that extent though.

@crtschin

crtschin commented Jul 16, 2026

Copy link
Copy Markdown
Author

Could you please try to reproduce that issue locally and see if that's any difference with the cabal from this patch?

I can confirm that this PR does not fix that issue. Readjusting the reproducing test case in this PR with the following patch gives the error mentioned in issue #11298. The only difference being using a shared --builddir instead of a --store-dir.

necessary diff to get reproducer for #11298
diff --git a/cabal-testsuite/PackageTests/ConcurrentDistInplace/concurrent-inplace-init.test.hs b/cabal-testsuite/PackageTests/ConcurrentStorePackageDb/concurrent-store-init.test.hs
index 0bd5e218b..716ef80da 100644
--- a/cabal-testsuite/PackageTests/ConcurrentDistInplace/concurrent-inplace-init.test.hs
+++ b/cabal-testsuite/PackageTests/ConcurrentStorePackageDb/concurrent-store-init.test.hs
@@ -1,4 +1,11 @@
--- | Regression test for #11298.
+-- | Regression test for #11329
+--
+-- When several cabal processes share one --store-dir and that store is cold,
+-- they all race 'createPackageDBIfMissing'.
+--
+-- This test builds N independent trivial projects concurrently against one
+-- shared store. Each project gets its own build dir so the only shared state
+-- is the store package DB.
 
 import Control.Concurrent
 import Control.Exception (SomeException, throwIO, try)
@@ -8,59 +15,44 @@ import System.Directory (createDirectoryIfMissing, removePathForcibly)
 import System.Exit (ExitCode (..))
 import Test.Cabal.Prelude
 
-main = cabalTest $ expectBroken 11298 $ do
+main = cabalTest $ do
   env <- getTestEnv
   cabalPath <- programPath <$> requireProgramM cabalProgram
   let n = 10
       ids = [1 .. n] :: [Int]
       root = testCurrentDir env
       store = testWorkDir env </> "shared-store"
-      dist = testWorkDir env </> "shared-dist"
       projDir i = root </> ("p" ++ show i)
       build i =
         run
           (Just (projDir i))
           (testEnvironment env)
           cabalPath
-          ["--store-dir=" ++ store, "build", "--builddir=" ++ dist]
+          ["--store-dir=" ++ store, "build", "--builddir=" ++ projDir i </> "dist"]
           Nothing
 
-  -- Generate N executables that all depend on one shared library 'dep'.
-  liftIO $ do
-    createDirectoryIfMissing True (root </> "dep")
-    writeFile (root </> "dep" </> "dep.cabal") $
+  -- Generate N independent trivial projects.
+  liftIO $ forM_ ids $ \i -> do
+    let p = projDir i
+    createDirectoryIfMissing True p
+    writeFile (p </> ("p" ++ show i ++ ".cabal")) $
       unlines
         [ "cabal-version: 2.4"
-        , "name: dep"
+        , "name: p" ++ show i
         , "version: 0.1"
-        , "library"
-        , "  exposed-modules: Dep"
+        , "executable p" ++ show i
+        , "  main-is: Main.hs"
         , "  build-depends: base"
         , "  default-language: Haskell2010"
         ]
-    writeFile (root </> "dep" </> "Dep.hs") "module Dep where\n"
-    forM_ ids $ \i -> do
-      let p = projDir i
-      createDirectoryIfMissing True p
-      writeFile (p </> ("p" ++ show i ++ ".cabal")) $
-        unlines
-          [ "cabal-version: 2.4"
-          , "name: p" ++ show i
-          , "version: 0.1"
-          , "executable p" ++ show i
-          , "  main-is: Main.hs"
-          , "  build-depends: base, dep"
-          , "  default-language: Haskell2010"
-          ]
-      writeFile (p </> "Main.hs") "import Dep ()\nmain :: IO ()\nmain = return ()\n"
-    writeFile (root </> "cabal.project") $
-      "packages: dep " ++ unwords ["p" ++ show i | i <- ids] ++ "\n"
 
-  -- Warm the plan, then wipe the build tree so 'dep' is cold.
-  _ <- liftIO $ build 1
-  liftIO $ removePathForcibly (dist </> "build")
+    writeFile (p </> "Main.hs") "main :: IO ()\nmain = return ()\n"
+    writeFile (p </> "cabal.project") "packages: .\n"
 
-  -- Build all projects concurrently against the shared --builddir.
+  -- Make sure the shared store package DB is cold right before the burst.
+  liftIO $ removePathForcibly store
+
+  -- Build all projects concurrently against the cold shared store.
   results <- liftIO $ do
     slots <- forM ids $ \i -> do
       mv <- newEmptyMVar
@@ -75,7 +67,7 @@ main = cabalTest $ expectBroken 11298 $ do
   liftIO $ forM_ results $ \(i, r) -> do
     let out = resultOutput r
     assertBool
-      ("cabal build for p" ++ show i ++ " hit the package.conf.inplace race:\n" ++ out)
+      ("cabal build for p" ++ show i ++ " hit the store package.db init race:\n" ++ out)
       (not ("already exists" `isInfixOf` out))
     assertEqual
       ("cabal build for p" ++ show i ++ " failed:\n" ++ out)

There's also https://gitlab.haskell.org/ghc/ghc/-/merge_requests/15498, which maybe fixes the same issue. Does it? Is this PR a cabal workaround for an underlying GHC problem maybe?

I haven't looked too deeply at the GHC issue, but from a glance I'm not convinced it fixes the issue addressed by this PR. The errors noted in the issue look different enough.

Is this test deterministic? Do we really need 10 threads to make it reliable? Anyway to make it deterministic with for example 2 threads?

Edit: woops pressed enter too early.

It's a race. So by definition, no it's not deterministic. I think you mean to ask, "is this test reliable?". I ran this repeatedly with and without my fix. Without, it fails 15/15. With it succeeded, 15/15.

I could reduce the number of threads, but that would reduce the probability of hitting the race. The number 10 is arbitrary.

(i, r) <- takeMVar mv
either throwIO (\res -> pure (i, res)) r

liftIO $ forM_ results $ \(i, r) -> do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's possible to make this entirely deterministic, after all we're testing for a race condition that depends on the timing of filesystem operations. The best we can do is make the timing as tight as we possibly can, and use a sufficiently large number of concurrent threads to make the race condition very likely to occur.
It's probably worth adding a comment to that extent though.

let out = resultOutput r
assertBool
("cabal build for p" ++ show i ++ " hit the store package.db init race:\n" ++ out)
(not ("already exists" `isInfixOf` out))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could do better here. There are three, not two, possible outcomes of the test:

  • We hit the race condition (== test failure)
  • No race occurred because no two builds ran createPackageDBIfMissing concurrently (== inconclusive, should re-run the test)
  • No race occurred because the locks worked as intended (== test pass)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could do better here. There are three, not two, possible outcomes of the test:

I don't quite see what the approach you're referring to is. How do you envision being able to discern (2) from (3)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Possible race condition when creating .cabal/store/ghc-....

6 participants