From 3e35a5219054ff63b215d3f6863ddd063627f77d Mon Sep 17 00:00:00 2001 From: Robert Vollmert <rob@vllmrt.net> Date: Tue, 25 Jan 2022 23:27:54 +0100 Subject: [PATCH] Add some unit tests for asyncFetchPackages This primarily trigger concurrency bugs that are fixed in the follow-up commits. --- cabal-install/cabal-install.cabal | 1 + cabal-install/tests/UnitTests.hs | 3 + .../Distribution/Client/FetchUtils.hs | 209 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 cabal-install/tests/UnitTests/Distribution/Client/FetchUtils.hs diff --git a/cabal-install/cabal-install.cabal b/cabal-install/cabal-install.cabal index e56d558ba4..c2edf81915 100644 --- a/cabal-install/cabal-install.cabal +++ b/cabal-install/cabal-install.cabal @@ -275,6 +275,7 @@ Test-Suite unit-tests UnitTests.Distribution.Client.ArbitraryInstances UnitTests.Distribution.Client.BuildReport UnitTests.Distribution.Client.Configure + UnitTests.Distribution.Client.FetchUtils UnitTests.Distribution.Client.Get UnitTests.Distribution.Client.Glob UnitTests.Distribution.Client.GZipUtils diff --git a/cabal-install/tests/UnitTests.hs b/cabal-install/tests/UnitTests.hs index c124fe50e0..c321f07eb3 100644 --- a/cabal-install/tests/UnitTests.hs +++ b/cabal-install/tests/UnitTests.hs @@ -5,6 +5,7 @@ import Test.Tasty import qualified UnitTests.Distribution.Client.BuildReport import qualified UnitTests.Distribution.Client.Configure +import qualified UnitTests.Distribution.Client.FetchUtils import qualified UnitTests.Distribution.Client.Get import qualified UnitTests.Distribution.Client.Glob import qualified UnitTests.Distribution.Client.GZipUtils @@ -33,6 +34,8 @@ main = do UnitTests.Distribution.Client.BuildReport.tests , testGroup "UnitTests.Distribution.Client.Configure" UnitTests.Distribution.Client.Configure.tests + , testGroup "UnitTests.Distribution.Client.FetchUtils" + UnitTests.Distribution.Client.FetchUtils.tests , testGroup "UnitTests.Distribution.Client.Get" UnitTests.Distribution.Client.Get.tests , testGroup "UnitTests.Distribution.Client.Glob" diff --git a/cabal-install/tests/UnitTests/Distribution/Client/FetchUtils.hs b/cabal-install/tests/UnitTests/Distribution/Client/FetchUtils.hs new file mode 100644 index 0000000000..7ecd5f7ad0 --- /dev/null +++ b/cabal-install/tests/UnitTests/Distribution/Client/FetchUtils.hs @@ -0,0 +1,209 @@ +{-# LANGUAGE ScopedTypeVariables #-} +module UnitTests.Distribution.Client.FetchUtils + ( tests, + ) +where + +import Control.Concurrent (threadDelay) +import Control.Exception +import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime) +import Distribution.Client.FetchUtils +import Distribution.Client.GlobalFlags (RepoContext (..)) +import Distribution.Client.HttpUtils (HttpCode, HttpTransport (..)) +import Distribution.Client.Types.PackageLocation (PackageLocation (..), ResolvedPkgLoc) +import Distribution.Client.Types.Repo (Repo (..), emptyRemoteRepo) +import Distribution.Client.Types.RepoName (RepoName (..)) +import Distribution.Types.PackageId (PackageIdentifier (..)) +import Distribution.Types.PackageName (mkPackageName) +import qualified Distribution.Verbosity as Verbosity +import Distribution.Version (mkVersion) +import Network.URI (URI, uriPath) +import Test.Tasty +import Test.Tasty.HUnit +import UnitTests.TempTestDir (withTestDir) + +tests :: [TestTree] +tests = + [ testGroup + "asyncFetchPackages" + [ testCase "handles an empty package list" testEmpty, + testCase "passes an unpacked local package through" testPassLocalPackage, + testCase "handles http" testHttp, + testCase "aborts on interrupt in GET" $ testGetInterrupt, + testCase "aborts on other exception in GET" $ testGetException, + testCase "aborts on interrupt in GET (uncollected download)" $ testUncollectedInterrupt, + testCase "continues on other exception in GET (uncollected download)" $ testUncollectedException + ] + ] + +verbosity :: Verbosity.Verbosity +verbosity = Verbosity.silent + +-- | An interval that we use to assert that something happens "immediately". +-- Must be shorter than 'longSleep' to ensure those are interrupted. +-- 1s would be a reasonable value, but failed tempfile cleanup on Windows CI +-- takes ~1s. +shortDelta :: NominalDiffTime +shortDelta = 5 -- 5s + +longSleep :: IO () +longSleep = threadDelay 10000000 -- 10s + +testEmpty :: Assertion +testEmpty = do + let repoCtxt = undefined + pkgLocs = [] + res <- asyncFetchPackages verbosity repoCtxt pkgLocs $ \_ -> + return () + res @?= () + +testPassLocalPackage :: Assertion +testPassLocalPackage = do + let repoCtxt = error "repoCtxt undefined" + loc = LocalUnpackedPackage "a" + res <- asyncFetchPackages verbosity repoCtxt [loc] $ \downloadMap -> + waitAsyncFetchPackage verbosity downloadMap loc + res @?= LocalUnpackedPackage "a" + +testHttp :: Assertion +testHttp = withFakeRepoCtxt get200 $ \repoCtxt repo -> do + let pkgId = mkPkgId "foo" + loc = RepoTarballPackage repo pkgId Nothing + res <- asyncFetchPackages verbosity repoCtxt [loc] $ \downloadMap -> + waitAsyncFetchPackage verbosity downloadMap loc + case res of + RepoTarballPackage repo' pkgId' _ -> do + repo' @?= repo + pkgId' @?= pkgId + _ -> assertFailure $ "expected RepoTarballPackage, got " ++ show res + where + get200 = \_uri -> return 200 + +testGetInterrupt :: Assertion +testGetInterrupt = testGetAny UserInterrupt + +testGetException :: Assertion +testGetException = testGetAny $ userError "some error" + +-- | Test that if a GET request fails with the given exception, +-- we exit promptly. We queue two slow downloads after the failing +-- download to cover a buggy scenario where +-- 1. first download throws +-- 2. second download is cancelled, but swallows AsyncCancelled +-- 3. third download keeps running +testGetAny :: Exception e => e -> Assertion +testGetAny exc = withFakeRepoCtxt get $ \repoCtxt repo -> do + let loc pkgId = RepoTarballPackage repo pkgId Nothing + pkgLocs = [loc throws, loc slowA, loc slowB] + + start <- getCurrentTime + res :: Either SomeException ResolvedPkgLoc <- + try $ + asyncFetchPackages verbosity repoCtxt pkgLocs $ \downloadMap -> do + waitAsyncFetchPackage verbosity downloadMap (loc throws) + assertFaster start shortDelta + case res of + Left _ -> pure () + Right _ -> assertFailure $ "expected an exception, got " ++ show res + where + throws = mkPkgId "throws" + slowA = mkPkgId "slowA" + slowB = mkPkgId "slowB" + get uri = case uriPath uri of + "package/throws-1.0.tar.gz" -> throwIO exc + "package/slowA-1.0.tar.gz" -> longSleep >> return 200 + "package/slowB-1.0.tar.gz" -> longSleep >> return 200 + _ -> assertFailure $ "unexpected URI: " ++ show uri + +-- | Test that when an undemanded download is interrupted (Ctrl-C), +-- we still abort directly. +testUncollectedInterrupt :: Assertion +testUncollectedInterrupt = withFakeRepoCtxt get $ \repoCtxt repo -> do + let loc pkgId = RepoTarballPackage repo pkgId Nothing + pkgLocs = [loc throws, loc slowA, loc slowB] + + start <- getCurrentTime + res :: Either SomeException ResolvedPkgLoc <- + try $ + asyncFetchPackages verbosity repoCtxt pkgLocs $ \downloadMap -> do + waitAsyncFetchPackage verbosity downloadMap (loc slowA) + assertFaster start shortDelta + case res of + Left _ -> pure () + Right _ -> assertFailure $ "expected an exception, got " ++ show res + where + throws = mkPkgId "throws" + slowA = mkPkgId "slowA" + slowB = mkPkgId "slowB" + get uri = case uriPath uri of + "package/throws-1.0.tar.gz" -> throwIO UserInterrupt + "package/slowA-1.0.tar.gz" -> longSleep >> return 200 + "package/slowB-1.0.tar.gz" -> longSleep >> return 200 + _ -> assertFailure $ "unexpected URI: " ++ show uri + +-- | Test that a download failure doesn't automatically abort things, +-- e.g. if we don't collect the download. (In practice, we might collect +-- the download and handle its exception.) +testUncollectedException :: Assertion +testUncollectedException = withFakeRepoCtxt get $ \repoCtxt repo -> do + let loc pkgId = RepoTarballPackage repo pkgId Nothing + pkgLocs = [loc throws, loc foo] + + start <- getCurrentTime + res <- asyncFetchPackages verbosity repoCtxt pkgLocs $ \downloadMap -> do + waitAsyncFetchPackage verbosity downloadMap (loc foo) + assertFaster start shortDelta + case res of + RepoTarballPackage repo' pkgId' _ -> do + repo' @?= repo + pkgId' @?= foo + _ -> assertFailure $ "expected RepoTarballPackage, got " ++ show res + where + throws = mkPkgId "throws" + foo = mkPkgId "foo" + get uri = case uriPath uri of + "package/throws-1.0.tar.gz" -> throwIO $ userError "failed download" + "package/foo-1.0.tar.gz" -> return 200 + _ -> assertFailure $ "unexpected URI: " ++ show uri + +assertFaster :: UTCTime -> NominalDiffTime -> Assertion +assertFaster start delta = do + t <- getCurrentTime + assertBool ("took longer than " ++ show delta) (diffUTCTime t start < delta) + +mkPkgId :: String -> PackageIdentifier +mkPkgId name = PackageIdentifier (mkPackageName name) (mkVersion [1, 0]) + +-- | Provide a repo and a repo context with the given GET handler. +withFakeRepoCtxt :: + (URI -> IO HttpCode) -> + (RepoContext -> Repo -> IO a) -> + IO a +withFakeRepoCtxt handleGet action = + withTestDir verbosity "fake repo" $ \tmpDir -> + let repo = + RepoRemote + { repoRemote = emptyRemoteRepo $ RepoName "fake", + repoLocalDir = tmpDir + } + repoCtxt = + RepoContext + { repoContextRepos = [repo], + repoContextGetTransport = return httpTransport, + repoContextWithSecureRepo = \_ _ -> + error "fake repo ctxt: repoContextWithSecureRepo not implemented", + repoContextIgnoreExpiry = error "fake repo ctxt: repoContextIgnoreExpiry not implemented" + } + in action repoCtxt repo + where + httpTransport = + HttpTransport + { getHttp = \_verbosity uri _etag _filepath _headers -> do + code <- handleGet uri + return (code, Nothing), + postHttp = error "fake transport: postHttp not implemented", + postHttpFile = error "fake transport: postHttpFile not implemented", + putHttpFile = error "fake transport: putHttp not implemented", + transportSupportsHttps = error "fake transport: transportSupportsHttps not implemented", + transportManuallySelected = True + } -- GitLab