Skip to content

Commit 6c3ef6a

Browse files
ostermanclaude
andauthored
feat: terminal steps - tty/interactive fields and exec step type (#2602)
* feat(commands): add tty/interactive step schema fields and interrupt suspension Add Interactive/Tty bools to Task and WorkflowStep with converter mappings. Add pkg/signals with a nestable interrupt-exit suspension counter, consulted by the main signal handler so foreground interactive steps own Ctrl-C. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pty): don't block completion on stdin copier; add DisableStdinForward The stdin copy goroutine was joined via WaitGroup, but io.Copy from a terminal only returns on the next read after the PTY closes - so ExecWithPTY hung until a keypress after the child exited. Detach the stdin copier (docker-CLI pattern) and drain IO errors non-blockingly. DisableStdinForward supports docker's -t-without-i semantics: the child gets a TTY but host input is not forwarded and the host terminal stays cooked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(process): add RunShellSession for terminal-attached shell steps Runs a shell command attached to the user's terminal: under a PTY when supported (masking preserved, raw mode routes Ctrl-C to the child for interactive sessions), otherwise via direct fd inheritance with a visible warning that masking is unavailable. Interactive sessions suspend the global SIGINT-exit handler; child exit codes propagate as ExitCodeError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(commands): route tty/interactive custom command steps to terminal sessions Shell steps with tty: true attach the user's terminal via RunShellSession; interactive: true suspends the Atmos SIGINT-exit handler so the step owns Ctrl-C. Plain steps keep the existing masked shell-interpreter path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(workflows): support tty/interactive shell steps Workflow shell steps with tty: true attach the user's terminal via RunShellSession (output modes don't apply); interactive: true suspends the Atmos SIGINT-exit handler so the step owns Ctrl-C. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(runner): support tty/interactive shell tasks in task runner and step handler Shell tasks with tty: true attach the user's terminal via RunShellSession (no capturable output; exit code recorded in metadata). Interactive tasks attach host stdin, force raw output mode (buffered modes would hide prompts), and suspend the Atmos SIGINT-exit handler while running. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document tty/interactive steps; update schemas, changelog, roadmap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(roadmap): link tty/interactive steps milestone to PR #2602 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(steps): add exec step type that replaces the Atmos process Steps with type: exec hand the process over entirely (shell exec semantics): execve of the system shell on Unix (inheriting env, working directory, and the terminal natively; ATMOS_SHLVL unchanged), spawn-and-propagate-exit-code emulation on Windows. Exec steps are validated to be the final step and must not set supervisor-only fields (tty, interactive, retry, timeout, output). Wired into custom commands, workflows, and the task runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(steps): route interactive steps through RunShellSession; cross-platform test helpers Interactive (non-tty) steps now use the shell session path so suspension and platform-aware shell selection live in one place (pkg/process); dry-run is checked before shell-level validation; the runner env inherits os.Environ. Tests use the test binary as a cross-platform subprocess helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(process): only warn about lost masking when a tty was requested Interactive (non-tty) sessions always attach the real streams, so missing masking is inherent there - debug-log it instead of warning on every step. The visible warning remains for the unexpected case: tty requested but PTY unavailable on the platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(steps): keep cmd/ and internal/exec to bare call sites Move shell-family step routing into pkg/process.RunShellStep (terminal steps to sessions, plain steps via caller fallback) and default masking wiring inside RunShellSession. Delete the cmd/ step helper file and the internal/exec helper - both locations now contain only inline switch-case call sites; the runner and step handler use the same shared routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pty): bound session teardown when grandchildren hold the PTY slave When the session command exits, grandchildren that inherited the PTY slave (e.g. aws ssm's session-manager-plugin) can keep it open, so the output copier never gets EIO: atmos hung with the terminal in raw mode (no prompt until a stray keypress, no echo afterwards). Drain output on a 1s deadline after child exit, forcing the pending read to return so the terminal is restored promptly. Reported-by: live SSM session test on PR #2602 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): suppress terminal queries from PTY test children The test binary used as a PTY child links TUI libraries that emit OSC/DSR terminal queries when stdout is a TTY and block ~5s per query waiting for replies no test PTY sends - hanging three pty tests past their deadlines and silently adding ~15s to the process suite. TERM=dumb/NO_COLOR in the child env suppresses the queries (pty suite 25s+fail -> 1.5s; process 16.6s -> 1.6s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: update workflow step-type hint expectations for exec The invalid-step-type hint now lists 'exec' among valid types; update the test-case pattern and regenerate the golden snapshot (-regenerate-snapshots). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(process): build cmd.exe session commands verbatim with /S /C Go converts argv to a process command line with C quoting rules, which cmd.exe does not parse - any quoted session command broke on Windows ('"C:\..." is not recognized'). Build the command line verbatim via SysProcAttr.CmdLine using cmd.exe /S /C "<command>" so cmd strips exactly the outer quotes and runs the command literally. Test helper now quotes Windows args plainly (the child parses standard quoting). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(schema): list TaskTypeExec in Task.Type field comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: raise patch coverage for terminal step paths - exec failure paths (LookPath miss, execve ENOEXEC) covered in-process - attached-session TTY masking warning branch and nil-context default - pty drain deadline, benign copy errors, isPtyEIO truth table - workflow exec step dry-run routing and not-last validation via ExecuteWorkflow - WorkflowStep exec validation rows for tty/interactive/retry (CodeRabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(process): shell-convention exit codes for signal-killed sessions; restore terminal on signal exit Sessions whose child died from a signal now report 128+signal (e.g. 130) instead of Go's -1 ('subcommand exited with code -1'). The PTY terminal restore is also registered as a signal-exit cleanup: os.Exit in the signal handler skips defers, which could leave the host terminal in raw mode when atmos was signalled mid-session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document the exec step type; extend changelog and roadmap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(process): exit silently on non-zero terminal-session exit (no post-session hang) A tty/interactive/exec session that exits non-zero produced a bare ExitCodeError that Atmos rendered through the themed Glamour formatter. That render triggers termenv's terminal queries (OSC 11 background color, DSR cursor) and reads the reply from os.Stdin - but the session's stdin copier steals it, so termenv blocks for its full ~5s timeout before the process exits (Matt's '3-5s then code -1'; Erik's hang on a failed-command exit). Mark session ExitCodeErrors Silent so the code propagates like a shell would, with no themed error box and no terminal query. Honored in CheckErrorPrintAndExit (custom commands), main.run (workflows), and the workflow step-error wrapper. Before: 5.0s on non-zero exit; after: 3ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7fc9c3e commit 6c3ef6a

43 files changed

Lines changed: 2674 additions & 45 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/cmd_utils.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
l "github.com/cloudposse/atmos/pkg/list"
3232
log "github.com/cloudposse/atmos/pkg/logger"
3333
"github.com/cloudposse/atmos/pkg/perf"
34+
"github.com/cloudposse/atmos/pkg/process"
3435
"github.com/cloudposse/atmos/pkg/reexec"
3536
stepPkg "github.com/cloudposse/atmos/pkg/runner/step"
3637
"github.com/cloudposse/atmos/pkg/schema"
@@ -729,6 +730,13 @@ func executeCustomCommand(
729730
log.Debug("Using working directory for custom command", "command", commandConfig.Name, "working_directory", workDir)
730731
}
731732

733+
// Validate exec steps before executing anything: an exec step replaces
734+
// the Atmos process, so it must be the final step and must not set
735+
// supervisor-only fields (tty, interactive, retry, timeout, output).
736+
if err := schema.ValidateExecTasks(commandConfig.Steps); err != nil {
737+
errUtils.CheckErrorPrintAndExit(err, "", "https://atmos.tools/cli/configuration/commands#interactive-and-tty-steps")
738+
}
739+
732740
// Initialize step executor once before loop - reused across steps to preserve outputs.
733741
executor := stepPkg.NewStepExecutor()
734742

