Skip to content

http2: distinguish graceful peer shutdown from forceful termination#1917

Draft
aki1770-del wants to merge 1 commit into
dart-lang:masterfrom
aki1770-del:fix-1913-graceful-shutdown
Draft

http2: distinguish graceful peer shutdown from forceful termination#1917
aki1770-del wants to merge 1 commit 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
Copy link
Copy Markdown

google-cla Bot commented May 10, 2026

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.

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

1 participant