Skip to content

Honor full JSON-RPC wait timeout#57

Merged
zackees merged 1 commit intomainfrom
fix/issue-49-json-rpc-timeout
Apr 17, 2026
Merged

Honor full JSON-RPC wait timeout#57
zackees merged 1 commit intomainfrom
fix/issue-49-json-rpc-timeout

Conversation

@zackees
Copy link
Copy Markdown
Member

@zackees zackees commented Apr 17, 2026

Summary

  • keep SerialMonitor.write_json_rpc() polling until the caller's full timeout expires
  • stop treating the first empty poll as a terminal condition for RPC waits
  • add focused unit tests for REMOTE: extraction and the empty-batch retry path

Fixes #49.

Verification

  • cargo test -p fbuild-python
  • cargo fmt --all --check

Summary by CodeRabbit

  • Refactor

    • Reorganized serial communication response detection logic for improved code organization and maintainability.
  • Tests

    • Added unit tests for serial communication response handling and edge cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The SerialMonitor::write_json_rpc timeout/polling logic was refactored to delegate remote JSON-RPC response detection to two new helper functions: wait_for_remote_json_rpc_response and extract_remote_json_rpc_response. The polling now continues until the deadline expires rather than terminating early on empty batches. New unit tests validate both helpers.

Changes

Cohort / File(s) Summary
SerialMonitor JSON-RPC Polling Refactor
crates/fbuild-python/src/lib.rs
Refactored write_json_rpc method by extracting poll-loop logic into wait_for_remote_json_rpc_response (continues polling until deadline, no early exit on empty batch) and response parsing into extract_remote_json_rpc_response (isolates REMOTE: prefix detection and extraction). Added unit tests for both helpers validating empty input handling and cross-batch polling behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Poem

🐰 A timeout loop that danced too quick,
Would exit early—oh, what a trick!
Now helper functions polish and refine,
Polling till the deadline's line,
RPC responses caught with care,
No more dropped frames in the air! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: refactoring the JSON-RPC timeout logic to honor the full wait timeout instead of exiting early on empty polls.
Linked Issues check ✅ Passed The code changes implement the second suggested fix from issue #49: continuing to poll until the caller's overall timeout expires, rather than exiting early when buffer drain is detected.
Out of Scope Changes check ✅ Passed All changes are directly scoped to refactoring the JSON-RPC timeout/polling logic and adding unit tests for the new helper functions, aligning with the PR's stated objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-49-json-rpc-timeout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/fbuild-python/src/lib.rs`:
- Around line 408-416: The loop currently spin-waits when the websocket is
closed because read_lines_inner returns an empty Vec and
wait_for_remote_json_rpc_response keeps polling; fix by making the poll contract
terminal-aware: change read_lines_inner (or add a thin wrapper used by
write_json_rpc) to return Option<Vec<String>> (None meaning stream terminated on
Ok(None)/Close) or a small enum, and update wait_for_remote_json_rpc_response to
treat None as a terminal condition and break/propagate a connection error
immediately instead of retrying until timeout; update call sites (the block that
calls wait_for_remote_json_rpc_response and any other callers around lines near
read_lines_inner / write_json_rpc) to handle the terminal signal accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ea30befd-365d-49d5-a5a6-379dda614bfb

📥 Commits

Reviewing files that changed from the base of the PR and between c50e56e and e678b06.

📒 Files selected for processing (1)
  • crates/fbuild-python/src/lib.rs

