diff --git a/waspc/cli/src/Wasp/Cli/Command/BuildStart.hs b/waspc/cli/src/Wasp/Cli/Command/BuildStart.hs index 06829c39e8..b9a0479e21 100644 --- a/waspc/cli/src/Wasp/Cli/Command/BuildStart.hs +++ b/waspc/cli/src/Wasp/Cli/Command/BuildStart.hs @@ -3,10 +3,11 @@ module Wasp.Cli.Command.BuildStart ) where -import Control.Concurrent.Async (concurrently) -import Control.Concurrent.Chan (newChan) -import Control.Monad.Except (MonadError (throwError), runExceptT) +import Control.Concurrent (Chan, newChan) +import qualified Control.Concurrent.Async as Async +import Control.Monad.Except (MonadError (throwError)) import Control.Monad.IO.Class (liftIO) +import System.Exit (ExitCode (..)) import Wasp.Cli.Command (Command, CommandError (CommandError), require) import Wasp.Cli.Command.BuildStart.ArgumentsParser (buildStartArgsParser) import Wasp.Cli.Command.BuildStart.Client (buildClient, startClient) @@ -20,9 +21,8 @@ import Wasp.Cli.Command.Require.InWaspProject (InWaspProject (InWaspProject)) import Wasp.Cli.Command.Require.ValidNodeAndNpm (ValidNodeAndNpm (ValidNodeAndNpm)) import Wasp.Cli.Command.Require.WaspSpecAvailable (WaspSpecAvailable (WaspSpecAvailable)) import Wasp.Cli.Util.Parser (withArguments) -import Wasp.Job.Except (ExceptJob) -import qualified Wasp.Job.Except as ExceptJob -import Wasp.Job.IO (readJobMessagesAndPrintThemPrefixed) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Output as Output import qualified Wasp.Message as Msg buildStart :: Arguments -> Command () @@ -48,31 +48,37 @@ buildStart = withArguments "wasp build start" buildStartArgsParser $ \args -> do buildAndStartServerAndClient :: BuildStartConfig -> Command () buildAndStartServerAndClient config = do cliSendMessageC $ Msg.Start "Building client..." - runAndPrintJob "Building client failed." $ - buildClient config + runAndPrintJobOutput (Job.runJob $ buildClient config) + >>= throwOnExitFailure "Building client failed." cliSendMessageC $ Msg.Success "Client built." cliSendMessageC $ Msg.Start "Building server..." - runAndPrintJob "Building server failed." $ - buildServer config + runAndPrintJobOutput (Job.runJob $ buildServer config) + >>= throwOnExitFailure "Building server failed." cliSendMessageC $ Msg.Success "Server built." cliSendMessageC $ Msg.Start "Starting client and server..." - runAndPrintJob "Starting Wasp app failed." $ - ExceptJob.race_ - (startClient config) - (startServer config) + firstExit <- + runAndPrintJobOutput $ \events -> + Async.race + (Job.runJob (startClient config) events) + (Job.runJob (startServer config) events) + case firstExit of + Left clientExit -> throwOnExitFailure "Serving client failed." clientExit + Right serverExit -> throwOnExitFailure "Running server failed." serverExit where - runAndPrintJob :: String -> ExceptJob -> Command () - runAndPrintJob errorMessage job = do - liftIO (runAndPrintJobIO job) - >>= either (throwError . CommandError errorMessage) return - - runAndPrintJobIO :: ExceptJob -> IO (Either String ()) - runAndPrintJobIO job = do + runAndPrintJobOutput :: (Chan Job.JobEvent -> IO a) -> Command a + runAndPrintJobOutput run = liftIO $ do chan <- newChan - (result, _) <- - concurrently - (runExceptT $ job chan) - (readJobMessagesAndPrintThemPrefixed chan) - return result + fst + <$> Async.concurrently + (run chan) + (Output.printEventsPrefixedUntilExit chan) + + throwOnExitFailure :: String -> ExitCode -> Command () + throwOnExitFailure _ ExitSuccess = return () + throwOnExitFailure errorTitle (ExitFailure code) = + throwError $ + CommandError + errorTitle + ("Process exited with code " <> show code <> ".") diff --git a/waspc/cli/src/Wasp/Cli/Command/BuildStart/Client.hs b/waspc/cli/src/Wasp/Cli/Command/BuildStart/Client.hs index dddfa1afb3..c7600a9aaa 100644 --- a/waspc/cli/src/Wasp/Cli/Command/BuildStart/Client.hs +++ b/waspc/cli/src/Wasp/Cli/Command/BuildStart/Client.hs @@ -4,29 +4,26 @@ module Wasp.Cli.Command.BuildStart.Client ) where -import Data.Function ((&)) import Wasp.Cli.Command.BuildStart.Config (BuildStartConfig) import qualified Wasp.Cli.Command.BuildStart.Config as Config -import qualified Wasp.Job as J -import Wasp.Job.Except (ExceptJob, toExceptJob) -import Wasp.Job.Process (runNodeCommandAsJob, runNodeCommandAsJobWithExtraEnv) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Node as Node -buildClient :: BuildStartConfig -> ExceptJob +buildClient :: BuildStartConfig -> Job.Job buildClient config = - runNodeCommandAsJobWithExtraEnv + Node.makeJobWithExtraEnv envVars projectDir "npx" ["vite", "build"] - J.WebApp - & toExceptJob (("Building the client failed with exit code: " <>) . show) + Job.WebApp where envVars = Config.clientEnvVars config projectDir = Config.projectDir config -startClient :: BuildStartConfig -> ExceptJob +startClient :: BuildStartConfig -> Job.Job startClient config = - runNodeCommandAsJob + Node.makeJob projectDir "npx" [ "vite", @@ -35,8 +32,7 @@ startClient config = port, "--strictPort" -- This will make it fail if the port is already in use. ] - J.WebApp - & toExceptJob (("Serving the client failed with exit code: " <>) . show) + Job.WebApp where port = show $ Config.clientPort config diff --git a/waspc/cli/src/Wasp/Cli/Command/BuildStart/Server.hs b/waspc/cli/src/Wasp/Cli/Command/BuildStart/Server.hs index 702d806081..605cf821ec 100644 --- a/waspc/cli/src/Wasp/Cli/Command/BuildStart/Server.hs +++ b/waspc/cli/src/Wasp/Cli/Command/BuildStart/Server.hs @@ -4,38 +4,32 @@ module Wasp.Cli.Command.BuildStart.Server ) where -import Data.Function ((&)) import qualified StrongPath as SP import System.Process (proc) import Wasp.Cli.Command.BuildStart.Config (BuildStartConfig) import qualified Wasp.Cli.Command.BuildStart.Config as Config -import qualified Wasp.Job as J -import Wasp.Job.Except (ExceptJob, toExceptJob) -import Wasp.Job.Process (runProcessAsJob) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Subprocess as Subprocess -buildServer :: BuildStartConfig -> ExceptJob +buildServer :: BuildStartConfig -> Job.Job buildServer config = - runProcessAsJob - (proc "docker" ["build", "--tag", dockerImageName, dockerContextDir]) - J.Server - & toExceptJob (("Building the server failed with exit code: " <>) . show) + Job.makeJob Job.Server $ + Subprocess.run (proc "docker" ["build", "--tag", dockerImageName, dockerContextDir]) where dockerContextDir = SP.fromAbsDir buildDir buildDir = Config.buildDir config dockerImageName = Config.dockerImageName config -startServer :: BuildStartConfig -> ExceptJob +startServer :: BuildStartConfig -> Job.Job startServer config = - runProcessAsJob - ( proc + Job.makeJob Job.Server $ + Subprocess.run $ + proc "docker" ( ["run", "--name", dockerContainerName, "--rm", "--network", "host"] <> envVarParams <> [dockerImageName] ) - ) - J.Server - & toExceptJob (("Running the server failed with exit code: " <>) . show) where envVarParams = toEnvVarParams $ Config.serverEnvVars config dockerContainerName = Config.dockerContainerName config diff --git a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs index f255f4f828..38f7b98ba6 100644 --- a/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs +++ b/waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs @@ -11,7 +11,8 @@ import Wasp.Cli.Command (Command, require) import Wasp.Cli.Command.Message (cliSendMessageC) import Wasp.Cli.Command.Require.InWaspProject (InWaspProject (InWaspProject)) import Wasp.Generator.DbGenerator.Jobs (runStudio) -import Wasp.Job.IO (readJobMessagesAndPrintThemPrefixed) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Output as Output import qualified Wasp.Message as Msg import Wasp.Project.Common (generatedAppDirInWaspProjectDir) @@ -23,6 +24,6 @@ studio = do cliSendMessageC $ Msg.Start "Running studio..." chan <- liftIO newChan - _ <- liftIO $ readJobMessagesAndPrintThemPrefixed chan `concurrently` runStudio genProjectDir chan + _ <- liftIO $ Output.printEventsPrefixedUntilExit chan `concurrently` Job.runJob (runStudio genProjectDir) chan error "This should never happen, studio should never stop." diff --git a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs index 62575d1555..12e9360fea 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Jobs.hs @@ -21,7 +21,7 @@ import Wasp.Generator.DbGenerator.Common (MigrateArgs (..), ResetArgs (..), dbSc import Wasp.Generator.ServerGenerator.Common (serverRootDirInGeneratedAppDir) import Wasp.Generator.ServerGenerator.Db.Seed (dbSeedNameEnvVarName) import qualified Wasp.Job as J -import Wasp.Job.Process (runNodeCommandAsJobWithExtraEnv) +import qualified Wasp.Job.Node as Node import Wasp.Project.Common (WaspProjectDir, waspProjectDirFromGeneratedAppDir) migrateDev :: Path' Abs (Dir GeneratedAppDir) -> MigrateArgs -> J.Job @@ -175,7 +175,7 @@ runPrismaCommandAsJobWithExtraEnv :: [String] -> J.Job runPrismaCommandAsJobWithExtraEnv fromDir envVars generatedAppDir cmdArgs = - runNodeCommandAsJobWithExtraEnv envVars fromDir (absPrismaExecutableFp waspProjectDir) cmdArgs J.Db + Node.makeJobWithExtraEnv envVars fromDir (absPrismaExecutableFp waspProjectDir) cmdArgs J.Db where waspProjectDir = generatedAppDir waspProjectDirFromGeneratedAppDir diff --git a/waspc/src/Wasp/Generator/DbGenerator/Operations.hs b/waspc/src/Wasp/Generator/DbGenerator/Operations.hs index 5e61f23e78..41825c96f8 100644 --- a/waspc/src/Wasp/Generator/DbGenerator/Operations.hs +++ b/waspc/src/Wasp/Generator/DbGenerator/Operations.hs @@ -38,11 +38,8 @@ import Wasp.Generator.DbGenerator.Common import qualified Wasp.Generator.DbGenerator.Jobs as DbJobs import Wasp.Generator.FileDraft.WriteableMonad (WriteableMonad (copyDirectoryRecursive, doesDirectoryExist)) import qualified Wasp.Generator.WriteFileDrafts as Generator.WriteFileDrafts -import Wasp.Job.IO - ( collectJobTextOutputUntilExitReceived, - printJobMsgsUntilExitReceived, - readJobMessagesAndPrintThemPrefixed, - ) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Output as Output import Wasp.Project.Db.Migrations (DbMigrationsDir) import Wasp.Util (checksumFromFilePath, hexToString) import Wasp.Util.IO (deleteFileIfExists, doesFileExist) @@ -61,8 +58,8 @@ migrateDevAndCopyToSource dbMigrationsDirInWaspProjectDirAbs generatedAppDirAbs chan <- newChan (_, dbExitCode) <- concurrently - (printJobMsgsUntilExitReceived chan) - (DbJobs.migrateDev generatedAppDirAbs migrateArgs chan) + (Output.printEventsUntilExit chan) + (Job.runJob (DbJobs.migrateDev generatedAppDirAbs migrateArgs) chan) case dbExitCode of ExitSuccess -> finalizeMigration generatedAppDirAbs dbMigrationsDirInWaspProjectDirAbs (getOnLastDbConcurrenceChecksumFileRefreshAction migrateArgs) ExitFailure code -> return $ Left $ "Migrate (dev) failed with exit code: " ++ show code @@ -137,7 +134,7 @@ dbReset generatedAppDir resetArgs = do removeDbSchemaChecksumFile generatedAppDir dbSchemaChecksumOnLastDbConcurrenceFileInGeneratedAppDir chan <- newChan ((), exitCode) <- - readJobMessagesAndPrintThemPrefixed chan `concurrently` DbJobs.reset generatedAppDir resetArgs chan + Output.printEventsPrefixedUntilExit chan `concurrently` Job.runJob (DbJobs.reset generatedAppDir resetArgs) chan return $ case exitCode of ExitSuccess -> Right () ExitFailure c -> Left $ "Failed with exit code " <> show c @@ -149,7 +146,7 @@ dbSeed :: dbSeed generatedAppDir seedName = do chan <- newChan ((), exitCode) <- - readJobMessagesAndPrintThemPrefixed chan `concurrently` DbJobs.seed generatedAppDir seedName chan + Output.printEventsPrefixedUntilExit chan `concurrently` Job.runJob (DbJobs.seed generatedAppDir seedName) chan return $ case exitCode of ExitSuccess -> Right () ExitFailure c -> Left $ "Failed with exit code " <> show c @@ -159,12 +156,12 @@ testDbConnection :: IO DbConnectionTestResult testDbConnection generatedAppDir = do chan <- newChan - exitCode <- DbJobs.dbExecuteTest generatedAppDir chan + exitCode <- Job.runJob (DbJobs.dbExecuteTest generatedAppDir) chan case exitCode of ExitSuccess -> return DbConnectionSuccess ExitFailure _ -> do - outputLines <- collectJobTextOutputUntilExitReceived chan + outputLines <- Output.collectTextUntilExit chan let databaseNotCreated = any prismaErrorContainsDbNotCreatedError outputLines return $ @@ -186,8 +183,8 @@ generatePrismaClient generatedAppDir = do chan <- newChan (_, exitCode) <- concurrently - (readJobMessagesAndPrintThemPrefixed chan) - (DbJobs.generatePrismaClient generatedAppDir chan) + (Output.printEventsPrefixedUntilExit chan) + (Job.runJob (DbJobs.generatePrismaClient generatedAppDir) chan) case exitCode of ExitFailure code -> return $ Left $ "Prisma client generation failed with exit code: " ++ show code ExitSuccess -> do @@ -207,8 +204,8 @@ doesSchemaMatchDb generatedAppDirAbs = do chan <- newChan (_, dbExitCode) <- concurrently - (readJobMessagesAndPrintThemPrefixed chan) - (DbJobs.migrateDiff generatedAppDirAbs chan) + (Output.printEventsPrefixedUntilExit chan) + (Job.runJob (DbJobs.migrateDiff generatedAppDirAbs) chan) -- Schema in sync: 0, Error: 1, Schema differs: 2 case dbExitCode of ExitSuccess -> return $ Just True @@ -225,8 +222,8 @@ areAllMigrationsAppliedToDb generatedAppDirAbs = do chan <- newChan (_, dbExitCode) <- concurrently - (readJobMessagesAndPrintThemPrefixed chan) - (DbJobs.migrateStatus generatedAppDirAbs chan) + (Output.printEventsPrefixedUntilExit chan) + (Job.runJob (DbJobs.migrateStatus generatedAppDirAbs) chan) case dbExitCode of ExitSuccess -> return $ Just True ExitFailure _ -> return Nothing diff --git a/waspc/src/Wasp/Generator/NpmInstall.hs b/waspc/src/Wasp/Generator/NpmInstall.hs index a1929df170..c0b5edcb3e 100644 --- a/waspc/src/Wasp/Generator/NpmInstall.hs +++ b/waspc/src/Wasp/Generator/NpmInstall.hs @@ -4,27 +4,29 @@ module Wasp.Generator.NpmInstall ) where -import Control.Concurrent (Chan, newChan, readChan, threadDelay, writeChan) +import Control.Concurrent (Chan, newChan, threadDelay) import Control.Concurrent.Async (concurrently) +import qualified Control.Concurrent.Async as Async import Control.Monad (when) import Control.Monad.Except (MonadError (throwError), runExceptT) import Control.Monad.IO.Class (liftIO) +import Control.Monad.Trans.Resource (allocate, release) import Data.Functor ((<&>)) import qualified Data.Text as T import StrongPath (Abs, Dir, Path') import qualified StrongPath as SP import System.Exit (ExitCode (..)) -import UnliftIO (race) import Wasp.AppSpec (AppSpec (waspProjectDir)) import Wasp.Generator.Common (GeneratedAppDir) import Wasp.Generator.Monad (GeneratorError (..)) import Wasp.Generator.NpmInstall.Common (AllNpmDeps (..), getAllNpmDeps) import Wasp.Generator.NpmInstall.InstalledNpmDepsLog (forgetInstalledNpmDepsLog, loadInstalledNpmDepsLog, saveInstalledNpmDepsLog) -import Wasp.Job (Job, JobMessage, JobType) -import qualified Wasp.Job as J -import Wasp.Job.IO.PrefixedWriter (PrefixedWriter, printJobMessagePrefixed, runPrefixedWriter) -import Wasp.Job.Process (runNodeCommandAsJob) +import qualified Wasp.Job as Job +import Wasp.Job.Internal (JobOutputSink, getJobOutputSink, writeJobOutput) +import qualified Wasp.Job.Node as Node +import qualified Wasp.Job.Output as Job.Output import Wasp.Project.Common (WaspProjectDir, nodeModulesDirInWaspProjectDir) +import Wasp.Util (secondsToMicroSeconds) import qualified Wasp.Util.IO as IOUitl -- Runs `npm install` in the user's Wasp project directory. @@ -66,46 +68,35 @@ installNpmDependenciesWithInstallRecord spec dstDir = runExceptT $ do -- Installs npm dependencies from the user's package.json, by running `npm install` . installProjectNpmDependencies :: - Chan JobMessage -> SP.Path SP.System Abs (Dir WaspProjectDir) -> IO (Either String ()) + Chan Job.JobEvent -> SP.Path SP.System Abs (Dir WaspProjectDir) -> IO (Either String ()) installProjectNpmDependencies messagesChan projectDir = - handleProjectInstallMessages messagesChan `concurrently` installProjectDepsJob - <&> snd + Job.Output.printEventsPrefixedUntilExit messagesChan `concurrently` Job.runJob installProjectDepsJob messagesChan <&> \case - ExitFailure code -> Left $ "Project setup failed with exit code " ++ show code ++ "." - _success -> Right () + (_, ExitFailure code) -> Left $ "Project setup failed with exit code " ++ show code ++ "." + (_, ExitSuccess) -> Right () where installProjectDepsJob = - installNpmDependenciesAndReport - (runNodeCommandAsJob projectDir "npm" ["install"] J.Wasp) - messagesChan - J.Wasp - handleProjectInstallMessages :: Chan J.JobMessage -> IO () - handleProjectInstallMessages = runPrefixedWriter . processMessages - where - processMessages :: Chan J.JobMessage -> PrefixedWriter () - processMessages chan = do - jobMsg <- liftIO $ readChan chan - case J._data jobMsg of - J.JobOutput {} -> printJobMessagePrefixed jobMsg >> processMessages chan - J.JobExit {} -> return () + Job.makeJob Job.Wasp $ + installNpmDependenciesAndReport $ + Node.run projectDir "npm" ["install"] -installNpmDependenciesAndReport :: Job -> Chan JobMessage -> JobType -> IO ExitCode -installNpmDependenciesAndReport installJob chan jobType = do - writeChan chan $ J.JobMessage {J._data = J.JobOutput "Starting npm install\n" J.Stdout, J._jobType = jobType} - result <- installJob chan `race` reportInstallationProgress chan jobType - case result of - Left exitCode -> return exitCode - Right _ -> error "This should never happen, reporting installation progress should run forever." +installNpmDependenciesAndReport :: Job.JobAction a -> Job.JobAction a +installNpmDependenciesAndReport install = do + Job.emitJobOutput Job.Stdout "Starting npm install\n" + outputSink <- getJobOutputSink + (progressReporterKey, _) <- allocate (Async.async $ reportInstallationProgress outputSink) Async.cancel + result <- install + release progressReporterKey + return result -reportInstallationProgress :: Chan JobMessage -> JobType -> IO () -reportInstallationProgress chan jobType = reportPeriodically allPossibleMessages +reportInstallationProgress :: JobOutputSink -> IO () +reportInstallationProgress outputSink = reportPeriodically allPossibleMessages where reportPeriodically messages = do - threadDelay $ secToMicroSec 5 - writeChan chan $ J.JobMessage {J._data = J.JobOutput (T.append (head messages) "\n") J.Stdout, J._jobType = jobType} - threadDelay $ secToMicroSec 5 + threadDelay $ secondsToMicroSeconds 5 + writeJobOutput outputSink Job.Stdout $ T.append (head messages) "\n" + threadDelay $ secondsToMicroSeconds 5 reportPeriodically $ drop 1 messages - secToMicroSec = (* 1000000) allPossibleMessages = cycle [ "Still installing npm dependencies!", diff --git a/waspc/src/Wasp/Generator/SdkGenerator.hs b/waspc/src/Wasp/Generator/SdkGenerator.hs index 79bb053a97..4a91169f1c 100644 --- a/waspc/src/Wasp/Generator/SdkGenerator.hs +++ b/waspc/src/Wasp/Generator/SdkGenerator.hs @@ -75,9 +75,9 @@ import qualified Wasp.Generator.ServerGenerator.Common as Server import Wasp.Generator.WaspLibs.AvailableLibs (waspLibs) import qualified Wasp.Generator.WaspLibs.WaspLib as WaspLib import qualified Wasp.Generator.WebAppGenerator.Common as WebApp -import qualified Wasp.Job as J -import Wasp.Job.IO (readJobMessagesAndPrintThemPrefixed) -import Wasp.Job.Process (runNodeCommandAsJob) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Node as Node +import qualified Wasp.Job.Output as Output import qualified Wasp.Node.Version as NodeVersion import qualified Wasp.Project.Db as Db import qualified Wasp.SemanticVersion.Version as SV @@ -90,8 +90,8 @@ buildSdk generatedAppDir = do chan <- newChan (_, exitCode) <- concurrently - (readJobMessagesAndPrintThemPrefixed chan) - (runNodeCommandAsJob sdkRootDir "npm" ["run", "build"] J.Wasp chan) + (Output.printEventsPrefixedUntilExit chan) + (Job.runJob (Node.makeJob sdkRootDir "npm" ["run", "build"] Job.Wasp) chan) return $ case exitCode of ExitSuccess -> Right () ExitFailure code -> Left $ "SDK build failed with exit code: " ++ show code diff --git a/waspc/src/Wasp/Generator/ServerGenerator/Start.hs b/waspc/src/Wasp/Generator/ServerGenerator/Start.hs index 9999cd1086..2e8f4d359d 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/Start.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/Start.hs @@ -6,10 +6,11 @@ where import StrongPath (Abs, Dir, Path', ()) import Wasp.Generator.Common (GeneratedAppDir) import qualified Wasp.Generator.ServerGenerator.Common as Common -import qualified Wasp.Job as J -import Wasp.Job.Process (runNodeCommandAsJob) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Node as Node -startServer :: Path' Abs (Dir GeneratedAppDir) -> J.Job -startServer generatedAppDir = do - let serverDir = generatedAppDir Common.serverRootDirInGeneratedAppDir - runNodeCommandAsJob serverDir "npm" ["run", "watch"] J.Server +startServer :: Path' Abs (Dir GeneratedAppDir) -> Job.Job +startServer generatedAppDir = + Node.makeJob serverDir "npm" ["run", "watch"] Job.Server + where + serverDir = generatedAppDir Common.serverRootDirInGeneratedAppDir diff --git a/waspc/src/Wasp/Generator/Start.hs b/waspc/src/Wasp/Generator/Start.hs index 1812047236..770d939cbf 100644 --- a/waspc/src/Wasp/Generator/Start.hs +++ b/waspc/src/Wasp/Generator/Start.hs @@ -12,8 +12,9 @@ import Wasp.Generator.Common (GeneratedAppDir) import Wasp.Generator.ServerGenerator.Start (startServer) import Wasp.Generator.WebAppGenerator.Start (startWebApp) import qualified Wasp.Job as J -import Wasp.Job.IO (readJobMessagesAndPrintThemPrefixed) +import qualified Wasp.Job.Output as Output import Wasp.Project.Common (WaspProjectDir) +import Wasp.Util (secondsToMicroSeconds) -- | This is a blocking action, that will start the processes that run web app and server. -- It will run as long as one of those processes does not fail. @@ -23,16 +24,16 @@ import Wasp.Project.Common (WaspProjectDir) start :: Path' Abs (Dir WaspProjectDir) -> Path' Abs (Dir GeneratedAppDir) -> IO () -> IO (Either String ()) start waspProjectDir outDir onJobsQuietDown = do chan <- newChan - let runStartJobs = startServer outDir chan `race` startWebApp waspProjectDir chan + let runStartJobs = J.runJob (startServer outDir) chan `race` J.runJob (startWebApp waspProjectDir) chan ((serverOrWebExitCode, _), _) <- runStartJobs - `concurrently` readJobMessagesAndPrintThemPrefixed chan + `concurrently` Output.printEventsPrefixedUntilExit chan `concurrently` (dupChan chan >>= (`listenForJobsQuietDown` onJobsQuietDown)) case serverOrWebExitCode of Left serverExitCode -> return $ Left $ "Server failed with exit code " ++ show serverExitCode ++ "." Right webAppExitCode -> return $ Left $ "Web app failed with exit code " ++ show webAppExitCode ++ "." -listenForJobsQuietDown :: Chan J.JobMessage -> IO () -> IO () +listenForJobsQuietDown :: Chan J.JobEvent -> IO () -> IO () listenForJobsQuietDown jobsChan onJobsQuietDown = do waitForJobMsg waitForPeriodOfSilence @@ -41,8 +42,7 @@ listenForJobsQuietDown jobsChan onJobsQuietDown = do where waitForJobMsg = void $ readChan jobsChan waitForPeriodOfSilence = do - jobMsgOrTimeout <- readChan jobsChan `race` threadDelay (secondsAsMs 5) + jobMsgOrTimeout <- readChan jobsChan `race` threadDelay (secondsToMicroSeconds 5) case jobMsgOrTimeout of Left _ -> waitForPeriodOfSilence Right _ -> return () - secondsAsMs s = s * 1000000 diff --git a/waspc/src/Wasp/Generator/Test.hs b/waspc/src/Wasp/Generator/Test.hs index 1b3fa4e325..3e73aa7dd1 100644 --- a/waspc/src/Wasp/Generator/Test.hs +++ b/waspc/src/Wasp/Generator/Test.hs @@ -8,15 +8,16 @@ import Control.Concurrent.Async (concurrently) import StrongPath (Abs, Dir, Path') import System.Exit (ExitCode (..)) import qualified Wasp.Generator.WebAppGenerator.Test as WebAppTest -import Wasp.Job.IO (readJobMessagesAndPrintThemPrefixed) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Output as Output import Wasp.Project.Common (WaspProjectDir) testWebApp :: [String] -> Path' Abs (Dir WaspProjectDir) -> IO (Either String ()) testWebApp args waspProjectDir = do chan <- newChan - let testWebAppJob = WebAppTest.testWebApp args waspProjectDir chan + let testWebAppJob = Job.runJob (WebAppTest.testWebApp args waspProjectDir) chan (testExitCode, _) <- - testWebAppJob `concurrently` readJobMessagesAndPrintThemPrefixed chan + testWebAppJob `concurrently` Output.printEventsPrefixedUntilExit chan case testExitCode of ExitSuccess -> return $ Right () -- Exit code 130 is thrown when user presses Ctrl+C. diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs index b4d06881d8..9f64d4d07e 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Start.hs @@ -4,10 +4,10 @@ module Wasp.Generator.WebAppGenerator.Start where import StrongPath (Abs, Dir, Path') -import qualified Wasp.Job as J -import Wasp.Job.Process (runNodeCommandAsJob) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Node as Node import Wasp.Project.Common (WaspProjectDir) -startWebApp :: Path' Abs (Dir WaspProjectDir) -> J.Job -startWebApp waspProjectDir = do - runNodeCommandAsJob waspProjectDir "npx" ["vite"] J.WebApp +startWebApp :: Path' Abs (Dir WaspProjectDir) -> Job.Job +startWebApp waspProjectDir = + Node.makeJob waspProjectDir "npx" ["vite"] Job.WebApp diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs b/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs index e7a5fbd8bc..aad6471f0e 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/Test.hs @@ -5,9 +5,9 @@ where import StrongPath (Abs, Dir, Path') import qualified Wasp.Job as J -import Wasp.Job.Process (runNodeCommandAsJob) +import qualified Wasp.Job.Node as Node import Wasp.Project.Common (WaspProjectDir) testWebApp :: [String] -> Path' Abs (Dir WaspProjectDir) -> J.Job -testWebApp args waspProjectDir = do - runNodeCommandAsJob waspProjectDir "npx" ("vitest" : args) J.WebApp +testWebApp args waspProjectDir = + Node.makeJob waspProjectDir "npx" ("vitest" : args) J.WebApp diff --git a/waspc/src/Wasp/Job.hs b/waspc/src/Wasp/Job.hs index ac34fcaceb..aafa0f3069 100644 --- a/waspc/src/Wasp/Job.hs +++ b/waspc/src/Wasp/Job.hs @@ -1,31 +1,26 @@ module Wasp.Job ( Job, - JobMessage (..), - JobMessageData (..), - JobOutputType (..), - JobType (..), + JobAction, + JobEvent (..), + JobEventData (..), + JobOutputKind (..), + JobKind (..), + makeJob, + runJob, + emitJobOutput, + requireExitSuccess, ) where -import Control.Concurrent (Chan) -import Data.Text (Text) -import System.Exit (ExitCode) - --- | Job is an IO action that communicates progress by writing messages to given channel --- until it is done, when it returns exit code. -type Job = Chan JobMessage -> IO ExitCode - -data JobMessage = JobMessage - { _data :: JobMessageData, - _jobType :: JobType - } - deriving (Show) - -data JobMessageData - = JobOutput Text JobOutputType - | JobExit ExitCode - deriving (Show) - -data JobOutputType = Stdout | Stderr deriving (Show, Eq) - -data JobType = WebApp | Server | Db | Wasp deriving (Show, Eq, Ord, Bounded, Enum) +import Wasp.Job.Internal + ( Job, + JobAction, + JobEvent (..), + JobEventData (..), + JobKind (..), + JobOutputKind (..), + emitJobOutput, + makeJob, + requireExitSuccess, + runJob, + ) diff --git a/waspc/src/Wasp/Job/Common.hs b/waspc/src/Wasp/Job/Common.hs deleted file mode 100644 index 8d6b046e9e..0000000000 --- a/waspc/src/Wasp/Job/Common.hs +++ /dev/null @@ -1,24 +0,0 @@ -module Wasp.Job.Common - ( getJobMessageOutHandle, - getJobMessageContent, - ) -where - -import qualified Data.Text as T -import System.Exit (ExitCode (..)) -import System.IO (Handle, stderr, stdout) -import qualified Wasp.Job as J - -getJobMessageOutHandle :: J.JobMessage -> Handle -getJobMessageOutHandle jobMsg = case J._data jobMsg of - J.JobOutput _ outputType -> - case outputType of - J.Stdout -> stdout - J.Stderr -> stderr - J.JobExit _ -> stdout - -getJobMessageContent :: J.JobMessage -> T.Text -getJobMessageContent jobMsg = case J._data jobMsg of - J.JobOutput output _ -> output - J.JobExit ExitSuccess -> "Job exited successfully." - J.JobExit (ExitFailure exitCode) -> T.pack $ "Job failed with exit code " <> show exitCode diff --git a/waspc/src/Wasp/Job/Except.hs b/waspc/src/Wasp/Job/Except.hs deleted file mode 100644 index fc7ee7e2e7..0000000000 --- a/waspc/src/Wasp/Job/Except.hs +++ /dev/null @@ -1,36 +0,0 @@ -module Wasp.Job.Except - ( ExceptJob, - toExceptJob, - race_, - ) -where - -import Control.Concurrent (Chan) -import qualified Control.Concurrent.Async as Async -import Control.Monad.Except (ExceptT (ExceptT), runExceptT) -import Data.Functor ((<&>)) -import System.Exit (ExitCode (ExitFailure, ExitSuccess)) -import Wasp.Job (Job) -import qualified Wasp.Job as J - -type ExceptJob = Chan J.JobMessage -> ExceptT String IO () - -toExceptJob :: (Int -> String) -> Job -> ExceptJob -toExceptJob exitCodeToErrorMessage job chan = - ExceptT $ - job chan - <&> fromExitCode exitCodeToErrorMessage - where - fromExitCode :: (Int -> String) -> ExitCode -> Either String () - fromExitCode _ ExitSuccess = Right () - fromExitCode toErrorMessage (ExitFailure code) = Left $ toErrorMessage code - -race_ :: ExceptJob -> ExceptJob -> ExceptJob -race_ except1 except2 chan = - ExceptT $ - unwrapEither - <$> Async.race - (runExceptT $ except1 chan) - (runExceptT $ except2 chan) - where - unwrapEither = either id id diff --git a/waspc/src/Wasp/Job/IO.hs b/waspc/src/Wasp/Job/IO.hs deleted file mode 100644 index 54fe3b8ca3..0000000000 --- a/waspc/src/Wasp/Job/IO.hs +++ /dev/null @@ -1,48 +0,0 @@ -module Wasp.Job.IO - ( readJobMessagesAndPrintThemPrefixed, - printJobMessage, - printJobMsgsUntilExitReceived, - collectJobTextOutputUntilExitReceived, - ) -where - -import Control.Concurrent (Chan, readChan) -import Control.Monad.IO.Class (liftIO) -import Data.Text (Text) -import qualified Data.Text.IO as T.IO -import System.IO (hFlush) -import qualified Wasp.Job as J -import Wasp.Job.Common (getJobMessageContent, getJobMessageOutHandle) -import Wasp.Job.IO.PrefixedWriter (printJobMessagePrefixed, runPrefixedWriter) - -printJobMsgsUntilExitReceived :: Chan J.JobMessage -> IO () -printJobMsgsUntilExitReceived chan = do - jobMsg <- readChan chan - case J._data jobMsg of - J.JobOutput {} -> printJobMessage jobMsg >> printJobMsgsUntilExitReceived chan - J.JobExit {} -> return () - -readJobMessagesAndPrintThemPrefixed :: Chan J.JobMessage -> IO () -readJobMessagesAndPrintThemPrefixed chan = runPrefixedWriter go - where - go = do - jobMsg <- liftIO $ readChan chan - case J._data jobMsg of - J.JobOutput {} -> printJobMessagePrefixed jobMsg >> go - J.JobExit {} -> return () - -collectJobTextOutputUntilExitReceived :: Chan J.JobMessage -> IO [Text] -collectJobTextOutputUntilExitReceived = go [] - where - go jobTextOutput chan = do - jobMsg <- readChan chan - case J._data jobMsg of - J.JobExit {} -> return jobTextOutput - J.JobOutput text _ -> go (text : jobTextOutput) chan - -printJobMessage :: J.JobMessage -> IO () -printJobMessage jobMsg = do - let outHandle = getJobMessageOutHandle jobMsg - let message = getJobMessageContent jobMsg - T.IO.hPutStr outHandle message - hFlush outHandle diff --git a/waspc/src/Wasp/Job/Internal.hs b/waspc/src/Wasp/Job/Internal.hs new file mode 100644 index 0000000000..450145f204 --- /dev/null +++ b/waspc/src/Wasp/Job/Internal.hs @@ -0,0 +1,89 @@ +module Wasp.Job.Internal + ( Job, + JobAction, + JobEvent (..), + JobEventData (..), + JobOutputKind (..), + JobKind (..), + JobOutputSink, + makeJob, + runJob, + emitJobOutput, + failWithExitCode, + requireExitSuccess, + getJobOutputSink, + writeJobOutput, + ) +where + +import Control.Concurrent (Chan, writeChan) +import Control.Monad.Except (ExceptT, MonadError (throwError), runExceptT) +import Control.Monad.IO.Class (MonadIO (liftIO)) +import Control.Monad.Reader (ReaderT, ask, runReaderT) +import Control.Monad.Trans.Resource (ResourceT, runResourceT) +import Data.Text (Text) +import System.Exit (ExitCode (..)) + +data Job = Job JobKind (JobAction ()) + +type JobAction = ReaderT JobOutputSink (ExceptT JobFailure (ResourceT IO)) + +newtype JobFailure = JobFailure Int + +newtype JobOutputSink = JobOutputSink + { writeJobOutput :: JobOutputKind -> Text -> IO () + } + +data JobEvent = JobEvent + { _eventData :: JobEventData, + _jobKind :: JobKind + } + deriving (Show) + +data JobEventData + = JobOutput JobOutputKind Text + | JobExited ExitCode + deriving (Show) + +data JobOutputKind = Stdout | Stderr deriving (Show, Eq) + +data JobKind = WebApp | Server | Db | Wasp deriving (Show, Eq, Ord, Bounded, Enum) + +makeJob :: JobKind -> JobAction () -> Job +makeJob = Job + +runJob :: Job -> Chan JobEvent -> IO ExitCode +runJob (Job jobKind action) chan = do + result <- + runResourceT $ + runExceptT $ + runReaderT action outputSink + let exitCode = either jobFailureExitCode (const ExitSuccess) result + emitEvent $ JobExited exitCode + return exitCode + where + outputSink = JobOutputSink $ \outputKind output -> emitEvent $ JobOutput outputKind output + emitEvent eventData = + writeChan chan $ + JobEvent + { _eventData = eventData, + _jobKind = jobKind + } + +jobFailureExitCode :: JobFailure -> ExitCode +jobFailureExitCode (JobFailure exitCode) = ExitFailure exitCode + +emitJobOutput :: JobOutputKind -> Text -> JobAction () +emitJobOutput outputKind output = do + outputSink <- getJobOutputSink + liftIO $ writeJobOutput outputSink outputKind output + +requireExitSuccess :: ExitCode -> JobAction () +requireExitSuccess ExitSuccess = return () +requireExitSuccess (ExitFailure exitCode) = failWithExitCode exitCode + +failWithExitCode :: Int -> JobAction a +failWithExitCode = throwError . JobFailure + +getJobOutputSink :: JobAction JobOutputSink +getJobOutputSink = ask diff --git a/waspc/src/Wasp/Job/Node.hs b/waspc/src/Wasp/Job/Node.hs new file mode 100644 index 0000000000..279de8652b --- /dev/null +++ b/waspc/src/Wasp/Job/Node.hs @@ -0,0 +1,64 @@ +module Wasp.Job.Node + ( makeCreateProcess, + makeCreateProcessWithExtraEnv, + run, + runReturningExitCode, + makeJob, + makeJobWithExtraEnv, + ) +where + +import Control.Monad.IO.Class (liftIO) +import qualified Data.Text as T +import StrongPath (Abs, Dir, Path') +import qualified StrongPath as SP +import System.Environment (getEnvironment) +import System.Exit (ExitCode) +import qualified System.Process as P +import qualified Wasp.Job as Job +import Wasp.Job.Internal (failWithExitCode) +import qualified Wasp.Job.Subprocess as Subprocess +import qualified Wasp.Node.Version as NodeVersion + +makeJob :: Path' Abs (Dir a) -> String -> [String] -> Job.JobKind -> Job.Job +makeJob = makeJobWithExtraEnv [] + +makeJobWithExtraEnv :: [(String, String)] -> Path' Abs (Dir a) -> String -> [String] -> Job.JobKind -> Job.Job +makeJobWithExtraEnv extraEnvVars workingDir executable arguments jobKind = + Job.makeJob jobKind $ + runCommandUsing Subprocess.run extraEnvVars workingDir executable arguments + +-- | Runs the command to completion, failing the Job on a nonzero child exit. +run :: Path' Abs (Dir a) -> String -> [String] -> Job.JobAction () +run = runCommandUsing Subprocess.run [] + +-- | Runs the command and returns the child process's exit status for explicit handling. +runReturningExitCode :: Path' Abs (Dir a) -> String -> [String] -> Job.JobAction ExitCode +runReturningExitCode = runCommandUsing Subprocess.runReturningExitCode [] + +runCommandUsing :: (P.CreateProcess -> Job.JobAction a) -> [(String, String)] -> Path' Abs (Dir dir) -> String -> [String] -> Job.JobAction a +runCommandUsing runProcess extraEnvVars workingDir executable arguments = do + requireValidNodeAndNpm + process <- liftIO $ makeCreateProcessWithExtraEnv extraEnvVars workingDir executable arguments + runProcess process + +requireValidNodeAndNpm :: Job.JobAction () +requireValidNodeAndNpm = + liftIO NodeVersion.checkUserNodeAndNpmMeetWaspRequirements >>= \case + NodeVersion.VersionCheckFail errorMsg -> do + Job.emitJobOutput Job.Stderr $ T.pack errorMsg + failWithExitCode 1 + NodeVersion.VersionCheckSuccess -> return () + +makeCreateProcess :: Path' Abs (Dir a) -> String -> [String] -> IO P.CreateProcess +makeCreateProcess = makeCreateProcessWithExtraEnv [] + +makeCreateProcessWithExtraEnv :: [(String, String)] -> Path' Abs (Dir a) -> String -> [String] -> IO P.CreateProcess +makeCreateProcessWithExtraEnv extraEnvVars workingDir executable arguments = do + envVars <- getAllEnvVars + return $ (P.proc executable arguments) {P.env = Just envVars, P.cwd = Just $ SP.fromAbsDir workingDir} + where + -- Haskell will use the first value for variable name it finds. Since env + -- vars in 'extraEnvVars' should override the inherited env vars, we + -- must prepend them. + getAllEnvVars = (extraEnvVars ++) <$> getEnvironment diff --git a/waspc/src/Wasp/Job/Output.hs b/waspc/src/Wasp/Job/Output.hs new file mode 100644 index 0000000000..ea1737f575 --- /dev/null +++ b/waspc/src/Wasp/Job/Output.hs @@ -0,0 +1,46 @@ +module Wasp.Job.Output + ( printEventsPrefixedUntilExit, + printEvent, + printEventsUntilExit, + collectTextUntilExit, + ) +where + +import Control.Concurrent (Chan, readChan) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Data.Text (Text) +import qualified Data.Text.IO as T.IO +import System.IO (hFlush) +import qualified Wasp.Job as Job +import Wasp.Job.Output.Internal (getEventContent, getEventOutHandle) +import Wasp.Job.Output.Prefixed (printEventPrefixed, runPrefixedWriter) + +printEventsUntilExit :: Chan Job.JobEvent -> IO () +printEventsUntilExit = consumeEventsUntilExit $ liftIO . printEvent + +printEventsPrefixedUntilExit :: Chan Job.JobEvent -> IO () +printEventsPrefixedUntilExit chan = + runPrefixedWriter $ consumeEventsUntilExit printEventPrefixed chan + +consumeEventsUntilExit :: (MonadIO m) => (Job.JobEvent -> m ()) -> Chan Job.JobEvent -> m () +consumeEventsUntilExit consumeEvent chan = do + event <- liftIO $ readChan chan + case Job._eventData event of + Job.JobOutput {} -> consumeEvent event >> consumeEventsUntilExit consumeEvent chan + Job.JobExited {} -> return () + +collectTextUntilExit :: Chan Job.JobEvent -> IO [Text] +collectTextUntilExit = go [] + where + go textOutput chan = do + event <- readChan chan + case Job._eventData event of + Job.JobExited {} -> return textOutput + Job.JobOutput _ text -> go (text : textOutput) chan + +printEvent :: Job.JobEvent -> IO () +printEvent event = do + let outHandle = getEventOutHandle event + let message = getEventContent event + T.IO.hPutStr outHandle message + hFlush outHandle diff --git a/waspc/src/Wasp/Job/Output/Internal.hs b/waspc/src/Wasp/Job/Output/Internal.hs new file mode 100644 index 0000000000..33d9abcae6 --- /dev/null +++ b/waspc/src/Wasp/Job/Output/Internal.hs @@ -0,0 +1,24 @@ +module Wasp.Job.Output.Internal + ( getEventContent, + getEventOutHandle, + ) +where + +import qualified Data.Text as T +import System.Exit (ExitCode (..)) +import System.IO (Handle, stderr, stdout) +import qualified Wasp.Job as Job + +getEventOutHandle :: Job.JobEvent -> Handle +getEventOutHandle event = case Job._eventData event of + Job.JobOutput outputKind _ -> + case outputKind of + Job.Stdout -> stdout + Job.Stderr -> stderr + Job.JobExited _ -> stdout + +getEventContent :: Job.JobEvent -> T.Text +getEventContent event = case Job._eventData event of + Job.JobOutput _ output -> output + Job.JobExited ExitSuccess -> "Job exited successfully." + Job.JobExited (ExitFailure exitCode) -> T.pack $ "Job failed with exit code " <> show exitCode diff --git a/waspc/src/Wasp/Job/IO/PrefixedWriter.hs b/waspc/src/Wasp/Job/Output/Prefixed.hs similarity index 76% rename from waspc/src/Wasp/Job/IO/PrefixedWriter.hs rename to waspc/src/Wasp/Job/Output/Prefixed.hs index 8e0325553b..a443f3f501 100644 --- a/waspc/src/Wasp/Job/IO/PrefixedWriter.hs +++ b/waspc/src/Wasp/Job/Output/Prefixed.hs @@ -1,8 +1,8 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE TupleSections #-} -module Wasp.Job.IO.PrefixedWriter - ( printJobMessagePrefixed, +module Wasp.Job.Output.Prefixed + ( printEventPrefixed, runPrefixedWriter, PrefixedWriter, ) @@ -17,9 +17,9 @@ import qualified Data.Set as S import qualified Data.Text as T import qualified Data.Text.IO as T.IO import System.IO (hFlush, stderr) -import Wasp.Job (JobType) -import qualified Wasp.Job as J -import Wasp.Job.Common (getJobMessageContent, getJobMessageOutHandle) +import Wasp.Job (JobKind) +import qualified Wasp.Job as Job +import Wasp.Job.Output.Internal (getEventContent, getEventOutHandle) import qualified Wasp.Util.Terminal as Term -- | @@ -67,27 +67,27 @@ import qualified Wasp.Util.Terminal as Term -- If not, or there was no previous message, then we ensure there is prefix at the start of -- the message. This helps with situations where output from one job was interrupted by the -- output from another job, or when message is the very first message. -printJobMessagePrefixed :: J.JobMessage -> PrefixedWriter () -printJobMessagePrefixed jobMessage = do - (PrefixedWriterState outputsWithPendingNewline lastJobMessage) <- get +printEventPrefixed :: Job.JobEvent -> PrefixedWriter () +printEventPrefixed event = do + (PrefixedWriterState outputsWithPendingNewline lastEvent) <- get let (outputsWithPendingNewline', messageContent) = - applyPendingNewline outputsWithPendingNewline jobMessage - let prefixedMessageContent = addPrefixWhereNeeded lastJobMessage messageContent + applyPendingNewline outputsWithPendingNewline event + let prefixedMessageContent = addPrefixWhereNeeded lastEvent messageContent - put $ PrefixedWriterState outputsWithPendingNewline' (Just jobMessage) + put $ PrefixedWriterState outputsWithPendingNewline' (Just event) liftIO $ printPrefixedMessageContent prefixedMessageContent where printPrefixedMessageContent :: T.Text -> IO () printPrefixedMessageContent content = T.IO.hPutStr outHandle content >> hFlush outHandle where - outHandle = getJobMessageOutHandle jobMessage + outHandle = getEventOutHandle event -- TODO: We haven't considered Windows much here, so in the future we might -- want to check that this works ok on Windows and tweak it a bit if not. - addPrefixWhereNeeded :: Maybe J.JobMessage -> T.Text -> T.Text - addPrefixWhereNeeded lastJobMessage = + addPrefixWhereNeeded :: Maybe Job.JobEvent -> T.Text -> T.Text + addPrefixWhereNeeded lastEvent = ensureNewlineAtStartIfInterruptingAnotherOutput . ensurePrefixAtStartIfNotContinuingOnSameOutput . addPrefixAfterSubstr "\r" @@ -99,7 +99,7 @@ printJobMessagePrefixed jobMessage = do ensurePrefixAtStartIfNotContinuingOnSameOutput :: T.Text -> T.Text ensurePrefixAtStartIfNotContinuingOnSameOutput text = let continuingOnSameOutput = - (getJobMessageOutput <$> lastJobMessage) == Just (getJobMessageOutput jobMessage) + (getEventOutput <$> lastEvent) == Just (getEventOutput event) prefixAtStart = or [(delimiter <> prefix) `T.isPrefixOf` text | delimiter <- ["\r", "\n", ""]] in if not continuingOnSameOutput && not prefixAtStart then prefix <> text else text @@ -107,19 +107,19 @@ printJobMessagePrefixed jobMessage = do ensureNewlineAtStartIfInterruptingAnotherOutput :: T.Text -> T.Text ensureNewlineAtStartIfInterruptingAnotherOutput text = let interruptingAnotherOutput = - (getJobMessageOutput <$> lastJobMessage) /= Just (getJobMessageOutput jobMessage) + (getEventOutput <$> lastEvent) /= Just (getEventOutput event) newlineAtStart = "\n" `T.isPrefixOf` text in if interruptingAnotherOutput && not newlineAtStart then "\n" <> text else text prefix :: T.Text - prefix = makeJobMessagePrefix jobMessage + prefix = makeEventPrefix event newtype PrefixedWriter a = PrefixedWriter {_runPrefixedWriter :: StateT PrefixedWriterState IO a} deriving (Functor, Applicative, Monad, MonadIO, MonadState PrefixedWriterState) data PrefixedWriterState = PrefixedWriterState { _outputsWithPendingNewline :: !OutputsWithPendingNewline, - _lastJobMessage :: !(Maybe J.JobMessage) + _lastEvent :: !(Maybe Job.JobEvent) } runPrefixedWriter :: PrefixedWriter a -> IO a @@ -128,12 +128,12 @@ runPrefixedWriter pw = fst <$> runStateT (_runPrefixedWriter pw) initState initState = PrefixedWriterState { _outputsWithPendingNewline = S.empty, - _lastJobMessage = Nothing + _lastEvent = Nothing } -- Job message output type. data Output = Output - { _outputJobType :: !J.JobType, + { _outputJobKind :: !Job.JobKind, _outputIsStderr :: !Bool } deriving (Eq, Ord) @@ -146,14 +146,14 @@ type OutputsWithPendingNewline = S.Set Output -- and in that case adds it to the set of pending newlines (while removing used pending newline). -- It returns this updated content and updated set of pending newlines. applyPendingNewline :: - OutputsWithPendingNewline -> J.JobMessage -> (OutputsWithPendingNewline, T.Text) -applyPendingNewline outputsWithPendingNewline jobMessage = (outputsWithPendingNewline', content') + OutputsWithPendingNewline -> Job.JobEvent -> (OutputsWithPendingNewline, T.Text) +applyPendingNewline outputsWithPendingNewline event = (outputsWithPendingNewline', content') where content' = addPendingNewlineToStartIfAny $ removeTrailingNewlineIfAny content where removeTrailingNewlineIfAny = if contentEndsWithNewline then T.init else id addPendingNewlineToStartIfAny = - if getJobMessageOutput jobMessage `S.member` outputsWithPendingNewline then ("\n" <>) else id + if getEventOutput event `S.member` outputsWithPendingNewline then ("\n" <>) else id outputsWithPendingNewline' = updateOp output outputsWithPendingNewline where @@ -161,18 +161,18 @@ applyPendingNewline outputsWithPendingNewline jobMessage = (outputsWithPendingNe contentEndsWithNewline = "\n" `T.isSuffixOf` content - output = getJobMessageOutput jobMessage - content = getJobMessageContent jobMessage + output = getEventOutput event + content = getEventContent event -getJobMessageOutput :: J.JobMessage -> Output -getJobMessageOutput jm = +getEventOutput :: Job.JobEvent -> Output +getEventOutput event = Output - { _outputJobType = J._jobType jm, - _outputIsStderr = getJobMessageOutHandle jm == stderr + { _outputJobKind = Job._jobKind event, + _outputIsStderr = getEventOutHandle event == stderr } -makeJobMessagePrefix :: J.JobMessage -> T.Text -makeJobMessagePrefix jobMsg = +makeEventPrefix :: Job.JobEvent -> T.Text +makeEventPrefix event = T.pack . concatMap (\(text, styles) -> Term.applyStyles styles text) . concat $ [ [(startDelimiter, jobStyles)], [unstyled namePaddingFront], @@ -195,21 +195,21 @@ makeJobMessagePrefix jobMsg = minPrefixLength = length $ startDelimiter <> " " <> longestJobName <> " " <> endDelimiter longestJobName = maximumBy (comparing length) $ - fst . getJobNameAndStyles <$> [(minBound :: JobType) .. maxBound] + fst . getJobNameAndStyles <$> [(minBound :: JobKind) .. maxBound] (startDelimiter, endDelimiter) = ("[", "]") styledFlags :: [StyledText] styledFlags = - [("!", [Term.Red, Term.Bold]) | getJobMessageOutHandle jobMsg == stderr] + [("!", [Term.Red, Term.Bold]) | getEventOutHandle event == stderr] - (jobName, jobStyles) = getJobNameAndStyles $ J._jobType jobMsg + (jobName, jobStyles) = getJobNameAndStyles $ Job._jobKind event getJobNameAndStyles = \case - J.Wasp -> ("Wasp", [Term.Yellow]) - J.Server -> ("Server", [Term.Magenta]) - J.WebApp -> ("Client", [Term.Cyan]) - J.Db -> ("Db", [Term.Blue]) + Job.Wasp -> ("Wasp", [Term.Yellow]) + Job.Server -> ("Server", [Term.Magenta]) + Job.WebApp -> ("Client", [Term.Cyan]) + Job.Db -> ("Db", [Term.Blue]) unstyled = (,[]) diff --git a/waspc/src/Wasp/Job/Process.hs b/waspc/src/Wasp/Job/Process.hs deleted file mode 100644 index 700a1096f8..0000000000 --- a/waspc/src/Wasp/Job/Process.hs +++ /dev/null @@ -1,121 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} - -module Wasp.Job.Process - ( runProcessAsJob, - runNodeCommandAsJob, - runNodeCommandAsJobWithExtraEnv, - ) -where - -import Control.Concurrent (writeChan) -import Control.Concurrent.Async (Concurrently (..)) -import Data.Conduit (runConduit, (.|)) -import qualified Data.Conduit.List as CL -import qualified Data.Conduit.Process as CP -import qualified Data.Text as T -import Data.Text.Encoding (decodeUtf8) -import StrongPath (Abs, Dir, Path') -import qualified StrongPath as SP -import System.Environment (getEnvironment) -import System.Exit (ExitCode (..)) -import qualified System.Info -import qualified System.Process as P -import UnliftIO.Exception (bracket) -import qualified Wasp.Job as J -import qualified Wasp.Node.Version as NodeVersion - --- TODO: --- Switch from Data.Conduit.Process to Data.Conduit.Process.Typed. --- It is a new module meant to replace Data.Conduit.Process which is about to become deprecated. - --- | Runs a given process while streaming its stderr and stdout to provided channel. Stdin is inherited. --- Returns exit code of the process once it finishes, and also sends it to the channel. --- Makes sure to terminate the process (or process group on *nix) if exception occurs. -runProcessAsJob :: P.CreateProcess -> J.JobType -> J.Job -runProcessAsJob process jobType chan = - bracket - (CP.streamingProcess process) - (\(_, _, _, sph) -> terminateStreamingProcess sph) - runStreamingProcessAsJob - where - runStreamingProcessAsJob (CP.Inherited, stdoutStream, stderrStream, processHandle) = do - let forwardStdoutToChan = - runConduit $ - stdoutStream - .| CL.mapM_ - ( \bs -> - writeChan chan $ - J.JobMessage - { J._data = J.JobOutput (decodeUtf8 bs) J.Stdout, - J._jobType = jobType - } - ) - - let forwardStderrToChan = - runConduit $ - stderrStream - .| CL.mapM_ - ( \bs -> - writeChan chan $ - J.JobMessage - { J._data = J.JobOutput (decodeUtf8 bs) J.Stderr, - J._jobType = jobType - } - ) - - exitCode <- - runConcurrently $ - Concurrently forwardStdoutToChan - *> Concurrently forwardStderrToChan - *> Concurrently (CP.waitForStreamingProcess processHandle) - - writeChan chan $ - J.JobMessage - { J._data = J.JobExit exitCode, - J._jobType = jobType - } - - return exitCode - - -- NOTE(shayne): On *nix, we use interruptProcessGroupOf instead of terminateProcess because many - -- processes we run will spawn child processes, which themselves may spawn child processes. - -- We want to ensure the entire process chain is stopped. - -- We are limiting support of this to *nix only now, as Windows requires create_group=True - -- but that surfaces an issue where a new process group that needs stdin but is started as a - -- background process gets terminated, appearing to hang. - -- Ref: https://stackoverflow.com/questions/61856063/spawning-a-process-with-create-group-true-set-pgid-hangs-when-starting-docke - terminateStreamingProcess streamingProcessHandle = do - let processHandle = CP.streamingProcessHandleRaw streamingProcessHandle - if System.Info.os == "mingw32" - then P.terminateProcess processHandle - else P.interruptProcessGroupOf processHandle - return $ ExitFailure 1 - -runNodeCommandAsJob :: Path' Abs (Dir a) -> String -> [String] -> J.JobType -> J.Job -runNodeCommandAsJob = runNodeCommandAsJobWithExtraEnv [] - -runNodeCommandAsJobWithExtraEnv :: [(String, String)] -> Path' Abs (Dir a) -> String -> [String] -> J.JobType -> J.Job -runNodeCommandAsJobWithExtraEnv extraEnvVars fromDir command args jobType chan = - NodeVersion.checkUserNodeAndNpmMeetWaspRequirements >>= \case - NodeVersion.VersionCheckFail errorMsg -> exitWithError (ExitFailure 1) (T.pack errorMsg) - NodeVersion.VersionCheckSuccess -> do - envVars <- getAllEnvVars - let nodeCommandProcess = (P.proc command args) {P.env = Just envVars, P.cwd = Just $ SP.fromAbsDir fromDir} - runProcessAsJob nodeCommandProcess jobType chan - where - -- Haskell will use the first value for variable name it finds. Since env - -- vars in 'extraEnvVars' should override the inherited env vars, we - -- must prepend them. - getAllEnvVars = (extraEnvVars ++) <$> getEnvironment - exitWithError exitCode errorMsg = do - writeChan chan $ - J.JobMessage - { J._data = J.JobOutput errorMsg J.Stderr, - J._jobType = jobType - } - writeChan chan $ - J.JobMessage - { J._data = J.JobExit exitCode, - J._jobType = jobType - } - return exitCode diff --git a/waspc/src/Wasp/Job/Subprocess.hs b/waspc/src/Wasp/Job/Subprocess.hs new file mode 100644 index 0000000000..47e202cf53 --- /dev/null +++ b/waspc/src/Wasp/Job/Subprocess.hs @@ -0,0 +1,18 @@ +module Wasp.Job.Subprocess + ( run, + runReturningExitCode, + ) +where + +import System.Exit (ExitCode) +import qualified System.Process as P +import Wasp.Job.Internal (JobAction, requireExitSuccess) +import qualified Wasp.Job.Subprocess.Finite as Finite + +-- | Runs the process to completion, failing the Job on a nonzero child exit. +run :: P.CreateProcess -> JobAction () +run process = runReturningExitCode process >>= requireExitSuccess + +-- | Runs the process to completion and returns its exit status for explicit handling. +runReturningExitCode :: P.CreateProcess -> JobAction ExitCode +runReturningExitCode = Finite.run diff --git a/waspc/src/Wasp/Job/Subprocess/Finite.hs b/waspc/src/Wasp/Job/Subprocess/Finite.hs new file mode 100644 index 0000000000..febdbed183 --- /dev/null +++ b/waspc/src/Wasp/Job/Subprocess/Finite.hs @@ -0,0 +1,50 @@ +module Wasp.Job.Subprocess.Finite + ( run, + ) +where + +import Control.Concurrent.Async (Concurrently (..)) +import Control.Monad.IO.Class (liftIO) +import Data.Conduit (runConduit, (.|)) +import qualified Data.Conduit.List as CL +import qualified Data.Conduit.Process as CP +import qualified Data.Conduit.Text as CT +import System.Exit (ExitCode) +import qualified System.Process as P +import UnliftIO.Exception (bracket, finally) +import Wasp.Job.Internal (JobAction, JobOutputKind (..), getJobOutputSink, writeJobOutput) + +-- TODO(#4575): +-- Switch from Data.Conduit.Process to Data.Conduit.Process.Typed. +-- It is a new module meant to replace Data.Conduit.Process which is about to become deprecated. + +run :: P.CreateProcess -> JobAction ExitCode +run process = do + outputSink <- getJobOutputSink + liftIO $ + bracket + (CP.streamingProcess process) + cleanUpStreamingProcess + (runStreamingProcessAndStreamOutput outputSink) + where + cleanUpStreamingProcess (_, _, _, streamingProcessHandle) = + terminateStreamingProcess streamingProcessHandle + `finally` CP.closeStreamingProcessHandle streamingProcessHandle + + runStreamingProcessAndStreamOutput outputSink (CP.Inherited, stdoutStream, stderrStream, processHandle) = do + let forwardOutput outputKind stream = + runConduit $ + stream .| CT.decodeUtf8Lenient .| CL.mapM_ (writeJobOutput outputSink outputKind) + + runConcurrently $ + Concurrently (forwardOutput Stdout stdoutStream) + *> Concurrently (forwardOutput Stderr stderrStream) + *> Concurrently (CP.waitForStreamingProcess processHandle) + + -- This generic runner does not create a process group, so it owns only the + -- root process. Group cleanup belongs to the managed implementation. + terminateStreamingProcess streamingProcessHandle = do + let processHandle = CP.streamingProcessHandleRaw streamingProcessHandle + CP.getStreamingProcessExitCode streamingProcessHandle >>= \case + Just _ -> return () + Nothing -> P.terminateProcess processHandle diff --git a/waspc/src/Wasp/Project/WaspFile/TypeScript.hs b/waspc/src/Wasp/Project/WaspFile/TypeScript.hs index fec5374d2b..162d5c6626 100644 --- a/waspc/src/Wasp/Project/WaspFile/TypeScript.hs +++ b/waspc/src/Wasp/Project/WaspFile/TypeScript.hs @@ -27,9 +27,9 @@ import qualified Wasp.AppSpec as AS import Wasp.AppSpec.Core.Decl.JSON () import Wasp.CompileOptions (CompileOptions) import qualified Wasp.CompileOptions as CompileOptions -import qualified Wasp.Job as J -import Wasp.Job.IO (readJobMessagesAndPrintThemPrefixed) -import Wasp.Job.Process (runNodeCommandAsJobWithExtraEnv) +import qualified Wasp.Job as Job +import qualified Wasp.Job.Node as Node +import qualified Wasp.Job.Output as Output import Wasp.NodePackageFFI (InstallablePackage (WaspSpecPackage), getInstallablePackageScriptInProject) import qualified Wasp.Project.BuildType as BuildType import Wasp.Project.Common @@ -81,33 +81,35 @@ runWaspSpecAnalyzer compileOptions prismaSchemaAst waspTsConfigFile waspFilePath chan <- newChan (_, runExitCode) <- do concurrently - (readJobMessagesAndPrintThemPrefixed chan) + (Output.printEventsPrefixedUntilExit chan) -- We invoke the script directly via `node` instead of `npx` because -- `npx` requires the bin file to be executable, and `cabal install` -- strips executable permissions from data files. - ( runNodeCommandAsJobWithExtraEnv - [ -- `NODE_ENV` is a convention which allows code to assume what environment it's running in. - -- Not related to `node` itself, so we have to set it manually. - -- It enables users to write environment specific code in the TS config. - -- NOTE: Some consider it an antipattern, but other frameworks/tools (Next.js, Nuxt, Vite) - -- also provide the `NODE_ENV` values for the "configuration runtime". - -- Maybe consider using a different key, e.g. `WASP_MODE`? - ("NODE_ENV", nodeEnvForBuildType compileOptions.buildType) - ] - compileOptions.waspProjectDir - "node" - [ fromRelFile $ getInstallablePackageScriptInProject WaspSpecPackage, - "analyze", - fromAbsFile waspFilePath, - fromAbsFile (compileOptions.waspProjectDir waspTsConfigFile), - fromAbsDir compileOptions.waspProjectDir, - fromAbsFile absSpecResultFile, - -- When the user is coding main.wasp.ts, TypeScript must know about - -- all the available entities to warn the user if they use an - -- entity that doesn't exist. - encodeToString allowedEntityNames - ] - J.Wasp + ( Job.runJob + ( Node.makeJobWithExtraEnv + [ -- `NODE_ENV` is a convention which allows code to assume what environment it's running in. + -- Not related to `node` itself, so we have to set it manually. + -- It enables users to write environment specific code in the TS config. + -- NOTE: Some consider it an antipattern, but other frameworks/tools (Next.js, Nuxt, Vite) + -- also provide the `NODE_ENV` values for the "configuration runtime". + -- Maybe consider using a different key, e.g. `WASP_MODE`? + ("NODE_ENV", nodeEnvForBuildType compileOptions.buildType) + ] + compileOptions.waspProjectDir + "node" + [ fromRelFile $ getInstallablePackageScriptInProject WaspSpecPackage, + "analyze", + fromAbsFile waspFilePath, + fromAbsFile (compileOptions.waspProjectDir waspTsConfigFile), + fromAbsDir compileOptions.waspProjectDir, + fromAbsFile absSpecResultFile, + -- When the user is coding main.wasp.ts, TypeScript must know about + -- all the available entities to warn the user if they use an + -- entity that doesn't exist. + encodeToString allowedEntityNames + ] + Job.Wasp + ) chan ) case runExitCode of diff --git a/waspc/src/Wasp/Util.hs b/waspc/src/Wasp/Util.hs index 52866a067a..cc9a7da9b4 100644 --- a/waspc/src/Wasp/Util.hs +++ b/waspc/src/Wasp/Util.hs @@ -300,8 +300,8 @@ naiveTrimJSON textContainingJson = textToLazyBS :: Text -> BSL.ByteString textToLazyBS = TLE.encodeUtf8 . TL.fromStrict -secondsToMicroSeconds :: Int -> Int -secondsToMicroSeconds = (* 1000000) +secondsToMicroSeconds :: Double -> Int +secondsToMicroSeconds seconds = round $ seconds * 1000000 findDuplicateElems :: (Ord a) => [a] -> [a] findDuplicateElems = map head . filter ((> 1) . length) . group . sort diff --git a/waspc/tests/Job/SubprocessTest.hs b/waspc/tests/Job/SubprocessTest.hs new file mode 100644 index 0000000000..f14026acba --- /dev/null +++ b/waspc/tests/Job/SubprocessTest.hs @@ -0,0 +1,67 @@ +module Job.SubprocessTest where + +import Control.Concurrent (Chan, newChan, readChan) +import Control.Monad.IO.Class (liftIO) +import Data.Maybe (isNothing) +import qualified Data.Text as T +import System.Exit (ExitCode (..)) +import qualified System.Process as P +import System.Timeout (timeout) +import Test.Hspec (Spec, describe, it, shouldBe, shouldReturn, shouldSatisfy) +import qualified Wasp.Job as J +import qualified Wasp.Job.Subprocess as Subprocess +import Wasp.Util (secondsToMicroSeconds) + +spec_runSubprocess :: Spec +spec_runSubprocess = + describe "Subprocess.run" $ do + it "decodes split and incomplete UTF-8 on stdout" $ + runSplitUtf8Process "stdout" J.Stdout `shouldReturn` "€�" + + it "decodes split and incomplete UTF-8 on stderr" $ + runSplitUtf8Process "stderr" J.Stderr `shouldReturn` "€�" + + it "fails the Job on a nonzero child exit" $ do + chan <- newChan + let action = Subprocess.run $ P.proc "node" ["-e", "process.exit(7)"] + J.runJob (J.makeJob J.Wasp action) chan `shouldReturn` ExitFailure 7 + + it "can return a nonzero child exit for explicit handling" $ do + chan <- newChan + let action = do + exitCode <- Subprocess.runReturningExitCode $ P.proc "node" ["-e", "process.exit(7)"] + liftIO $ exitCode `shouldBe` ExitFailure 7 + J.runJob (J.makeJob J.Wasp action) chan `shouldReturn` ExitSuccess + +runSplitUtf8Process :: String -> J.JobOutputKind -> IO T.Text +runSplitUtf8Process streamName expectedOutputType = do + chan <- newChan + let action = Subprocess.run $ P.proc "node" ["-e", splitUtf8Script streamName] + exitCode <- J.runJob (J.makeJob J.Wasp action) chan + exitCode `shouldBe` ExitSuccess + output <- collectOutputUntilExit expectedOutputType chan + remainingMessage <- timeout (secondsToMicroSeconds 0.1) $ readChan chan + remainingMessage `shouldSatisfy` isNothing + return output + +splitUtf8Script :: String -> String +splitUtf8Script streamName = + "process." + <> streamName + <> ".write(Buffer.from([0xe2])); setTimeout(() => process." + <> streamName + <> ".write(Buffer.from([0x82, 0xac, 0xe2])), 200);" + +collectOutputUntilExit :: J.JobOutputKind -> Chan J.JobEvent -> IO T.Text +collectOutputUntilExit expectedOutputType chan = go [] + where + go collected = do + event <- readChan chan + J._jobKind event `shouldBe` J.Wasp + case J._eventData event of + J.JobOutput outputKind output -> do + outputKind `shouldBe` expectedOutputType + go (output : collected) + J.JobExited exitCode -> do + exitCode `shouldBe` ExitSuccess + return $ T.concat $ reverse collected diff --git a/waspc/tests/JobTest.hs b/waspc/tests/JobTest.hs new file mode 100644 index 0000000000..e241bf3047 --- /dev/null +++ b/waspc/tests/JobTest.hs @@ -0,0 +1,72 @@ +module JobTest where + +import Control.Concurrent (newChan, newEmptyMVar, putMVar, readChan, takeMVar, threadDelay) +import qualified Control.Concurrent.Async as Async +import Control.Monad.IO.Class (liftIO) +import Control.Monad.Trans.Resource (register) +import Data.IORef (newIORef, readIORef, writeIORef) +import Data.Maybe (isNothing) +import System.Exit (ExitCode (..)) +import System.Timeout (timeout) +import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe, shouldReturn, shouldSatisfy) +import qualified Wasp.Job as Job +import Wasp.Util (secondsToMicroSeconds) + +spec_Job :: Spec +spec_Job = + describe "Job" $ do + it "short-circuits on a required subprocess failure" $ do + events <- newChan + let action = do + Job.emitJobOutput Job.Stdout "before failure" + Job.requireExitSuccess $ ExitFailure 7 + Job.emitJobOutput Job.Stdout "after failure" + + exitCode <- Job.runJob (Job.makeJob Job.Wasp action) events + + exitCode `shouldBe` ExitFailure 7 + firstEvent <- readChan events + Job._jobKind firstEvent `shouldBe` Job.Wasp + case Job._eventData firstEvent of + Job.JobOutput Job.Stdout output -> output `shouldBe` "before failure" + eventData -> expectationFailure $ "Expected stdout output, got: " <> show eventData + + secondEvent <- readChan events + case Job._eventData secondEvent of + Job.JobExited jobExitCode -> jobExitCode `shouldBe` ExitFailure 7 + eventData -> expectationFailure $ "Expected JobExited, got: " <> show eventData + + remainingEvent <- timeout (secondsToMicroSeconds 0.1) $ readChan events + remainingEvent `shouldSatisfy` isNothing + + it "releases resources before emitting JobExited" $ do + events <- newChan + released <- newIORef False + let action = do + _ <- register $ writeIORef released True + Job.requireExitSuccess $ ExitFailure 7 + + _ <- Job.runJob (Job.makeJob Job.Wasp action) events + + readIORef released `shouldReturn` True + event <- readChan events + case Job._eventData event of + Job.JobExited exitCode -> exitCode `shouldBe` ExitFailure 7 + eventData -> expectationFailure $ "Expected JobExited, got: " <> show eventData + + it "releases resources without emitting JobExited when cancelled" $ do + events <- newChan + resourceRegistered <- newEmptyMVar + released <- newEmptyMVar + let action = do + _ <- register $ putMVar released () + liftIO $ putMVar resourceRegistered () + liftIO $ threadDelay $ secondsToMicroSeconds 10 + + Async.withAsync (Job.runJob (Job.makeJob Job.Wasp action) events) $ \job -> do + takeMVar resourceRegistered + Async.cancel job + + takeMVar released + maybeEvent <- timeout (secondsToMicroSeconds 0.1) $ readChan events + maybeEvent `shouldSatisfy` isNothing diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index bccd21ad7d..444ecf3620 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -150,6 +150,7 @@ library pretty-simple ^>=4.1.3, process ^>=1.6.19, regex-tdfa ^>=1.3.2, + resourcet ^>=1.3.0, split ^>=0.2.5, strong-path ^>=1.2.0, template-haskell ^>=2.20.0, @@ -160,7 +161,12 @@ library utf8-string ^>=1.0.2, validation-selective ^>=0.2.0, - other-modules: Paths_waspc + other-modules: + Paths_waspc + Wasp.Job.Internal + Wasp.Job.Output.Internal + Wasp.Job.Subprocess.Finite + exposed-modules: Wasp.Analyzer Wasp.Analyzer.AnalyzeError @@ -339,11 +345,10 @@ library Wasp.Generator.WebSocket Wasp.Generator.WriteFileDrafts Wasp.Job - Wasp.Job.Common - Wasp.Job.Except - Wasp.Job.IO - Wasp.Job.IO.PrefixedWriter - Wasp.Job.Process + Wasp.Job.Node + Wasp.Job.Output + Wasp.Job.Output.Prefixed + Wasp.Job.Subprocess Wasp.JsImport Wasp.Message Wasp.Node.Internal @@ -562,6 +567,7 @@ test-suite waspc-tests build-depends: aeson, + async, base, containers, deepseq, @@ -573,6 +579,8 @@ test-suite waspc-tests neat-interpolation, parsec, path, + process, + resourcet, split, strong-path, tasty ^>=1.5.3, @@ -608,6 +616,8 @@ test-suite waspc-tests Generator.JsImportTest Generator.MockWriteableMonad Generator.WriteFileDraftsTest + Job.SubprocessTest + JobTest JsImportTest Node.InternalTest Paths_waspc