@@ -892,8 +900,27 @@ func executeCustomCommand(
892900
switch stepType {
893901
case "shell":
894902
// Execute shell command (backward compatible).
903+
// Steps with tty/interactive attach the user's terminal so commands
904+
// like `aws ssm start-session` get a real TTY and own Ctrl-C.
895905
commandName := fmt.Sprintf("%s-step-%d", commandConfig.Name, i)
896-
err = e.ExecuteShell(commandToRun, commandName, workDir, env, false)
906+
err = process.RunShellStep(context.Background(), &process.ShellSessionSpec{
907+
Command: commandToRun,
908+
Name: commandName,
909+
Dir: workDir,
910+
Env: env,
911+
TTY: step.Tty,
912+
Interactive: step.Interactive,
913+
}, func() error {
914+
return e.ExecuteShell(commandToRun, commandName, workDir, env, false)
915+
})
916+
case schema.TaskTypeExec:
917+
// Replace the Atmos process with the command (shell exec semantics).
918+
err = process.ReplaceShellSession(&process.ExecSpec{
919+
Command: commandToRun,
920+
Name: fmt.Sprintf("%s-step-%d", commandConfig.Name, i),
921+
Dir: workDir,
922+
Env: env,
923+
})
897924
case "atmos":
898925
// Execute atmos command.
899926
args := strings.Fields(commandToRun)

errors/error_funcs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,15 @@ func CheckErrorPrintAndExit(err error, title string, suggestion string) {
336336
Exit(0)
337337
return
338338
}
339+
// Silent exits propagate the code without printing (terminal-handoff
340+
// steps; rendering would query the terminal and can hang).
341+
if exitCodeErr.Silent {
342+
if atmosConfig != nil && atmosConfig.Errors.Sentry.Enabled {
343+
CloseSentry()
344+
}
345+
Exit(exitCodeErr.Code)
346+
return
347+
}
339348
// Non-zero exit codes: print error and exit with that code
340349
CheckErrorAndPrint(err, title, suggestion)
341350

errors/error_funcs_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,32 @@ func TestCheckErrorPrintAndExit_ExitCodeError(t *testing.T) {
123123
}
124124
}
125125

126+
func TestCheckErrorPrintAndExit_SilentExitCodeError(t *testing.T) {
127+
if os.Getenv("TEST_SILENT_EXIT") == "1" {
128+
err := ExitCodeError{Code: 7, Silent: true}
129+
CheckErrorPrintAndExit(err, "Should Not Print", "")
130+
return
131+
}
132+
133+
execPath, err := exec.LookPath(os.Args[0])
134+
assert.Nil(t, err)
135+
cmd := exec.Command(execPath, "-test.run=TestCheckErrorPrintAndExit_SilentExitCodeError")
136+
cmd.Env = append(os.Environ(), "TEST_SILENT_EXIT=1")
137+
var stderr bytes.Buffer
138+
cmd.Stderr = &stderr
139+
runErr := cmd.Run()
140+
141+
var exitError *exec.ExitError
142+
if errors.As(runErr, &exitError) {
143+
assert.Equal(t, 7, exitError.ExitCode(), "silent error must still propagate the exit code")
144+
} else {
145+
assert.Fail(t, "Expected an exit error with code 7")
146+
}
147+
// Silent errors must not render a themed error box (which would query the terminal).
148+
assert.NotContains(t, stderr.String(), "subcommand exited")
149+
assert.NotContains(t, stderr.String(), "# Error")
150+
}
151+
126152
func TestCheckErrorPrintAndExit_ExecExitError(t *testing.T) {
127153
if os.Getenv("TEST_EXEC_EXIT") == "1" {
128154
// Create an exec.ExitError using platform-appropriate command.

errors/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,12 @@ var (
12231223
// This avoids deep exits (os.Exit) which are untestable.
12241224
type ExitCodeError struct {
12251225
Code int
1226+
// Silent suppresses themed error rendering: the process exits with Code
1227+
// without printing an error box. Used for terminal-handoff steps (tty,
1228+
// interactive, exec) where, like a shell, a non-zero exit from the child
1229+
// program should propagate the code without Atmos rendering its own error
1230+
// (which would query the terminal and can hang when stdin is contended).
1231+
Silent bool
12261232
}
12271233

12281234
func (e ExitCodeError) Error() string {

internal/exec/workflow_utils.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
envpkg "github.com/cloudposse/atmos/pkg/env"
2828
log "github.com/cloudposse/atmos/pkg/logger"
2929
"github.com/cloudposse/atmos/pkg/perf"
30+
"github.com/cloudposse/atmos/pkg/process"
3031
"github.com/cloudposse/atmos/pkg/retry"
3132
stepPkg "github.com/cloudposse/atmos/pkg/runner/step"
3233
"github.com/cloudposse/atmos/pkg/schema"
@@ -279,6 +280,18 @@ func ExecuteWorkflow(
279280
// Check if the workflow steps have the `name` attribute
280281
checkAndGenerateWorkflowStepNames(workflowDefinition)
281282

283+
// Validate exec steps before executing anything: an exec step replaces
284+
// the Atmos process, so it must be the final step and must not set
285+
// supervisor-only fields (tty, interactive, retry, timeout, output).
286+
if err := schema.ValidateExecWorkflowSteps(workflowDefinition.Steps); err != nil {
287+
return errUtils.Build(err).
288+
WithTitle(WorkflowErrTitle).
289+
WithHint("Steps of type `exec` replace the Atmos process; move the exec step to the end of the workflow and remove unsupported fields").
290+
WithContext("workflow", workflow).
291+
WithExitCode(1).
292+
Err()
293+
}
294+
282295
log.Debug("Executing workflow", "workflow", workflow, "path", workflowPath)
283296

284297
if atmosConfig.Logs.Level == u.LogLevelTrace || atmosConfig.Logs.Level == u.LogLevelDebug {
@@ -402,10 +415,34 @@ func ExecuteWorkflow(
402415
switch commandType {
403416
case "shell":
404417
// Render command before execution if show.command is enabled.
418+
// Steps with tty/interactive attach the user's terminal; plain
419+
// steps keep the existing masked shell-interpreter behavior.
405420
stepPkg.RenderCommand(&step, workflowDefinition, command)
406421
commandName := fmt.Sprintf("%s-step-%d", workflow, stepIdx)
407422
err = retry.Do(context.Background(), step.Retry, func() error {
408-
return ExecuteShell(command, commandName, ".", stepEnv, dryRun)
423+
return process.RunShellStep(context.Background(), &process.ShellSessionSpec{
424+
Command: command,
425+
Name: commandName,
426+
Dir: ".",
427+
Env: stepEnv,
428+
TTY: step.Tty,
429+
Interactive: step.Interactive,
430+
DryRun: dryRun,
431+
}, func() error {
432+
return ExecuteShell(command, commandName, ".", stepEnv, dryRun)
433+
})
434+
})
435+
case schema.TaskTypeExec:
436+
// Replace the Atmos process with the command (shell exec semantics).
437+
// Validated earlier to be the final step; no retry wrapper (the
438+
// process is replaced, so a retry could never run).
439+
stepPkg.RenderCommand(&step, workflowDefinition, command)
440+
err = process.ReplaceShellSession(&process.ExecSpec{
441+
Command: command,
442+
Name: fmt.Sprintf("%s-step-%d", workflow, stepIdx),
443+
Dir: ".",
444+
Env: stepEnv,
445+
DryRun: dryRun,
409446
})
410447
case "atmos":
411448
// Parse command using shell.Fields for proper quote handling.
@@ -464,7 +501,7 @@ func ExecuteWorkflow(
464501
return errUtils.Build(errUtils.ErrInvalidWorkflowStepType).
465502
WithTitle(WorkflowErrTitle).
466503
WithHintf("Step type '%s' is not supported", commandType).
467-
WithHint("Each step must specify a valid type: 'atmos', 'shell', or an interactive type like 'input', 'confirm', 'choose'").
504+
WithHint("Each step must specify a valid type: 'atmos', 'shell', 'exec', or an interactive type like 'input', 'confirm', 'choose'").
468505
WithExitCode(1).
469506
Err()
470507
}
@@ -476,6 +513,13 @@ func ExecuteWorkflow(
476513
if progressRenderer.IsEnabled() {
477514
progressRenderer.Done()
478515
}
516+
// Terminal-handoff steps (tty/interactive/exec) that exit non-zero
517+
// propagate the code silently, like a shell - don't wrap them in a
518+
// themed workflow error (which would query the terminal post-session).
519+
var silentExit errUtils.ExitCodeError
520+
if errors.As(err, &silentExit) && silentExit.Silent {
521+
return err
522+
}
479523
return buildWorkflowStepError(err, &workflowStepErrorContext{
480524
WorkflowPath: workflowPath,
481525
WorkflowBasePath: atmosConfig.Workflows.BasePath,

internal/exec/workflow_utils_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,3 +1694,61 @@ func TestDoubleHyphenIssue1967(t *testing.T) {
16941694
assert.Equal(t, "--", args[5], "sixth arg should be --")
16951695
assert.Equal(t, "-consolidate-warnings=false", args[6], "seventh arg should be -consolidate-warnings=false")
16961696
}
1697+
1698+
func TestExecuteWorkflowExecStep_ValidationViaSchema(t *testing.T) {
1699+
// The workflow runner validates exec steps before executing anything.
1700+
err := schema.ValidateExecWorkflowSteps([]schema.WorkflowStep{
1701+
{Type: schema.TaskTypeExec, Command: "psql"},
1702+
{Type: schema.TaskTypeShell, Command: "echo never runs"},
1703+
})
1704+
require.ErrorIs(t, err, schema.ErrExecStepNotLast)
1705+
1706+
err = schema.ValidateExecWorkflowSteps([]schema.WorkflowStep{
1707+
{Type: schema.TaskTypeShell, Command: "echo first"},
1708+
{Type: schema.TaskTypeExec, Command: "psql"},
1709+
})
1710+
assert.NoError(t, err)
1711+
}
1712+
1713+
func TestExecuteWorkflow_ExecStepDryRun(t *testing.T) {
1714+
testDir := "../../tests/fixtures/scenarios/workflows"
1715+
t.Setenv("ATMOS_CLI_CONFIG_PATH", testDir)
1716+
t.Setenv("ATMOS_BASE_PATH", testDir)
1717+
1718+
atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
1719+
require.NoError(t, err)
1720+
1721+
workflowDefinition := &schema.WorkflowDefinition{
1722+
Description: "Exec step as the final step",
1723+
Steps: []schema.WorkflowStep{
1724+
{Name: "prep", Type: "shell", Command: "echo preparing"},
1725+
{Name: "session", Type: schema.TaskTypeExec, Command: "echo session"},
1726+
},
1727+
}
1728+
1729+
// Dry-run exercises validation and the exec routing without replacing the process.
1730+
err = ExecuteWorkflow(atmosConfig, "exec-dry-run", "test.yaml", workflowDefinition, true, "", "", "")
1731+
require.NoError(t, err)
1732+
}
1733+
1734+
func TestExecuteWorkflow_ExecStepNotLastFails(t *testing.T) {
1735+
testDir := "../../tests/fixtures/scenarios/workflows"
1736+
t.Setenv("ATMOS_CLI_CONFIG_PATH", testDir)
1737+
t.Setenv("ATMOS_BASE_PATH", testDir)
1738+
1739+
atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
1740+
require.NoError(t, err)
1741+
1742+
workflowDefinition := &schema.WorkflowDefinition{
1743+
Description: "Exec step in the wrong position",
1744+
Steps: []schema.WorkflowStep{
1745+
{Name: "session", Type: schema.TaskTypeExec, Command: "echo session"},
1746+
{Name: "after", Type: "shell", Command: "echo never runs"},
1747+
},
1748+
}
1749+
1750+
// Validation must fail before any step executes, even in dry-run.
1751+
err = ExecuteWorkflow(atmosConfig, "exec-not-last", "test.yaml", workflowDefinition, true, "", "", "")
1752+
require.Error(t, err)
1753+
assert.ErrorIs(t, err, schema.ErrExecStepNotLast)
1754+
}

main.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"errors"
45
"os"
56
"os/signal"
67
"strings"
@@ -11,23 +12,33 @@ import (
1112
ioLayer "github.com/cloudposse/atmos/pkg/io"
1213
log "github.com/cloudposse/atmos/pkg/logger"
1314
"github.com/cloudposse/atmos/pkg/panics"
15+
"github.com/cloudposse/atmos/pkg/signals"
1416
)
1517

1618
func main() {
1719
// Set up signal handling for graceful shutdown.
1820
sigChan := make(chan os.Signal, 1)
1921
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
2022
go func() {
21-
sig := <-sigChan
22-
// Clean up resources before exit.
23-
cmd.Cleanup()
24-
// Exit with correct POSIX exit code (128 + signal number).
25-
// Use errUtils.OsExit to allow test interception (Go 1.25+ panics on os.Exit in tests).
26-
if s, ok := sig.(syscall.Signal); ok {
27-
errUtils.OsExit(128 + int(s))
23+
for sig := range sigChan {
24+
// While an interactive/TTY step owns the terminal, SIGINT belongs to
25+
// the foreground child process - keep waiting instead of exiting.
26+
if !shouldExitOnSignal(sig) {
27+
continue
28+
}
29+
// Run registered exit cleanups (e.g. restore the terminal from raw
30+
// mode) - os.Exit below skips deferred functions.
31+
signals.RunExitCleanups()
32+
// Clean up resources before exit.
33+
cmd.Cleanup()
34+
// Exit with correct POSIX exit code (128 + signal number).
35+
// Use errUtils.OsExit to allow test interception (Go 1.25+ panics on os.Exit in tests).
36+
if s, ok := sig.(syscall.Signal); ok {
37+
errUtils.OsExit(128 + int(s))
38+
}
39+
// Fallback to SIGINT exit code if signal type assertion fails.
40+
errUtils.OsExit(130)
2841
}
29-
// Fallback to SIGINT exit code if signal type assertion fails.
30-
errUtils.OsExit(130)
3142
}()
3243

3344
// Disable timestamp in logs so snapshots work. We will address this in a future PR updating styles, etc.
@@ -81,6 +92,13 @@ func run() (exitCode int) {
8192

8293
err := cmd.Execute()
8394
if err != nil {
95+
// Silent exit-code carriers (terminal-handoff steps) propagate the
96+
// child's code without themed rendering, which would query the
97+
// terminal and can hang when stdin is still contended by the session.
98+
if code, ok := silentExitCode(err); ok {
99+
return code
100+
}
101+
84102
// Capture error to Sentry if configured (safe to call even if Sentry not initialized).
85103
errUtils.CaptureError(err)
86104

@@ -97,6 +115,25 @@ func run() (exitCode int) {
97115
return 0
98116
}
99117

118+
// silentExitCode reports the exit code to use when err is a silent exit-code
119+
// carrier (a terminal-handoff step that exited non-zero). Such errors must
120+
// propagate the code without themed rendering, which would query the terminal
121+
// and can hang when the session still contends for stdin.
122+
func silentExitCode(err error) (int, bool) {
123+
var exitCodeErr errUtils.ExitCodeError
124+
if errors.As(err, &exitCodeErr) && exitCodeErr.Silent {
125+
return exitCodeErr.Code, true
126+
}
127+
return 0, false
128+
}
129+
130+
// shouldExitOnSignal reports whether the process should exit in response to sig.
131+
// SIGINT is ignored while a foreground interactive/TTY step owns the terminal
132+
// (the child process handles Ctrl-C). SIGTERM always exits as an escape hatch.
133+
func shouldExitOnSignal(sig os.Signal) bool {
134+
return sig != os.Interrupt || !signals.InterruptExitSuspended()
135+
}
136+
100137
// hasVersionFlag checks if --version flag is present in args.
101138
// Only checks for --version as the first argument after the program name
102139
// to catch the simple "atmos --version" case for early exit; other flag

0 commit comments

Comments
 (0)