Skip to content

fix(viewer): isolate chafa terminal IO#62

Merged
leszek3737 merged 2 commits into
mainfrom
fix/imge
May 27, 2026
Merged

fix(viewer): isolate chafa terminal IO#62
leszek3737 merged 2 commits into
mainfrom
fix/imge

Conversation

@leszek3737
Copy link
Copy Markdown
Owner

@leszek3737 leszek3737 commented May 27, 2026

Summary by Sourcery

Isolate chafa execution from the terminal to prevent its IO from interfering with viewer input handling and simplify process management.

Bug Fixes:

  • Prevent chafa from probing or writing directly to the terminal so its responses are not misinterpreted as viewer input, such as triggering the search dialog instead of rendering the image preview.

Enhancements:

  • Simplify chafa process handling by using blocking output collection instead of a custom timeout loop.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 27, 2026

Reviewer's Guide

Refactors the chafa viewer loader to run chafa in a fully isolated, non-interactive way using Command::output with terminal probing/passthrough disabled, simplifying process handling while preserving error reporting and ANSI parsing.

Sequence diagram for isolated chafa viewer execution

sequenceDiagram
    participant Viewer as ViewerLoader
    participant Cmd as Command
    participant Chafa as chafa_process

    Viewer->>Cmd: Command::new(chafa)
    Cmd->>Cmd: arg(-f symbols)
    Cmd->>Cmd: arg(--probe off)
    Cmd->>Cmd: arg(--passthrough none)
    Cmd->>Cmd: arg(--polite on)
    Cmd->>Cmd: arg(--size <widthxheight>)
    Cmd->>Cmd: arg(-- path)
    Cmd->>Cmd: stdin(Stdio::null)
    Cmd->>Chafa: output()
    Chafa-->>Cmd: std::process::Output

    alt out.status.success()
        Cmd->>Cmd: stdout.into_text()
        alt into_text Ok
            Cmd-->>Viewer: Text
        else into_text Err
            Cmd-->>Viewer: Text::raw("Failed to parse ANSI: ...")
        end
    else out.status not success
        Cmd->>Cmd: String::from_utf8_lossy(stderr)
        Cmd-->>Viewer: Text::raw("Chafa error: ...")
    end

    Note over Viewer: On spawn error: Text::raw("Failed to execute chafa ...")
Loading

File-Level Changes

Change Details Files
Run chafa as a non-interactive subprocess with terminal probing and passthrough disabled, simplifying process handling and timeout logic.
  • Replace manual child process management and timeout loop with Command::output to wait for chafa to complete and capture stdout/stderr in one call
  • Disable chafa terminal probing, passthrough, and enable polite mode to prevent it from talking directly to the terminal and interfering with crossterm input handling
  • Close stdin for the chafa process by setting it to Stdio::null to ensure fully isolated IO
  • Remove the fixed 10-second timeout and polling loop around try_wait, relying instead on the synchronous output call
  • Preserve and slightly refactor error handling for command execution failure, non-zero exit statuses, and ANSI parsing errors into Text responses
src/ui/viewer/loader.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request simplifies the execution of the chafa command by switching from a spawned child process with a manual timeout loop to a blocking .output() call, while also disabling terminal probing and passthrough to prevent input interference. However, removing the timeout mechanism introduces a risk where a hung chafa process could leak background threads and system resources indefinitely. It is recommended to retain a timeout mechanism to safely terminate the process if it hangs.

Comment thread src/ui/viewer/loader.rs Outdated
Comment on lines 54 to 79
match std::process::Command::new("chafa")
.arg("-f")
.arg("symbols")
.arg("--probe")
.arg("off")
.arg("--passthrough")
.arg("none")
.arg("--polite")
.arg("on")
.arg("--size")
.arg(&size_str)
.arg("--")
.arg(path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.stdin(std::process::Stdio::null())
.output()
{
Ok(c) => c,
Err(e) => return Text::raw(format!("Failed to execute chafa (is it installed?): {}", e)),
};

let deadline = Instant::now() + CHAFA_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let out = child
.wait_with_output()
.unwrap_or_else(|_| std::process::Output {
status,
stdout: Vec::new(),
stderr: Vec::new(),
});
if out.status.success() {
return match out.stdout.into_text() {
Ok(text) => text,
Err(e) => Text::raw(format!("Failed to parse ANSI: {}", e)),
};
}
let err_msg = String::from_utf8_lossy(&out.stderr);
return Text::raw(format!("Chafa error: {}", err_msg));
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Text::raw("Chafa timed out".to_string());
}
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
return Text::raw(format!("Chafa wait error: {}", e));
}
Ok(out) if out.status.success() => match out.stdout.into_text() {
Ok(text) => text,
Err(e) => Text::raw(format!("Failed to parse ANSI: {}", e)),
},
Ok(out) => {
let err_msg = String::from_utf8_lossy(&out.stderr);
Text::raw(format!("Chafa error: {}", err_msg))
}
Err(e) => Text::raw(format!("Failed to execute chafa (is it installed?): {}", e)),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

