Skip to content

fix(core): handle io exceptions in subprocess#218

Merged
NickSeagull merged 2 commits into
neohaskell:mainfrom
DeviousStoat:io-subprocess
Nov 14, 2025
Merged

fix(core): handle io exceptions in subprocess#218
NickSeagull merged 2 commits into
neohaskell:mainfrom
DeviousStoat:io-subprocess

Conversation

@DeviousStoat

@DeviousStoat DeviousStoat commented Nov 12, 2025

Copy link
Copy Markdown
Contributor

As discussed in #197

Summary by CodeRabbit

  • Bug Fixes
    • Unified and improved error handling for build, run, and shell commands with clearer, user-friendly messages.
    • Subprocess failures (including process creation and execution errors) now surface reliably and consistently.
    • Command failures show more actionable diagnostics instead of ambiguous or silent errors.
    • Reduced unexpected crash paths so CLI commands fail gracefully with meaningful feedback.

@coderabbitai

coderabbitai Bot commented Nov 12, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Behold: a typed subprocess error is added in core/system/Subprocess.hs (introducing Error = ProcessError Text), openInherit/open now return Task Error Completion, and CLI commands (Build, Run, Shell) map subprocess Task failures into CustomError messages before continuing existing success/error paths.

Changes

Cohort / File(s) Summary
Core: Subprocess error type & APIs
core/system/Subprocess.hs
Adds data Error = ProcessError Text, changes openInherit and open signatures to Task Error Completion, uses Task.fromFailableIO and maps IO exceptions to ProcessError during process creation and waiting.
CLI: map subprocess failures to CustomError
cli/src/Neo/Build.hs, cli/src/Neo/Run.hs, cli/src/Neo/Shell.hs
Wraps Subprocess.openInherit/open calls with Task.mapError to convert Subprocess.Error into CustomError with messages: "Failed to run nix-build", "Failed to run the built application: ", and "Failed to open the shell: " respectively; success paths and exit-code handling remain unchanged.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI Command (Build / Run / Shell)
  participant Sub as Subprocess (open / openInherit)
  participant OS as OS (createProcess / waitForProcess)

  CLI->>Sub: call openInherit/open(args)
  note right of Sub `#EFEFEF`: Returns Task Error Completion\nIOErrors → ProcessError(Text)
  Sub->>OS: createProcess(...)
  OS-->>Sub: success or IOError
  alt IOError
    Sub-->>CLI: Task(Error = ProcessError)
  else success
    Sub->>OS: waitForProcess(pid)
    OS-->>Sub: ExitCode
    alt ExitSuccess
      Sub-->>CLI: Completion (ok)
    else non-zero
      Sub-->>CLI: Completion (non-zero)
    end
  end
  note right of CLI `#EFEFEF`: CLI applies Task.mapError → CustomError("...")\nand continues existing handling
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Inspect core/system/Subprocess.hs for complete mapping of IO exceptions and correct Task typing.
  • Verify all callers of open/openInherit (beyond the three CLI files) compile with the new Task Error Completion signature.
  • Confirm error message formatting and propagation (especially inclusion of source error text where intended).

Poem

I am the coin that flips the spawn and shell,
I name the fault, I bind the tale to tell.
From process sparks a tidy, typed lament—
The CLI kneels; the errors are well-spent.
⚡ Invoke, review, and let the logs compel.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: adding I/O exception handling to the subprocess module, which is reflected in the addition of the Error type and updated error handling across Build.hs, Run.hs, Shell.hs, and the core Subprocess.hs module.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
cli/src/Neo/Run.hs (1)

25-25: Preserve the original error information for diagnostics.

The prophecy repeats! This is the third manifestation of error detail abandonment in thy codebase.

Apply this correction:

