Skip to content

http2: distinguish graceful peer shutdown from forceful termination#1917

Open
aki1770-del wants to merge 2 commits into
dart-lang:masterfrom
aki1770-del:fix-1913-graceful-shutdown
Open

http2: distinguish graceful peer shutdown from forceful termination#1917
aki1770-del wants to merge 2 commits into
dart-lang:masterfrom
aki1770-del:fix-1913-graceful-shutdown

Conversation

@aki1770-del

Copy link
Copy Markdown

Summary

Per #1913 and @brianquinlan's vote for shape (b) ("breaking but cleaner"), this PR distinguishes graceful peer-initiated shutdown (GOAWAY with NO_ERROR followed by transport close) from forceful termination at the Connection._terminate() site.

Behavior change

When the peer sends GOAWAY with errorCode == NO_ERROR and then closes the transport, _terminate() now detects the passive-finishing state (set by processGoawayFrame + _finishing(active: false)) and surfaces:

TransportConnectionException(
  errorCode: ErrorCode.NO_ERROR,
  message: 'Connection gracefully closed by peer.',
)

instead of the prior:

TransportConnectionException(
  errorCode: <CONNECT_ERROR>,
  message: 'Connection is being forcefully terminated.',
)

Forceful-termination paths (onError, _frameWriter.doneFuture, settings handshake failure, etc.) continue to surface the prior "forcefully terminated" text unchanged.

Why this shape vs literal "complete cleanly without exception"

A literal reading of issue-body shape (b) would have pending operations (e.g. an in-flight connection.ping() or a settings ACK) complete cleanly, not error. That requires passing null to sub-component terminate() methods, which breaks several existing onTerminated(Object? error) impls that null-check the error (e.g. SettingsHandler.onTerminated line 165 uses error!).

The simplest implementation that delivers the shape-(b) intent — let callers distinguish graceful from forceful close — is to keep the existing TransportConnectionException type but flag the graceful case via errorCode == NO_ERROR + distinct message text. This minimizes sub-component refactoring while still being a breaking change at the consumer-text level. Open to amending toward the literal interpretation if you prefer the deeper refactor.

Open questions for review

  1. Exception text vs subtype: would you prefer a dedicated GracefulShutdownException extends TransportConnectionException subtype rather than reusing TransportConnectionException with a new errorCode/message? Subtype is more typesafe for caller-side is-checks but slightly larger API surface.

  2. Sub-component refactoring: shape (b) literal — having sub-components' done futures complete cleanly on graceful close — would require updating SettingsHandler, PingHandler, _closeStreamAbnormally etc. to handle a null/graceful sentinel. Worth doing now (in this PR) or as a follow-up?

  3. mosuem: @brianquinlan tagged you on the issue; any thoughts on the chosen shape?

Test coverage

Adds graceful-server-finish-distinct-shutdown-signal under transport-test in pkgs/http2/test/transport_test.dart:

  • Races a client ping() with server-side finish()
  • Asserts the ping errors with TransportConnectionException(errorCode == NO_ERROR) + does NOT contain "forcefully terminated" text

All existing transport-test cases pass (15/15 including the new one). dart analyze: 0 issues.

CHANGELOG

pkgs/http2/CHANGELOG.md updated under 3.0.1-wip with a BREAKING entry noting the migration shape for consumers that matched the prior "forcefully terminated" text.

Draft status

Opening as DRAFT to surface the implementation choice for @brianquinlan + @mosuem review before marking ready. Happy to amend toward shape (a) (gracefulShutdown: bool field on existing exception) or a literal shape (b) refactor depending on your preference.

Closes #1913 (pending maintainer ratification).

…art-lang#1913)

