From 8d1ff6eb864b0e9a2cc0a2c8e0602e4e5d42c3a2 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Wed, 20 May 2026 14:53:11 -0400 Subject: [PATCH] fix: read subprocess output as raw bytes to avoid locale encoding errors readProcessWithExitCode returns stdout/stderr as String, which forces GHC to decode raw bytes using the current locale encoding. In a Docker container with C locale (default), non-ASCII UTF-8 bytes (like 0xE2) cause 'invalid argument' errors. Replace with readProcessBytes which uses createProcess + hSetBinaryMode to capture stdout/stderr as raw lazy ByteStrings, bypassing locale decoding entirely. JSON is decoded directly from raw bytes via A.decode. --- src/Roux/Server.hs | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index 8ba1412..7d20e83 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -50,7 +50,8 @@ import qualified Roux.Html as Html import qualified Roux.RecipeIndex as Idx import Roux.SchemaOrg (SchemaOrgRecipe (..), parseSchemaOrgRecipe, schemaOrgToCooklang) import System.Exit (ExitCode (..)) -import System.Process (readProcessWithExitCode) +import System.IO (hSetBinaryMode) +import System.Process (CreateProcess (..), StdStream (CreatePipe), createProcess, proc, waitForProcess) -- | Read the full request body as a lazy ByteString. readRequestBody :: Wai.Request -> IO LB.ByteString @@ -234,6 +235,22 @@ parseFormBody body = in toEnum hexVal : go rest go (c : rest) = c : go rest +-- | Run a subprocess and capture stdout/stderr as raw bytes (no locale decoding). +readProcessBytes :: FilePath -> [String] -> IO (ExitCode, LB.ByteString, LB.ByteString) +readProcessBytes cmd args = do + let process = + (proc cmd args) + { std_out = CreatePipe + , std_err = CreatePipe + } + (Just outH, Just errH, _, ph) <- createProcess process + hSetBinaryMode outH True + hSetBinaryMode errH True + out <- LB.hGetContents outH + err <- LB.hGetContents errH + ec <- waitForProcess ph + return (ec, out, err) + -- | Locate the scrape-recipe script: check PATH first, then relative to project. findScrapeScript :: IO FilePath findScrapeScript = do @@ -250,23 +267,19 @@ runImportPipeline recipeDir url = do -- Step 1: locate and run the scraper script <- findScrapeScript putStrLn $ "[roux] importing from " <> T.unpack url <> " using script " <> script - (exitCode, stdout, stderr) <- - readProcessWithExitCode - script - ["--pretty", T.unpack url] - "" + (exitCode, stdoutBytes, stderrBytes) <- + readProcessBytes script ["--pretty", T.unpack url] case exitCode of ExitFailure _ -> do putStrLn $ "[roux] scrape-recipe exited with code " <> show exitCode <> " for " <> T.unpack url - putStrLn $ "[roux] scrape-recipe stderr: " <> stderr + putStrLn $ "[roux] scrape-recipe stderr: " <> T.unpack (TE.decodeUtf8 (LB.toStrict stderrBytes)) return $ Left $ Html.ImportError $ - "Could not scrape URL (" <> url <> "). The scraper exited with: " <> T.pack stderr + "Could not scrape URL (" <> url <> "). The scraper exited with: " <> TE.decodeUtf8 (LB.toStrict stderrBytes) ExitSuccess -> do - -- Step 2: decode stdout (String) as JSON Value - let jsonBytes = LB.fromStrict (TE.encodeUtf8 (T.pack stdout)) - jsonVal = A.decode jsonBytes :: Maybe A.Value + -- Step 2: decode stdout (raw bytes) as JSON Value + let jsonVal = A.decode stdoutBytes :: Maybe A.Value case jsonVal of Nothing -> do putStrLn $ "[roux] failed to decode JSON from scrape-recipe stdout for " <> T.unpack url