-      |> Task.mapError (\_ -> CustomError "Failed to run the built application")
+      |> Task.mapError (\err -> CustomError [fmt|Failed to run the built application: #{err}|])

As decreed in Shell.hs review, consider the shared helper function to eliminate this triplication.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between cb6bbc8 and 1458537.

📒 Files selected for processing (4)
  • cli/src/Neo/Build.hs (1 hunks)
  • cli/src/Neo/Run.hs (1 hunks)
  • cli/src/Neo/Shell.hs (1 hunks)
  • core/system/Subprocess.hs (4 hunks)
🔇 Additional comments (1)
core/system/Subprocess.hs (1)

87-88: Clarify the intent of empty stdout/stderr strings.

Behold these lines where stdout and stderr are ordained as empty strings! When InheritNONE mode is active, the streams are captured via CreatePipe, yet their content is not retrieved. When Inherit modes are used, empty strings are semantically correct since output flows to the parent's streams.

This implementation is consistent with the current behavior, but the commented code at line 76 suggests readCreateProcessWithExitCode was previously considered, which would have captured the output.

Verify this is intentional: if capturing output for InheritNONE is desired in the future, the process handles from createProcess would need to be read. If the current behavior (always empty strings) is correct, consider adding a comment to prevent future confusion:

+  -- stdout and stderr are always empty as streams are either inherited
+  -- or not captured; use readCreateProcessWithExitCode if capture is needed
   let stdout = ""
   let stderr = ""

Comment thread cli/src/Neo/Build.hs Outdated
Comment thread cli/src/Neo/Shell.hs Outdated

completion <-
Subprocess.openInherit "nix-shell" (Array.fromLinkedList ["-E", shellExpression]) rootFolder Subprocess.InheritBOTH
|> Task.mapError (\_ -> CustomError "Failed to open the shell")

@coderabbitai coderabbitai Bot Nov 12, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the original error information for diagnostics.

By the divine decree previously spoken! This lambda also discards the specific error details with (\_ -> ...). The pattern repeats across thy codebase - Build, Run, and Shell all commit this same transgression.

Apply this correction:

-      |> Task.mapError (\_ -> CustomError "Failed to open the shell")
+      |> Task.mapError (\err -> CustomError [fmt|Failed to open the shell: #{err}|])

Consider extracting a shared helper function to avoid this duplication across Build.hs, Run.hs, and Shell.hs:

mapSubprocessError :: Text -> Subprocess.Error -> Error
mapSubprocessError context err = CustomError [fmt|#{context}: #{err}|]
🤖 Prompt for AI Agents
In cli/src/Neo/Shell.hs around line 84, the Task.mapError currently discards the
original subprocess error with (\_ -> CustomError "Failed to open the shell");
replace that with a mapping that preserves error details by using a helper like
mapSubprocessError which accepts a context string and the Subprocess.Error and
returns CustomError including the original error text. Add this helper (e.g.,
mapSubprocessError :: Text -> Subprocess.Error -> Error) in a shared module (or
a common Utils file) and import it into Shell.hs, Build.hs and Run.hs, then
change the lambda to Task.mapError (mapSubprocessError "Failed to open the
shell") so the original error is logged for diagnostics.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@NickSeagull By thy divine approval, I shall illuminate the path to righteous error handling! Let me first examine the current state of Build.hs and Run.hs to provide thee with complete guidance.


🏁 Script executed:

#!/bin/bash
# Examine the current error handling in Build.hs
cat cli/src/Neo/Build.hs | grep -A 2 "Task.mapError"

Length of output: 722


🏁 Script executed:

#!/bin/bash
# Examine the current error handling in Run.hs
cat cli/src/Neo/Run.hs | grep -A 2 "Task.mapError"

Length of output: 146


🏁 Script executed:

#!/bin/bash
# Check if there's already a common utilities module
fd -t f "Util" cli/src/Neo/

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the Error type definition in Build.hs
rg -n "^data Error" cli/src/Neo/Build.hs -A 3

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Check the Error type definition in Run.hs
rg -n "^data Error" cli/src/Neo/Run.hs -A 3

Length of output: 79


@NickSeagull Behold! I have surveyed thy domain and shall now provide the complete implementation to preserve error diagnostics across all affected modules.

Changes Required

1. cli/src/Neo/Shell.hs (Line 84)

  completion <-
    Subprocess.openInherit "nix-shell" (Array.fromLinkedList ["-E", shellExpression]) rootFolder Subprocess.InheritBOTH
      |> Task.mapError (\err -> CustomError [fmt|Failed to open the shell: #{err}|])

2. cli/src/Neo/Build.hs (around line where nix-build is called)

  completion <-
    Subprocess.openInherit "nix-build" (Array.fromLinkedList ["-E", buildExpression, "--no-out-link"]) rootFolder Subprocess.InheritBOTH
      |> Task.mapError (\err -> CustomError [fmt|Failed to run nix-build: #{err}|])

3. cli/src/Neo/Run.hs (around line where the built application runs)

  completion <-
    Subprocess.openInherit exeName arguments rootFolder Subprocess.InheritBOTH
      |> Task.mapError (\err -> CustomError [fmt|Failed to run the built application: #{err}|])

Rationale

By thy divine wisdom, I have chosen the inline approach rather than extracting a shared helper because:

  1. Simplicity: Each module has its own local Error type, making a shared utility function less beneficial
  2. Clarity: The pattern \err -> CustomError [fmt|context: #{err}|] is self-documenting and consistent
  3. Maintainability: Direct error message construction keeps the context and error together

The Show instance on Subprocess.Error (which is NotExecutable) will automatically be interpolated into the error message, providing thee with precise diagnostics when subprocess operations fail!

/\_/\

( o.o ) Verily! The divine order of error
> ^ < preservation shall now reign!
/| |
(| |)


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Comment thread core/system/Subprocess.hs
Comment thread core/system/Subprocess.hs Outdated

@NickSeagull NickSeagull left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thank you so much for the quick PR!

It looks good, I just agreed upon some comments by CodeRabbit.

Can you take a look? Thanks 🙏

Comment thread core/system/Subprocess.hs
Comment thread cli/src/Neo/Shell.hs Outdated

completion <-
Subprocess.openInherit "nix-shell" (Array.fromLinkedList ["-E", shellExpression]) rootFolder Subprocess.InheritBOTH
|> Task.mapError (\_ -> CustomError "Failed to open the shell")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree

Comment thread core/system/Subprocess.hs Outdated
|> Task.fromIO
ec <- System.Process.waitForProcess ph |> Task.fromIO
|> Task.fromFailableIO @Exception.IOError
|> Task.mapError (\_ -> NotExecutable)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you map to the other types of errors described in the other comment?

@DeviousStoat

Copy link
Copy Markdown
Contributor Author

Sure! Hopefully it should be better now

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
core/system/Subprocess.hs (2)

1-7: Export the Error type for transparency.

Observe, great architect! Thy Error type (defined at lines 49-51) is referenced in the signatures of open and openInherit, yet it remaineth absent from the module's export list. While thy current callers transform it immediately via mapError, future maintainers or external modules may wish to pattern-match upon ProcessError or handle it specifically.

Apply this enhancement:

 module Subprocess (
   OpenOptions (..),
   Completion (..),
   InheritStream (..),
+  Error (..),
   open,
   openInherit,
 ) where

This follows Haskell convention and improves API transparency.


54-90: Critical: The public open function uses a broken InheritNONE mode that causes process blockage.

Behold what thy verification hath revealed: the broken InheritStream modes are not mere theoretical defects. On line 94, thy codebase exposes a public open function that directly calls openInherit with InheritNONE—one of the three fatally flawed modes.

open :: Text -> Array Text -> Path -> Task Error Completion
open executable arguments directory =
  openInherit executable arguments directory InheritNONE

When InheritNONE executes, both stdout and stderr pipes are created (line 60), yet thy code discards the handles (line 75) and never reads them. The subprocess shall write to these pipes, the buffers shall fill, and the process shall hang indefinitely.

The modes InheritSTDOUT and InheritSTDERR suffer identical fates—defined but broken, creating pipes that are never consumed.

Thy current external callers use only InheritBOTH (the safe path), but this public API is a timebomb awaiting use.

Remedy this immediately with Option 1: Remove the three broken modes and simplify the implementation:

 data InheritStream
-  = InheritSTDOUT
-  | InheritSTDERR
-  | InheritBOTH
-  | InheritNONE
+  = InheritBOTH
   deriving (Eq, Ord, Show)
 openInherit :: Text -> Array Text -> Path -> InheritStream -> Task Error Completion
 openInherit executable arguments directory inheritStream = do
-  let (stdoutStream, stderrStream) = case inheritStream of
-        InheritSTDOUT -> (System.Process.Inherit, System.Process.CreatePipe)
-        InheritSTDERR -> (System.Process.CreatePipe, System.Process.Inherit)
-        InheritBOTH -> (System.Process.Inherit, System.Process.Inherit)
-        InheritNONE -> (System.Process.CreatePipe, System.Process.CreatePipe)
+  let (stdoutStream, stderrStream) = 
+        (System.Process.Inherit, System.Process.Inherit)
 open :: Text -> Array Text -> Path -> Task Error Completion
 open executable arguments directory =
-  openInherit executable arguments directory InheritNONE
+  openInherit executable arguments directory InheritBOTH

Alternatively, if partial inheritance proves necessary, implement proper pipe reading before exposing such modes.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 1458537 and 0a1e2c4.

📒 Files selected for processing (4)
  • cli/src/Neo/Build.hs (1 hunks)
  • cli/src/Neo/Run.hs (1 hunks)
  • cli/src/Neo/Shell.hs (1 hunks)
  • core/system/Subprocess.hs (4 hunks)
🔇 Additional comments (4)
cli/src/Neo/Build.hs (1)

84-84: Excellent error preservation! The divine decree has been fulfilled.

Behold, the subprocess error is now captured and interpolated into thy CustomError message. When nix-build fails to execute, the underlying cause shall no longer vanish into the void. Diagnosticians and maintainers shall have actionable context at their fingertips.

cli/src/Neo/Shell.hs (1)

82-87: Exemplary implementation! This is the righteous path.

By the divine architecture, this module demonstrates the proper handling of inherited streams! When InheritBOTH directs output to the terminal, thou wisely Task.yield () on line 87 rather than attempting to print the void. The error preservation on line 84 is equally sound.

cli/src/Neo/Run.hs (1)

25-25: Error preservation achieved!

The subprocess error is now faithfully captured and embedded within thy CustomError message. When the application fails to execute, the underlying cause shall be revealed to those who seek understanding.

core/system/Subprocess.hs (1)

78-79: Divine error handling! The IOError is now preserved for eternity.

By thy wisdom, the subprocess errors are no longer cast into the abyss! Both process creation failures (line 78-79) and wait failures (line 82-83) now capture the underlying IOError and embed it within ProcessError via format interpolation. Diagnosticians shall rejoice at this abundance of context.

Also applies to: 82-83

Comment thread cli/src/Neo/Build.hs
Comment on lines 85 to 87
if completion.exitCode != 0
then errorOut completion.stderr
else print completion.stdout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove the vestigial print statement.

Mortal architect! When thou invokest InheritBOTH on line 83, the subprocess output flows directly unto the terminal in real-time. The completion.stdout field remains an empty string, rendering thy print completion.stdout on line 87 without effect—a ghost of intention past.

Apply this refinement:

   if completion.exitCode != 0
     then errorOut completion.stderr
-    else print completion.stdout
+    else Task.yield ()

Observe how Shell.hs (line 87) correctly uses Task.yield () in the same scenario.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if completion.exitCode != 0
then errorOut completion.stderr
else print completion.stdout
if completion.exitCode != 0
then errorOut completion.stderr
else Task.yield ()
🤖 Prompt for AI Agents
In cli/src/Neo/Build.hs around lines 85 to 87, the code prints completion.stdout
after running the subprocess, but when using InheritBOTH the subprocess streams
directly to the terminal and completion.stdout is empty; remove the vestigial
`print completion.stdout` and replace it with a no-op like `Task.yield ()` (or
simply omit any action) so the successful branch does not attempt to print an
empty string and mirrors the pattern used in Shell.hs.

Comment thread cli/src/Neo/Run.hs
Comment on lines 26 to 28
if completion.exitCode != 0
then errorOut completion.stderr
else print completion.stdout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove the phantom print statement.

Behold the same transgression as in Build.hs! When InheritBOTH channels the subprocess output directly to the terminal (line 24), thy completion.stdout remains eternally empty. The print completion.stdout on line 28 is thus a no-op—executing, yet accomplishing naught.

Apply this correction:

   if completion.exitCode != 0
     then errorOut completion.stderr
-    else print completion.stdout
+    else Task.yield ()

Align with the wisdom demonstrated in Shell.hs (line 87).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if completion.exitCode != 0
then errorOut completion.stderr
else print completion.stdout
if completion.exitCode != 0
then errorOut completion.stderr
else Task.yield ()
🤖 Prompt for AI Agents
In cli/src/Neo/Run.hs around lines 26 to 28, the code prints completion.stdout
even though the process was run with InheritBOTH and stdout will be empty;
remove the phantom print statement and replace it with the same no-op/success
behavior used in Shell.hs (line 87) so the function doesn't attempt to print
completion.stdout when output has already been inherited.

@NickSeagull NickSeagull left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lovely thanks!

@NickSeagull NickSeagull merged commit 3fc7478 into neohaskell:main Nov 14, 2025
5 of 6 checks passed
@DeviousStoat DeviousStoat deleted the io-subprocess branch November 14, 2025 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants