Skip to content

Bound the frame write so a wedged server cannot freeze the client - #10

Merged
jserv merged 3 commits into
sysprog21:mainfrom
Suzu1Dev:fix/send-frame-write-timeout
Aug 29, 2026
Merged

Bound the frame write so a wedged server cannot freeze the client#10
jserv merged 3 commits into
sysprog21:mainfrom
Suzu1Dev:fix/send-frame-write-timeout

Conversation

@Suzu1Dev

@Suzu1Dev Suzu1Dev commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Problem

Transport::send_frame (src/frama-c/transport.rs) writes with write_all and no timeout. Every send happens while holding the FramaCClient mutex, and the poll loop that enforces request timeouts sends through this same path every 100 ms. If the Frama-C process stops draining its socket (wedged in a computation or a deadlocked plugin), the kernel socket buffer fills with poll frames, write_all blocks forever, and the client mutex is never released. Every later MCP tool call then blocks on that mutex: the server freezes silently, and the very mechanism meant to bound request time is what deadlocks.

Reproduction

A Unix-socket peer that accepts and never reads stands in for a wedged Frama-C. Driving Transport::send_frame with 1 MiB frames:

  • HEAD: still blocked after 25 s, no error returned (aborted by watchdog)
  • this branch: returns Operation timeout after 10s at 10.0 s

Fix

Wrap the write in a 10 s tokio::time::timeout and surface FramaCError::Timeout on expiry. 10 s is far above any legitimate stall: a healthy server drains its socket promptly and the poll frames are tens of bytes. On the reload path the existing poison logic in ensure_main_spawned respawns the instance; other tools now surface the timeout to the caller instead of hanging forever.

Testing

  • Full suite on Frama-C 33.0 / why3 1.8.2 / Alt-Ergo 2.6.3 / Z3 4.13.3: 534 tests pass (unit 370, integration 12, mcp-stdio 89, process-lifecycle 43, reload-project-regression 10, store-conclusion 10)
  • cargo clippy --all-targets clean

Summary by cubic

Bounded the frame write in Transport::send_frame with a 10-second timeout so a wedged Frama-C server can't freeze the client. Previously write_all had no timeout; a stalled write blocked forever while holding the client mutex, freezing every later request including the poll loop that enforces timeouts. Because write_all isn't cancellation-safe, a timed-out or failed write now poisons the transport — it returns FramaCError::Timeout, shuts down the socket, and every later call fails fast with a BrokenPipe error instead of corrupting the length-prefixed protocol.

Recovery requires a new Transport, and the respawn is only partially automatic: ensure_main_spawned decides in-place vs respawn from MainFramaCState alone, so a reload with explicit files respawns only on the second attempt, and sandbox clients have no respawn path at all.

Written for commit 0f86023. Summary will update on new commits.

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

@Suzu1Dev
Suzu1Dev marked this pull request as draft August 27, 2026 11:42
@Suzu1Dev

Copy link
Copy Markdown
Contributor Author

Fixed in bb239e3: the transport now poisons itself on any write that did
not run to completion (timeout or I/O error) and shuts the stream down.
Every later send_frame/recv_frame fails fast with
"transport poisoned by an incomplete frame write", so no frame can ever
follow the partial prefix. Recovery goes through the existing respawn
path: the next reload_project fails on the poisoned transport, poisons
the instance state, and the following reload respawns Frama-C.

With the fix the same experiment fails the second send in 7.8 µs and the
peer sees only the partial frame, then EOF. Full suite: 534 tests pass.

@Suzu1Dev
Suzu1Dev marked this pull request as ready for review August 27, 2026 12:08

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/frama-c/transport.rs">

<violation number="1" location="src/frama-c/transport.rs:45">
P2: When a request timeout is shorter than 10 seconds, a backpressured POLL write can exceed that deadline and return only after `WRITE_TIMEOUT`. Pass the remaining request duration to the frame write and cap it at `WRITE_TIMEOUT` so the poll loop enforces its caller's timeout.</violation>