When a peer sends GOAWAY with NO_ERROR and then closes the underlying
transport, Connection._terminate() is invoked with
causedByTransportError=true and previously surfaced
TransportConnectionException("Connection is being forcefully
terminated.") to all sub-components — the same exception text used for
genuine forceful-termination paths. Consumers (notably grpc-dart, see
the referenced grpc/grpc-dart#827 attempt) could not distinguish a
clean peer-initiated shutdown from an actual fault without inspecting
the exception text.

This change detects the graceful-shutdown path in _terminate() (peer
has set FinishingPassive via processGoawayFrame + _finishing(false))
and surfaces a distinct exception under that condition:

  TransportConnectionException(
    errorCode: ErrorCode.NO_ERROR,
    message: 'Connection gracefully closed by peer.',
  )

Forceful-termination paths continue to surface the prior message
("Connection is being forcefully terminated.") unchanged. Consumers
can distinguish either via .errorCode == NO_ERROR or the new message
text.

Per the issue thread, brianquinlan voted for shape (b) — breaking but
cleaner. This implementation realizes that shape while keeping the
TransportConnectionException type stable so sub-component
onTerminated handlers (e.g. SettingsHandler null-check on error) keep
working without further refactoring.

Adds a regression test under transport-test verifying the
graceful-close path surfaces NO_ERROR + does not contain the forceful-
termination text.

Closes dart-lang#1913 (pending maintainer review of the chosen shape).
@google-cla

google-cla Bot commented May 10, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@aki1770-del

Copy link
Copy Markdown
Author

CLA signed. Ready for review when convenient.

@brianquinlan

Copy link
Copy Markdown
Collaborator

@mosuem do you want to look at this or should I?

@aki1770-del

Copy link
Copy Markdown
Author

Thanks @brianquinlan — no rush on my end, and I appreciate you picking it up. Happy to help however's easiest for you and @mosuem: I can walk through the graceful-peer-shutdown vs forceful-termination distinction, point you at the test coverage, or rebase if it's drifted at all. Whenever's convenient.

@mosuem

mosuem commented Jul 3, 2026

Copy link
Copy Markdown
Member

Added a small suggestion in #1946 - @aki1770-del let me know what you think!

@aki1770-del

Copy link
Copy Markdown
Author

Thanks @mosuem — this is nicer than what I had. Driving the graceful/forceful distinction off the actual connection state instead of the message text is the right call.

Pulled #1946 and ran it locally — all 117 http2 tests pass (incl. your two new ones, graceful-server-finish-distinct-shutdown-signal and nonzero-goaway-not-treated-as-graceful), analyze clean. Traced it through and it holds: peer GOAWAY(NO_ERROR)_peerGoawayErrorCode = NO_ERROR + _finishing(active: false) (which sets FinishingPassive) → transport onDone_terminate(…, transportClosed: true), so isGracefulShutdown is true on exactly the #1913 shape-(b) path.

Two things I like beyond the causedByTransportError → transportClosed rename (which reads much clearer):

  • _peerGoawayErrorCode ?? errorCode in the forceful branch — a peer that GOAWAYs with a real error (say PROTOCOL_ERROR) then drops now surfaces that actual code instead of a generic CONNECT_ERROR. That's a real improvement over my version, and your nonzero-goaway-not-treated-as-graceful test pins it.
  • Abrupt transport close with no GOAWAY leaves _peerGoawayErrorCode null → forceful with errorCode, the correct fallback.

Happy to take it. Do you want to merge #1946 into the PR branch, or shall I fold the change into #1917 with your co-authorship? Either way — thanks for shaping it.

@mosuem

mosuem commented Jul 6, 2026

Copy link
Copy Markdown
Member

Please take it over into your PR. Thanks!

@aki1770-del

Copy link
Copy Markdown
Author

Done — cherry-picked your commit onto this branch (kept your authorship on it), so #1917 now carries the connection-state approach. Ran the full http2 suite on the merged branch: 117/117 pass, analyze clean, including your graceful-server-finish-distinct-shutdown-signal and nonzero-goaway-not-treated-as-graceful. Thanks for the cleaner version — the _peerGoawayErrorCode ?? errorCode surfacing is a real improvement. Ready for review; happy to squash on merge if you'd prefer a single commit.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR Health

Unused Dependencies ✔️
Package Status
http2 ✔️ All dependencies utilized correctly.

For details on how to fix these, see dependency_validator.

This check can be disabled by tagging the PR with skip-unused-dependencies-check.

Breaking changes ✔️
Package Change Current Version New Version Needed Version Looking good?
http2 Non-Breaking 2.3.1 3.0.1-wip 3.0.1-wip ✔️

This check can be disabled by tagging the PR with skip-breaking-check.

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.

Package Leaked API symbol Leaking sources

This check can be disabled by tagging the PR with skip-leaking-check.

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check.

Coverage ✔️
File Coverage
pkgs/http2/lib/src/connection.dart 💚 83 % ⬆️ 1 %

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 skip-coverage-check.

License Headers ✔️
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
Files
no missing headers

All source files should start with a license header.

Unrelated files missing license headers
Files
pkgs/cupertino_http/example/example.dart
pkgs/http/example/main.dart
pkgs/http_multi_server/test/cert.dart

This check can be disabled by tagging the PR with skip-license-check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graceful shutdown (GOAWAY NO_ERROR) currently surfaces as TransportConnectionException

3 participants