codex exec --json hangs indefinitely when --image flag is used

Resolved 💬 11 comments Opened Oct 26, 2025 by jet-ds Closed Oct 28, 2025
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Bug Description

codex exec --json hangs indefinitely when the --image flag is used. The CLI successfully initializes (thread.started event is emitted) but then never processes the request or emits any further events.

Environment

  • Codex CLI Version: 0.50.0
  • Platform: macOS (Darwin 25.0.0)
  • Installation: npm global install (npm i -g @openai/codex)

Minimal Reproduction

# Create or use any test image
echo "Describe what you see in this image" | codex exec --json --image /path/to/any/image.png

Expected behavior:

  • Codex should process the image and prompt
  • Stream events like item.*, agent_message_delta, agent_message, etc.
  • Eventually emit turn.completed or task_complete

Actual behavior:

  1. Emits {"type":"thread.started","thread_id":"..."}
  2. Hangs indefinitely - no further output
  3. Never completes, must be killed manually

Without Images: Works Fine

echo "Write a hello world script" | codex exec --json --cd /tmp

This works perfectly and streams all expected events (item.completed, agent_message, etc.).

Additional Context

  • The bug exists in both 0.49.0 and 0.50.0
  • The hang occurs after initialization, suggesting a state machine bug in the event loop
  • The TypeScript SDK (@openai/codex-sdk) is also affected since it uses --image flags internally
  • No errors are emitted to stderr
  • The process doesn't crash; it just stops producing output

Workaround

Currently using a timeout mechanism to detect the hang:

try:
    line = await asyncio.wait_for(
        process.stdout.readline(), timeout=90
    )
except asyncio.TimeoutError:
    # Kill and report error
    process.kill()

Impact

This makes image-based workflows completely unusable in non-interactive mode, which is critical for CI/CD pipelines and programmatic integrations.

Related Issues

This may be related to:

  • #2473 (codex-cli vision capability issues)
  • #1619 (image processing limitations)

View original on GitHub ↗

11 Comments

github-actions[bot] contributor · 8 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #4946

Powered by Codex Action

jet-ds · 8 months ago

This is NOT a duplicate of #4946.

Different Bug, Different Symptoms

Issue #4946:

  • Invalid format (SVG) causes retry loop
  • System attempts retries with exponential backoff
  • Error messages are logged: "image data you provided does not represent a valid image"
  • Eventually fails after 5 retries

This issue (#5773):

  • Valid formats (PNG, JPG, etc.) cause complete hang
  • Emits thread.started then produces no output whatsoever
  • No error messages, no retries, no timeout
  • Process never exits, must be killed manually

Test Case Difference

#4946 involves unsupported formats:

codex exec --image unsupported.svg "describe this"
# Results in: retry loop with error messages

This issue (#5773) involves supported formats:

codex exec --json --image valid.png "describe this"
# Results in: {"type":"thread.started",...} then infinite silence

Root Cause Difference

  • #4946: Error handling and retry logic for invalid formats
  • #5773: Event loop/state machine never starts processing after initialization when images are present

These are distinct bugs in different parts of the Codex CLI codebase.

synestiqx · 8 months ago

2025-10-21T00:42:17.364881Z DEBUG ChatComposer::handle_paste_image_path próba preview=11;rgb:1818/1818/1818

<img width="1069" height="270" alt="Image" src="https://github.com/user-attachments/assets/2f7b4ca1-2b06-4421-a751-05e34053b86b" />

Url

I described the problem here — it seems that in this case it could also be the cause. You can see in the logs what’s happening with the image.

#5683

jet-ds · 8 months ago

Thanks for the reference @AdsQnn! I dug into the source code to check if these are related.

Code Analysis

You're right that there's a connection - both interactive paste and exec --image use the same underlying code path:

Interactive mode (tui/src/chatwidget.rs:1339-1344):

for path in image_paths {
    items.push(UserInput::LocalImage { path });
}
self.codex_op_tx.send(Op::UserInput { items })

Exec mode (exec/src/lib.rs:324-328):

let items: Vec<UserInput> = images
    .into_iter()
    .map(|path| UserInput::LocalImage { path })
    .collect();
conversation.submit(Op::UserInput { items }).await?;

Both create UserInput::LocalImage { path } and submit via Op::UserInput.

Why Exec Hangs Specifically

The exec mode hang occurs here (exec/src/lib.rs:330-341):

while let Ok(event) = conversation.next_event().await {
    if event.id == initial_images_event_id
        && matches!(event.msg, EventMsg::TaskComplete(..))
    {
        break;
    }
}

Exec waits for TaskComplete before sending the text prompt. If that event is never emitted, it hangs forever.

Relationship to #5683

The issues might share the same buggy image handling subsystem, but manifest differently:

  • #5683: Input corruption in interactive mode → user sees errors
  • This issue: Event generation failure in exec mode → silent hang

I'll keep this issue open since:

  1. It's a distinct bug with different symptoms and reproduction
  2. Affects a different use case (automation/CI vs interactive)
  3. Needs a separate fix (whatever generates TaskComplete for images)

But you're right to flag the connection - there may be deeper image handling bugs that affect multiple code paths.

synestiqx · 8 months ago

Okay, but the whole point is that I never pasted any image at all. The terminal is injecting it on its own. So I suspect that in both modes, the terminal picks up the color or the presence of “/” as if it were an image — one that doesn’t actually exist — and that’s why it never returns a response and ends up freezing.

And now, when you launch Codex with --image, it immediately mounts //rgb in the paste terminal, which is detected as an image paste. I think that might be what’s causing the lag. We’ll see if it kicks in and works :)

It’s simply that under the hood, //rgb gets injected into the input — something you can’t see as a user without checking the logs. It goes straight into the paste buffer.

jet-ds · 8 months ago

Thanks for the detailed investigation @AdsQnn! Your fix for the color query bug in #5683 is really valuable. I wanted to understand how it might relate to this exec mode hang, so I dug into the code a bit.

What I Found in Exec Mode

I checked the exec implementation and it seems to work differently from interactive mode:

Exec source (codex-rs/exec/src/):

  • Doesn't import crossterm or tui
  • Stdin comes from a pipe (like echo "text" | codex exec) rather than terminal
  • Only terminal check is is_terminal() to validate stdin type

When I tested:

echo "Test" | codex exec --json --image test.png

Output:

{"type":"thread.started",...}
[hangs - no more events]

The Wait Loop

I found exec waits for a TaskComplete event after submitting images (exec/src/lib.rs:330-341):

let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?;
while let Ok(event) = conversation.next_event().await {
    if event.id == initial_images_event_id && matches!(event.msg, EventMsg::TaskComplete(..)) {
        break;
    }
}

That event never arrives, so it hangs.

My Take

I think the color query issue affects interactive mode specifically, while exec has a different manifestation of buggy image handling - maybe something in the shared UserInput::LocalImage processing that fails to complete properly?

But I could be missing something about how terminal I/O works in exec. What do you think?

synestiqx · 8 months ago

Could be :) I didn’t check it that thoroughly — I just wrote it because it seemed to me there might be some connection. But since you’ve checked and it doesn’t trigger anything, then yeah, it’s probably just as you said :)

