Skip to content

fix: gracefully handle non-TTY context in interactive commands#675

Merged
stack72 merged 1 commit intomainfrom
fix/non-tty-interactive-crash-674
Mar 11, 2026
Merged

fix: gracefully handle non-TTY context in interactive commands#675
stack72 merged 1 commit intomainfrom
fix/non-tty-interactive-crash-674

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Mar 11, 2026

Fixes #674

Summary

  • Interactive search commands (model search, type search, workflow search, etc.) use Ink for terminal UIs which requires raw mode on stdin. In non-TTY contexts (piped input, CI, AI agents), Ink crashes with "Raw mode is not supported". This was a pre-existing bug — it existed before datastores — but the introduction of the datastore lock made it much worse: the crash now leaks the lock, blocking all subsequent swamp commands for ~60 seconds until timeout.

  • Fix 1 — interactiveOutputMode() helper (src/cli/context.ts): A new function that returns "json" when ctx.outputMode is "json" OR when stdin is not a TTY. Applied surgically to all 10 interactive search commands so they fall back to structured JSON output instead of crashing. Non-interactive commands (version, model get, workflow run, etc.) are unaffected — they keep their normal output mode.

  • Fix 2 — Safety net in main.ts: Added flushDatastoreSync() to the top-level catch block so the datastore lock is released even on unexpected crashes that bypass runCli()'s own error handling. flushDatastoreSync() is idempotent (nulls its state on first call), so calling it a second time is harmless.

Commands updated with interactiveOutputMode:

  • model search, type search, vault search, vault type search
  • workflow search, workflow run search, workflow history search
  • model method history search, model output search, data search

Test Plan

  • All 2797 tests pass (zero failures)
  • deno fmt --check passes
  • deno lint passes
  • deno check passes
  • Verified the global outputMode approach (auto-switching all commands) caused 6 test failures — confirmed the surgical per-command approach is correct
  • Verified non-interactive commands retain log-mode output in non-TTY contexts

🤖 Generated with Claude Code

Interactive search commands (model search, type search, workflow search,
etc.) use Ink for terminal UIs which requires raw mode on stdin. In
non-TTY contexts (piped input, CI, AI agents), Ink crashes with "Raw
mode is not supported". This was a pre-existing bug, but became more
impactful with the introduction of datastores — the crash now also
leaks the datastore lock, blocking subsequent commands for ~60 seconds.

Two fixes:

1. Add `interactiveOutputMode()` helper that falls back to JSON mode
   when stdin is not a TTY. Applied to all 10 interactive search
   commands so they output structured JSON instead of crashing.

2. Add `flushDatastoreSync()` safety net in main.ts catch block so
   the datastore lock is released even on unexpected crashes that
   bypass runCli()'s own error handling.

Co-authored-by: Blake Irvin <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Review Summary

This PR gracefully handles non-TTY contexts in interactive Ink-based search commands by falling back to JSON output mode when stdin is not a TTY. The implementation is clean, well-documented, and follows project conventions.

No Blocking Issues

All changes comply with the project's code style and architectural guidelines.

Code Quality Assessment

TypeScript/Code Style:

  • No any types used in new code
  • Named exports used correctly throughout
  • License headers present on all modified files
  • Clean, consistent pattern applied across all 10 interactive commands

Domain-Driven Design:

  • Changes are appropriately placed in CLI/infrastructure layers
  • isStdinTty() is a pure infrastructure concern (terminal state detection)
  • interactiveOutputMode() is presentation layer logic (output mode selection)
  • No domain logic pollution - this is proper layering

Security:

  • No vulnerabilities introduced
  • Adds safety via datastore lock release in main.ts catch block
  • isStdinTty() safely wraps Deno.stdin.isTerminal() with try/catch

Safety Net in main.ts:

  • flushDatastoreSync() is correctly idempotent (nulls state immediately)
  • Proper error handling for each operation
  • Releases lock even if S3 push fails

