Repository navigation
fix(http2): finish() and terminate() must not hang after a failed socket write - #2014
Conversation
3a2ff2d to
6a45c6d
Compare
6a45c6d to
001d14f
Compare
001d14f to
b64e5fd
Compare
PR HealthCoverage ✔️
This check for test coverage is informational (issues shown here will not fail the PR). This check can be disabled by tagging the PR with License Headers ✔️
All source files should start with a license header. Unrelated files missing license headers
This check can be disabled by tagging the PR with Breaking changes ✔️
This check can be disabled by tagging the PR with Unused Dependencies ✔️
For details on how to fix these, see dependency_validator. This check can be disabled by tagging the PR with API leaks ✔️The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.
This check can be disabled by tagging the PR with Changelog Entry ✔️
Changes to files need to be accounted for in their respective changelogs. This check can be disabled by tagging the PR with |
…ket write > [!NOTE] > This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem. ### Summary `BufferedSink` (the sink behind `FrameWriter`) defined its completion as ```dart _doneFuture = Future.wait([_controller.stream.pipe(dataSink), dataSink.done]); ``` For a `dart:io` `Socket`, `done` only completes after an explicit `close()`. When a write fails — typically `SocketException: Connection reset by peer` because the peer already closed the connection — `addStream` completes with the error, `Stream.pipe` does *not* close the sink, and `Socket.done` never completes. `Future.wait` (non-eager) then waits forever, and so does everything built on `doneFuture`: `FrameWriter.close()`, `Connection.finish()`, `Connection.terminate()`, `ClientPool.terminate()` / `Http2Client.closed`, and grpc-dart's `ClientChannel.shutdown()` (which awaits `transport.finish()`). `pipe` itself already resolves through `dataSink.close()`, i.e. through `done`, in the success case, so the extra wait only ever mattered in the failure case — where it hangs. ### Changes - **`lib/src/async_utils/async_utils.dart`**: `doneFuture` completes once the pipe has finished. A failed write completes it normally (like a cancelled sink already did): the connection is dead and the owner learns about it through the incoming side (`Connection` also terminates itself when `doneFuture` completes). ### Test Verification (Fails Before $\rightarrow$ Passes After) - `buffered-sink-done-after-failed-write` in `test/src/async_utils/async_utils_test.dart`, using a `FailingSink` that behaves like a reset socket (`addStream` fails, `done` never completes without `close()`). - `finish-and-terminate-complete-when-the-socket-write-fails` in `test/client_test.dart`: `ClientTransportConnection.viaStreams(..., FailingSink())`, then `finish()` / `terminate()` must complete. **Before fix:** ```text 00:02 +0 -1: test/src/async_utils/async_utils_test.dart: async_utils buffered-sink-done-after-failed-write [E] TimeoutException after 0:00:02.000000: Future not completed 00:02 +0 -2: test/client_test.dart: client-tests client-errors finish-and-terminate-complete-when-the-socket-write-fails [E] TimeoutException after 0:00:02.000000: Future not completed ``` **After fix:** ```text 00:00 +2: All tests passed! ```
b64e5fd to
a59c01f
Compare
Note
This PR was generated by an AI coding agent (Jetski) on behalf of @mosuem.
Summary
BufferedSink(the sink behindFrameWriter) defined its completion asFor a
dart:ioSocket,doneonly completes after an explicitclose(). When a write fails — typicallySocketException: Connection reset by peerbecause the peer already closed the connection —addStreamcompletes with the error,Stream.pipedoes not close the sink, andSocket.donenever completes.Future.wait(non-eager) then waits forever, and so does everything built ondoneFuture:FrameWriter.close(),Connection.finish(),Connection.terminate(),ClientPool.terminate()/Http2Client.closed, and grpc-dart'sClientChannel.shutdown()(which awaitstransport.finish()).pipeitself already resolves throughdataSink.close(), i.e. throughdone, in the success case, so the extra wait only ever mattered in the failure case — where it hangs.Changes
lib/src/async_utils/async_utils.dart:doneFuturecompletes once the pipe has finished. A failed write completes it normally (like a cancelled sink already did): the connection is dead and the owner learns about it through the incoming side (Connectionalso terminates itself whendoneFuturecompletes).Test Verification (Fails Before$\rightarrow$ Passes After)
buffered-sink-done-after-failed-writeintest/src/async_utils/async_utils_test.dart, using aFailingSinkthat behaves like a reset socket (addStreamfails,donenever completes withoutclose()).finish-and-terminate-complete-when-the-socket-write-failsintest/client_test.dart:ClientTransportConnection.viaStreams(..., FailingSink()), thenfinish()/terminate()must complete.Before fix:
After fix: