Description
Overview
At a high level, Stack allows packages to have circular dependencies if it's linearizable at the component level (Cabal flat out rejects to build this situation). However, Stack will fail to recognize that it can reuse the first library and rebuild, causing the second library to be using an old version of the first library.
This is similar to #3130 in that both issues stem from a library still using an old version of a dependent library. But in #3130, the problem is Stack not rebuilding enough, and in this issue, the problem is Stack rebuilds unnecessarily. In fact, #3130 is not an issue if you stack clean
before every build, but this issue is still present even from a clean build.
Problems
In all of the situations below, we have the following project:
-- my-lib/my-lib.cabal
cabal-version: 3.0
name: my-lib
version: 0
build-type: Simple
library
exposed-modules: MyLib
hs-source-dirs: src
build-depends: base
test-suite my-lib-tests
type: exitcode-stdio-1.0
main-is: Test.hs
build-depends: base, my-lib, my-test-utils
-- my-test-utils/my-test-utils.cabal
cabal-version: 3.0
name: my-test-utils
version: 0
build-type: Simple
library
exposed-modules: MyTestUtils
hs-source-dirs: src
build-depends: base, my-lib
This creates a dependency chain that looks like:
my-lib:lib
│ └─> my-test-utils:lib
│ ↓
└─> my-lib:test-suite
When running a stack test
, Stack will do the following:
- Build
my-lib:lib
- Build
my-test-utils:lib
againstmy-lib:lib (1)
- Rebuild (!!)
my-lib:lib
- Build
my-lib:test-suite
againstmy-test-utils:lib
andmy-lib:lib (2)
Problem 1: compilation error with old data type
In this minimal repro, we can see that my-test-utils
is still using the old library's Foo
data type (as exposed by the foo
function), as GHC complains that you're trying to compare an old Foo
type with a new Foo
type.
-- my-lib/src/MyLib.hs
module MyLib (foo) where
data Foo = Foo Int True
deriving (Show, Eq)
foo :: Foo
foo = Foo 1 True
-- my-test-utils/src/MyTestUtils.hs
module MyTestUtils (foo) where
import MyLib
-- my-lib/Test.hs
import qualified MyLib
import qualified MyTestUtils
main :: IO ()
main = print $ MyLib.foo == MyTestUtils.foo
Expected
Works
Actual
my-lib/Test.hs:9:24: error: [GHC-83865]
• Couldn't match expected type ‘MyLib.Foo’
with actual type ‘my-lib-0:MyLib.Foo’
NB: ‘MyLib.Foo’ is defined in ‘MyLib’ in package ‘my-lib-0’
‘my-lib-0:MyLib.Foo’ is defined in ‘MyLib’ in package ‘my-lib-0’
• In the second argument of ‘(==)’, namely ‘MyTestUtils.foo’
In the second argument of ‘($)’, namely
‘MyLib.foo == MyTestUtils.foo’
In a stmt of a 'do' block: print $ MyLib.foo == MyTestUtils.foo
|
9 | print $ MyLib.foo == MyTestUtils.foo
| ^^^^^^^^^^^^^^^
Problem 2: surprising behavior with global IORef
This situation is the original problem I ran into, and it was surprising because it actually compiles successfully (unlike problem 1), but it has surprising behavior at runtime: the global IORef is actually initialized twice, once for my-lib:lib (1)
and once for my-lib:lib (2)
. So writeRefFromTestUtils
actually sets a different IORef than the one read from readRefFromLib
.
-- my-lib/src/MyLib.hs
module MyLib (
readRefFromLib,
setRefForTests,
) where
import Data.IORef
import System.IO.Unsafe
myRef :: IORef Int
myRef = unsafePerformIO $ newIORef 0
{-# NOINLINE myRef #-}
readRefFromLib :: IO ()
readRefFromLib = do
x <- readIORef myRef
putStrLn $ "readRefFromLib: " <> show x
setRefForTests :: Int -> IO ()
setRefForTests x = do
writeIORef myRef x
putStrLn $ "setRefForTests: " <> show x
-- my-test-utils/src/MyTestUtils.hs
module MyTestUtils where
import Data.IORef
import MyLib
writeRefFromTestUtils :: IO ()
writeRefFromTestUtils =
setRefForTests 123
-- my-lib/Test.hs
import MyLib
import MyTestUtils
main :: IO ()
main = do
readRefFromLib
writeRefFromTestUtils
readRefFromLib
Expected
readRefFromLib: 0
setRefForTests: 123
readRefFromLib: 123
Actual
readRefFromLib: 0
setRefForTests: 123
readRefFromLib: 0