Comment on lines +408 to 416
if let Some(json_part) = wait_for_remote_json_rpc_response(timeout, |remaining| {
// read_lines takes &mut self but we only have &self here —
// use the raw WS read directly
let lines = self.read_lines_inner(remaining.min(1.0));
for line in &lines {
if let Some(json_part) = line.strip_prefix("REMOTE:") {
let json_module = py.import_bound("json")?;
let result = json_module.call_method1("loads", (json_part.trim(),))?;
return Ok(result.to_object(py));
}
}
if lines.is_empty() {
break;
}
// use the raw WS read directly.
self.read_lines_inner(remaining.min(1.0))
}) {
let json_module = py.import_bound("json")?;
let result = json_module.call_method1("loads", (json_part.trim(),))?;
return Ok(result.to_object(py));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Risk of tight-spin until timeout when the WebSocket is closed/errored.

The refactor correctly stops treating a single empty batch as terminal, but it also drops the only signal the outer loop had for a dead stream. read_lines_inner returns an empty Vec<String> promptly (without consuming wall time) whenever the underlying ws_read.next() yields Ok(None) or Ok(Some(Message::Close(_))) (see lines 525), i.e. after the daemon disconnects. In that scenario wait_for_remote_json_rpc_response will now re-invoke poll immediately, every iteration returning instantly with vec![], burning a core until the caller's timeout elapses — and only then surfacing PyTimeoutError instead of a prompt connection error.

The previous code at least bailed out of the loop on the first empty batch, so a severed session failed fast. The new logic needs an equivalent "no more data will ever come" exit path.

Suggested shape: let the poller distinguish "waited and got nothing" from "stream is done", e.g. return Option<Vec<String>> (None = terminal) or a small enum, and have wait_for_remote_json_rpc_response break on the terminal variant:

♻️ Sketch of a terminal-aware poll contract
-fn wait_for_remote_json_rpc_response<F>(timeout: f64, mut poll: F) -> Option<String>
-where
-    F: FnMut(f64) -> Vec<String>,
-{
+fn wait_for_remote_json_rpc_response<F>(timeout: f64, mut poll: F) -> Option<String>
+where
+    F: FnMut(f64) -> Option<Vec<String>>, // None => stream terminated
+{
     let deadline = std::time::Instant::now() + std::time::Duration::from_secs_f64(timeout);
 
     while std::time::Instant::now() < deadline {
         let remaining = (deadline - std::time::Instant::now()).as_secs_f64();
-        let lines = poll(remaining);
+        let Some(lines) = poll(remaining) else {
+            return None; // peer gone, don't spin
+        };
         if let Some(json_part) = extract_remote_json_rpc_response(&lines) {
             return Some(json_part);
         }
     }
 
     None
 }

…and have read_lines_inner (or a thin wrapper used only by write_json_rpc) report the Ok(None) / Close case as None. As a cheaper alternative, detect "poll returned faster than remaining" and insert a bounded sleep / break, but an explicit terminal signal is more robust.

Also applies to: 542-557

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/fbuild-python/src/lib.rs` around lines 408 - 416, The loop currently
spin-waits when the websocket is closed because read_lines_inner returns an
empty Vec and wait_for_remote_json_rpc_response keeps polling; fix by making the
poll contract terminal-aware: change read_lines_inner (or add a thin wrapper
used by write_json_rpc) to return Option<Vec<String>> (None meaning stream
terminated on Ok(None)/Close) or a small enum, and update
wait_for_remote_json_rpc_response to treat None as a terminal condition and
break/propagate a connection error immediately instead of retrying until
timeout; update call sites (the block that calls
wait_for_remote_json_rpc_response and any other callers around lines near
read_lines_inner / write_json_rpc) to handle the terminal signal accordingly.

@zackees zackees merged commit 0f01103 into main Apr 17, 2026
76 checks passed
@zackees zackees deleted the fix/issue-49-json-rpc-timeout branch April 17, 2026 15:14
zackees added a commit that referenced this pull request Apr 18, 2026
#65)

Completes the remaining async surface from #65:
- __aenter__ / __aexit__ — async context-manager that opens the
  /ws/serial-monitor session, sends the attach handshake, stores the
  split sink/source halves, and cleans up on exit.
- read_lines(timeout_secs) — async batch read, honors
  auto_reconnect (Preempted → continue; Reconnected → continue).
- write(data) — async write that waits for daemon write_ack.
- write_json_rpc(request, timeout_secs) — async JSON-RPC send + poll
  for REMOTE: response, preserving the PR #57 full-timeout guarantee
  by keeping polling across empty batches.

Send+Sync refactor:
- Split WebSocketStream via .split() into WsSink + WsSource.
- Each half stored in its own Arc<tokio::sync::Mutex<Option<_>>> so
  concurrent read/write futures do not serialize each other and each
  future owns its own Arc clone.

Sync SerialMonitor is untouched — additive only.

Deferred follow-up: unit tests for the new async methods. The
existing 11 fbuild-python tests still pass; new tests would need
a more elaborate WebSocket mock than the HTTP mock used for
send_op_async (PR #84).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zackees added a commit that referenced this pull request Apr 18, 2026
#65) (#107)

Completes the remaining async surface from #65:
- __aenter__ / __aexit__ — async context-manager that opens the
  /ws/serial-monitor session, sends the attach handshake, stores the
  split sink/source halves, and cleans up on exit.
- read_lines(timeout_secs) — async batch read, honors
  auto_reconnect (Preempted → continue; Reconnected → continue).
- write(data) — async write that waits for daemon write_ack.
- write_json_rpc(request, timeout_secs) — async JSON-RPC send + poll
  for REMOTE: response, preserving the PR #57 full-timeout guarantee
  by keeping polling across empty batches.

Send+Sync refactor:
- Split WebSocketStream via .split() into WsSink + WsSource.
- Each half stored in its own Arc<tokio::sync::Mutex<Option<_>>> so
  concurrent read/write futures do not serialize each other and each
  future owns its own Arc clone.

Sync SerialMonitor is untouched — additive only.

Deferred follow-up: unit tests for the new async methods. The
existing 11 fbuild-python tests still pass; new tests would need
a more elaborate WebSocket mock than the HTTP mock used for
send_op_async (PR #84).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

bug: FbuildSerialAdapter 50ms read timeout drops RPC responses

1 participant