What matters is that you managed to find the problem — nice!

---

Two Consumers of the Same Event Stream - Race Condition Analysis

The Problem: Race Condition

Two consumers are competing for the same event stream:

  1. Background thread (line 298): conversation.next_event() → sends to channel rx
  2. Main thread (line 330): conversation.next_event() → waits directly

When TaskComplete arrives:

  • It randomly goes to the background thread OR the main thread
  • If it goes to the background thread → the main thread waits indefinitely
  • Race condition: whoever calls conversation.next_event() first gets the event

Why the Fix Works

Single source of truth - all events flow through the channel rx:

// BEFORE: two competing consumers
conversation.next_event()  // background thread
conversation.next_event()  // main thread ❌ RACE CONDITION!

// AFTER: one consumer, everyone reads from the channel
conversation.next_event()  // background thread → tx.send()
rx.recv()                  // main thread ✅ DETERMINISTIC

Every event passes through the background thread → channel tx → main thread receives from rx. Zero race conditions, everything deterministic.

---

Code - How It Is (Buggy)

// Line 283: Background thread consumes events
tokio::spawn(async move {
    loop {
        res = conversation.next_event() => match res {
            Ok(event) => { tx.send(event) }  // Sends to rx
        }
    }
});

// Line 328: We send images
let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?;

// Line 330: ❌ ERROR - second consumer of the same stream!
while let Ok(event) = conversation.next_event().await {
    if event.id == initial_images_event_id
        && matches!(event.msg, EventMsg::TaskComplete(..)) {
        break;
    }
}

Code - How It Should Be (Fixed)

// Line 283: Background thread consumes events (no changes)
tokio::spawn(async move {
    loop {
        res = conversation.next_event() => match res {
            Ok(event) => { tx.send(event) }  // Sends to rx
        }
    }
});

// Line 328: We send images
let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?;

// Line 330: ✅ FIX - read from channel rx instead of directly
while let Some(event) = rx.recv().await {
    if event.id == initial_images_event_id
        && matches!(event.msg, EventMsg::TaskComplete(..)) {
        break;
    }
}

Key Difference

Instead of conversation.next_event(), we use rx.recv() - all events flow through a single channel, eliminating the race condition entirely.

I thought I'd take a quick look while I'm at it :)

jet-ds · 8 months ago

Thanks so much @AdsQnn for finding the race condition! That's exactly the issue - I verified that conversation.clone() creates competing receivers on the same async_channel. Really clean diagnosis.

Are you planning to submit a PR with this fix? If not, I'd be happy to help, but wanted to defer to you since you diagnosed it. :)

synestiqx · 8 months ago

You found it — I just gave a little hint :) Go ahead and send the fix if you want :)

jet-ds · 8 months ago

Thanks again for the detailed diagnosis\! I've submitted the fix in #5814. Really appreciate you taking the time to track down the race condition—saved me a lot of debugging time.

etraut-openai contributor · 8 months ago

@jet-ds, I asked a colleague to review your PR. He's more familiar with this code than I am. He suggested an alternate fix that results in simpler code. I've posted a PR here if you'd like to review.