By switching from a spawned child with a timeout loop to a blocking .output() call, there is no longer any protection against chafa hanging indefinitely (e.g., when processing a corrupted or extremely complex image, or due to a bug in chafa).

Since ImagePreviewLoader spawns a new thread and drops the old one when navigating files, a hung chafa process will leak both the background thread and the chafa process itself. Over time, this can exhaust system resources (PIDs and memory).

Consider retaining a timeout mechanism to ensure the process is killed if it exceeds a reasonable limit (e.g., 10 seconds).

    let mut child = match std::process::Command::new("chafa")
        .arg("-f")
        .arg("symbols")
        .arg("--probe")
        .arg("off")
        .arg("--passthrough")
        .arg("none")
        .arg("--polite")
        .arg("on")
        .arg("--size")
        .arg(&size_str)
        .arg("--")
        .arg(path)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => return Text::raw(format!("Failed to execute chafa (is it installed?): {}", e)),
    };

    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
    loop {
        match child.try_wait() {
            Ok(Some(status)) => {
                let out = child
                    .wait_with_output()
                    .unwrap_or_else(|_| std::process::Output {
                        status,
                        stdout: Vec::new(),
                        stderr: Vec::new(),
                    });
                if out.status.success() {
                    return match out.stdout.into_text() {
                        Ok(text) => text,
                        Err(e) => Text::raw(format!("Failed to parse ANSI: {}", e)),
                    };
                }
                let err_msg = String::from_utf8_lossy(&out.stderr);
                return Text::raw(format!("Chafa error: {}", err_msg));
            }
            Ok(None) => {
                if std::time::Instant::now() >= deadline {
                    let _ = child.kill();
                    let _ = child.wait();
                    return Text::raw("Chafa timed out".to_string());
                }
                std::thread::sleep(std::time::Duration::from_millis(50));
            }
            Err(e) => {
                return Text::raw(format!("Chafa wait error: {}", e));
            }
        }
    }

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • By switching from a manual loop with CHAFA_TIMEOUT to Command::output(), the chafa call can now block indefinitely; consider reintroducing a timeout (e.g., using a separate thread or async with a timeout) so a hung chafa process cannot freeze the viewer.
  • For non-successful chafa executions you only surface stderr, but when into_text() fails you return only the parse error; consider including at least part of stderr in that case as well to make diagnosing chafa issues easier.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- By switching from a manual loop with `CHAFA_TIMEOUT` to `Command::output()`, the chafa call can now block indefinitely; consider reintroducing a timeout (e.g., using a separate thread or async with a timeout) so a hung chafa process cannot freeze the viewer.
- For non-successful chafa executions you only surface `stderr`, but when `into_text()` fails you return only the parse error; consider including at least part of `stderr` in that case as well to make diagnosing chafa issues easier.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 27, 2026

Greptile Summary

This PR fixes a bug where chafa (the image-to-text renderer) was probing the terminal directly, causing crossterm to misread those escape sequences as user input and spuriously open the search dialog. The fix adds --probe off, --passthrough none, and --polite on flags to the chafa invocation and sets stdin to null, effectively isolating chafa from the shared terminal.

  • The terminal-isolation flags are the right approach — --probe off stops chafa from sending capability-query sequences that crossterm would absorb as keyboard input.
  • The polling loop + 10-second hard timeout (with child.kill()) was removed in favour of the simpler blocking .output() call, but no replacement timeout was added, leaving background threads able to block indefinitely if chafa hangs on a corrupt or very large file.

Confidence Score: 3/5

The terminal-isolation fix is correct and addresses a real UX bug, but removing the 10-second hard timeout introduces a new reliability hole — a hung chafa process will leave its background thread blocked indefinitely with no cleanup path.

The core change (chafa terminal isolation via --probe off / --passthrough none) is sound. The concern is the dropped timeout: the original code killed the child after 10 seconds and cleaned up cleanly; the replacement output() call blocks forever if chafa stalls on a corrupt image or a filesystem hiccup. Since ImagePreviewLoader drops the JoinHandle without joining, orphaned threads accumulate silently. This is a real regression in robustness for the image-preview path.