Suggestions (Non-blocking)

  1. Unit tests for interactiveOutputMode(): Consider adding tests for the ctx.outputMode === "json" case in context_test.ts. While testing TTY state is environment-dependent, the JSON passthrough case can be tested deterministically:

    Deno.test("interactiveOutputMode returns json when ctx.outputMode is json", () => {
      const ctx: CommandContext = { outputMode: "json", verbosity: "normal", logger: getSwampLogger([]) };
      assertEquals(interactiveOutputMode(ctx), "json");
    });
  2. The explanatory comment in model_search.ts:135-136 is excellent - consider adding similar brief comments in other command files for consistency, though this is purely stylistic.

Verification

  • CI checks pass (lint, test, format)
  • 2797 tests passing
  • Pattern consistently applied to all affected commands

Great fix for a subtle but important issue that affects CI/agent usability.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Adversarial Review

Critical / High

None found.

Medium

  1. Incomplete fix: extension_search.ts not updated (src/cli/commands/extension_search.ts:122-136)

    The PR updates 10 interactive search commands to use interactiveOutputMode(), but extension search was missed. It still passes ctx.outputMode directly to renderExtensionSearch():

    const result = await renderExtensionSearch(
      {...},
      ctx.outputMode,  // Should be interactiveOutputMode(ctx)
    );

    Breaking example: echo "" | swamp extension search aws will crash with "Raw mode is not supported" in non-TTY contexts.

    Severity rationale: This is MEDIUM rather than HIGH because unlike the other search commands, extension_search doesn't acquire a datastore lock until after the interactive selection completes (line 140 is inside if (result?.action === "install")). So while the command will still crash in non-TTY, it won't cause the lock leak issue that this PR primarily addresses.

    Suggested fix: Apply the same pattern to extension_search.ts:

    import { interactiveOutputMode } from "../context.ts";
    // ...
    const effectiveMode = interactiveOutputMode(ctx);
    const result = await renderExtensionSearch({...}, effectiveMode);

Low

  1. Unused parameter outputMode in RequireRepoOptions (src/cli/repo_context.ts:57-59)

    The outputMode parameter in RequireRepoOptions is passed by all commands but never used within requireInitializedRepo(). This is pre-existing dead code, not introduced by this PR, but the pattern of passing effectiveMode to it (while correct semantically) doesn't actually accomplish anything.

Verdict

PASS - The core fix is sound. The interactiveOutputMode() helper correctly detects non-TTY contexts and falls back to JSON. The flushDatastoreSync() safety net in main.ts is properly idempotent (nulls globals on first call). The double-flush scenario (inside runCli() catch + main.ts catch) is handled correctly. All 10 claimed commands are updated consistently.

The missed extension_search command is a gap in coverage but doesn't undermine the PR's primary goal of preventing lock leaks, since that command doesn't hold a lock when Ink is invoked.

@stack72 stack72 merged commit fa48cb0 into main Mar 11, 2026
6 checks passed
@stack72 stack72 deleted the fix/non-tty-interactive-crash-674 branch March 11, 2026 10:50
stack72 added a commit that referenced this pull request Mar 11, 2026
Follow-up to #678 (datastore lock deadlock fix) and #675 (non-TTY
interactive command fix). Addresses three issues identified in
adversarial review of those PRs:

1. TOCTOU race in lock release: Added `forceRelease(expectedNonce)` to
   the `DistributedLock` interface so each backend (S3Lock, FileLock)
   can verify the nonce and delete in the tightest possible sequence.
   The `datastore lock release` command now delegates to this method
   instead of branching on config type with separate delete logic.

2. Double marker file read in `requireInitializedRepo`: The refactoring
   in #678 caused `requireInitializedRepo` to read the marker file
   twice — once in `resolveDatastoreForRepo` and again directly.
   `resolveDatastoreForRepo` now returns the marker it already read,
   and `requireInitializedRepo` reuses it.

3. `extension_search.ts` missed in #675: The PR updated 10 interactive
   search commands to use `interactiveOutputMode()` but missed
   `extension_search`. Applied the same pattern so it won't crash with
   "Raw mode is not supported" in non-TTY contexts.

Co-authored-by: Claude Opus 4.6 <[email protected]>
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.

Datastore lock not released when interactive command fails in non-TTY context

1 participant