Skip to content

Lock store creation against concurrent processes - #12114

Merged
mergify[bot] merged 9 commits into
haskell:masterfrom
crtschin:crtschin/fix-concurrent-store-create
Sep 2, 2026
Merged

mergify[bot] merged 9 commits into
haskell:masterfrom
crtschin:crtschin/fix-concurrent-store-create

Conversation

@crtschin

@crtschin crtschin commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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
Contributor 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.

Comment thread cabal-install/src/Distribution/Client/Store.hs Outdated
@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
Contributor 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?

@crtschin

crtschin commented Jul 16, 2026

Copy link
Copy Markdown
Contributor 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.

@crtschin
crtschin requested a review from tdammers July 23, 2026 18:48
@Bodigrim

Bodigrim commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

This is an important PR, which I would ideally see backported to 3.18.

@tdammers could you please review again and resolve previous round of suggestions if applicable? Otherwise the PR is blocked.

@crtschin
crtschin force-pushed the crtschin/fix-concurrent-store-create branch from fcafa0e to acd698a Compare July 30, 2026 19:11
@Bodigrim

Copy link
Copy Markdown
Collaborator

@tdammers could you please give it another look (and mark resolved conversations as such)?

@Bodigrim

Copy link
Copy Markdown
Collaborator

@crtschin are all the comments resolved? If yes, could you please mark them as such?

@crtschin

Copy link
Copy Markdown
Contributor Author

Yes, done 👍🏼

@Bodigrim

Copy link
Copy Markdown
Collaborator

This awaits for a second approval please.

@ulysses4ever

Copy link
Copy Markdown
Collaborator

we need @tdammers to explicitly unblock (or even better, approve) perhaps?

@ffaf1

ffaf1 commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

This is important enough to backport to 3.18.2.0.

Since it is a point release and of course we do not want to introduc further regressions, it is paramount for the backport to happen that all the pending requests for clarifications are resolved thoroughly.

@ulysses4ever

Copy link
Copy Markdown
Collaborator

Should we dismiss the stale change request for the sake of this getting into 3.18.2.0? @Bodigrim @Mikolaj @ffaf1 @philderbeast

@Bodigrim

Copy link
Copy Markdown
Collaborator

I think so, the requested changes were limited to tests only.

@ulysses4ever ulysses4ever added squash+merge me Tell Mergify Bot to squash-merge and removed attention: needs-review labels Aug 30, 2026
@mergify mergify Bot added the ready and waiting Mergify is waiting out the cooldown period label Aug 30, 2026

@tdammers tdammers left a comment

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.

Looks good to me. Apologies for the late response.

Comment thread changelog.d/pr-12114.md Outdated
@haskell haskell deleted a comment from humh25 Aug 31, 2026
@mergify mergify Bot added merge delay passed Applied (usually by Mergify) when PR approved and received no updates for 2 days queued labels Sep 2, 2026
@mergify

mergify Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

This pull request spent 2 hours 17 minutes 36 seconds in the queue, including 2 hours 6 minutes 46 seconds running CI.

Required conditions to merge
  • #review-threads-unresolved = 0 [🛡 GitHub branch protection]
  • github-review-approved [🛡 GitHub branch protection]
  • any of [🛡 GitHub branch protection]:
    • check-success = Doctest Cabal
    • check-neutral = Doctest Cabal
    • check-skipped = Doctest Cabal
  • any of [🛡 GitHub branch protection]:
    • check-success = Meta checks
    • check-neutral = Meta checks
    • check-skipped = Meta checks
  • any of [🛡 GitHub branch protection]:
    • check-success = docs/readthedocs.org:cabal
    • check-neutral = docs/readthedocs.org:cabal
    • check-skipped = docs/readthedocs.org:cabal
  • any of [🛡 GitHub branch protection]:
    • check-success = Validate post job
    • check-neutral = Validate post job
    • check-skipped = Validate post job
  • any of [🛡 GitHub branch protection]:
    • check-success = fourmolu
    • check-neutral = fourmolu
    • check-skipped = fourmolu
  • any of [🛡 GitHub branch protection]:
    • check-success = hlint
    • check-neutral = hlint
    • check-skipped = hlint
  • any of [🛡 GitHub branch protection]:
    • check-success = Bootstrap post job
    • check-neutral = Bootstrap post job
    • check-skipped = Bootstrap post job
  • any of [🛡 GitHub branch protection]:
    • check-success = whitespace
    • check-neutral = whitespace
    • check-skipped = whitespace
  • any of [🛡 GitHub branch protection]:
    • check-success = Check sdist post job
    • check-neutral = Check sdist post job
    • check-skipped = Check sdist post job
  • any of [🛡 GitHub branch protection]:
    • check-success = Changelogs
    • check-neutral = Changelogs
    • check-skipped = Changelogs

@mergify
mergify Bot merged commit f068346 into haskell:master Sep 2, 2026
63 checks passed
@mergify mergify Bot removed the queued label Sep 2, 2026
@zlonast

zlonast commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

@mergify backport 3.18

@mergify

mergify Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

backport 3.18

✅ Backports have been created

Details

mergify Bot added a commit that referenced this pull request Sep 5, 2026
Backport #12114: Lock store creation against concurrent processes
Mikolaj pushed a commit to Mikolaj/cabal that referenced this pull request Sep 10, 2026
* Lock store creation against concurrent processes

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.

* Add changelog entry

* Move `withFileLock` to `Distribution.Simple.Utils`

* fixup! Move `withFileLock` to `Distribution.Simple.Utils`

* fixup! Lock store creation against concurrent processes

* Test for lock contention in test using logs and retry

* fixup! Test for lock contention in test using logs and retry

* Mark the concurrent store test flaky on CI

* fixup! Add changelog entry

(cherry picked from commit f068346)
leana8959 pushed a commit to leana8959/cabal that referenced this pull request Sep 12, 2026
* Lock store creation against concurrent processes

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.

* Add changelog entry

* Move `withFileLock` to `Distribution.Simple.Utils`

* fixup! Move `withFileLock` to `Distribution.Simple.Utils`

* fixup! Lock store creation against concurrent processes

* Test for lock contention in test using logs and retry

* fixup! Test for lock contention in test using logs and retry

* Mark the concurrent store test flaky on CI

* fixup! Add changelog entry
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attention: needs-backport 3.18 merge delay passed Applied (usually by Mergify) when PR approved and received no updates for 2 days ready and waiting Mergify is waiting out the cooldown period squash+merge me Tell Mergify Bot to squash-merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

8 participants