<violation number="2" location="src/frama-c/transport.rs:91">
P1: After a regular client operation times out while writing, the first `reload_project` cannot recover the session. `ensure_main_spawned` still sees `MainFramaCState.poisoned == false`, attempts an in-place reload on this poisoned transport, returns `BrokenPipe`, and only then marks the state poisoned; propagate transport failure to the session state or respawn when this transport is poisoned.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/frama-c/transport.rs
/// completion can have left a partial frame in the socket, and no
/// later frame may follow it on this stream.
async fn poison(&mut self, error: FramaCError) -> FramaCError {
self.poisoned = true;

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: After a regular client operation times out while writing, the first reload_project cannot recover the session. ensure_main_spawned still sees MainFramaCState.poisoned == false, attempts an in-place reload on this poisoned transport, returns BrokenPipe, and only then marks the state poisoned; propagate transport failure to the session state or respawn when this transport is poisoned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/frama-c/transport.rs, line 91:

<comment>After a regular client operation times out while writing, the first `reload_project` cannot recover the session. `ensure_main_spawned` still sees `MainFramaCState.poisoned == false`, attempts an in-place reload on this poisoned transport, returns `BrokenPipe`, and only then marks the state poisoned; propagate transport failure to the session state or respawn when this transport is poisoned.</comment>

<file context>
@@ -56,4 +82,22 @@ impl Transport {
+    /// completion can have left a partial frame in the socket, and no
+    /// later frame may follow it on this stream.
+    async fn poison(&mut self, error: FramaCError) -> FramaCError {
+        self.poisoned = true;
+        let _ = self.stream.shutdown().await;
+        error
</file context>

Comment thread src/frama-c/transport.rs
let frame = codec::encode_frame(payload);
self.stream.write_all(&frame).await?;
Ok(())
match tokio::time::timeout(WRITE_TIMEOUT, self.stream.write_all(&frame)).await {

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: When a request timeout is shorter than 10 seconds, a backpressured POLL write can exceed that deadline and return only after WRITE_TIMEOUT. Pass the remaining request duration to the frame write and cap it at WRITE_TIMEOUT so the poll loop enforces its caller's timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/frama-c/transport.rs, line 45:

<comment>When a request timeout is shorter than 10 seconds, a backpressured POLL write can exceed that deadline and return only after `WRITE_TIMEOUT`. Pass the remaining request duration to the frame write and cap it at `WRITE_TIMEOUT` so the poll loop enforces its caller's timeout.</comment>

<file context>
@@ -17,19 +33,29 @@ impl Transport {
         let frame = codec::encode_frame(payload);
-        self.stream.write_all(&frame).await?;
-        Ok(())
+        match tokio::time::timeout(WRITE_TIMEOUT, self.stream.write_all(&frame)).await {
+            Ok(Ok(())) => Ok(()),
+            Ok(Err(e)) => Err(self.poison(FramaCError::Io(e)).await),
</file context>

@Suzu1Dev
Suzu1Dev marked this pull request as draft August 27, 2026 12:43
@Suzu1Dev
Suzu1Dev marked this pull request as ready for review August 28, 2026 08:37

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 existing issue remains and 1 new issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/frama-c/transport.rs">

<violation number="1" location="src/frama-c/transport.rs:57">
P2: A wedged-write timeout is reported as `FramaCError::Timeout`, which `src/error.rs` maps to diagnostic code `WpTimeout` and the `mcp_server_timeout` triage message "The MCP request timed out before a WP goal reported prover timeout." That misclassifies this new failure: the cause is a stuck socket write and a now-poisoned transport that needs a Frama-C respawn, not a WP prover timeout. The PR notes users must recover by restarting/respawning, but the surfaced diagnostic actively points them at a prover timeout instead. Use a distinct error (or an `Io` error with `ErrorKind::TimedOut`) so the write/poison path is diagnosable apart from a genuine WP timeout.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread src/frama-c/transport.rs
match tokio::time::timeout(WRITE_TIMEOUT, self.stream.write_all(&frame)).await {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(self.poison(FramaCError::Io(e)).await),
Err(_) => Err(self.poison(FramaCError::Timeout(WRITE_TIMEOUT)).await),

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: A wedged-write timeout is reported as FramaCError::Timeout, which src/error.rs maps to diagnostic code WpTimeout and the mcp_server_timeout triage message "The MCP request timed out before a WP goal reported prover timeout." That misclassifies this new failure: the cause is a stuck socket write and a now-poisoned transport that needs a Frama-C respawn, not a WP prover timeout. The PR notes users must recover by restarting/respawning, but the surfaced diagnostic actively points them at a prover timeout instead. Use a distinct error (or an Io error with ErrorKind::TimedOut) so the write/poison path is diagnosable apart from a genuine WP timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/frama-c/transport.rs, line 57:

<comment>A wedged-write timeout is reported as `FramaCError::Timeout`, which `src/error.rs` maps to diagnostic code `WpTimeout` and the `mcp_server_timeout` triage message "The MCP request timed out before a WP goal reported prover timeout." That misclassifies this new failure: the cause is a stuck socket write and a now-poisoned transport that needs a Frama-C respawn, not a WP prover timeout. The PR notes users must recover by restarting/respawning, but the surfaced diagnostic actively points them at a prover timeout instead. Use a distinct error (or an `Io` error with `ErrorKind::TimedOut`) so the write/poison path is diagnosable apart from a genuine WP timeout.</comment>

<file context>
@@ -17,19 +42,29 @@ impl Transport {
+        match tokio::time::timeout(WRITE_TIMEOUT, self.stream.write_all(&frame)).await {
+            Ok(Ok(())) => Ok(()),
+            Ok(Err(e)) => Err(self.poison(FramaCError::Io(e)).await),
+            Err(_) => Err(self.poison(FramaCError::Timeout(WRITE_TIMEOUT)).await),
+        }
     }
</file context>

@jserv
jserv merged commit a5a3580 into sysprog21:main Aug 29, 2026
9 checks passed
@jserv

jserv commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Thank @Suzu1Dev for contributing!

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.

2 participants