Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/kimi_cli/web/runner/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,8 +661,30 @@ async def send_message(self, message: str) -> None:
logger.error(f"{e.__class__.__name__} {e}: Invalid JSONRPC in message: {message}")
return

process.stdin.write((message + "\n").encode("utf-8"))
await process.stdin.drain()
try:
process.stdin.write((message + "\n").encode("utf-8"))
await process.stdin.drain()
except (BrokenPipeError, ConnectionResetError) as e:
# Subprocess died between our `start()` check above and the actual write.
# `_read_loop` will eventually observe the exit and emit "stopped" /
# "crashed", but right now the caller (FastAPI / websocket handler) would
# otherwise see a raw exception propagate to the response. Emit an error
# status so any attached websocket clients see the failure synchronously.
#
# If this was a prompt, its id was already registered in
# `_in_flight_prompt_ids` above; drop it so the failed turn doesn't
# leave the session wedged in `is_busy` forever.
if isinstance(in_message, JSONRPCPromptMessage):
self._in_flight_prompt_ids.discard(in_message.id)
logger.warning(
f"send_message: subprocess stdin {e.__class__.__name__}; "
f"process likely exited (returncode={process.returncode})"
)
await self._emit_status(
"error",
reason="stdin_broken",
detail=f"{e.__class__.__name__}: {e}",
)
Comment on lines +683 to +687

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear in-flight prompts before emitting stdin_broken

When the broken stdin write is for a JSONRPCPromptMessage, the prompt id was already added to _in_flight_prompt_ids, but this new error path emits the stdin_broken status without clearing it. That makes the session report an error while is_busy remains true, so clients reacting immediately to the status can still be rejected by paths such as get_editable_session()'s busy check until _read_loop later catches up; the existing EOF/error paths explicitly clear in-flight ids before broadcasting for this reason. Clear _in_flight_prompt_ids before this _emit_status call.

Useful? React with 👍 / 👎.

Comment on lines +667 to +687

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.

🟡 In-flight prompt ID not cleaned up on BrokenPipeError leaves session stuck in 'busy' state

When send_message handles a JSONRPCPromptMessage, it adds the prompt ID to _in_flight_prompt_ids at line 647 and emits a "busy" status at line 649 before the stdin write attempt. If the write then fails with BrokenPipeError, the exception handler (lines 667-681) emits an "error" status but never removes the prompt ID from _in_flight_prompt_ids. This leaves is_busy returning True even though the prompt was never delivered to the subprocess.

The comment says _read_loop will eventually clean up, but there's a race window where _read_loop may have already completed its EOF handling (and called clear()) before the ID was added at line 647. In that case, no one clears the orphaned ID until the next start() call or the error-state recovery path in sessions.py:1011-1016. During this window, the session is simultaneously in "error" and "busy" states, and non-prompt messages (like cancel) that check is_busy will behave incorrectly.

Suggested change
except (BrokenPipeError, ConnectionResetError) as e:
# Subprocess died between our `start()` check above and the actual write.
# `_read_loop` will eventually observe the exit and emit "stopped" /
# "crashed", but right now the caller (FastAPI / websocket handler) would
# otherwise see a raw exception propagate to the response. Emit an error
# status so any attached websocket clients see the failure synchronously.
logger.warning(
f"send_message: subprocess stdin {e.__class__.__name__}; "
f"process likely exited (returncode={process.returncode})"
)
await self._emit_status(
"error",
reason="stdin_broken",
detail=f"{e.__class__.__name__}: {e}",
)
except (BrokenPipeError, ConnectionResetError) as e:
# Subprocess died between our `start()` check above and the actual write.
# `_read_loop` will eventually observe the exit and emit "stopped" /
# "crashed", but right now the caller (FastAPI / websocket handler) would
# otherwise see a raw exception propagate to the response. Emit an error
# status so any attached websocket clients see the failure synchronously.
logger.warning(
f"send_message: subprocess stdin {e.__class__.__name__}; "
f"process likely exited (returncode={process.returncode})"
)
self._in_flight_prompt_ids.clear()
await self._emit_status(
"error",
reason="stdin_broken",
detail=f"{e.__class__.__name__}: {e}",
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



class KimiCLIRunner:
Expand Down
49 changes: 49 additions & 0 deletions tests/web/test_session_error_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,52 @@ def test_session_in_error_state_clears_stale_ids_on_new_prompt() -> None:
sp.clear_in_flight()

assert sp.is_busy is False


# ---------------------------------------------------------------------------
# Tests: send_message broken stdin clears in-flight
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_message_broken_pipe_clears_in_flight() -> None:
"""A BrokenPipeError while writing a prompt to stdin must drop the prompt's
in-flight id, so the failed turn doesn't leave the session stuck in 'busy'.
"""
sp = SessionProcess(uuid4())

# Don't spawn a real subprocess.
async def noop_start() -> None:
return None

sp.start = noop_start # type: ignore[assignment]

# stdin whose drain() reports the worker is gone.
async def broken_drain() -> None:
raise BrokenPipeError("worker gone")

mock_stdin = MagicMock()
mock_stdin.drain = broken_drain

mock_process = MagicMock()
mock_process.stdin = mock_stdin
mock_process.returncode = 1
sp._process = mock_process

# Keep the message untouched and broadcasts inert.
async def passthrough_handle(in_message: object) -> None:
return None

async def noop_broadcast(msg: str) -> None:
return None

sp._handle_in_message = passthrough_handle # type: ignore[assignment]
sp._broadcast = noop_broadcast # type: ignore[assignment]

prompt = '{"jsonrpc":"2.0","method":"prompt","id":"p1","params":{"user_input":"hi"}}'
await sp.send_message(prompt)

assert "p1" not in sp._in_flight_prompt_ids
assert sp.is_busy is False
assert sp.status.state == "error"
assert sp.status.reason == "stdin_broken"