diff --git a/changelog.md b/changelog.md index ee39666..66abf18 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-16 - 4.5.9 - fix(remoteingress-core) +delay stream close until downstream response draining finishes to prevent truncated transfers + +- Waits for the hub-to-client download task to finish before sending the stream CLOSE frame +- Prevents upstream reads from being cancelled mid-response during asymmetric transfers such as git fetch +- Retains the existing timeout so stalled downloads still clean up safely + ## 2026-03-16 - 4.5.8 - fix(remoteingress-core) ensure upstream writes cancel promptly and reliably deliver CLOSE_BACK frames diff --git a/rust/crates/remoteingress-core/src/edge.rs b/rust/crates/remoteingress-core/src/edge.rs index 1a1b7fb..39fc78a 100644 --- a/rust/crates/remoteingress-core/src/edge.rs +++ b/rust/crates/remoteingress-core/src/edge.rs @@ -749,27 +749,24 @@ async fn handle_client_connection( } } - // Send CLOSE frame via DATA channel (must arrive AFTER last DATA for this stream). - // Use send().await to guarantee delivery (try_send silently drops if channel full). - if !client_token.is_cancelled() { - let close_frame = encode_frame(stream_id, FRAME_CLOSE, &[]); - let _ = tunnel_data_tx.send(close_frame).await; - } - - // Wait for the download task (hub → client) to finish draining all buffered - // response data. Upload EOF just means the client is done sending; the download - // must continue until all response data has been written to the client. - // This is critical for asymmetric transfers like git fetch (small request, large response). - // The download task will exit when: - // - back_rx returns None (back_tx dropped below after await, or hub sent CLOSE_BACK) - // - client_write fails (client disconnected) - // - client_token is cancelled + // Wait for the download task (hub → client) to finish BEFORE sending CLOSE. + // Upload EOF (client done sending) does NOT mean the response is done. + // For asymmetric transfers like git fetch (small request, large response), + // the response is still streaming when the upload finishes. + // Sending CLOSE before the response finishes would cause the hub to cancel + // the upstream reader mid-response, truncating the data. let _ = tokio::time::timeout( Duration::from_secs(300), // 5 min max wait for download to finish &mut hub_to_client, ).await; - // Now safe to clean up — download has finished or timed out + // NOW send CLOSE — the response has been fully delivered (or timed out). + if !client_token.is_cancelled() { + let close_frame = encode_frame(stream_id, FRAME_CLOSE, &[]); + let _ = tunnel_data_tx.send(close_frame).await; + } + + // Clean up { let mut writers = client_writers.lock().await; writers.remove(&stream_id); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e5bd2ac..9b34648 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/remoteingress', - version: '4.5.8', + version: '4.5.9', description: 'Edge ingress tunnel for DcRouter - accepts incoming TCP connections at network edge and tunnels them to DcRouter SmartProxy preserving client IP via PROXY protocol v1.' }