Skip to content
42 changes: 41 additions & 1 deletion crates/fdev/src/commands/v1.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use freenet_stdlib::client_api::{ContractResponse, HostResponse};

use super::*;

pub(super) async fn start_api_client(cfg: BaseConfig) -> anyhow::Result<WebApi> {
Expand Down Expand Up @@ -32,5 +34,43 @@ pub(super) async fn execute_command(
api_client: &mut WebApi,
) -> anyhow::Result<()> {
api_client.send(request).await?;
Ok(())

// Wait for the server's response before closing the connection
let response = api_client
.recv()
.await
.map_err(|e| anyhow::anyhow!("Failed to receive response: {e}"))?;

match response {
HostResponse::ContractResponse(contract_response) => match contract_response {
ContractResponse::PutResponse { key } => {
tracing::info!(%key, "Contract published successfully");
Ok(())
}
ContractResponse::UpdateResponse { key, summary } => {
tracing::info!(%key, ?summary, "Contract updated successfully");
Ok(())
}
other => {
tracing::warn!(?other, "Unexpected contract response");
Ok(())
}
},
HostResponse::DelegateResponse { key, values } => {
tracing::info!(%key, response_count = values.len(), "Delegate registered successfully");
Ok(())
}
HostResponse::Ok => {
tracing::info!("Operation completed successfully");
Ok(())
}
HostResponse::QueryResponse(query_response) => {
tracing::info!(?query_response, "Query response received");
Ok(())
}
_ => {
tracing::warn!(?response, "Unexpected response type");
Ok(())
}
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

The catch-all pattern at line 71 using _ will match error responses like HostResponse::Err without proper handling. Error responses from the server should be treated as failures rather than logged as warnings and returning Ok(()). Consider explicitly matching HostResponse::Err and returning an error in that case.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Valid point. In the restructured approach, error handling will be at each callsite where we know the expected response type, making it clearer what constitutes an error.

[AI-assisted - Claude]

}
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

This change breaks the existing query.rs and diagnostics.rs modules. These modules call execute_command() followed by an explicit client.recv() call to receive and process specific response types. With this change, execute_command() now consumes the response internally, causing the subsequent client.recv() calls in those modules to hang waiting for a second response that will never arrive.

For example, in query.rs lines 14-20, there's execute_command() followed by client.recv() expecting a QueryResponse::ConnectedPeers. Similarly in diagnostics.rs lines 39-48, there's execute_command() followed by client.recv() expecting a QueryResponse::NodeDiagnostics.

Consider one of these approaches:

  1. Return the response from execute_command() instead of consuming it, allowing callers to handle it
  2. Split the functionality into two functions: one that waits for a response (for put/update operations) and one that doesn't (for query operations)
  3. Add a parameter to control whether to wait for and consume the response
Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You're absolutely right - this is a critical issue I missed. The existing pattern in query.rs and diagnostics.rs is:

execute_command(request, &mut client).await?; let response = client.recv().await?;

My change breaks this pattern by consuming the response inside execute_command(), causing those subsequent recv() calls to hang.

I'll restructure this to follow the existing pattern: keep execute_command() as send-only, and fix the Put/Update/Delegate callsites in commands.rs to explicitly call recv() with proper response handling. This maintains API consistency.

[AI-assisted - Claude]

}
132 changes: 132 additions & 0 deletions crates/fdev/tests/websocket_response.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Integration test verifying fdev properly waits for server responses
//!
//! This test ensures that the WebSocket client doesn't close the connection
//! before receiving a response from the server (fixing issue #2278).
use freenet_stdlib::client_api::{
ClientRequest, ContractRequest, ContractResponse, HostResponse, WebApi,
};
use freenet_stdlib::prelude::*;
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message;

static PORT: AtomicU16 = AtomicU16::new(54000);

/// Test that the WebSocket client properly waits for a PutResponse
///
/// Before the fix for #2278, fdev would close the connection before
/// receiving the response, causing "Connection reset without closing handshake"
/// errors on the server side.
#[tokio::test]
async fn test_websocket_client_waits_for_put_response() {
let port = PORT.fetch_add(1, Ordering::SeqCst);

// Create a mock contract key for the response (base58 encoded)
let mock_key = ContractKey::from_id("11111111111111111111111111111111").expect("valid key");
let response: HostResponse<WrappedState> =
HostResponse::ContractResponse(ContractResponse::PutResponse { key: mock_key });

// Channel to signal when server received request
let (request_tx, request_rx) = oneshot::channel::<bool>();

// Start the mock server
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port))
.await
.expect("bind");

let server_response = response.clone();
let server_handle = tokio::spawn(async move {
let (stream, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept())
.await
.expect("accept timeout")
.expect("accept");

let mut ws_stream = tokio_tungstenite::accept_async(stream)
.await
.expect("ws accept");

use futures::{SinkExt, StreamExt};

// Receive the request
let msg = tokio::time::timeout(Duration::from_secs(5), ws_stream.next())
.await
.expect("receive timeout")
.expect("stream not empty")
.expect("receive");

// Just verify we received a binary message (which is what contract requests are)
match msg {
Message::Binary(_) => {} // Request received successfully
_ => panic!("expected binary message"),
};

// Signal that we received the request
let _ = request_tx.send(true);

// Send back the response
let response_bytes = bincode::serialize(&Ok::<_, freenet_stdlib::client_api::ClientError>(
server_response,
))
.expect("serialize");
ws_stream
.send(Message::Binary(response_bytes.into()))
.await
.expect("send response");

// Give the client time to receive the response
tokio::time::sleep(Duration::from_millis(100)).await;
});

// Give server time to start listening
tokio::time::sleep(Duration::from_millis(50)).await;
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

The test relies on fixed sleep durations (50ms and 100ms) to coordinate between client and server, which can lead to flaky test behavior on slower systems or under high load. Consider using synchronization primitives like channels or barriers instead of sleep-based timing to make the test more robust.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. The sleeps are fragile. In the updated approach I'll remove these in favor of proper synchronization - the WebSocket protocol itself provides the sequencing (connect must complete before send, send before recv), so explicit sleeps shouldn't be necessary.

[AI-assisted - Claude]


// Connect client
let url = format!("ws://127.0.0.1:{port}/v1/contract/command?encodingProtocol=native");
let (stream, _) = tokio_tungstenite::connect_async(&url)
.await
.expect("connect");
let mut client = WebApi::start(stream);

// Create a minimal contract for the request
let code = ContractCode::from(vec![0u8; 32]);
let wrapped = WrappedContract::new(Arc::new(code), Parameters::from(vec![]));
let api_version = ContractWasmAPIVersion::V1(wrapped);
let contract = ContractContainer::from(api_version);

// Send a Put request (simulating what fdev does)
let request = ClientRequest::ContractOp(ContractRequest::Put {
contract,
state: WrappedState::new(vec![]),
related_contracts: RelatedContracts::default(),
subscribe: false,
});

client.send(request).await.expect("send request");

// This is the key fix: we must receive the response before dropping the client
// Before the fix, fdev would exit here without waiting, causing connection reset
let response = tokio::time::timeout(Duration::from_secs(5), client.recv())
.await
.expect("response timeout")
.expect("receive response");

// Verify we got the expected response
match response {
HostResponse::ContractResponse(ContractResponse::PutResponse { key }) => {
assert_eq!(key, mock_key);
}
other => panic!("unexpected response: {:?}", other),
}

// Verify the server received the request
let received = request_rx.await.expect("server signaled");
assert!(received, "server should have received the request");

// Wait for server to complete
server_handle.await.expect("server task");
}
Comment on lines 26 to 125
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

This test validates the behavior of calling send() followed by recv() on the WebApi directly, but it doesn't actually test the fix made to the execute_command function in commands/v1.rs. Consider adding a test that specifically exercises the execute_command function to ensure it properly waits for the server response before closing the connection, as that's what the actual fix addresses.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good observation. The test demonstrates the protocol-level behavior (client waits for response) but doesn't exercise the actual fdev code path. I'll update it to better reflect the fix.

[AI-assisted - Claude]