src/ui/viewer/loader.rs — specifically the run_chafa function and how the calling ImagePreviewLoader thread handles a non-terminating child process.

Important Files Changed

Filename Overview
src/ui/viewer/loader.rs Replaces polling loop + 10-second kill timeout with blocking output() call; adds chafa flags to prevent terminal probing. The terminal-isolation fix is correct but the timeout removal leaves threads able to block indefinitely on a hung chafa process.

Sequence Diagram

sequenceDiagram
    participant UI as UI Thread
    participant IPL as ImagePreviewLoader (bg thread)
    participant Chafa as chafa process

    UI->>IPL: ImagePreviewLoader::start(path, w, h)
    IPL->>Chafa: "Command::new("chafa")<br/>--probe off --passthrough none<br/>--polite on --size WxH<br/>stdin=null"
    Note over Chafa: Renders image to ANSI symbols<br/>No terminal probing/passthrough
    Chafa-->>IPL: stdout (ANSI text), exit status
    IPL->>IPL: stdout.into_text()
    IPL-->>UI: tx.send((width, height, text))
    UI->>UI: try_recv() → render preview

    Note over IPL,Chafa: If chafa hangs: thread blocks indefinitely<br/>(no timeout/kill in new code)
Loading

Reviews (1): Last reviewed commit: "fix(viewer): isolate chafa terminal IO" | Re-trigger Greptile

Comment thread src/ui/viewer/loader.rs
Comment on lines 49 to 79
pub fn run_chafa(path: &Path, width: u16, height: u16) -> Text<'static> {
let size_str = format!("{}x{}", width, height);
let mut child = match std::process::Command::new("chafa")
// Keep terminal probing and passthrough disabled. If chafa talks directly
// to the terminal, crossterm can read those responses as viewer input and
// open the search dialog instead of showing the image preview.
match std::process::Command::new("chafa")
.arg("-f")
.arg("symbols")
.arg("--probe")
.arg("off")
.arg("--passthrough")
.arg("none")
.arg("--polite")
.arg("on")
.arg("--size")
.arg(&size_str)
.arg("--")
.arg(path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.stdin(std::process::Stdio::null())
.output()
{
Ok(c) => c,
Err(e) => return Text::raw(format!("Failed to execute chafa (is it installed?): {}", e)),
};

let deadline = Instant::now() + CHAFA_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let out = child
.wait_with_output()
.unwrap_or_else(|_| std::process::Output {
status,
stdout: Vec::new(),
stderr: Vec::new(),
});
if out.status.success() {
return match out.stdout.into_text() {
Ok(text) => text,
Err(e) => Text::raw(format!("Failed to parse ANSI: {}", e)),
};
}
let err_msg = String::from_utf8_lossy(&out.stderr);
return Text::raw(format!("Chafa error: {}", err_msg));
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Text::raw("Chafa timed out".to_string());
}
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
return Text::raw(format!("Chafa wait error: {}", e));
}
Ok(out) if out.status.success() => match out.stdout.into_text() {
Ok(text) => text,
Err(e) => Text::raw(format!("Failed to parse ANSI: {}", e)),
},
Ok(out) => {
let err_msg = String::from_utf8_lossy(&out.stderr);
Text::raw(format!("Chafa error: {}", err_msg))
}
Err(e) => Text::raw(format!("Failed to execute chafa (is it installed?): {}", e)),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing timeout — background threads can block indefinitely

The old implementation used CHAFA_TIMEOUT (10 s) with an explicit child.kill() + child.wait() fallback. The new code calls .output(), which blocks until the child exits. If chafa hangs on a malformed or very large image, the ImagePreviewLoader background thread never unblocks. The Drop impl drops the JoinHandle without joining it (let _ = self._handle.take()), so the thread is simply orphaned. Rapid file-panel navigation across many images could accumulate tens of stuck threads, each holding OS resources until the chafa child eventually exits (or the process does).

Previously, the `chafa` process could hang indefinitely when rendering
an image preview, blocking the UI. This change introduces a 10-second
timeout for `chafa` by spawning it and monitoring its execution. If
`chafa` does not complete within the deadline, it is killed, and a
timeout error is reported.
@leszek3737 leszek3737 merged commit 124475d into main May 27, 2026
5 checks passed
@leszek3737 leszek3737 deleted the fix/imge branch May 27, 2026 17:05
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.

1 participant