From c173588adc7e23ce39d4925f7a791943bc6927fc Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Thu, 1 Oct 2026 14:41:59 -0700 Subject: [PATCH 1/2] feat: add TCP connection protocol --- clients/dotnet/README.md | 53 + .../Generated/Actions.generated.cs | 116 + .../Generated/Commands.generated.cs | 42 + .../Generated/Errors.generated.cs | 2 + .../JsonSerializerContext.generated.cs | 21 + .../Generated/State.generated.cs | 287 +- .../dotnet/src/AgentHostProtocol/AhpClient.cs | 183 +- .../dotnet/src/AgentHostProtocol/Errors.cs | 14 + .../Generated/ActionMetadata.generated.cs | 30 + .../Hosts/HostClientHandle.cs | 22 + .../Hosts/MultiHostClient.cs | 52 +- .../dotnet/src/AgentHostProtocol/Reducers.cs | 112 + .../src/AgentHostProtocol/Subscription.cs | 79 +- .../src/AgentHostProtocol/TcpConnection.cs | 734 +++++ .../AgentHostProtocol.Tests/ClientTests.cs | 1299 +++++++- .../FixtureDrivenReducerTests.cs | 70 +- .../NativeReducerTests.cs | 75 + .../AgentHostProtocol.Tests/TransportTests.cs | 40 + .../TypesRoundTripFixtures.cs | 6 + clients/go/README.md | 66 + clients/go/ahp/client.go | 263 +- clients/go/ahp/client_test.go | 308 ++ clients/go/ahp/error.go | 10 + clients/go/ahp/hosts/hosts.go | 210 +- clients/go/ahp/hosts/hosts_test.go | 276 ++ clients/go/ahp/reducers.go | 169 + clients/go/ahp/reducers_fixture_test.go | 213 +- clients/go/ahp/tcp.go | 1048 +++++++ clients/go/ahp/tcp_test.go | 1376 ++++++++ clients/go/ahptypes/actions.generated.go | 146 + clients/go/ahptypes/commands.generated.go | 90 + clients/go/ahptypes/errors.generated.go | 9 +- clients/go/ahptypes/roundtrip_fixture_test.go | 12 + clients/go/ahptypes/state.generated.go | 119 +- clients/kotlin/README.md | 48 + .../microsoft/agenthostprotocol/Reducers.kt | 115 + .../agenthostprotocol/TcpConnection.kt | 433 +++ .../generated/Actions.generated.kt | 112 + .../generated/Commands.generated.kt | 47 +- .../generated/Errors.generated.kt | 1 + .../generated/State.generated.kt | 168 +- .../FixtureDrivenReducerTest.kt | 83 +- .../agenthostprotocol/GeneratedStructsTest.kt | 19 + .../agenthostprotocol/ReducersTest.kt | 466 +++ .../agenthostprotocol/RoundTripCorpusTest.kt | 6 + clients/rust/Cargo.lock | 7 + clients/rust/crates/ahp-types/src/actions.rs | 125 + clients/rust/crates/ahp-types/src/commands.rs | 41 +- clients/rust/crates/ahp-types/src/errors.rs | 2 + clients/rust/crates/ahp-types/src/state.rs | 223 +- .../ahp-types/tests/roundtrip_corpus.rs | 13 +- clients/rust/crates/ahp/Cargo.toml | 1 + clients/rust/crates/ahp/README.md | 75 + clients/rust/crates/ahp/src/client.rs | 425 ++- clients/rust/crates/ahp/src/error.rs | 20 +- clients/rust/crates/ahp/src/hosts/runtime.rs | 125 +- clients/rust/crates/ahp/src/hosts/types.rs | 38 + clients/rust/crates/ahp/src/lib.rs | 10 +- .../crates/ahp/src/multi_host_state_mirror.rs | 2 + clients/rust/crates/ahp/src/reducers.rs | 453 ++- clients/rust/crates/ahp/src/tcp.rs | 2768 +++++++++++++++++ .../rust/crates/ahp/tests/client_roundtrip.rs | 191 ++ clients/swift/AgentHostProtocol/README.md | 54 + .../Generated/Actions.generated.swift | 204 ++ .../Generated/Commands.generated.swift | 54 +- .../Generated/Errors.generated.swift | 1 + .../Generated/State.generated.swift | 230 +- .../AgentHostProtocol/NativeReducer.swift | 10 + .../Sources/AgentHostProtocol/Reducers.swift | 115 + .../AgentHostProtocolClient/AHPClient.swift | 194 +- .../AHPClientError.swift | 11 + .../AHPStateMirror.swift | 3 + .../Hosts/HostClientHandle.swift | 10 + .../Hosts/HostRuntime.swift | 64 +- .../Hosts/HostShared.swift | 56 + .../MultiHostStateMirror.swift | 3 + .../TcpConnection.swift | 569 ++++ .../AHPClientTestHelpers.swift | 17 + .../AHPClientTests.swift | 1193 +++++++ .../TypesRoundTripFixtureTests.swift | 6 + .../FixtureDrivenReducerTests.swift | 83 +- .../NativeReducerTests.swift | 70 + clients/typescript/README.md | 83 +- clients/typescript/src/client/async-queue.ts | 56 +- clients/typescript/src/client/client.ts | 203 +- clients/typescript/src/client/error.ts | 20 +- .../src/client/hosts/host-client-handle.ts | 8 + .../typescript/src/client/hosts/runtime.ts | 22 +- clients/typescript/src/client/index.ts | 3 + .../typescript/src/client/tcp-connection.ts | 395 +++ clients/typescript/test/async-queue.test.ts | 49 + clients/typescript/test/client.test.ts | 139 + .../typescript/test/tcp-connection.test.ts | 634 ++++ .../typescript/test/types-round-trip.test.ts | 6 +- docs/.vitepress/config.mts | 2 + docs/proposals/tcp-channels.md | 91 + docs/specification/lifecycle.md | 7 + docs/specification/subscriptions.md | 10 + docs/specification/tcp-channel.md | 366 +++ schema/actions.schema.json | 401 +++ schema/commands.schema.json | 462 +++ schema/errors.schema.json | 468 ++- schema/notifications.schema.json | 195 ++ schema/state.schema.json | 195 ++ scripts/find-protocol-sources.ts | 1 + scripts/generate-action-origin.ts | 30 +- scripts/generate-csharp.ts | 34 +- scripts/generate-go.ts | 40 +- scripts/generate-json-schema.test.ts | 55 +- scripts/generate-json-schema.ts | 17 +- scripts/generate-kotlin.ts | 24 +- scripts/generate-markdown.ts | 25 + scripts/generate-rust.ts | 29 +- scripts/generate-swift.ts | 26 +- types/action-origin.generated.ts | 52 + types/actions.ts | 1 + types/channels-tcp/actions.ts | 139 + types/channels-tcp/commands.ts | 46 + types/channels-tcp/reducer.ts | 113 + types/channels-tcp/state.ts | 128 + types/commands.ts | 1 + types/common/actions.ts | 34 +- types/common/commands.ts | 16 + types/common/errors.ts | 4 + types/common/reducer-helpers.ts | 4 +- types/common/state.ts | 49 +- types/index.ts | 1 + types/messages.test.ts | 1 + types/reducers.test.ts | 171 +- types/reducers.ts | 1 + types/state.ts | 1 + ...-counts-decoded-bytes-without-payload.json | 55 + .../287-tcp-canonical-base64-1-bytes.json | 55 + .../288-tcp-canonical-base64-2-bytes.json | 55 + .../289-tcp-canonical-base64-3-bytes.json | 55 + .../290-tcp-canonical-base64-6-bytes.json | 55 + ...t-exhaustion-release-and-stale-credit.json | 104 + ...-tcp-duplicate-input-ranges-are-noops.json | 65 + ...f-close-is-independent-and-idempotent.json | 73 + ...se-permits-crossing-output-and-credit.json | 71 + ...tcp-host-close-permits-crossing-input.json | 58 + .../296-tcp-client-reset-is-terminal.json | 71 + .../297-tcp-host-reset-is-terminal.json | 71 + ...98-tcp-output-credit-eof-full-history.json | 69 + ...tcp-output-credit-eof-replayed-suffix.json | 64 + .../300-tcp-unknown-action-is-noop.json | 53 + .../301-tcp-rejects-empty-payload.json | 56 + ...2-tcp-rejects-incomplete-base64-group.json | 56 + ...03-tcp-rejects-missing-base64-padding.json | 56 + ...304-tcp-rejects-excess-base64-padding.json | 56 + .../305-tcp-rejects-padding-only.json | 56 + ...-tcp-rejects-three-padding-characters.json | 56 + .../307-tcp-rejects-interior-padding.json | 56 + ...rejects-noncanonical-two-padding-bits.json | 56 + ...rejects-noncanonical-one-padding-bits.json | 56 + .../310-tcp-rejects-url-base64-alphabet.json | 56 + .../311-tcp-rejects-base64-whitespace.json | 56 + ...2-tcp-rejects-invalid-base64-alphabet.json | 56 + ...-tcp-rejects-oversized-unpadded-chunk.json | 56 + ...14-tcp-rejects-oversized-padded-chunk.json | 56 + ...-tcp-rejects-decoded-chunk-over-limit.json | 56 + ...316-tcp-rejects-input-window-overflow.json | 56 + ...7-tcp-rejects-input-unreceived-credit.json | 55 + .../318-tcp-rejects-input-data-after-eof.json | 58 + ...19-tcp-rejects-input-data-after-close.json | 56 + ...320-tcp-rejects-input-eof-after-close.json | 55 + ...21-tcp-rejects-input-wrong-eof-offset.json | 55 + ...22-tcp-rejects-output-window-overflow.json | 56 + ...-tcp-rejects-output-unreceived-credit.json | 55 + ...324-tcp-rejects-output-data-after-eof.json | 58 + ...5-tcp-rejects-output-data-after-close.json | 56 + ...26-tcp-rejects-output-eof-after-close.json | 55 + ...7-tcp-rejects-output-wrong-eof-offset.json | 55 + .../328-tcp-rejects-partial-overlap.json | 61 + .../reducers/329-tcp-rejects-gap.json | 61 + .../330-tcp-rejects-negative-offset.json | 56 + .../331-tcp-rejects-fractional-offset.json | 56 + .../332-tcp-rejects-unsafe-offset.json | 56 + .../333-tcp-rejects-offset-overflow.json | 56 + .../053-initialize-tcp-capability.json | 18 + .../054-action-envelope-tcp-input.json | 18 + .../055-action-envelope-tcp-host-reset.json | 16 + .../056-tcp-connection-snapshot.json | 34 + .../057-subscribe-create-tcp-connection.json | 30 + .../058-reconnect-snapshot-missing-tcp.json | 16 + .../059-tcp-connection-open-error-data.json | 14 + .../060-reconnect-replay-tcp-data.json | 24 + ...61-reconnect-snapshot-without-missing.json | 14 + types/version/registry.ts | 10 + 189 files changed, 23962 insertions(+), 436 deletions(-) create mode 100644 clients/dotnet/src/AgentHostProtocol/TcpConnection.cs create mode 100644 clients/go/ahp/tcp.go create mode 100644 clients/go/ahp/tcp_test.go create mode 100644 clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/TcpConnection.kt create mode 100644 clients/rust/crates/ahp/src/tcp.rs create mode 100644 clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/TcpConnection.swift create mode 100644 clients/typescript/src/client/tcp-connection.ts create mode 100644 clients/typescript/test/tcp-connection.test.ts create mode 100644 docs/proposals/tcp-channels.md create mode 100644 docs/specification/tcp-channel.md create mode 100644 types/channels-tcp/actions.ts create mode 100644 types/channels-tcp/commands.ts create mode 100644 types/channels-tcp/reducer.ts create mode 100644 types/channels-tcp/state.ts create mode 100644 types/test-cases/reducers/286-tcp-binary-input-counts-decoded-bytes-without-payload.json create mode 100644 types/test-cases/reducers/287-tcp-canonical-base64-1-bytes.json create mode 100644 types/test-cases/reducers/288-tcp-canonical-base64-2-bytes.json create mode 100644 types/test-cases/reducers/289-tcp-canonical-base64-3-bytes.json create mode 100644 types/test-cases/reducers/290-tcp-canonical-base64-6-bytes.json create mode 100644 types/test-cases/reducers/291-tcp-credit-exhaustion-release-and-stale-credit.json create mode 100644 types/test-cases/reducers/292-tcp-duplicate-input-ranges-are-noops.json create mode 100644 types/test-cases/reducers/293-tcp-half-close-is-independent-and-idempotent.json create mode 100644 types/test-cases/reducers/294-tcp-client-close-permits-crossing-output-and-credit.json create mode 100644 types/test-cases/reducers/295-tcp-host-close-permits-crossing-input.json create mode 100644 types/test-cases/reducers/296-tcp-client-reset-is-terminal.json create mode 100644 types/test-cases/reducers/297-tcp-host-reset-is-terminal.json create mode 100644 types/test-cases/reducers/298-tcp-output-credit-eof-full-history.json create mode 100644 types/test-cases/reducers/299-tcp-output-credit-eof-replayed-suffix.json create mode 100644 types/test-cases/reducers/300-tcp-unknown-action-is-noop.json create mode 100644 types/test-cases/reducers/301-tcp-rejects-empty-payload.json create mode 100644 types/test-cases/reducers/302-tcp-rejects-incomplete-base64-group.json create mode 100644 types/test-cases/reducers/303-tcp-rejects-missing-base64-padding.json create mode 100644 types/test-cases/reducers/304-tcp-rejects-excess-base64-padding.json create mode 100644 types/test-cases/reducers/305-tcp-rejects-padding-only.json create mode 100644 types/test-cases/reducers/306-tcp-rejects-three-padding-characters.json create mode 100644 types/test-cases/reducers/307-tcp-rejects-interior-padding.json create mode 100644 types/test-cases/reducers/308-tcp-rejects-noncanonical-two-padding-bits.json create mode 100644 types/test-cases/reducers/309-tcp-rejects-noncanonical-one-padding-bits.json create mode 100644 types/test-cases/reducers/310-tcp-rejects-url-base64-alphabet.json create mode 100644 types/test-cases/reducers/311-tcp-rejects-base64-whitespace.json create mode 100644 types/test-cases/reducers/312-tcp-rejects-invalid-base64-alphabet.json create mode 100644 types/test-cases/reducers/313-tcp-rejects-oversized-unpadded-chunk.json create mode 100644 types/test-cases/reducers/314-tcp-rejects-oversized-padded-chunk.json create mode 100644 types/test-cases/reducers/315-tcp-rejects-decoded-chunk-over-limit.json create mode 100644 types/test-cases/reducers/316-tcp-rejects-input-window-overflow.json create mode 100644 types/test-cases/reducers/317-tcp-rejects-input-unreceived-credit.json create mode 100644 types/test-cases/reducers/318-tcp-rejects-input-data-after-eof.json create mode 100644 types/test-cases/reducers/319-tcp-rejects-input-data-after-close.json create mode 100644 types/test-cases/reducers/320-tcp-rejects-input-eof-after-close.json create mode 100644 types/test-cases/reducers/321-tcp-rejects-input-wrong-eof-offset.json create mode 100644 types/test-cases/reducers/322-tcp-rejects-output-window-overflow.json create mode 100644 types/test-cases/reducers/323-tcp-rejects-output-unreceived-credit.json create mode 100644 types/test-cases/reducers/324-tcp-rejects-output-data-after-eof.json create mode 100644 types/test-cases/reducers/325-tcp-rejects-output-data-after-close.json create mode 100644 types/test-cases/reducers/326-tcp-rejects-output-eof-after-close.json create mode 100644 types/test-cases/reducers/327-tcp-rejects-output-wrong-eof-offset.json create mode 100644 types/test-cases/reducers/328-tcp-rejects-partial-overlap.json create mode 100644 types/test-cases/reducers/329-tcp-rejects-gap.json create mode 100644 types/test-cases/reducers/330-tcp-rejects-negative-offset.json create mode 100644 types/test-cases/reducers/331-tcp-rejects-fractional-offset.json create mode 100644 types/test-cases/reducers/332-tcp-rejects-unsafe-offset.json create mode 100644 types/test-cases/reducers/333-tcp-rejects-offset-overflow.json create mode 100644 types/test-cases/round-trips/053-initialize-tcp-capability.json create mode 100644 types/test-cases/round-trips/054-action-envelope-tcp-input.json create mode 100644 types/test-cases/round-trips/055-action-envelope-tcp-host-reset.json create mode 100644 types/test-cases/round-trips/056-tcp-connection-snapshot.json create mode 100644 types/test-cases/round-trips/057-subscribe-create-tcp-connection.json create mode 100644 types/test-cases/round-trips/058-reconnect-snapshot-missing-tcp.json create mode 100644 types/test-cases/round-trips/059-tcp-connection-open-error-data.json create mode 100644 types/test-cases/round-trips/060-reconnect-replay-tcp-data.json create mode 100644 types/test-cases/round-trips/061-reconnect-snapshot-without-missing.json diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index b5d3839b8..47ab388ba 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -50,9 +50,62 @@ var state = new SessionState { /* ... */ }; Reducers.ApplyToSession(state, action); // mutates `state` in place ``` +For TCP accounting, `Reducers.TcpReducer(state, action)` returns a new state +and throws `InvalidOperationException` on invalid actions. + See [`examples/`](examples/) for runnable `ConnectWs` and `ReducersDemo` console apps. +## TCP streams + +After `InitializeAsync` negotiates `TcpConnections`, the concrete `AhpClient` +provides an integrated adapter: + +```csharp +await using var tcp = await client.OpenTcpConnectionAsync(sessionUri, + new TcpConnectionSubscription + { + Type = "tcpConnection", Host = "localhost", Port = 3000, + Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 65536, MaximumChunkSize = 16384, + }, cancellationToken); +await tcp.WriteAsync(requestBytes, cancellationToken); +await tcp.EndAsync(cancellationToken); // input EOF; output remains readable +while (await tcp.ReadAsync(cancellationToken) is { } bytes) + await ConsumeAsync(bytes); +``` + +The SDK owns buffering, flow control, and replay. One reader and one writer may +run concurrently. `ReadAsync` releases receive credit; `DrainAsync` waits for +destination consumption. `CloseAsync` stops writes but retains crossing output, +so keep reading during graceful close. Disposal aborts without draining. + +Transport loss suspends the same handles. On a fresh `AhpClient`, call +`ReconnectTcpConnectionsAsync(reconnectParams, handles, token)` with the original +`ClientId`; apply its returned replay to ordinary subscriptions. For deliberate +transport replacement, use `ShutdownAsync(preserveTcpConnections: true)` instead +of normal shutdown, which terminates streams. + +With `MultiHostClient`, use `HostClientHandle.OpenTcpConnectionAsync(session, create)` +for automatic reconnect. Host removal/shutdown terminates retained streams. +Missing resources and snapshot fallback fail streams rather than creating new +sockets. TCP streams do not belong in ordinary state mirrors. + +Transport policy, connection limits, and native socket bridges remain +application-owned. See the [TCP channel contract](../../docs/specification/tcp-channel.md). + +## Strict events + +For custom loss-sensitive consumers, attach +`client.CreateEventStream(failOnOverflow: true)` before sending requests and +retain it for `Events.ReadAllAsync`. Overflow throws `SubscriptionLagException`; +decode loss throws `AhpTransportException` of kind `"protocol"`. Both terminate +the receiver rather than skipping events. Capacity uses +`ClientConfig.SubscriptionBufferCapacity`; ordinary receivers are unchanged. +These raw receivers are global. The owned TCP adapter instead registers a strict +child-scoped receiver during creation reply processing and reattaches it per +child on reconnect. Unrelated traffic cannot exhaust a TCP stream's event buffer. + ## Dependency injection Register the services with `AddAgentHostProtocol` (in the diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs index 751701393..3f0339c2d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs @@ -231,6 +231,26 @@ public ActionType(string value) public static readonly ActionType AutomationRunCancelRequested = new ActionType("automationRun/cancelRequested"); + public static readonly ActionType TcpInput = new ActionType("tcp/input"); + + public static readonly ActionType TcpData = new ActionType("tcp/data"); + + public static readonly ActionType TcpInputConsumed = new ActionType("tcp/inputConsumed"); + + public static readonly ActionType TcpDataConsumed = new ActionType("tcp/dataConsumed"); + + public static readonly ActionType TcpInputEof = new ActionType("tcp/inputEof"); + + public static readonly ActionType TcpDataEof = new ActionType("tcp/dataEof"); + + public static readonly ActionType TcpClientClose = new ActionType("tcp/clientClose"); + + public static readonly ActionType TcpHostClose = new ActionType("tcp/hostClose"); + + public static readonly ActionType TcpClientReset = new ActionType("tcp/clientReset"); + + public static readonly ActionType TcpHostReset = new ActionType("tcp/hostReset"); + /// public bool Equals(ActionType other) => string.Equals(Value, other.Value, StringComparison.Ordinal); @@ -2400,6 +2420,92 @@ public sealed record ResourceWatchChangedAction public JsonElement Changes { get; init; } } +/// Client bytes. Never apply optimistically to the authoritative reducer. +/// Write to the destination only when accepted input.receivedBytes advances. +public sealed record TcpInputAction +{ + public ActionType Type { get; init; } = ActionType.TcpInput; + + /// Absolute decoded-byte offset. + public long Offset { get; init; } + + /// Nonempty canonical padded RFC 4648 base64; no whitespace. + public required string Data { get; init; } +} + +/// Host bytes. Deliver once, only when output.receivedBytes advances. +public sealed record TcpDataAction +{ + public ActionType Type { get; init; } = ActionType.TcpData; + + /// Absolute decoded-byte offset. + public long Offset { get; init; } + + /// Nonempty canonical padded RFC 4648 base64; no whitespace. + public required string Data { get; init; } +} + +/// Cumulative input bytes released from the host's bounded write buffer. +/// Not an acknowledgment that the destination application processed the bytes. +public sealed record TcpInputConsumedAction +{ + public ActionType Type { get; init; } = ActionType.TcpInputConsumed; + + public long ConsumedBytes { get; init; } +} + +/// Cumulative output bytes released by the client's bounded stream consumer. +public sealed record TcpDataConsumedAction +{ + public ActionType Type { get; init; } = ActionType.TcpDataConsumed; + + public long ConsumedBytes { get; init; } +} + +/// Half-close client input after all preceding input bytes have been written. +public sealed record TcpInputEofAction +{ + public ActionType Type { get; init; } = ActionType.TcpInputEof; + + public long FinalOffset { get; init; } +} + +/// Half-close host output after all preceding output bytes have been delivered. +public sealed record TcpDataEofAction +{ + public ActionType Type { get; init; } = ActionType.TcpDataEof; + + public long FinalOffset { get; init; } +} + +/// Client's final close. Respond with hostClose if not already sent. +public sealed record TcpClientCloseAction +{ + public ActionType Type { get; init; } = ActionType.TcpClientClose; +} + +/// Host's final close. Respond with clientClose if not already sent. +public sealed record TcpHostCloseAction +{ + public ActionType Type { get; init; } = ActionType.TcpHostClose; +} + +/// Abort both directions and discard buffered payload. +public sealed record TcpClientResetAction +{ + public ActionType Type { get; init; } = ActionType.TcpClientReset; + + public TcpResetReason Reason { get; init; } +} + +/// Abort both directions and discard buffered payload. +public sealed record TcpHostResetAction +{ + public ActionType Type { get; init; } = ActionType.TcpHostReset; + + public TcpResetReason Reason { get; init; } +} + /// Upsert an {@link Annotation} in the annotations channel — adds a new /// annotation, or replaces an existing one identified by /// {@link Annotation.id}. @@ -2836,6 +2942,16 @@ public StateActionConverter() ["terminal/commandExecuted"] = typeof(TerminalCommandExecutedAction), ["terminal/commandFinished"] = typeof(TerminalCommandFinishedAction), ["resourceWatch/changed"] = typeof(ResourceWatchChangedAction), + ["tcp/input"] = typeof(TcpInputAction), + ["tcp/data"] = typeof(TcpDataAction), + ["tcp/inputConsumed"] = typeof(TcpInputConsumedAction), + ["tcp/dataConsumed"] = typeof(TcpDataConsumedAction), + ["tcp/inputEof"] = typeof(TcpInputEofAction), + ["tcp/dataEof"] = typeof(TcpDataEofAction), + ["tcp/clientClose"] = typeof(TcpClientCloseAction), + ["tcp/hostClose"] = typeof(TcpHostCloseAction), + ["tcp/clientReset"] = typeof(TcpClientResetAction), + ["tcp/hostReset"] = typeof(TcpHostResetAction), ["annotations/set"] = typeof(AnnotationsSetAction), ["annotations/removed"] = typeof(AnnotationsRemovedAction), ["annotations/entrySet"] = typeof(AnnotationsEntrySetAction), diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 9d0877928..f1973356e 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -394,6 +394,10 @@ public sealed record InitializeResult /// host does not expose an automation catalogue or automation commands. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AutomationCapabilities? Automations { get; init; } + + /// Enables atomic creation of session-scoped, replay-only TCP channels. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TcpConnectionsCapability? TcpConnections { get; init; } } /// Identifies a protocol implementation — the software (and build) on one end @@ -571,6 +575,12 @@ public sealed record ReconnectSnapshotResult /// Fresh snapshots for each subscription public required List Snapshots { get; init; } + + /// Subscriptions that cannot be restored. Hosts supporting TCP MUST list all + /// requested TCP channels here and dispose their sockets on snapshot fallback. + /// Omitted by older hosts; absence does not authorize snapshot-restoring TCP. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Missing { get; init; } } /// Subscribe to a URI-identified channel. @@ -604,6 +614,12 @@ public sealed record SubscribeParams /// default snapshot. Clients MUST tolerate receiving more state than requested. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SubscribeView? View { get; init; } + + /// Atomically create a private child channel and subscribe to it. + /// Requires the advertised tcpConnections capability. channel identifies + /// the parent session; snapshot.resource identifies the created TCP channel. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TcpConnectionSubscription? Create { get; init; } } /// Optional client-requested shape for a subscription snapshot. @@ -643,6 +659,32 @@ public sealed record SubscribeResult public Snapshot? Snapshot { get; init; } } +/// Creates and exclusively subscribes to one TCP connection. +/// +/// SubscribeParams.channel MUST identify the parent `ahp-session:` channel. +/// The host returns the new `ahp-tcp:` URI in snapshot.resource, not the parent. +/// It installs the subscription and sends the response before any TCP actions. +/// Unknown creation kinds MUST be rejected, never treated as normal subscribe. +public sealed record TcpConnectionSubscription +{ + public required string Type { get; init; } + + /// DNS name or IP literal, not a URL. + public required string Host { get; init; } + + /// Destination port. + public long Port { get; init; } + + /// Selected from InitializeResult.tcpConnections.encodings. + public TcpDataEncoding Encoding { get; init; } + + /// Client receive window in decoded bytes. + public long ReceiveWindowBytes { get; init; } + + /// Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes. + public long MaximumChunkSize { get; init; } +} + // TODO: could not generate SessionForkSource: Error: Interface SessionForkSource not found /// Creates a new session with the specified agent provider. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs index 02a52462b..855ef4182 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Errors.generated.cs @@ -45,6 +45,8 @@ public static class AhpErrorCodes public const int AlreadyExists = -32010; /// An optimistic-concurrency precondition failed. Returned when a request carries a precondition token that no longer matches the receiver's current state — for example, `resourceWrite` with an `ifMatch` etag that has been superseded by a concurrent write. Callers SHOULD re-read the resource (e.g. via `resourceResolve`) and decide whether to retry the operation with the fresh token or surface the conflict to the user. public const int Conflict = -32011; + /// TCP creation failed; data MUST contain TcpConnectionOpenErrorData. + public const int TcpConnectionOpenFailed = -32012; } /// Detail payload of an AuthRequired (-32007) error. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs index 8e496cb27..37774d626 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -221,6 +221,7 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(FileEditCollection))] [JsonSerializable(typeof(FileEditDiffStats))] [JsonSerializable(typeof(FileEditSide))] +[JsonSerializable(typeof(FlowControlledByteDirectionState))] [JsonSerializable(typeof(ForkChatSource))] [JsonSerializable(typeof(HookCustomization))] [JsonSerializable(typeof(Icon))] @@ -411,6 +412,26 @@ namespace Microsoft.AgentHostProtocol; [JsonSerializable(typeof(SubscribeView))] [JsonSerializable(typeof(SubscriptionDeliveryOptions))] [JsonSerializable(typeof(SystemNotificationResponsePart))] +[JsonSerializable(typeof(TcpClientCloseAction))] +[JsonSerializable(typeof(TcpClientResetAction))] +[JsonSerializable(typeof(TcpConnectionOpenErrorData))] +[JsonSerializable(typeof(TcpConnectionOpenFailureReason))] +[JsonSerializable(typeof(TcpConnectionsCapability))] +[JsonSerializable(typeof(TcpConnectionState))] +[JsonSerializable(typeof(TcpConnectionSubscription))] +[JsonSerializable(typeof(TcpDataAction))] +[JsonSerializable(typeof(TcpDataConsumedAction))] +[JsonSerializable(typeof(TcpDataEncoding))] +[JsonSerializable(typeof(TcpDataEofAction))] +[JsonSerializable(typeof(TcpEndpoint))] +[JsonSerializable(typeof(TcpHostCloseAction))] +[JsonSerializable(typeof(TcpHostResetAction))] +[JsonSerializable(typeof(TcpInputAction))] +[JsonSerializable(typeof(TcpInputConsumedAction))] +[JsonSerializable(typeof(TcpInputEofAction))] +[JsonSerializable(typeof(TcpResetReason))] +[JsonSerializable(typeof(TcpResetState))] +[JsonSerializable(typeof(TcpTarget))] [JsonSerializable(typeof(TelemetryCapabilities))] [JsonSerializable(typeof(TerminalClaim))] [JsonSerializable(typeof(TerminalClaimedAction))] diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index b05b6b10e..cf0d2b95d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -9,6 +9,181 @@ namespace Microsoft.AgentHostProtocol; // ─── Enums ──────────────────────────────────────────────────────────── +/// Payload encodings advertised by the host. +[JsonConverter(typeof(TcpDataEncodingConverter))] +public readonly struct TcpDataEncoding : IEquatable +{ + private readonly string? _value; + + /// Wraps a raw wire value — including one this build does not recognize. + /// The raw wire string. + public TcpDataEncoding(string value) + { + _value = value; + } + + /// The raw wire value. + public string Value => _value ?? string.Empty; + + public static readonly TcpDataEncoding Base64 = new TcpDataEncoding("base64"); + + /// + public bool Equals(TcpDataEncoding other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is TcpDataEncoding other && Equals(other); + + /// + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Ordinal equality over the raw wire value. + public static bool operator ==(TcpDataEncoding left, TcpDataEncoding right) => left.Equals(right); + + /// Ordinal inequality over the raw wire value. + public static bool operator !=(TcpDataEncoding left, TcpDataEncoding right) => !left.Equals(right); +} + +/// Reads and writes as its raw wire string, preserving unrecognized values. +internal sealed class TcpDataEncodingConverter : JsonConverter +{ + /// + public override TcpDataEncoding Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new TcpDataEncoding(reader.GetString() ?? throw new JsonException("TcpDataEncoding expects a JSON string.")); + + /// + public override void Write(Utf8JsonWriter writer, TcpDataEncoding value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} + +/// Endpoint that closes or resets a connection. +[JsonConverter(typeof(WireEnumConverter))] +public enum TcpEndpoint +{ + [WireValue("client")] + Client, + [WireValue("host")] + Host, +} + +/// Why a connection was aborted. +[JsonConverter(typeof(TcpResetReasonConverter))] +public readonly struct TcpResetReason : IEquatable +{ + private readonly string? _value; + + /// Wraps a raw wire value — including one this build does not recognize. + /// The raw wire string. + public TcpResetReason(string value) + { + _value = value; + } + + /// The raw wire value. + public string Value => _value ?? string.Empty; + + public static readonly TcpResetReason ConnectionReset = new TcpResetReason("connectionReset"); + + public static readonly TcpResetReason ConnectionAborted = new TcpResetReason("connectionAborted"); + + public static readonly TcpResetReason ProtocolError = new TcpResetReason("protocolError"); + + public static readonly TcpResetReason ReplayUnavailable = new TcpResetReason("replayUnavailable"); + + public static readonly TcpResetReason PolicyRevoked = new TcpResetReason("policyRevoked"); + + public static readonly TcpResetReason SessionDisposed = new TcpResetReason("sessionDisposed"); + + public static readonly TcpResetReason InternalError = new TcpResetReason("internalError"); + + /// + public bool Equals(TcpResetReason other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is TcpResetReason other && Equals(other); + + /// + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Ordinal equality over the raw wire value. + public static bool operator ==(TcpResetReason left, TcpResetReason right) => left.Equals(right); + + /// Ordinal inequality over the raw wire value. + public static bool operator !=(TcpResetReason left, TcpResetReason right) => !left.Equals(right); +} + +/// Reads and writes as its raw wire string, preserving unrecognized values. +internal sealed class TcpResetReasonConverter : JsonConverter +{ + /// + public override TcpResetReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new TcpResetReason(reader.GetString() ?? throw new JsonException("TcpResetReason expects a JSON string.")); + + /// + public override void Write(Utf8JsonWriter writer, TcpResetReason value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} + +/// Expected connection establishment failures. +[JsonConverter(typeof(TcpConnectionOpenFailureReasonConverter))] +public readonly struct TcpConnectionOpenFailureReason : IEquatable +{ + private readonly string? _value; + + /// Wraps a raw wire value — including one this build does not recognize. + /// The raw wire string. + public TcpConnectionOpenFailureReason(string value) + { + _value = value; + } + + /// The raw wire value. + public string Value => _value ?? string.Empty; + + public static readonly TcpConnectionOpenFailureReason ConnectionFailed = new TcpConnectionOpenFailureReason("connectionFailed"); + + public static readonly TcpConnectionOpenFailureReason NameResolutionFailed = new TcpConnectionOpenFailureReason("nameResolutionFailed"); + + public static readonly TcpConnectionOpenFailureReason ResourceShortage = new TcpConnectionOpenFailureReason("resourceShortage"); + + public static readonly TcpConnectionOpenFailureReason SessionNotReady = new TcpConnectionOpenFailureReason("sessionNotReady"); + + /// + public bool Equals(TcpConnectionOpenFailureReason other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is TcpConnectionOpenFailureReason other && Equals(other); + + /// + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Ordinal equality over the raw wire value. + public static bool operator ==(TcpConnectionOpenFailureReason left, TcpConnectionOpenFailureReason right) => left.Equals(right); + + /// Ordinal inequality over the raw wire value. + public static bool operator !=(TcpConnectionOpenFailureReason left, TcpConnectionOpenFailureReason right) => !left.Equals(right); +} + +/// Reads and writes as its raw wire string, preserving unrecognized values. +internal sealed class TcpConnectionOpenFailureReasonConverter : JsonConverter +{ + /// + public override TcpConnectionOpenFailureReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new TcpConnectionOpenFailureReason(reader.GetString() ?? throw new JsonException("TcpConnectionOpenFailureReason expects a JSON string.")); + + /// + public override void Write(Utf8JsonWriter writer, TcpConnectionOpenFailureReason value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} + /// Policy configuration state for a model. [JsonConverter(typeof(WireEnumConverter))] public enum PolicyState @@ -6158,6 +6333,99 @@ public sealed record ResourceWatchState public JsonElement? Includes { get; init; } } +/// State of one host-assigned `ahp-tcp:` channel. +/// +/// Payload is never stored in this state. Only the creating authenticated +/// logical client may observe or dispatch to the channel. Reconnect requires +/// the original sockets, local stream state, and complete action replay; +/// a snapshot cannot restore this channel. +/// +/// Close flags record the two-sided handshake. Either flag means closing; +/// both mean closed. A present reset terminates the connection immediately, +/// independently of the close history. +public sealed record TcpConnectionState +{ + public required string Session { get; init; } + + public required TcpTarget Target { get; init; } + + public TcpDataEncoding Encoding { get; init; } + + /// Client to destination socket. + public required FlowControlledByteDirectionState Input { get; init; } + + /// Destination socket to client. + public required FlowControlledByteDirectionState Output { get; init; } + + public bool ClientClosed { get; init; } + + public bool HostClosed { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TcpResetState? Reset { get; init; } +} + +public sealed record TcpTarget +{ + /// DNS name or IP literal, resolved and connected in the host endpoint's network. + public required string Host { get; init; } + + /// Destination port. + public long Port { get; init; } +} + +public sealed record TcpResetState +{ + public TcpEndpoint Source { get; init; } + + public TcpResetReason Reason { get; init; } +} + +/// Bounded byte credit in one direction of a stream. +/// All counters are nonnegative safe integers (at most 2^53 - 1). +/// 0 <= consumedBytes <= receivedBytes and +/// receivedBytes - consumedBytes <= windowBytes. +public sealed record FlowControlledByteDirectionState +{ + /// Maximum accepted-but-not-consumed decoded bytes. + public long WindowBytes { get; init; } + + /// Maximum decoded bytes per chunk; MUST NOT exceed windowBytes. + public long MaximumChunkSize { get; init; } + + /// Cumulative accepted bytes. + public long ReceivedBytes { get; init; } + + /// Cumulative bytes released by the bounded consumer. + public long ConsumedBytes { get; init; } + + /// Present after EOF; equals receivedBytes permanently. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? EofAtBytes { get; init; } +} + +/// Host support for private, session-scoped TCP channels. +/// Presence on initialize is required before using subscribe.create. +public sealed record TcpConnectionsCapability +{ + /// Supported encodings. The base64 profile MUST be supported. + public required List Encodings { get; init; } + + /// Informational limit; runtime policy may impose a lower limit. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? MaximumConnectionsPerClient { get; init; } +} + +/// Required detail for TcpConnectionOpenFailed (-32012). +/// Policy denial and malformed requests use PermissionDenied and InvalidParams. +public sealed record TcpConnectionOpenErrorData +{ + public TcpConnectionOpenFailureReason Reason { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Retryable { get; init; } +} + /// A single change observed by a resource watcher. public sealed record ResourceChange { @@ -7726,14 +7994,18 @@ public override void Write(Utf8JsonWriter writer, ToolInput value, JsonSerialize /// /// SnapshotState is the state payload of a snapshot — root, session, - /// chat, terminal, changeset, resource-watch, annotations, automation catalogue, - /// or automation-run state. Read +/// chat, terminal, changeset, resource-watch, annotations, automation catalogue, +/// automation-run, or TCP state. Read /// probes for distinctive fields in an order where no probe shadows another -/// (chat → session → terminal → changeset → resource-watch → annotations → root). +/// (tcp → automationRun → automations → session → chat → terminal → changeset → +/// resource-watch → annotations → root). /// [JsonConverter(typeof(SnapshotStateConverter))] public sealed class SnapshotState { + /// Private TCP channel state variant, when populated. + public TcpConnectionState? Tcp { get; set; } + /// Root state variant, when populated. public RootState? Root { get; set; } @@ -7770,7 +8042,13 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; var result = new SnapshotState(); - if (root.TryGetProperty("automation", out _) && + if (root.TryGetProperty("input", out _) && + root.TryGetProperty("output", out _) && + root.TryGetProperty("target", out _)) + { + result.Tcp = root.Deserialize(AhpJsonTypeInfo.Get(options)); + } + else if (root.TryGetProperty("automation", out _) && root.TryGetProperty("origin", out _) && root.TryGetProperty("sessions", out _)) { @@ -7816,6 +8094,7 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) { + if (value.Tcp is not null) { JsonSerializer.Serialize(writer, value.Tcp, AhpJsonTypeInfo.Get(options)); return; } if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } diff --git a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs index e90eb47d0..f3a2645ac 100644 --- a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs +++ b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs @@ -156,7 +156,7 @@ public enum ConnectionState /// All public methods are safe to call from multiple threads. /// /// -public sealed class AhpClient : IAhpClient +public sealed partial class AhpClient : IAhpClient { // ── State that lives for the client lifetime ────────────────────────── @@ -169,11 +169,13 @@ public sealed class AhpClient : IAhpClient // In-flight request correlation keyed by JSON-RPC id. private readonly ConcurrentDictionary> _pending = new(); + private readonly ConcurrentDictionary> _resultHandlers = new(); // Per-URI subscription fan-out. private readonly object _subsLock = new(); private readonly Dictionary> _subscriptions = new(); private readonly List _eventListeners = new(); + private readonly Dictionary> _resourceEventListeners = new(StringComparer.Ordinal); // Multicast connection-state fan-out. Guarded by `_subsLock` (same lock as the // event listeners — every fan-out path already takes it). @@ -223,7 +225,18 @@ internal int SubscriptionCount } } - internal int EventListenerCount { get { lock (_subsLock) { return _eventListeners.Count; } } } + internal int EventListenerCount + { + get + { + lock (_subsLock) + { + int count = _eventListeners.Count; + foreach (var list in _resourceEventListeners.Values) count += list.Count; + return count; + } + } + } internal int StateListenerCount { get { lock (_subsLock) { return _stateListeners.Count; } } } @@ -311,7 +324,7 @@ public static AhpClient Connect( /// /// A that completes once the client begins teardown (either - /// via or a transport failure). + /// via or a transport failure). /// public Task Completion => _doneTcs.Task; @@ -327,11 +340,22 @@ public static AhpClient Connect( /// closed. The underlying transport is closed too. /// Safe to call multiple times. /// - public async Task ShutdownAsync(CancellationToken cancellationToken = default) + public Task ShutdownAsync(CancellationToken cancellationToken = default) + => ShutdownAsync(preserveTcpConnections: false, cancellationToken); + + /// Shuts down transport, optionally retaining TCP handles for explicit reconnect. + public async Task ShutdownAsync(bool preserveTcpConnections, CancellationToken cancellationToken = default) { - await ShutdownWithErrorAsync(null).ConfigureAwait(false); - // Wait for both background tasks to exit. - await Task.WhenAll(_readerTask, _writerTask).WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!preserveTcpConnections) await DisposeTcpConnectionsAsync().ConfigureAwait(false); + } + finally + { + await ShutdownWithErrorAsync(null).ConfigureAwait(false); + // Wait for both background tasks to exit. + await Task.WhenAll(_readerTask, _writerTask).WaitAsync(cancellationToken).ConfigureAwait(false); + } } /// @@ -408,6 +432,8 @@ private async Task ShutdownWithErrorAsync(Exception? cause, bool fromKeepAlive = tcs.TrySetException(shutdownEx); } } + foreach (var kv in _tcpCreationResponses) + if (_tcpCreationResponses.TryRemove(kv.Key, out var response)) response.TrySetException(shutdownEx); // Close every subscription and listener. List allSubs; @@ -419,6 +445,8 @@ private async Task ShutdownWithErrorAsync(Exception? cause, bool fromKeepAlive = allSubs.AddRange(list); allListeners = new List(_eventListeners); + foreach (var list in _resourceEventListeners.Values) allListeners.AddRange(list); + _resourceEventListeners.Clear(); _eventListeners.Clear(); } // Each Close() runs the subscription's detach hook, which removes it from the @@ -615,9 +643,10 @@ private async Task RunReaderAsync() { msg = _serializer.DecodeMessage(frame); } - catch + catch (Exception ex) { - // Skip malformed frames; protocol resync is the server's responsibility. + FailStrictEvents("ahp: malformed inbound JSON-RPC frame", ex); + // Ordinary receivers retain their existing skip behavior. AhpTelemetry.MalformedFrames.Add(1); continue; } @@ -656,16 +685,25 @@ private void Dispatch(JsonRpcMessage msg) // requests" limitation; mirrors the TS client's handleServerRequest.) _ = HandleServerRequestAsync(msg.Request); } + else + { + FailStrictEvents("ahp: inbound frame has no JSON-RPC message"); + } } private void Deliver(ulong id, JsonElement result, AhpRpcException? rpcError) { - if (_pending.TryRemove(id, out var tcs)) + if (_pending.TryRemove(id, out var tcs) || _tcpCreationResponses.TryRemove(id, out tcs)) { + _resultHandlers.TryRemove(id, out var onResult); if (rpcError is not null) tcs.TrySetException(rpcError); else + { + try { onResult?.Invoke(result); } + catch (Exception ex) { tcs.TrySetException(ex); return; } tcs.TrySetResult(result); + } } } @@ -740,7 +778,13 @@ private async Task EnqueueReplyAsync(JsonRpcMessage msg) private void HandleNotification(JsonRpcNotification n) { - if (n.Params is null) return; + if (n.Params is null) + { + if (n.Method is "action" or "root/sessionAdded" or "root/sessionRemoved" + or "root/sessionSummaryChanged" or "root/progress" or "auth/required") + FailStrictEvents("ahp: inbound subscription notification is missing params"); + return; + } var paramsEl = n.Params.Value; switch (n.Method) @@ -749,7 +793,7 @@ private void HandleNotification(JsonRpcNotification n) { ActionEnvelope env; try { env = _serializer.Deserialize(paramsEl); } - catch { return; } + catch (Exception ex) { FailStrictEvents("ahp: failed to decode inbound action envelope", ex); return; } FanOut(env.Channel, new SubscriptionEventAction(env)); break; } @@ -757,7 +801,7 @@ private void HandleNotification(JsonRpcNotification n) { SessionAddedParams p; try { p = _serializer.Deserialize(paramsEl); } - catch { return; } + catch (Exception ex) { FailStrictEvents("ahp: failed to decode inbound session-added notification", ex); return; } FanOut(p.Channel, new SubscriptionEventSessionAdded(p)); break; } @@ -765,7 +809,7 @@ private void HandleNotification(JsonRpcNotification n) { SessionRemovedParams p; try { p = _serializer.Deserialize(paramsEl); } - catch { return; } + catch (Exception ex) { FailStrictEvents("ahp: failed to decode inbound session-removed notification", ex); return; } FanOut(p.Channel, new SubscriptionEventSessionRemoved(p)); break; } @@ -773,7 +817,7 @@ private void HandleNotification(JsonRpcNotification n) { SessionSummaryChangedParams p; try { p = _serializer.Deserialize(paramsEl); } - catch { return; } + catch (Exception ex) { FailStrictEvents("ahp: failed to decode inbound session-summary notification", ex); return; } FanOut(p.Channel, new SubscriptionEventSessionSummaryChanged(p)); break; } @@ -781,7 +825,7 @@ private void HandleNotification(JsonRpcNotification n) { ProgressParams p; try { p = _serializer.Deserialize(paramsEl); } - catch { return; } + catch (Exception ex) { FailStrictEvents("ahp: failed to decode inbound progress notification", ex); return; } FanOut(p.Channel, new SubscriptionEventProgress(p)); break; } @@ -789,13 +833,25 @@ private void HandleNotification(JsonRpcNotification n) { AuthRequiredParams p; try { p = _serializer.Deserialize(paramsEl); } - catch { return; } + catch (Exception ex) { FailStrictEvents("ahp: failed to decode inbound auth notification", ex); return; } FanOut(p.Channel, new SubscriptionEventAuthRequired(p)); break; } } } + private void FailStrictEvents(string message, Exception? inner = null) + { + List listeners; + lock (_subsLock) + { + listeners = new List(_eventListeners); + foreach (var list in _resourceEventListeners.Values) listeners.AddRange(list); + } + var error = new AhpTransportException("protocol", message, inner); + foreach (var listener in listeners) listener.FailIfStrict(error); + } + private void FanOut(string channel, SubscriptionEvent ev) { // Snapshot each bucket under the lock (so TrySend runs outside it), but @@ -812,6 +868,12 @@ private void FanOut(string channel, SubscriptionEvent ev) listeners = _eventListeners.Count > 0 ? new List(_eventListeners) : Array.Empty(); + if (_resourceEventListeners.TryGetValue(channel, out var resourceListeners)) + { + var matching = new List(listeners); + matching.AddRange(resourceListeners); + listeners = matching; + } } for (var i = 0; i < subs.Count; i++) subs[i].TrySend(ev); @@ -838,10 +900,15 @@ private void FanOut(string channel, SubscriptionEvent ev) /// would be non-null. /// /// - public async Task RequestAsync( + public Task RequestAsync( string method, TParams parameters, CancellationToken cancellationToken = default) + => RequestCoreAsync(method, parameters, cancellationToken); + + private async Task RequestCoreAsync( + string method, TParams parameters, CancellationToken cancellationToken, + bool ownsTcpCreation = false, Action? onResult = null) { Guard.ThrowIfNull(method, nameof(method)); @@ -879,7 +946,11 @@ private void FanOut(string channel, SubscriptionEvent ev) ulong id = (ulong)(Interlocked.Increment(ref _nextId) - 1); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (ownsTcpCreation) _tcpCreationResponses[id] = tcs; + if (onResult is not null) _resultHandlers[id] = onResult; _pending[id] = tcs; + bool abandonedTcpCreation = false; + bool requestMayHaveBeenSent = false; activity?.SetTag(AhpTelemetryNames.AttrRequestId, id); AhpTelemetry.InflightRequests.Add(1); @@ -907,6 +978,7 @@ private void FanOut(string channel, SubscriptionEvent ev) try { + requestMayHaveBeenSent = true; await SendMessageAsync(req, requestCts.Token).ConfigureAwait(false); } catch @@ -936,6 +1008,15 @@ private void FanOut(string channel, SubscriptionEvent ev) // Distinguish caller cancellation (the caller's token) from a request // timeout (the configured default-timeout fired its linked token) from a // genuine error — the metric and the span status differ for each. + if (ownsTcpCreation) + { + _pending.TryRemove(id, out _); + if (requestMayHaveBeenSent) + { + abandonedTcpCreation = true; + _ = ReleaseAbandonedTcpCreationAsync(id, tcs.Task); + } + } bool callerCancelled = ex is OperationCanceledException && cancellationToken.IsCancellationRequested; outcome = ex switch { @@ -949,6 +1030,8 @@ private void FanOut(string channel, SubscriptionEvent ev) } finally { + _resultHandlers.TryRemove(id, out _); + if (ownsTcpCreation && !abandonedTcpCreation) _tcpCreationResponses.TryRemove(id, out _); AhpTelemetry.InflightRequests.Add(-1); AhpTelemetry.RequestDuration.Record( Compatibility.GetElapsedTime(startTimestamp).TotalMilliseconds, @@ -1045,6 +1128,8 @@ public async Task InitializeAsync( "protocol", $"ahp: server selected unoffered protocol version '{result.ProtocolVersion}'"); } + _tcpClientId = clientId; + _tcpCapability = result.TcpConnections; return result; } @@ -1399,17 +1484,71 @@ T Decode(JsonElement? el) => el is { } e /// streams may exist concurrently. /// public EventStream CreateEventStream() + => CreateEventStream(failOnOverflow: false); + + /// + /// Registers a bounded global receiver before returning. With + /// enabled, overflow preserves the buffered + /// prefix and permanently faults the receiver with . + /// Discarded malformed inbound frames or notification payloads instead fault + /// strict receivers with of kind protocol. + /// Other receivers are unaffected. The caller owns reset/unsubscribe and reconnect handling. + /// + public EventStream CreateEventStream(bool failOnOverflow) { - var stream = new EventStream(_cfg.SubscriptionBufferCapacity); + var stream = new EventStream(_cfg.SubscriptionBufferCapacity, failOnOverflow); + stream.OnClose(() => { lock (_subsLock) { _eventListeners.Remove(stream); } }); lock (_subsLock) { _eventListeners.Add(stream); } - // Detach on dispose so an abandoned stream is removed from the fan-out - // list rather than receiving (dropped) events for the client's lifetime. - stream.OnClose(() => { lock (_subsLock) { _eventListeners.Remove(stream); } }); return stream; } + + private EventStream CreateResourceEventStream(string resource) + { + var stream = new EventStream(_cfg.SubscriptionBufferCapacity, failOnOverflow: true) { Resource = resource }; + // Install cleanup before publishing the receiver: it may fail as soon + // as the receive loop sees it. + stream.OnClose(() => { lock (_subsLock) { RemoveEventStream(stream); } }); + lock (_subsLock) + { + if (Volatile.Read(ref _shutdownStarted) == 1) stream.Close(); + else AddEventStream(stream); + } + return stream; + } + + private void AddEventStream(EventStream stream) + { + if (stream.Resource is null) _eventListeners.Add(stream); + else + { + if (!_resourceEventListeners.TryGetValue(stream.Resource, out var list)) + _resourceEventListeners[stream.Resource] = list = new List(); + list.Add(stream); + } + } + + private void RemoveEventStream(EventStream stream) + { + if (stream.Resource is null) _eventListeners.Remove(stream); + else if (_resourceEventListeners.TryGetValue(stream.Resource, out var list)) + { + list.Remove(stream); + if (list.Count == 0) _resourceEventListeners.Remove(stream.Resource); + } + } + + private void BindEventStream(EventStream stream, string resource) + { + lock (_subsLock) + { + RemoveEventStream(stream); + stream.Resource = resource; + if (!stream.IsClosed) AddEventStream(stream); + } + } } // ─── Connection-state stream ──────────────────────────────────────────────────── diff --git a/clients/dotnet/src/AgentHostProtocol/Errors.cs b/clients/dotnet/src/AgentHostProtocol/Errors.cs index 78e89c18f..5a648afed 100644 --- a/clients/dotnet/src/AgentHostProtocol/Errors.cs +++ b/clients/dotnet/src/AgentHostProtocol/Errors.cs @@ -20,6 +20,20 @@ protected AhpException(string message) : base(message) { } protected AhpException(string message, Exception? inner) : base(message, inner) { } } +/// A strict event receiver overflowed and permanently terminated. +public sealed class SubscriptionLagException : AhpException +{ + /// The receiver's maximum buffered event count. + public int Capacity { get; } + + /// Creates a terminal receiver-lag exception. + public SubscriptionLagException(int capacity) + : base($"ahp: event receiver exceeded its capacity of {capacity}; the receiver is permanently terminated") + { + Capacity = capacity; + } +} + /// /// Thrown by implementations when the underlying /// connection experiences a transport-level fault. diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs index e20e88d36..6c4d9ac69 100644 --- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -346,6 +346,36 @@ public static bool TryGetActionType(object action, out ActionType actionType) case SessionWorkingDirectorySetAction value: actionType = value.Type; return true; + case TcpClientCloseAction value: + actionType = value.Type; + return true; + case TcpClientResetAction value: + actionType = value.Type; + return true; + case TcpDataAction value: + actionType = value.Type; + return true; + case TcpDataConsumedAction value: + actionType = value.Type; + return true; + case TcpDataEofAction value: + actionType = value.Type; + return true; + case TcpHostCloseAction value: + actionType = value.Type; + return true; + case TcpHostResetAction value: + actionType = value.Type; + return true; + case TcpInputAction value: + actionType = value.Type; + return true; + case TcpInputConsumedAction value: + actionType = value.Type; + return true; + case TcpInputEofAction value: + actionType = value.Type; + return true; case TerminalClaimedAction value: actionType = value.Type; return true; diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs index 733e50902..797b93ec4 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostClientHandle.cs @@ -16,6 +16,8 @@ namespace Microsoft.AgentHostProtocol.Hosts; /// Port of Swift's HostClientHandle (Swift surfaces the reconnect case as /// hostReconnected; the .NET typed-error set folds that into "not the /// connection you held — reacquire"). +/// Owned TCP streams opened through this handle survive replay reconnects even +/// though the handle itself becomes stale. Removal/shutdown terminates them. /// public sealed class HostClientHandle { @@ -57,6 +59,26 @@ private AhpClient CheckAlive() /// public void CheckAliveOrThrow() => CheckAlive(); + /// Opens a TCP stream retained and replayed by this host's supervisor. + public async Task OpenTcpConnectionAsync( + string session, TcpConnectionSubscription create, CancellationToken cancellationToken = default) + { + CheckAlive(); + var entry = _owner.TryGetEntry(HostId) ?? throw new HostShutDownException(HostId); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, entry.LifetimeCts.Token); + await entry.ConnectionGate.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + var client = CheckAlive(); + using var creation = CancellationTokenSource.CreateLinkedTokenSource(linked.Token, entry.TcpCreations.Token); + var connection = await client.OpenTcpConnectionAsync(session, create, creation.Token).ConfigureAwait(false); + entry.TcpConnections[connection.Resource] = connection; + connection.OnRelease(() => entry.TcpConnections.TryRemove(connection.Resource, out _)); + return connection; + } + finally { entry.ConnectionGate.Release(); } + } + /// /// Dispatches an action through this connection on , /// refusing (throwing) if the host was removed or the connection has been diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs index 9e7ce14bf..8eb2b4582 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Security.Cryptography; using System.Text.Json; using System.Threading; @@ -156,6 +157,7 @@ public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; LifetimeCts.Dispose(); + TcpCreations.Dispose(); ConnectionGate.Dispose(); _manualReconnect.Dispose(); // EndAttempt normally disposes the per-attempt CTS, but a teardown that @@ -186,6 +188,19 @@ public HostEntry(HostId id, HostConfig config, string clientId) /// to read one published reference. /// public AhpClient? CurrentClient => _client; + internal AhpClient? PreviousClient { get; set; } + internal ConcurrentDictionary TcpConnections { get; } = new(); + internal CancellationTokenSource TcpCreations { get; private set; } = new(); + + internal async Task CloseTcpAsync() + { + try + { + await Task.WhenAll(TcpConnections.Values.Select(c => c.DisposeAsync().AsTask())).ConfigureAwait(false); + return null; + } + catch (Exception error) { return error; } + } public void SetClient(AhpClient? client, string protoVer) { @@ -202,6 +217,8 @@ public void SetClient(AhpClient? client, string protoVer) } else { + TcpCreations.Dispose(); + TcpCreations = new CancellationTokenSource(); _clientReady.TrySetResult(client); } } @@ -658,9 +675,11 @@ public async Task RemoveHostAsync(HostId id, CancellationToken cancellationToken FinishPerHostListeners(id.ToString()); entry!.LifetimeCts.Cancel(); + Exception? tcpCleanupError = null; await entry.ConnectionGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { + tcpCleanupError = await entry.CloseTcpAsync().ConfigureAwait(false); var client = entry.CurrentClient; if (client is not null) { @@ -680,6 +699,7 @@ public async Task RemoveHostAsync(HostId id, CancellationToken cancellationToken // after teardown so a consumer that reacts to the removed event observes // a host that is already gone (Host(id) == null). BroadcastHostEvent(HostEvent.Removed(id)); + if (tcpCleanupError is not null) throw tcpCleanupError; } // ── Event channels ──────────────────────────────────────────────────── @@ -724,6 +744,7 @@ public async Task ShutdownAsync(CancellationToken cancellationToken = default) _rootCts.Cancel(); var entries = new List(_hosts.Values); + var tcpCleanupErrors = new List(); _hosts.Clear(); // Finish per-host listener streams for every host so their consumers' @@ -741,6 +762,7 @@ public async Task ShutdownAsync(CancellationToken cancellationToken = default) await entry.ConnectionGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { + if (await entry.CloseTcpAsync().ConfigureAwait(false) is { } error) tcpCleanupErrors.Add(error); var client = entry.CurrentClient; if (client is not null) { @@ -772,6 +794,7 @@ public async Task ShutdownAsync(CancellationToken cancellationToken = default) } _rootCts.Dispose(); + if (tcpCleanupErrors.Count > 0) throw new AggregateException(tcpCleanupErrors); } /// @@ -840,6 +863,7 @@ private async Task OpenHostCoreAsync( // Register before the first handshake request so notifications that // race initialize/reconnect are buffered rather than discarded. var stream = client.CreateEventStream(); + if (entry.PreviousClient is { } previous) client.InheritTcpClient(previous); // On a reconnect with a known serverSeq, issue the AHP `reconnect` command // (clientId + lastSeenServerSeq) so the host REPLAYS the actions missed @@ -851,24 +875,38 @@ private async Task OpenHostCoreAsync( { var snap = entry.Snapshot(); subscriptions = snap.Subscriptions; + var tcpConnections = entry.TcpConnections.Values.ToArray(); ReconnectResult? reconnectResult = null; try { - reconnectResult = await client.ReconnectAsync( - snap.ClientId, snap.ServerSeq, subscriptions, cancellationToken) + reconnectResult = tcpConnections.Length == 0 + ? await client.ReconnectAsync(snap.ClientId, snap.ServerSeq, subscriptions, cancellationToken).ConfigureAwait(false) + : await client.ReconnectTcpConnectionsAsync( + new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, + ClientId = snap.ClientId, + LastSeenServerSeq = snap.ServerSeq, + Subscriptions = subscriptions.ToList(), + }, tcpConnections, cancellationToken) .ConfigureAwait(false); } - catch (Exception) when (!cancellationToken.IsCancellationRequested) + catch (Exception reconnectError) when (!cancellationToken.IsCancellationRequested + && (tcpConnections.Length == 0 || reconnectError is AhpRpcException)) { // Host does not support `reconnect` (or it errored) — fall through // to a fresh `initialize` on the still-live client below. A // cancellation (shutdown/dispose) is NOT swallowed: it propagates // so the supervisor tears down promptly instead of blocking on a // fallback initialize. + if (await entry.CloseTcpAsync().ConfigureAwait(false) is { } error) throw error; } if (reconnectResult?.Value is ReconnectReplayResult replay) { + // TCP may need an older checkpoint than the ordinary host mirror. + if (tcpConnections.Length > 0) + replay = replay with { Actions = replay.Actions.Where(action => action.ServerSeq > snap.ServerSeq).ToList() }; var summaries = await FetchSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); await entry.ConnectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -914,6 +952,7 @@ private async Task OpenHostCoreAsync( } } + subscriptions = subscriptions?.Where(uri => !uri.StartsWith("ahp-tcp:", StringComparison.Ordinal)).ToArray(); var result = await client.InitializeAsync( entry.ClientId, entry.Config.ProtocolVersions, @@ -951,7 +990,7 @@ private async Task OpenHostCoreAsync( { if (client is not null) { - try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + try { await client.ShutdownAsync(preserveTcpConnections: true, CancellationToken.None).ConfigureAwait(false); } catch { } } else if (transport is not null) { @@ -983,6 +1022,7 @@ private void InstallOpenHost( { applyHandshake(); CompleteOpenHost(entry, stream); + entry.PreviousClient = null; } catch { @@ -1237,6 +1277,7 @@ private async Task SuperviseAsync(HostEntry entry) // or we're forcing a manual reconnect). Serialize replacement with // subscribe/unsubscribe, then drain the old event pump so reconnect // snapshots the final sequence observed on that connection. + entry.TcpCreations.Cancel(); try { await entry.ConnectionGate.WaitAsync(ct).ConfigureAwait(false); @@ -1248,12 +1289,13 @@ private async Task SuperviseAsync(HostEntry entry) try { var oldPump = entry.PumpTask; + entry.PreviousClient = client; BeginReconnect(entry, new HostState { Kind = HostStateKind.Reconnecting, Attempt = 1, }); - try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + try { await client.ShutdownAsync(preserveTcpConnections: true, CancellationToken.None).ConfigureAwait(false); } catch { } try { await oldPump.ConfigureAwait(false); } catch (OperationCanceledException) { } catch { } } finally diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index af977f375..0ee30b7d0 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -30,6 +30,118 @@ public enum ReduceOutcome /// public static class Reducers { + /// + /// Returns TCP accounting state without retaining payloads or restoring streams. + /// Invalid actions throw before changing state. Adapters must reset/close the + /// channel on failure and only write data when ReceivedBytes advances. + /// + /// The TCP action violates the stream contract. + public static TcpConnectionState TcpReducer(TcpConnectionState state, StateAction action) + { + Guard.ThrowIfNull(state, nameof(state)); + Guard.ThrowIfNull(action, nameof(action)); + if (state.Reset is not null) return state; + var input = state.Input; + var output = state.Output; + switch (action.Value) + { + case TcpInputAction a: + input = TcpReceive(input, a.Offset, a.Data, state.ClientClosed); + break; + case TcpDataAction a: + output = TcpReceive(output, a.Offset, a.Data, state.HostClosed); + break; + case TcpInputConsumedAction a: + input = TcpConsume(input, a.ConsumedBytes); + break; + case TcpDataConsumedAction a: + output = TcpConsume(output, a.ConsumedBytes); + break; + case TcpInputEofAction a: + input = TcpEof(input, a.FinalOffset, state.ClientClosed); + break; + case TcpDataEofAction a: + output = TcpEof(output, a.FinalOffset, state.HostClosed); + break; + case TcpClientCloseAction: + return state.ClientClosed ? state : state with { ClientClosed = true }; + case TcpHostCloseAction: + return state.HostClosed ? state : state with { HostClosed = true }; + case TcpClientResetAction a: + return state with { Reset = new TcpResetState { Source = TcpEndpoint.Client, Reason = a.Reason } }; + case TcpHostResetAction a: + return state with { Reset = new TcpResetState { Source = TcpEndpoint.Host, Reason = a.Reason } }; + default: + return state; + } + return ReferenceEquals(input, state.Input) && ReferenceEquals(output, state.Output) + ? state : state with { Input = input, Output = output }; + } + + private static void RequireTcp(bool condition, string message) + { + if (!condition) throw new InvalidOperationException($"Invalid TCP action: {message}"); + } + + private static void RequireTcpOffset(long value) => + RequireTcp(value >= 0 && value <= 9007199254740991L, "offset must be a nonnegative safe integer"); + + private static int TcpBase64Value(char c) => c switch + { + >= 'A' and <= 'Z' => c - 'A', + >= 'a' and <= 'z' => c - 'a' + 26, + >= '0' and <= '9' => c - '0' + 52, + '+' => 62, + '/' => 63, + _ => -1, + }; + + private static long TcpPayloadLength(string data, long maximumChunkSize) + { + RequireTcp(data.Length > 0 && data.Length <= 4 * (maximumChunkSize / 3 + (maximumChunkSize % 3 > 0 ? 1 : 0)), "chunk size"); + int padding = data.EndsWith("==", StringComparison.Ordinal) ? 2 : data[data.Length - 1] == '=' ? 1 : 0; + RequireTcp(data.Length % 4 == 0, "base64 encoding"); + int last = 0; + for (int i = 0; i < data.Length - padding; i++) + { + last = TcpBase64Value(data[i]); + RequireTcp(last >= 0, "base64 encoding"); + } + if (padding > 0) + RequireTcp(last % (padding == 2 ? 16 : 4) == 0, "noncanonical base64 padding bits"); + long length = (long)data.Length / 4 * 3 - padding; + RequireTcp(length <= maximumChunkSize, "chunk size"); + return length; + } + + private static FlowControlledByteDirectionState TcpReceive(FlowControlledByteDirectionState direction, long offset, string data, bool senderClosed) + { + RequireTcpOffset(offset); + long end = offset + TcpPayloadLength(data, direction.MaximumChunkSize); + RequireTcpOffset(end); + if (end <= direction.ReceivedBytes) return direction; + RequireTcp(offset == direction.ReceivedBytes, "gap or overlapping byte range"); + RequireTcp(!senderClosed && direction.EofAtBytes is null, "data after EOF or sender close"); + RequireTcp(end - direction.ConsumedBytes <= direction.WindowBytes, "receive window exceeded"); + return direction with { ReceivedBytes = end }; + } + + private static FlowControlledByteDirectionState TcpConsume(FlowControlledByteDirectionState direction, long consumedBytes) + { + RequireTcpOffset(consumedBytes); + RequireTcp(consumedBytes <= direction.ReceivedBytes, "consuming bytes not received"); + return consumedBytes <= direction.ConsumedBytes ? direction : direction with { ConsumedBytes = consumedBytes }; + } + + private static FlowControlledByteDirectionState TcpEof(FlowControlledByteDirectionState direction, long finalOffset, bool senderClosed) + { + RequireTcpOffset(finalOffset); + RequireTcp(finalOffset == direction.ReceivedBytes, "EOF offset"); + if (direction.EofAtBytes == finalOffset) return direction; + RequireTcp(!senderClosed, "EOF after sender close"); + return direction with { EofAtBytes = finalOffset }; + } + // ─── Injectable timestamp ────────────────────────────────────────────── private static volatile Func s_now = () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); diff --git a/clients/dotnet/src/AgentHostProtocol/Subscription.cs b/clients/dotnet/src/AgentHostProtocol/Subscription.cs index 684321c77..14e9a6d2a 100644 --- a/clients/dotnet/src/AgentHostProtocol/Subscription.cs +++ b/clients/dotnet/src/AgentHostProtocol/Subscription.cs @@ -93,7 +93,7 @@ public sealed class SubscriptionEventAuthRequired : SubscriptionEvent /// /// A tagged with the channel URI it was -/// scoped to. Returned by . +/// scoped to. Returned by . /// public sealed class ClientEvent { @@ -117,19 +117,26 @@ public ClientEvent(string channel, SubscriptionEvent @event) /// Internal bounded drop-oldest channel shared by the three public stream /// wrappers (, , and /// StateChangeStream). Encapsulates the creation, -/// the idempotent close lifecycle, and the drop-oldest delivery so each public +/// the idempotent close lifecycle, and the default drop-oldest delivery so each public /// wrapper stays a thin, sealed, domain-named handle. +/// Strict event receivers opt into terminal overflow instead. /// internal sealed class BoundedDropOldestChannel { private readonly Channel _channel; + private readonly object? _strictWriteLock; + private readonly int _capacity; + private readonly Action? _onDropped; private int _closed; - internal BoundedDropOldestChannel(int bufferCapacity, Action? onDropped = null) + internal BoundedDropOldestChannel(int bufferCapacity, Action? onDropped = null, bool failOnOverflow = false) { + _capacity = bufferCapacity; + _onDropped = onDropped; + _strictWriteLock = failOnOverflow ? new object() : null; var options = new BoundedChannelOptions(bufferCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, + FullMode = failOnOverflow ? BoundedChannelFullMode.Wait : BoundedChannelFullMode.DropOldest, SingleReader = false, SingleWriter = false, }; @@ -145,24 +152,56 @@ internal BoundedDropOldestChannel(int bufferCapacity, Action? onDropped = nul /// Completes the channel. Safe to call multiple times. internal void Close() + { + if (_strictWriteLock is not null) + { + lock (_strictWriteLock) { Complete(null); } + return; + } + Complete(null); + } + + private void Complete(Exception? error) { if (Interlocked.CompareExchange(ref _closed, 1, 0) == 0) { - _channel.Writer.TryComplete(); + _channel.Writer.TryComplete(error); } } + internal bool FailIfStrict(Exception error) + { + if (_strictWriteLock is null) return false; + lock (_strictWriteLock) { Complete(error); } + return true; + } + /// - /// Delivers the item, evicting the oldest buffered item if the channel is full + /// By default delivers the item, evicting the oldest buffered item if the channel is full /// () — the newest item is always /// accepted, so a slow consumer loses the stalest items rather than the latest. /// Each eviction is reported via the onDropped callback supplied at - /// construction. Mirrors the Go trySend. + /// construction. Strict receivers instead preserve the prefix and fault + /// permanently on the first rejected write. /// - internal void TrySend(T item) + internal bool TrySend(T item) { - if (Volatile.Read(ref _closed) == 1) return; - _channel.Writer.TryWrite(item); + if (_strictWriteLock is not null) + { + lock (_strictWriteLock) + { + if (Volatile.Read(ref _closed) == 1) return false; + if (!_channel.Writer.TryWrite(item)) + { + Complete(new SubscriptionLagException(_capacity)); + _onDropped?.Invoke(item); + return false; + } + return true; + } + } + if (Volatile.Read(ref _closed) == 1) return false; + return _channel.Writer.TryWrite(item); } } @@ -172,7 +211,7 @@ internal void TrySend(T item) /// Per-URI fan-out handle returned by and /// . Drop the handle by calling /// (or ) or let -/// tear it down. +/// tear it down. /// public sealed class Subscription : IDisposable { @@ -227,7 +266,7 @@ public void Close() /// /// Top-level fan-in receiver over every inbound event from an , /// tagged with the channel URI. Multiple streams may exist concurrently. -/// Returned by . +/// Returned by . /// // CA1711: "Stream" here names the AHP event-stream concept (mirroring Go's // EventStream and Swift's AsyncStream usage), not a System.IO.Stream subclass. @@ -236,6 +275,8 @@ public void Close() Justification = "EventStream names the AHP event-stream abstraction (mirrors Go/Swift API), not a System.IO.Stream subclass.")] public sealed class EventStream : IDisposable { + internal string? Resource { get; set; } + internal bool IsClosed => Volatile.Read(ref _closed) != 0; private readonly BoundedDropOldestChannel _channel; private Action? _onClose; private int _closed; @@ -243,10 +284,10 @@ public sealed class EventStream : IDisposable private static readonly KeyValuePair DropTag = new(AhpTelemetryNames.AttrStream, AhpTelemetryNames.StreamEvent); /// Creates a new event stream. - internal EventStream(int bufferCapacity) + internal EventStream(int bufferCapacity, bool failOnOverflow = false) { _channel = new BoundedDropOldestChannel( - bufferCapacity, _ => AhpTelemetry.DroppedEvents.Add(1, DropTag)); + bufferCapacity, _ => AhpTelemetry.DroppedEvents.Add(1, DropTag), failOnOverflow); } /// @@ -272,7 +313,15 @@ public void Close() /// public void Dispose() => Close(); - internal void TrySend(ClientEvent ev) => _channel.TrySend(ev); + internal void TrySend(ClientEvent ev) + { + if (!_channel.TrySend(ev)) Close(); + } + + internal void FailIfStrict(Exception error) + { + if (_channel.FailIfStrict(error)) Close(); + } } /// diff --git a/clients/dotnet/src/AgentHostProtocol/TcpConnection.cs b/clients/dotnet/src/AgentHostProtocol/TcpConnection.cs new file mode 100644 index 000000000..cbf287c9b --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/TcpConnection.cs @@ -0,0 +1,734 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; + +namespace Microsoft.AgentHostProtocol; + +/// An owned TCP byte stream. One reader and one writer may run concurrently. +public sealed class TcpConnection : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly Queue _received = new(); + private readonly SortedDictionary _pending = new(); + private TaskCompletionSource _changed = Signal(); + private AhpClient _client; + private EventStream? _events; + private CancellationTokenSource? _pumpCancellation; + private TcpConnectionState _state; + private Exception? _error; + private long _sentBytes; + private long _consumedBytes; + private long _checkpoint; + private long _lastClientSeq; + private int _generation; + private bool _suspended; + private bool _writing; + private bool _reading; + private bool _ending; + private bool _closing; + private bool _closed; + private bool _released; + private Action? _onRelease; + + internal void OnRelease(Action callback) + { + lock (_gate) + { + if (!_released) { _onRelease = callback; return; } + } + callback(); + } + + internal TcpConnection(AhpClient client, string clientId, Snapshot snapshot) + { + _client = client; + ClientId = clientId; + Resource = snapshot.Resource; + _state = snapshot.State.Tcp!; + _sentBytes = _state.Input.ReceivedBytes; + _consumedBytes = _state.Output.ConsumedBytes; + _checkpoint = snapshot.FromSeq; + } + + /// The private channel URI, unchanged across replay reconnects. + public string Resource { get; } + /// The logical client that owns this stream. + public string ClientId { get; } + /// Last accepted server state; writes are never optimistically reduced. + public TcpConnectionState State { get { lock (_gate) return _state; } } + /// Last applied checkpoint, including bytes retained in the read buffer. + public long AppliedCheckpoint { get { lock (_gate) return _checkpoint; } } + /// Whether transport delivery is suspended. + public bool IsSuspended { get { lock (_gate) return _suspended; } } + internal long LastClientSequence { get { lock (_gate) return _lastClientSeq; } } + internal bool IsClosed { get { lock (_gate) return _closed; } } + internal AhpClient Owner { get { lock (_gate) return _client; } } + internal bool CanRebind { get { lock (_gate) return !_closed && (_suspended || _client.ConnectionState == ConnectionState.Disconnected); } } + + private static TaskCompletionSource Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private void Wake() + { + var previous = _changed; + _changed = Signal(); + previous.TrySetResult(true); + } + + private void ThrowIfFailed() + { + if (_error is not null) throw _error; + } + + private (AhpClient Client, long Sequence, StateAction Action)? Enqueue(StateAction action) + { + long seq = _client.ReserveTcpSequence(); + _lastClientSeq = seq; + _pending.Add(seq, action); + return _suspended ? null : (_client, seq, action); + } + + private async Task SendAsync((AhpClient Client, long Sequence, StateAction Action)? item) + { + if (item is not { } send) return; + try + { + await send.Client.DispatchAsync(Resource, send.Action, send.Sequence).ConfigureAwait(false); + } + catch (AhpException) when (send.Client.ConnectionState == ConnectionState.Disconnected) + { + lock (_gate) { if (ReferenceEquals(send.Client, _client)) Suspend(); } + } + } + + /// Reads one chunk, releasing its receive credit. Null means drained EOF. + public async Task ReadAsync(CancellationToken cancellationToken = default) + { + lock (_gate) + { + ThrowIfFailed(); + if (_reading) throw new InvalidOperationException("TCP permits one reader at a time"); + _reading = true; + } + try + { + while (true) + { + Task wait; + byte[]? data = null; + (AhpClient, long, StateAction)? send = null; + lock (_gate) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfFailed(); + if (!_suspended || _closed) + { + if (_received.Count > 0) + { + data = _received.Peek(); + if (!_closed) + send = Enqueue(new StateAction(new TcpDataConsumedAction { Type = ActionType.TcpDataConsumed, ConsumedBytes = _consumedBytes + data.Length })); + _received.Dequeue(); + _consumedBytes += data.Length; + } + else if (_closed || _state.HostClosed || _state.Output.EofAtBytes is not null) return null; + } + wait = _changed.Task; + } + if (data is not null) + { + await SendAsync(send).ConfigureAwait(false); + return data; + } + await wait.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + finally { lock (_gate) _reading = false; } + } + + /// Writes bounded chunks, waiting for destination credit. Concurrent writes are rejected. + public async Task WriteAsync(byte[] data, CancellationToken cancellationToken = default) + { + Guard.ThrowIfNull(data, nameof(data)); + lock (_gate) + { + ThrowIfFailed(); + if (_writing || _ending || _closing || _closed) throw new InvalidOperationException("TCP write requires an open, idle writer"); + _writing = true; + } + try + { + int offset = 0; + while (offset < data.Length) + { + Task wait; + (AhpClient, long, StateAction)? send = null; + lock (_gate) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfFailed(); + if (_closing || _closed) throw new InvalidOperationException("TCP closed during write"); + long credit = _state.Input.WindowBytes - (_sentBytes - _state.Input.ConsumedBytes); + if (!_suspended && credit > 0) + { + int length = (int)Math.Min(data.Length - offset, Math.Min(credit, _state.Input.MaximumChunkSize)); + TcpProtocol.Safe(_sentBytes + length); + var action = new StateAction(new TcpInputAction + { + Type = ActionType.TcpInput, + Offset = _sentBytes, + Data = Convert.ToBase64String(data, offset, length), + }); + send = Enqueue(action); + _sentBytes += length; + offset += length; + } + wait = _changed.Task; + } + if (send is not null) await SendAsync(send).ConfigureAwait(false); + else await wait.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + finally { lock (_gate) { _writing = false; Wake(); } } + } + + /// Waits for all reserved input bytes to be consumed by the destination. + public async Task DrainAsync(CancellationToken cancellationToken = default) + { + while (true) + { + Task wait; + lock (_gate) + { + ThrowIfFailed(); + if (_state.Input.ConsumedBytes >= _sentBytes) return; + if (_closed) throw new InvalidOperationException("TCP closed before drain completed"); + wait = _changed.Task; + } + await wait.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// Half-closes input after all writes have finished; output remains readable. + public async Task EndAsync(CancellationToken cancellationToken = default) + { + lock (_gate) + { + ThrowIfFailed(); + if (_writing || _closing || _closed) throw new InvalidOperationException("TCP end requires an open, idle writer"); + if (_ending) return; + _ending = true; + } + bool queued = false; + try + { + while (true) + { + Task wait; + (AhpClient, long, StateAction)? send; + lock (_gate) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfFailed(); + if (_closing || _closed) throw new InvalidOperationException("TCP closed before EOF"); + send = _suspended ? null : Enqueue(new StateAction(new TcpInputEofAction { Type = ActionType.TcpInputEof, FinalOffset = _sentBytes })); + queued = send is not null; + wait = _changed.Task; + } + if (send is not null) { await SendAsync(send).ConfigureAwait(false); return; } + await wait.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + catch + { + if (!queued) lock (_gate) { _ending = false; Wake(); } + throw; + } + } + + /// Stops input; retains crossing output and ownership until both sides close and drain. + public async Task CloseAsync() + { + (AhpClient, long, StateAction)? send; + lock (_gate) + { + if (_closing || _closed) return; + _closing = true; + send = Enqueue(new StateAction(new TcpClientCloseAction { Type = ActionType.TcpClientClose })); + Wake(); + } + try + { + await SendAsync(send).ConfigureAwait(false); + } + catch (Exception error) + { + await FailAsync(error).ConfigureAwait(false); + throw; + } + } + + private async Task FinishCloseAsync() + { + lock (_gate) + { + if (_closed || !_closing || !_state.ClientClosed || !_state.HostClosed + || _state.Input.ConsumedBytes < _sentBytes + || _state.Output.ConsumedBytes < _state.Output.ReceivedBytes + || _received.Count != 0 || _pending.Count != 0) return; + _closed = true; + Wake(); + } + await ReleaseAsync().ConfigureAwait(false); + } + + internal void Suspend() + { + lock (_gate) + { + if (_closed) return; + _suspended = true; + _generation++; + _pumpCancellation?.Cancel(); + _pumpCancellation?.Dispose(); + _pumpCancellation = null; + _events?.Dispose(); + _events = null; + Wake(); + } + } + + internal void Bind(AhpClient client) + { + lock (_gate) + { + if (_closed) return; + if (!_suspended && _client.ConnectionState != ConnectionState.Disconnected) + throw new InvalidOperationException("Suspend the previous transport before reconnecting TCP"); + Suspend(); + _lastClientSeq = Math.Max(_lastClientSeq, _client.LastAssignedClientSequence); + if (!ReferenceEquals(client, _client)) + { + client.TrackTcpConnection(this); + _client.ForgetTcpConnection(this); + } + _client = client; + } + } + + internal async Task AcceptAsync(ActionEnvelope envelope, int? generation = null) + { + Exception? failure = null; + bool close = false; + lock (_gate) + { + if (_closed || envelope.Channel != Resource || (generation is not null && generation != _generation)) return; + try + { + TcpProtocol.Safe(envelope.ServerSeq); + bool clientEcho = envelope.Action.Value is TcpInputAction or TcpDataConsumedAction or TcpInputEofAction + or TcpClientCloseAction or TcpClientResetAction; + if (clientEcho) + { + if (envelope.Origin is not { } origin || origin.ClientId != ClientId) + throw new InvalidOperationException("TCP client echo requires the owning client origin"); + TcpProtocol.Safe(origin.ClientSeq); + if (origin.ClientSeq > _lastClientSeq) + throw new InvalidOperationException("TCP client echo has an unassigned sequence"); + } + if (envelope.ServerSeq <= _checkpoint) return; + if (envelope.RejectionReason is not null) throw new InvalidOperationException(envelope.RejectionReason); + var previous = _state; + var next = Reducers.TcpReducer(previous, envelope.Action); + if (clientEcho) + { + if (_pending.TryGetValue(envelope.Origin!.ClientSeq, out var expected)) + { + if (!Equals(expected.Value, envelope.Action.Value)) + throw new InvalidOperationException("TCP client echo does not match its pending action"); + } + else if (next != previous) + throw new InvalidOperationException("TCP advancing client echo has no pending action"); + } + if (next.Output.ReceivedBytes - _consumedBytes > next.Output.WindowBytes) + throw new InvalidOperationException("TCP output exceeds locally released credit"); + if (envelope.Action.Value is TcpDataAction data && next.Output.ReceivedBytes > previous.Output.ReceivedBytes) + _received.Enqueue(Convert.FromBase64String(data.Data)); + _state = next; + _checkpoint = envelope.ServerSeq; + if (clientEcho) _pending.Remove(envelope.Origin!.ClientSeq); + if (next.Reset is not null) failure = new InvalidOperationException($"TCP reset: {next.Reset.Reason}"); + else close = next.HostClosed; + Wake(); + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException) + { + failure = ex; + } + } + if (failure is not null) await FailAsync(failure, reset: State.Reset is null).ConfigureAwait(false); + else + { + if (close) await CloseAsync().ConfigureAwait(false); + await FinishCloseAsync().ConfigureAwait(false); + } + } + + internal async Task ResumeAsync(EventStream events) + { + KeyValuePair[] pending; + lock (_gate) + { + if (_closed) { events.Dispose(); return; } + pending = _pending.ToArray(); + } + foreach (var item in pending) + await _client.DispatchAsync(Resource, item.Value, item.Key).ConfigureAwait(false); + Start(events); + } + + internal void Start(EventStream events) + { + int generation; + CancellationToken cancellation; + lock (_gate) + { + if (_closed) { events.Dispose(); return; } + _events = events; + _suspended = false; + _pumpCancellation?.Dispose(); + _pumpCancellation = new CancellationTokenSource(); + cancellation = _pumpCancellation.Token; + generation = ++_generation; + Wake(); + } + _ = PumpAsync(events, generation, cancellation); + } + + private async Task PumpAsync(EventStream events, int generation, CancellationToken cancellation) + { + try + { + await foreach (var item in events.Events.ReadAllAsync(cancellation).ConfigureAwait(false)) + if (item.Event is SubscriptionEventAction action) + await AcceptAsync(action.Envelope, generation).ConfigureAwait(false); + lock (_gate) { if (generation == _generation) Suspend(); } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + catch (Exception ex) + { + lock (_gate) { if (generation != _generation) return; } + try { await FailAsync(ex, reset: true).ConfigureAwait(false); } + catch (Exception cleanupError) + { + lock (_gate) { _error = new AggregateException(ex, cleanupError); Wake(); } + } + } + finally { events.Dispose(); } + } + + internal async Task FailAsync(Exception error, bool reset = false) + { + (AhpClient, long, StateAction)? send = null; + lock (_gate) + { + if (_closed) return; + _error = error; + _closed = true; + if (reset && !_suspended) + { + try { send = Enqueue(new StateAction(new TcpClientResetAction { Type = ActionType.TcpClientReset, Reason = TcpResetReason.ProtocolError })); } + catch (InvalidOperationException sequenceError) { _error = new AggregateException(error, sequenceError); } + } + _received.Clear(); + _pending.Clear(); + Wake(); + } + try { await SendAsync(send).ConfigureAwait(false); } + finally { await ReleaseAsync().ConfigureAwait(false); } + } + + private async Task ReleaseAsync() + { + AhpClient client; + Action? onRelease; + lock (_gate) + { + if (_released) return; + _released = true; + _pending.Clear(); + _generation++; + _pumpCancellation?.Cancel(); + _pumpCancellation?.Dispose(); + _pumpCancellation = null; + _events?.Dispose(); + _events = null; + client = _client; + onRelease = _onRelease; + _onRelease = null; + } + client.ForgetTcpConnection(this); + onRelease?.Invoke(); + if (client.ConnectionState != ConnectionState.Disconnected) + { + try { await client.UnsubscribeAsync(Resource).ConfigureAwait(false); } + catch (Exception cleanupError) + { + lock (_gate) { _error = _error is null ? cleanupError : new AggregateException(_error, cleanupError); Wake(); } + throw; + } + } + } + + /// Terminates pending operations and releases this subscription once. + public ValueTask DisposeAsync() => new(FailAsync(new ObjectDisposedException(nameof(TcpConnection)))); + +} + +public sealed partial class AhpClient +{ + private string? _tcpClientId; + private TcpConnectionsCapability? _tcpCapability; + private readonly object _tcpOwnershipGate = new(); + private readonly HashSet _ownedTcpConnections = new(); + private bool _tcpDisposed; + + internal void TrackTcpConnection(TcpConnection connection) + { + lock (_tcpOwnershipGate) + { + if (_tcpDisposed) throw new AhpClientClosedException(); + _ownedTcpConnections.Add(connection); + } + } + + internal void ForgetTcpConnection(TcpConnection connection) + { + lock (_tcpOwnershipGate) _ownedTcpConnections.Remove(connection); + } + + private Task DisposeTcpConnectionsAsync() + { + TcpConnection[] connections; + lock (_tcpOwnershipGate) + { + _tcpDisposed = true; + connections = _ownedTcpConnections.ToArray(); + } + return Task.WhenAll(connections.Select(c => c.DisposeAsync().AsTask())); + } + internal void InheritTcpClient(AhpClient previous) + { + _tcpClientId = previous._tcpClientId; + _tcpCapability = previous._tcpCapability; + if (previous.LastAssignedClientSequence >= 0) AdvanceTcpSequence(previous.LastAssignedClientSequence); + } + internal long LastAssignedClientSequence => Interlocked.Read(ref _nextClientSeq) - 1; + private readonly ConcurrentDictionary> _tcpCreationResponses = new(); + + private async Task ReleaseAbandonedTcpCreationAsync(ulong id, Task response) + { + try + { + JsonElement result; + try { result = await response.ConfigureAwait(false); } + catch (AhpRpcException) { return; } + catch (AhpClientClosedException) { return; } + if (result.ValueKind == JsonValueKind.Object + && result.TryGetProperty("snapshot", out var snapshot) && snapshot.ValueKind == JsonValueKind.Object + && snapshot.TryGetProperty("resource", out var resource) && resource.ValueKind == JsonValueKind.String + && resource.GetString() is { } uri && uri.StartsWith("ahp-tcp:", StringComparison.Ordinal) + && ConnectionState != ConnectionState.Disconnected) + await UnsubscribeAsync(uri).ConfigureAwait(false); + } + catch (Exception error) + { + await ShutdownWithErrorAsync(new AhpTransportException("io", "ahp: failed to release abandoned TCP creation", error)).ConfigureAwait(false); + } + finally { _tcpCreationResponses.TryRemove(id, out _); } + } + + internal long ReserveTcpSequence() + { + while (true) + { + long current = Interlocked.Read(ref _nextClientSeq); + TcpProtocol.Safe(current); + if (Interlocked.CompareExchange(ref _nextClientSeq, current + 1, current) == current) return current; + } + } + + private void AdvanceTcpSequence(long sequence) + { + TcpProtocol.Safe(sequence); + while (true) + { + long current = Interlocked.Read(ref _nextClientSeq); + if (current > sequence) return; + if (Interlocked.CompareExchange(ref _nextClientSeq, sequence + 1, current) == current) return; + } + } + + /// Creates an owned TCP stream, registering its strict child route during reply processing. + public async Task OpenTcpConnectionAsync( + string session, TcpConnectionSubscription create, CancellationToken cancellationToken = default) + { + Guard.ThrowIfNull(session, nameof(session)); + Guard.ThrowIfNull(create, nameof(create)); + cancellationToken.ThrowIfCancellationRequested(); + var clientId = _tcpClientId ?? throw new InvalidOperationException("Initialize before opening TCP"); + TcpProtocol.ValidateRequest(session, create, _tcpCapability); + var events = CreateResourceEventStream(""); + Snapshot? snapshot = null; + TcpConnection? connection = null; + try + { + var result = await RequestCoreAsync("subscribe", + new SubscribeParams { Channel = session, Create = create }, cancellationToken, ownsTcpCreation: true, + onResult: raw => + { + if (raw.ValueKind == JsonValueKind.Object && raw.TryGetProperty("snapshot", out var child) + && child.ValueKind == JsonValueKind.Object && child.TryGetProperty("resource", out var resource) + && resource.ValueKind == JsonValueKind.String) + BindEventStream(events, resource.GetString()!); + }).ConfigureAwait(false); + snapshot = result?.Snapshot; + TcpProtocol.ValidateSnapshot(session, create, snapshot); + cancellationToken.ThrowIfCancellationRequested(); + connection = new TcpConnection(this, clientId, snapshot!); + TrackTcpConnection(connection); + connection.Start(events); + lock (_tcpOwnershipGate) { if (_tcpDisposed) throw new AhpClientClosedException(); } + return connection; + } + catch + { + events.Dispose(); + if (connection is not null) await connection.DisposeAsync().ConfigureAwait(false); + else if (snapshot?.Resource.StartsWith("ahp-tcp:", StringComparison.Ordinal) == true + && ConnectionState != ConnectionState.Disconnected) + await UnsubscribeAsync(snapshot.Resource, CancellationToken.None).ConfigureAwait(false); + throw; + } + } + + /// + /// Rebinds suspended TCP handles to this fresh transport. Reconciles replay before live + /// delivery and resends only unacknowledged actions with their original identities. + /// Returned replay excludes actions at or below the caller's original checkpoint. + /// Snapshot fallback and missing resources close handles; sockets are never recreated. + /// + public async Task ReconnectTcpConnectionsAsync( + ReconnectParams parameters, IReadOnlyList connections, + CancellationToken cancellationToken = default) + { + Guard.ThrowIfNull(parameters, nameof(parameters)); + Guard.ThrowIfNull(connections, nameof(connections)); + TcpProtocol.Safe(parameters.LastSeenServerSeq); + if (parameters.Channel != ProtocolVersion.RootResourceUri + || (_tcpClientId is not null && _tcpClientId != parameters.ClientId) + || connections.Any(c => c.ClientId != parameters.ClientId || !c.CanRebind) + || connections.Select(c => c.Resource).Distinct(StringComparer.Ordinal).Count() != connections.Count) + throw new InvalidOperationException("TCP reconnect requires the same logical client and distinct live handles"); + long checkpoint = parameters.LastSeenServerSeq; + var resources = new HashSet(parameters.Subscriptions, StringComparer.Ordinal); + foreach (var connection in connections) + { + checkpoint = Math.Min(checkpoint, connection.AppliedCheckpoint); + resources.Add(connection.Resource); + } + var receivers = connections.Select(c => CreateResourceEventStream(c.Resource)).ToArray(); + try + { + if (connections.Count > 0) InheritTcpClient(connections[0].Owner); + foreach (var connection in connections) connection.Bind(this); + foreach (var connection in connections) AdvanceTcpSequence(connection.LastClientSequence); + var result = await ReconnectAsync(parameters.ClientId, checkpoint, resources.ToArray(), cancellationToken).ConfigureAwait(false); + _tcpClientId = parameters.ClientId; + if (result.Value is ReconnectReplayResult replay) + { + long previous = checkpoint; + foreach (var envelope in replay.Actions) + { + TcpProtocol.Safe(envelope.ServerSeq); + if (envelope.ServerSeq <= previous) throw new InvalidOperationException("TCP replay is not ordered"); + previous = envelope.ServerSeq; + } + foreach (var connection in connections) + { + if (replay.Missing.Contains(connection.Resource)) + await connection.FailAsync(new InvalidOperationException("TCP resource is missing on reconnect")).ConfigureAwait(false); + else + foreach (var envelope in replay.Actions) + await connection.AcceptAsync(envelope).ConfigureAwait(false); + } + for (int i = 0; i < connections.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + await connections[i].ResumeAsync(receivers[i]).ConfigureAwait(false); + } + return new ReconnectResult(replay with + { + Actions = replay.Actions.Where(action => action.ServerSeq > parameters.LastSeenServerSeq).ToList(), + }); + } + else + { + foreach (var connection in connections) + await connection.FailAsync(new InvalidOperationException("TCP cannot be restored from a reconnect snapshot")).ConfigureAwait(false); + foreach (var receiver in receivers) receiver.Dispose(); + } + return result; + } + catch + { + foreach (var connection in connections) connection.Suspend(); + foreach (var receiver in receivers) receiver.Dispose(); + throw; + } + } +} + +internal static class TcpProtocol +{ + internal static void Safe(long value) + { + if (value < 0 || value > 9007199254740991L) throw new InvalidOperationException("TCP counter must be a nonnegative safe integer"); + } + + private static void ValidateLimits(long windowBytes, long maximumChunkSize) + { + if (windowBytes < 1 || windowBytes > uint.MaxValue || maximumChunkSize < 1 || maximumChunkSize > windowBytes) + throw new InvalidOperationException("TCP window and chunk limits must be positive UInt32 values, with chunk no larger than window"); + } + + internal static void ValidateRequest(string session, TcpConnectionSubscription create, TcpConnectionsCapability? capability) + { + if (!session.StartsWith("ahp-session:", StringComparison.Ordinal) || create.Type != "tcpConnection" + || string.IsNullOrWhiteSpace(create.Host) || create.Port < 1 || create.Port > 65535 + || create.Encoding != TcpDataEncoding.Base64 || capability?.Encodings.Contains(create.Encoding) != true) + throw new InvalidOperationException("Invalid or unsupported TCP creation request"); + ValidateLimits(create.ReceiveWindowBytes, create.MaximumChunkSize); + } + + internal static void ValidateSnapshot(string session, TcpConnectionSubscription create, Snapshot? snapshot) + { + if (snapshot?.State?.Tcp is not { } state || !snapshot.Resource.StartsWith("ahp-tcp:", StringComparison.Ordinal) + || state.Session != session || state.Target.Host != create.Host || state.Target.Port != create.Port + || state.Encoding != create.Encoding || state.ClientClosed || state.HostClosed || state.Reset is not null) + throw new InvalidOperationException("Invalid TCP creation snapshot"); + Safe(snapshot.FromSeq); + foreach (var direction in new[] { state.Input, state.Output }) + { + ValidateLimits(direction.WindowBytes, direction.MaximumChunkSize); + if (direction.ReceivedBytes != 0 || direction.ConsumedBytes != 0 || direction.EofAtBytes is not null) + throw new InvalidOperationException("TCP creation requires fresh byte directions"); + } + if (state.Output.WindowBytes > create.ReceiveWindowBytes || state.Output.MaximumChunkSize > create.MaximumChunkSize) + throw new InvalidOperationException("TCP creation exceeded requested receive limits"); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs index cc3beed99..cef6225b5 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs @@ -9,6 +9,7 @@ using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.AgentHostProtocol; +using Microsoft.AgentHostProtocol.Hosts; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -20,21 +21,18 @@ namespace Microsoft.AgentHostProtocol.Tests; /// Paired in-memory transport. The two ends share linked channels so frames /// flow from one's outbox directly into the other's inbox, exactly as the Go /// memTransport helper works. +/// Graceful close rejects new sends but delivers accepted frames before closed. /// internal sealed class MemTransport : ITransport { private readonly Channel _inbox; private readonly Channel _outbox; - private readonly CancellationTokenSource _closeCts; - private MemTransport( Channel inbox, - Channel outbox, - CancellationTokenSource closeCts) + Channel outbox) { _inbox = inbox; _outbox = outbox; - _closeCts = closeCts; } /// Creates a linked pair. Frames sent to A appear on B's inbox and vice versa. @@ -42,29 +40,25 @@ public static (MemTransport A, MemTransport B) CreatePair() { var a2b = Channel.CreateBounded(new BoundedChannelOptions(16) { FullMode = BoundedChannelFullMode.Wait }); var b2a = Channel.CreateBounded(new BoundedChannelOptions(16) { FullMode = BoundedChannelFullMode.Wait }); - var cts = new CancellationTokenSource(); // shared — closing either side closes both. - return (new MemTransport(b2a, a2b, cts), new MemTransport(a2b, b2a, cts)); + return (new MemTransport(b2a, a2b), new MemTransport(a2b, b2a)); } public async ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closeCts.Token); - try { await _outbox.Writer.WriteAsync(message, linked.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (_closeCts.IsCancellationRequested) + try { await _outbox.Writer.WriteAsync(message, cancellationToken).ConfigureAwait(false); } + catch (ChannelClosedException) { throw new AhpTransportException("closed"); } } public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closeCts.Token); - try { return await _inbox.Reader.ReadAsync(linked.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (_closeCts.IsCancellationRequested) + try { return await _inbox.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); } + catch (ChannelClosedException) { throw new AhpTransportException("closed"); } } public ValueTask CloseAsync(CancellationToken cancellationToken = default) { - _closeCts.Cancel(); _outbox.Writer.TryComplete(); _inbox.Writer.TryComplete(); return ValueTask.CompletedTask; @@ -109,8 +103,1285 @@ public sealed class ClientTests { private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + private static TcpConnectionSubscription TcpCreation() => new() + { + Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 4, MaximumChunkSize = 2, + }; + + private static Snapshot TcpSnapshot(string resource = "ahp-tcp:/created") => new() + { + Resource = resource, FromSeq = 0, State = new SnapshotState + { + Tcp = new TcpConnectionState + { + Session = "ahp-session:/s1", Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Encoding = TcpDataEncoding.Base64, + Input = new FlowControlledByteDirectionState { WindowBytes = 4, MaximumChunkSize = 2 }, + Output = new FlowControlledByteDirectionState { WindowBytes = 4, MaximumChunkSize = 2 }, + }, + }, + }; + + private static async Task<(MultiHostClient Multi, ChannelReader Servers, MemTransport Server, TcpConnection Connection)> + OpenTcpHost(CancellationToken token, bool autoReconnect = false) + { + var servers = Channel.CreateUnbounded(); + var multi = new MultiHostClient(); + var add = multi.AddHostAsync(new HostConfig + { + Id = new HostId("tcp"), ClientId = "owner", + ReconnectPolicy = autoReconnect + ? new ReconnectPolicy { InitialBackoff = TimeSpan.FromMilliseconds(1), MaxBackoff = TimeSpan.FromMilliseconds(10) } + : ReconnectPolicy.Disabled, + TransportFactory = (_, _) => + { + var (side, server) = MemTransport.CreatePair(); + servers.Writer.TryWrite(server); + return Task.FromResult(side); + }, + }, token); + var initial = await servers.Reader.ReadAsync(token); + await TcpResponse(initial, await TcpRequest(initial, "initialize", token), new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, Snapshots = new(), + TcpConnections = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }, + }, token); + await TcpHostSessions(initial, token); + await add; + var open = multi.ClientFor(new HostId("tcp"))!.OpenTcpConnectionAsync("ahp-session:/s1", TcpCreation(), token); + await TcpResponse(initial, await TcpRequest(initial, "subscribe", token), new SubscribeResult { Snapshot = TcpSnapshot() }, token); + return (multi, servers.Reader, initial, await open); + } + + private static async Task TcpHostSessions(MemTransport server, CancellationToken token) + => await TcpResponse(server, await TcpRequest(server, "listSessions", token), new ListSessionsResult { Items = new() }, token); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpHostReconnectRetainsStreamCreditPayloadAndGlobalSequence(bool spontaneous) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var token = timeout.Token; + var (multi, servers, oldServer, connection) = await OpenTcpHost(token, spontaneous); + await using var cleanup = multi; + var id = new HostId("tcp"); + var handle = multi.ClientFor(id)!; + var write = connection.WriteAsync(new byte[] { 1, 2, 3, 4, 5, 6 }, token); + var first = await TcpDispatch(oldServer, token); + var second = await TcpDispatch(oldServer, token); + await TcpPush(oldServer, 1, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), token); + await TcpPush(oldServer, 2, first.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = first.ClientSeq }); + await handle.DispatchAsync(new StateAction(new SessionTitleChangedAction { Type = ActionType.SessionTitleChanged, Title = "ordinary" }), + "ahp-session:/s1", 1000, token); + Assert.Equal(1000, (await TcpDispatch(oldServer, token)).ClientSeq); + while (connection.AppliedCheckpoint != 2) await Task.Delay(1, token); + Assert.False(write.IsCompleted); + await FakeHost.SendNotificationAsync(oldServer, "action", new ActionEnvelope + { + Channel = ProtocolVersion.RootResourceUri, ServerSeq = 50, + Action = new StateAction(new RootActiveSessionsChangedAction { Type = ActionType.RootActiveSessionsChanged, ActiveSessions = 1 }), + }, token); + while (multi.Host(id)!.ServerSeq != 50) await Task.Delay(1, token); + + if (spontaneous) await oldServer.CloseAsync(token); + else await multi.ReconnectAsync(id, token); + var server = await servers.ReadAsync(token); + var request = await TcpRequest(server, "reconnect", token); + var parameters = Ser.Deserialize(request.Params!.Value); + Assert.Equal("owner", parameters.ClientId); + Assert.Equal(2, parameters.LastSeenServerSeq); + Assert.Contains(connection.Resource, parameters.Subscriptions); + await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, Missing = new(), Actions = new() + { + new ActionEnvelope { Channel = connection.Resource, ServerSeq = 3, + Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }) }, + new ActionEnvelope { Channel = connection.Resource, ServerSeq = 4, + Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) }, + new ActionEnvelope { Channel = ProtocolVersion.RootResourceUri, ServerSeq = 5, + Action = new StateAction(new RootActiveSessionsChangedAction { Type = ActionType.RootActiveSessionsChanged, ActiveSessions = 999 }) }, + }, + }), token); + var resent = await TcpDispatch(server, token); + Assert.Equal(second.ClientSeq, resent.ClientSeq); + Assert.Equal(second.Action.Value, resent.Action.Value); + DispatchActionParams? tail = null; + bool sessions = false; + while (tail is null || !sessions) + { + var frame = Ser.DecodeMessage(await server.ReceiveAsync(token)); + if (frame.Request is { } list) + { + Assert.Equal("listSessions", list.Method); + await TcpResponse(server, list, new ListSessionsResult { Items = new() }, token); + sessions = true; + } + else + { + tail = Ser.Deserialize(Assert.IsType(frame.Notification).Params!.Value); + } + } + Assert.Equal(4, Assert.IsType(tail.Action.Value).Offset); + Assert.True(tail.ClientSeq > 1000); + await write.WaitAsync(token); + while (multi.Host(id)!.Generation == handle.Generation) await Task.Delay(1, token); + Assert.Equal(1, multi.Host(id)!.ActiveSessions); + Assert.Throws(() => handle.CheckAliveOrThrow()); + Assert.Equal(new byte[] { 7, 8 }, await connection.ReadAsync(token)); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + + var fresh = multi.ClientFor(id)!; + var open = fresh.OpenTcpConnectionAsync("ahp-session:/s1", TcpCreation(), token); + await TcpResponse(server, await TcpRequest(server, "subscribe", token), new SubscribeResult { Snapshot = TcpSnapshot("ahp-tcp:/second") }, token); + var additional = await open; + await additional.DisposeAsync(); + await TcpUnsubscribe(server, additional.Resource, token); + var read = connection.ReadAsync(token); + Assert.False(read.IsCompleted); // replayed duplicate data was not enqueued twice + var remove = multi.RemoveHostAsync(id, token); + await remove; + await TcpUnsubscribe(server, connection.Resource, token); + await Assert.ThrowsAsync(() => read); + } + + [Theory] + [InlineData("snapshot")] + [InlineData("missing")] + [InlineData("initialize")] + public async Task TcpHostReconnectFallbackFailsStreamsClosed(string mode) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var token = timeout.Token; + var (multi, servers, _, connection) = await OpenTcpHost(token); + await using var cleanup = multi; + var id = new HostId("tcp"); + var generation = multi.Host(id)!.Generation; + var read = connection.ReadAsync(token); + await multi.ReconnectAsync(id, token); + var server = await servers.ReadAsync(token); + var request = await TcpRequest(server, "reconnect", token); + if (mode == "initialize") + { + await server.SendAsync(Ser.EncodeMessage(new JsonRpcMessage + { + ErrorResponse = new JsonRpcErrorResponse + { + Id = request.Id, Error = new JsonRpcErrorObject { Code = -32601, Message = "reconnect unavailable" }, + }, + }), token); + } + else + { + var result = mode == "snapshot" + ? new ReconnectResult(new ReconnectSnapshotResult { Type = ReconnectResultType.Snapshot, Snapshots = new() { TcpSnapshot() } }) + : new ReconnectResult(new ReconnectReplayResult { Type = ReconnectResultType.Replay, Actions = new(), Missing = new() { connection.Resource } }); + await TcpResponse(server, request, result, token); + } + await TcpUnsubscribe(server, connection.Resource, token); + if (mode == "initialize") + { + var initialize = await TcpRequest(server, "initialize", token); + var parameters = Ser.Deserialize(initialize.Params!.Value); + Assert.NotNull(parameters.InitialSubscriptions); + Assert.DoesNotContain(connection.Resource, parameters.InitialSubscriptions); + await TcpResponse(server, initialize, new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, token); + } + await TcpHostSessions(server, token); + while (multi.Host(id)!.Generation == generation) await Task.Delay(1, token); + await Assert.ThrowsAnyAsync(() => read); + await connection.DisposeAsync(); + await multi.ShutdownAsync(token); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpHostShutdownTerminatesBlockedOperationsAndPendingCreation(bool disconnected) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var token = timeout.Token; + var (multi, _, server, connection) = await OpenTcpHost(token); + await using var cleanup = multi; + var write = connection.WriteAsync(new byte[6], token); + _ = await TcpDispatch(server, token); + _ = await TcpDispatch(server, token); + var read = connection.ReadAsync(token); + var drain = connection.DrainAsync(token); + Task? creation = null; + if (disconnected) + { + await server.CloseAsync(token); + while (!connection.IsSuspended) await Task.Delay(1, token); + } + else + { + creation = multi.ClientFor(new HostId("tcp"))!.OpenTcpConnectionAsync("ahp-session:/s1", TcpCreation(), token); + _ = await TcpRequest(server, "subscribe", token); + } + Assert.False(write.IsCompleted); + Assert.False(read.IsCompleted); + Assert.False(drain.IsCompleted); + var shutdown = multi.ShutdownAsync(token); + if (!disconnected) await TcpUnsubscribe(server, connection.Resource, token); + await shutdown.WaitAsync(token); + if (creation is not null) await Assert.ThrowsAnyAsync(() => creation); + await Assert.ThrowsAsync(() => read); + await Assert.ThrowsAsync(() => write); + await Assert.ThrowsAsync(() => drain); + } + + [Fact] + public async Task TcpHostShutdownDuringReconnectTerminatesRetainedStream() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (multi, servers, oldServer, connection) = await OpenTcpHost(token, autoReconnect: true); + await using var cleanup = multi; + var read = connection.ReadAsync(token); + await oldServer.CloseAsync(token); + var server = await servers.ReadAsync(token); + _ = await TcpRequest(server, "reconnect", token); + await multi.ShutdownAsync(token).WaitAsync(TimeSpan.FromSeconds(2), token); + await Assert.ThrowsAsync(() => read); + } + + private static async Task TcpRequest(MemTransport server, string method, CancellationToken token) + { + var request = Assert.IsType(Ser.DecodeMessage(await server.ReceiveAsync(token)).Request); + Assert.Equal(method, request.Method); + if (method == "subscribe" && request.Params!.Value.TryGetProperty("create", out var create)) + Assert.Equal("tcpConnection", create.GetProperty("type").GetString()); + return request; + } + + private static async Task TcpResponse(MemTransport server, JsonRpcRequest request, T result, CancellationToken token) + => await server.SendAsync(Ser.EncodeMessage(new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse { Id = request.Id, Result = Ser.SerializeToElement(result) }, + }), token); + + private static async Task TcpDispatch(MemTransport server, CancellationToken token) + { + var notification = Assert.IsType(Ser.DecodeMessage(await server.ReceiveAsync(token)).Notification); + Assert.Equal("dispatchAction", notification.Method); + return Ser.Deserialize(notification.Params!.Value); + } + + private static async Task TcpUnsubscribe(MemTransport server, string resource, CancellationToken token) + { + var notification = Assert.IsType(Ser.DecodeMessage(await server.ReceiveAsync(token)).Notification); + Assert.Equal("unsubscribe", notification.Method); + Assert.Equal(resource, notification.Params!.Value.GetProperty("channel").GetString()); + } + + private static async Task TcpPush(MemTransport server, long sequence, StateAction action, CancellationToken token, ActionOrigin? origin = null, string? rejectionReason = null, string channel = "ahp-tcp:/created") + => await server.SendAsync(Ser.EncodeMessage(new JsonRpcMessage + { + Notification = new JsonRpcNotification { Method = "action", Params = Ser.SerializeToElement(new ActionEnvelope + { + Channel = channel, ServerSeq = sequence, Action = action, Origin = origin, RejectionReason = rejectionReason, + }) }, + }), token); + + private static async Task TcpUnrelatedBurst(AhpClient client, MemTransport server, long firstSequence, CancellationToken token) + { + using var barrier = client.AttachSubscription("ahp-session:/barrier"); + for (int i = 0; i < 16; i++) + { + await TcpPush(server, firstSequence + i * 2, new StateAction(new SessionTitleChangedAction + { Type = ActionType.SessionTitleChanged, Title = "busy" }), token, channel: "ahp-session:/other"); + await TcpPush(server, firstSequence + i * 2 + 1, new StateAction(new TcpDataAction + { Type = ActionType.TcpData, Offset = i, Data = "AA==" }), token, channel: "ahp-tcp:/other"); + } + await TcpPush(server, firstSequence + 32, new StateAction(new SessionTitleChangedAction + { Type = ActionType.SessionTitleChanged, Title = "barrier" }), token, channel: barrier.Uri); + await barrier.Events.ReadAsync(token); + } + + [Fact] + public async Task TcpScopedCreationAndActiveTraffic() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side, new ClientConfig { SubscriptionBufferCapacity = 2 }); + var initialize = client.InitializeAsync("owner", cancellationToken: token); + await TcpResponse(server, await TcpRequest(server, "initialize", token), new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, Snapshots = new(), + TcpConnections = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }, + }, token); + await initialize; + var opening = client.OpenTcpConnectionAsync("ahp-session:/s1", TcpCreation(), token); + var request = await TcpRequest(server, "subscribe", token); + await TcpUnrelatedBurst(client, server, 1, token); + await TcpResponse(server, request, new SubscribeResult { Snapshot = TcpSnapshot() with { FromSeq = 33 } }, token); + await TcpPush(server, 34, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bw==" }), token); + var connection = await opening.WaitAsync(token); + await TcpUnrelatedBurst(client, server, 35, token); + Assert.Equal(new byte[] { 7 }, await connection.ReadAsync(token)); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + await TcpPush(server, 68, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 1, Data = "CA==" }), token); + Assert.Equal(new byte[] { 8 }, await connection.ReadAsync(token)); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + await CloseTcp(connection, server, token); + Assert.Equal(0, client.EventListenerCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpScopedReconnectIsolatesTrafficAndReportsOwnedOverflow(bool overflow) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (oldSide, oldServer) = MemTransport.CreatePair(); + await using var old = AhpClient.Connect(oldSide); + var connection = await OpenTcp(old, oldServer, token); + await old.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + var (side, server) = MemTransport.CreatePair(); + await using var fresh = AhpClient.Connect(side, new ClientConfig { SubscriptionBufferCapacity = 2 }); + var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 0, Subscriptions = new(), + }, new[] { connection }, token); + var request = await TcpRequest(server, "reconnect", token); + await TcpUnrelatedBurst(fresh, server, 2, token); + if (overflow) + { + for (int i = 0; i < 3; i++) + await TcpPush(server, 35 + i, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = i, Data = "AA==" }), token); + await TcpUnrelatedBurst(fresh, server, 38, token); + } + await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, Missing = new(), + Actions = overflow ? new() : new() + { + new ActionEnvelope { Channel = connection.Resource, ServerSeq = 1, + Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bw==" }) }, + }, + }), token); + if (!overflow) + await TcpPush(server, 35, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 1, Data = "CA==" }), token); + await reconnect.WaitAsync(token); + if (overflow) + { + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + await TcpUnsubscribe(server, connection.Resource, token); + await Assert.ThrowsAsync(() => connection.ReadAsync(token)); + } + else + { + await TcpUnrelatedBurst(fresh, server, 36, token); + Assert.Equal(new byte[] { 7 }, await connection.ReadAsync(token)); + Assert.Equal(new byte[] { 8 }, await connection.ReadAsync(token)); + _ = await TcpDispatch(server, token); + _ = await TcpDispatch(server, token); + await CloseTcp(connection, server, token); + } + Assert.Equal(0, fresh.EventListenerCount); + } + + private static async Task OpenTcp(AhpClient client, MemTransport server, CancellationToken token, bool firstAction = false, bool invalidSnapshot = false, int maximumChunkSize = 2) + { + var initialize = client.InitializeAsync("owner", cancellationToken: token); + await TcpResponse(server, await TcpRequest(server, "initialize", token), new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, Snapshots = new(), + TcpConnections = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }, + }, token); + await initialize; + var open = client.OpenTcpConnectionAsync("ahp-session:/s1", new TcpConnectionSubscription + { + Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = Math.Max(4, maximumChunkSize), MaximumChunkSize = maximumChunkSize, + }, token); + var direction = new FlowControlledByteDirectionState { WindowBytes = Math.Max(4, maximumChunkSize), MaximumChunkSize = maximumChunkSize, ReceivedBytes = invalidSnapshot ? 1 : 0 }; + await TcpResponse(server, await TcpRequest(server, "subscribe", token), new SubscribeResult + { + Snapshot = new Snapshot { Resource = "ahp-tcp:/created", FromSeq = 0, State = new SnapshotState + { + Tcp = new TcpConnectionState + { + Session = "ahp-session:/s1", Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Encoding = TcpDataEncoding.Base64, Input = direction, Output = direction, + }, + } }, + }, token); + if (firstAction) + await TcpPush(server, 1, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), token); + return await open; + } + + private static async Task CloseTcp(TcpConnection connection, MemTransport server, CancellationToken token) + { + var close = connection.CloseAsync(); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + await close; + await connection.DisposeAsync(); + await TcpUnsubscribe(server, connection.Resource, token); + } + + [Fact] + public void TcpCreationRequiresCanonicalDiscriminator() + { + var create = TcpCreation() with { Type = "tcpConnection" }; + var capability = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }; + TcpProtocol.ValidateRequest("ahp-session:/s1", create, capability); + Assert.Equal("tcpConnection", Ser.SerializeToElement(new SubscribeParams + { + Channel = "ahp-session:/s1", Create = create, + }).GetProperty("create").GetProperty("type").GetString()); + Assert.Throws(() => + TcpProtocol.ValidateRequest("ahp-session:/s1", create with { Type = "tcp" }, capability)); + } + + [Theory] + [InlineData(1L)] + [InlineData(4294967295L)] + [InlineData(0L)] + [InlineData(-1L)] + [InlineData(4294967296L)] + [InlineData(9007199254740991L)] + public void TcpCreationAndSnapshotLimitsUseUInt32Range(long limit) + { + var capability = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }; + var valid = limit >= 1 && limit <= 4294967295L; + foreach (bool chunk in new[] { false, true }) + { + var create = TcpCreation() with { ReceiveWindowBytes = limit, MaximumChunkSize = chunk ? limit : 1 }; + if (valid) TcpProtocol.ValidateRequest("ahp-session:/s1", create, capability); + else Assert.Throws(() => TcpProtocol.ValidateRequest("ahp-session:/s1", create, capability)); + var request = TcpCreation() with { ReceiveWindowBytes = 4294967295L, MaximumChunkSize = 4294967295L }; + foreach (bool input in new[] { false, true }) + { + var snapshot = TcpSnapshot(); + var direction = new FlowControlledByteDirectionState { WindowBytes = limit, MaximumChunkSize = chunk ? limit : 1 }; + var state = snapshot.State.Tcp!; + snapshot = snapshot with { State = new SnapshotState { Tcp = input ? state with { Input = direction } : state with { Output = direction } } }; + if (valid) TcpProtocol.ValidateSnapshot("ahp-session:/s1", request, snapshot); + else Assert.Throws(() => TcpProtocol.ValidateSnapshot("ahp-session:/s1", request, snapshot)); + } + } + } + + [Fact] + public async Task TcpSingleClientReconnectFiltersReturnedReplayAtCallerCheckpoint() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (oldSide, oldServer) = MemTransport.CreatePair(); + await using var old = AhpClient.Connect(oldSide); + var connection = await OpenTcp(old, oldServer, token); + await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 10, + Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 0 }) }); + await old.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + var (side, server) = MemTransport.CreatePair(); + await using var fresh = AhpClient.Connect(side); + var parameters = new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 100, + Subscriptions = new() { "ahp-session:/s1" }, + }; + var reconnect = fresh.ReconnectTcpConnectionsAsync(parameters, new[] { connection }, token); + var request = await TcpRequest(server, "reconnect", token); + Assert.Equal(10, Ser.Deserialize(request.Params!.Value).LastSeenServerSeq); + var actions = new System.Collections.Generic.List(); + for (long sequence = 11; sequence <= 101; sequence++) + actions.Add(new ActionEnvelope { Channel = sequence == 50 ? connection.Resource : "ahp-session:/s1", ServerSeq = sequence, + Action = sequence == 50 + ? new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) + : new StateAction(new SessionTitleChangedAction { Type = ActionType.SessionTitleChanged, Title = $"title-{sequence}" }) }); + actions.Add(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 102, + Action = new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 2 }) }); + await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, Actions = actions, Missing = new() { "ahp-session:/missing" }, + }), token); + var returned = Assert.IsType((await reconnect.WaitAsync(token)).Value); + Assert.Equal(100, parameters.LastSeenServerSeq); + Assert.Equal(102, connection.AppliedCheckpoint); + Assert.Equal(2, connection.State.Output.ReceivedBytes); + Assert.Equal(2, connection.State.Output.EofAtBytes); + Assert.Equal(new byte[] { 7, 8 }, await connection.ReadAsync(token)); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + Assert.Equal(new long[] { 101, 102 }, returned.Actions.ConvertAll(action => action.ServerSeq)); + Assert.Equal(new[] { "ahp-session:/missing" }, returned.Missing); + await connection.DisposeAsync(); + await TcpUnsubscribe(server, connection.Resource, token); + } + + [Fact] + public async Task TcpPeerCloseRespondsWithoutWaitingForCreditOrUnreadOutput() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var connection = await OpenTcp(client, server, token); + var write = connection.WriteAsync(new byte[5], token); + var first = await TcpDispatch(server, token); + var second = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + await TcpPush(server, 1, first.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = first.ClientSeq }); + await TcpPush(server, 2, second.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = second.ClientSeq }); + await TcpPush(server, 3, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), token); + await TcpPush(server, 4, new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }), token); + var close = await TcpDispatch(server, token); + Assert.IsType(close.Action.Value); + Assert.False(drain.IsCompleted); + Assert.Equal(0, connection.State.Input.ConsumedBytes); + Assert.Equal(0, connection.State.Output.ConsumedBytes); + Assert.Equal(1, client.EventListenerCount); + await Assert.ThrowsAsync(() => write.WaitAsync(token)); + await TcpPush(server, 5, close.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = close.ClientSeq }); + await TcpPush(server, 6, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 4 }), token); + await drain.WaitAsync(token); + Assert.Equal(new byte[] { 7, 8 }, await connection.ReadAsync(token)); + var credit = await TcpDispatch(server, token); + Assert.IsType(credit.Action.Value); + await TcpPush(server, 7, credit.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = credit.ClientSeq }); + await TcpUnsubscribe(server, connection.Resource, token); + Assert.Null(await connection.ReadAsync(token)); + } + + [Fact] + public async Task TcpLocalCloseRetainsCrossingTrafficUntilBothDirectionsDrain() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var connection = await OpenTcp(client, server, token); + await connection.WriteAsync(new byte[] { 1, 2 }, token); + var input = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + await connection.CloseAsync(); + var close = await TcpDispatch(server, token); + Assert.IsType(close.Action.Value); + Assert.False(connection.IsClosed); + Assert.Equal(1, client.EventListenerCount); + var read = connection.ReadAsync(token); + Assert.False(read.IsCompleted); + await TcpPush(server, 1, input.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = input.ClientSeq }); + await TcpPush(server, 2, close.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = close.ClientSeq }); + await TcpPush(server, 3, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), token); + Assert.Equal(new byte[] { 7, 8 }, await read.WaitAsync(token)); + var credit = await TcpDispatch(server, token); + Assert.Equal(2, Assert.IsType(credit.Action.Value).ConsumedBytes); + await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 4, + Action = new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }) }); + Assert.False(connection.IsClosed); + Assert.False(drain.IsCompleted); + Assert.Equal(1, client.EventListenerCount); + await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 5, + Action = credit.Action, Origin = new ActionOrigin { ClientId = "owner", ClientSeq = credit.ClientSeq } }); + Assert.False(connection.IsClosed); + await TcpPush(server, 6, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }), token); + await TcpUnsubscribe(server, connection.Resource, token); + await drain.WaitAsync(token); + Assert.Null(await connection.ReadAsync(token)); + Assert.True(connection.IsClosed); + Assert.Equal(0, client.EventListenerCount); + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + + [Fact] + public async Task TcpAdapterRejectsStaleCreationAndDetachesCancelledSetup() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + await Assert.ThrowsAsync(() => OpenTcp(client, server, token, invalidSnapshot: true)); + await TcpUnsubscribe(server, "ahp-tcp:/created", token); + Assert.Equal(0, client.EventListenerCount); + using var cancellation = new CancellationTokenSource(); + var open = client.OpenTcpConnectionAsync("ahp-session:/s1", new TcpConnectionSubscription + { + Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 4, MaximumChunkSize = 2, + }, cancellation.Token); + _ = await TcpRequest(server, "subscribe", token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => open); + Assert.Equal(0, client.EventListenerCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpResetOrDisposeTerminatesClosingStream(bool reset) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var connection = await OpenTcp(client, server, token); + var write = connection.WriteAsync(new byte[5], token); + _ = await TcpDispatch(server, token); + _ = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + await connection.CloseAsync(); + _ = await TcpDispatch(server, token); + await Assert.ThrowsAsync(() => write.WaitAsync(token)); + Assert.False(drain.IsCompleted); + if (reset) + { + await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 1, + Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) }); + await TcpPush(server, 2, new StateAction(new TcpHostResetAction { Type = ActionType.TcpHostReset, Reason = TcpResetReason.ProtocolError }), token); + await TcpUnsubscribe(server, connection.Resource, token); + await Assert.ThrowsAsync(() => connection.ReadAsync(token)); + } + else + { + var read = connection.ReadAsync(token); + Assert.False(read.IsCompleted); + await connection.DisposeAsync(); + await TcpUnsubscribe(server, connection.Resource, token); + await Assert.ThrowsAsync(() => read); + } + await Assert.ThrowsAnyAsync(() => drain.WaitAsync(token)); + Assert.Equal(0, client.EventListenerCount); + await connection.DisposeAsync(); + await connection.CloseAsync(); + } + + [Fact] + public async Task TcpCloseWhileSuspendedReplaysAndDrainsBeforeRelease() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (oldSide, oldServer) = MemTransport.CreatePair(); + await using var old = AhpClient.Connect(oldSide); + var connection = await OpenTcp(old, oldServer, token); + await old.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + await connection.CloseAsync(); + Assert.False(connection.IsClosed); + var read = connection.ReadAsync(token); + Assert.False(read.IsCompleted); + var (side, server) = MemTransport.CreatePair(); + await using var fresh = AhpClient.Connect(side); + var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 0, Subscriptions = new(), + }, new[] { connection }, token); + var request = await TcpRequest(server, "reconnect", token); + Assert.Contains(connection.Resource, Ser.Deserialize(request.Params!.Value).Subscriptions); + await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, Missing = new(), Actions = new() + { + new() { Channel = connection.Resource, ServerSeq = 1, Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) }, + new() { Channel = connection.Resource, ServerSeq = 2, Action = new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }) }, + }, + }), token); + await reconnect.WaitAsync(token); + var close = await TcpDispatch(server, token); + Assert.IsType(close.Action.Value); + Assert.Equal(new byte[] { 7, 8 }, await read.WaitAsync(token)); + var credit = await TcpDispatch(server, token); + Assert.IsType(credit.Action.Value); + Assert.Equal(1, fresh.EventListenerCount); + await TcpPush(server, 3, close.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = close.ClientSeq }); + await TcpPush(server, 4, credit.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = credit.ClientSeq }); + await TcpUnsubscribe(server, connection.Resource, token); + Assert.Null(await connection.ReadAsync(token)); + Assert.Equal(0, fresh.EventListenerCount); + } + + [Theory] + [InlineData(false, "ahp-tcp:/late")] + [InlineData(true, "ahp-tcp:/late")] + [InlineData(true, "ahp-session:/s1")] + public async Task TcpAdapterReleasesLateCreationWithoutUnsubscribingParent(bool cancel, string resource) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var clock = new FakeTimeProvider(); + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side, new ClientConfig { TimeProvider = clock, DefaultRequestTimeout = TimeSpan.FromMinutes(1) }); + var initial = await OpenTcp(client, server, token); + await CloseTcp(initial, server, token); + using var cancellation = new CancellationTokenSource(); + var open = client.OpenTcpConnectionAsync("ahp-session:/s1", new TcpConnectionSubscription + { + Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 4, MaximumChunkSize = 2, + }, cancellation.Token); + var request = await TcpRequest(server, "subscribe", token); + if (cancel) cancellation.Cancel(); + else clock.Advance(TimeSpan.FromMinutes(1)); + await Assert.ThrowsAnyAsync(() => open.WaitAsync(token)); + Assert.Equal(0, client.EventListenerCount); + Assert.Equal(0, client.PendingRequestCount); + await TcpResponse(server, request, new { snapshot = new { resource } }, token); + if (resource.StartsWith("ahp-tcp:", StringComparison.Ordinal)) await TcpUnsubscribe(server, resource, token); + using var barrier = client.AttachSubscription("ahp-session:/barrier"); + await TcpResponse(server, request, new { snapshot = new { resource } }, token); + await server.SendAsync(BuildActionNotification("ahp-session:/barrier", 99, "barrier"), token); + _ = await barrier.Events.ReadAsync(token); + var probe = client.RequestAsync("probe", new SubscribeParams { Channel = "ahp-session:/s1" }, token); + await TcpResponse(server, await TcpRequest(server, "probe", token), new SubscribeResult(), token); + await probe.WaitAsync(token); + Assert.Equal(ConnectionState.Connected, client.ConnectionState); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task TcpAdapterResetCloseAndDisposeWakeAllBlockedOperations(int terminal) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var connection = await OpenTcp(client, server, token); + var read = connection.ReadAsync(token); + var write = connection.WriteAsync(new byte[5], token); + _ = await TcpDispatch(server, token); + _ = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + if (terminal == 1) + await TcpPush(server, 1, new StateAction(new TcpHostResetAction { Type = ActionType.TcpHostReset, Reason = TcpResetReason.ProtocolError }), token); + else if (terminal == 2) + await CloseTcp(connection, server, token); + else + await connection.DisposeAsync(); + if (terminal != 2) await TcpUnsubscribe(server, connection.Resource, token); + await Assert.ThrowsAnyAsync(() => read.WaitAsync(token)); + foreach (var operation in new Task[] { write, drain }) + await Assert.ThrowsAnyAsync(() => operation.WaitAsync(token)); + await connection.DisposeAsync(); + Assert.Equal(0, client.EventListenerCount); + } + + [Fact] + public async Task TcpAdapterReconnectContinuesBlockedWriterAfterReplayCredit() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (oldSide, oldServer) = MemTransport.CreatePair(); + await using var oldClient = AhpClient.Connect(oldSide); + var connection = await OpenTcp(oldClient, oldServer, token); + var write = connection.WriteAsync(new byte[6], token); + var first = await TcpDispatch(oldServer, token); + var second = await TcpDispatch(oldServer, token); + await TcpPush(oldServer, 1, first.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = first.ClientSeq }); + await TcpPush(oldServer, 2, second.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = second.ClientSeq }); + await TcpPush(oldServer, 3, new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 0 }), token); + Assert.Null(await connection.ReadAsync(token)); + Assert.False(write.IsCompleted); + await oldClient.DispatchAsync("ahp-session:/s1", + new StateAction(new SessionTitleChangedAction { Type = ActionType.SessionTitleChanged, Title = "ordinary action" }), 100, token); + Assert.Equal(100, (await TcpDispatch(oldServer, token)).ClientSeq); + await oldClient.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + var (side, server) = MemTransport.CreatePair(); + await using var fresh = AhpClient.Connect(side); + var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", Subscriptions = new(), LastSeenServerSeq = 20, + }, new[] { connection }, token); + var request = await TcpRequest(server, "reconnect", token); + Assert.Equal(3, Ser.Deserialize(request.Params!.Value).LastSeenServerSeq); + await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, Missing = new(), Actions = new() + { + new() { Channel = connection.Resource, ServerSeq = 4, Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }) }, + }, + }), token); + await reconnect.WaitAsync(token); + var tail = await TcpDispatch(server, token); + Assert.Equal(4, Assert.IsType(tail.Action.Value).Offset); + Assert.True(tail.ClientSeq > 100); + await write.WaitAsync(token); + await CloseTcp(connection, server, token); + } + + [Fact] + public async Task TcpAdapterReservesCreditChunksReadsDuplicatesAndHalfCloses() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (clientSide, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + var connection = await OpenTcp(client, server, token); + var write = connection.WriteAsync(new byte[] { 1, 2, 3, 4, 5 }, token); + var first = await TcpDispatch(server, token); + var second = await TcpDispatch(server, token); + Assert.Equal(2, Convert.FromBase64String(Assert.IsType(first.Action.Value).Data).Length); + Assert.Equal(2, Assert.IsType(second.Action.Value).Offset); + Assert.False(write.IsCompleted); + await Assert.ThrowsAsync(() => connection.WriteAsync(new byte[] { 9 }, token)); + await TcpPush(server, 1, first.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = first.ClientSeq }); + await TcpPush(server, 2, second.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = second.ClientSeq }); + await TcpPush(server, 3, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }), token); + var third = await TcpDispatch(server, token); + await write.WaitAsync(token); + Assert.Equal(4, Assert.IsType(third.Action.Value).Offset); + var drain = connection.DrainAsync(token); + Assert.False(drain.IsCompleted); + await TcpPush(server, 4, third.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = third.ClientSeq }); + await TcpPush(server, 5, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 5 }), token); + await drain.WaitAsync(token); + var data = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }); + await TcpPush(server, 6, data, token); + await TcpPush(server, 7, data, token); + await TcpPush(server, 8, new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 2 }), token); + Assert.Equal(new byte[] { 7, 8 }, await connection.ReadAsync(token)); + Assert.Equal(2, Assert.IsType((await TcpDispatch(server, token)).Action.Value).ConsumedBytes); + Assert.Null(await connection.ReadAsync(token)); + await connection.EndAsync(token); + Assert.Equal(5, Assert.IsType((await TcpDispatch(server, token)).Action.Value).FinalOffset); + await CloseTcp(connection, server, token); + Assert.Equal(0, client.EventListenerCount); + } + + [Fact] + public async Task TcpAdapterPreservesFirstActionAndStrictLossWakesWaiters() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (clientSide, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide); + var connection = await OpenTcp(client, server, token, firstAction: true); + Assert.Equal(new byte[] { 7, 8 }, await connection.ReadAsync(token)); + _ = await TcpDispatch(server, token); + var read = connection.ReadAsync(token); + var write = connection.WriteAsync(new byte[5], token); + _ = await TcpDispatch(server, token); + _ = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + await server.SendAsync(TransportMessage.FromText("{"), token); + await Assert.ThrowsAsync(() => read); + await Assert.ThrowsAsync(() => write); + await Assert.ThrowsAsync(() => drain); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + await TcpUnsubscribe(server, connection.Resource, token); + await connection.DisposeAsync(); + Assert.Equal(0, client.EventListenerCount); + } + + [Theory] + [InlineData("missing")] + [InlineData("owner")] + [InlineData("negative")] + [InlineData("unsafe")] + [InlineData("unassigned")] + [InlineData("wrong-pending")] + [InlineData("reused")] + [InlineData("payload")] + [InlineData("eof")] + [InlineData("credit")] + [InlineData("close")] + [InlineData("reset")] + [InlineData("rejected-empty")] + public async Task TcpAdapterRejectsMalformedClientEchoWithoutAdvancingState(string malformed) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var connection = await OpenTcp(client, server, token); + var read = connection.ReadAsync(token); + var write = connection.WriteAsync(new byte[] { 1, 2, 3, 4, 5 }, token); + var first = await TcpDispatch(server, token); + var second = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + StateAction action = first.Action; + ActionOrigin? origin = new() { ClientId = "owner", ClientSeq = first.ClientSeq }; + switch (malformed) + { + case "missing": origin = null; break; + case "owner": origin = origin with { ClientId = "other" }; break; + case "negative": origin = origin with { ClientSeq = -1 }; break; + case "unsafe": origin = origin with { ClientSeq = 9007199254740992 }; break; + case "unassigned": origin = origin with { ClientSeq = second.ClientSeq + 1 }; break; + case "wrong-pending": origin = origin with { ClientSeq = second.ClientSeq }; break; + case "reused": + await TcpPush(server, 1, first.Action, token, origin); + action = second.Action; + break; + case "payload": action = new StateAction(new TcpInputAction { Type = ActionType.TcpInput, Offset = 0, Data = "AgE=" }); break; + case "eof": origin = null; action = new StateAction(new TcpInputEofAction { Type = ActionType.TcpInputEof, FinalOffset = 0 }); break; + case "credit": origin = null; action = new StateAction(new TcpDataConsumedAction { Type = ActionType.TcpDataConsumed, ConsumedBytes = 0 }); break; + case "close": origin = null; action = new StateAction(new TcpClientCloseAction { Type = ActionType.TcpClientClose }); break; + case "reset": origin = null; action = new StateAction(new TcpClientResetAction { Type = ActionType.TcpClientReset, Reason = TcpResetReason.ProtocolError }); break; + } + await TcpPush(server, 2, action, token, origin, malformed == "rejected-empty" ? "" : null); + foreach (var pending in new Task[] { read, write, drain }) + await Assert.ThrowsAsync(() => pending.WaitAsync(token)); + Assert.Equal(malformed == "reused" ? 2 : 0, connection.State.Input.ReceivedBytes); + Assert.Equal(0, connection.State.Input.ConsumedBytes); + Assert.IsType((await TcpDispatch(server, token)).Action.Value); + await TcpUnsubscribe(server, connection.Resource, token); + Assert.Equal(0, client.EventListenerCount); + await connection.DisposeAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpAdapterReconnectRetainsReadersAndResendsOnlyUnacknowledgedActions(bool acknowledged) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (oldSide, oldServer) = MemTransport.CreatePair(); + await using var oldClient = AhpClient.Connect(oldSide); + var connection = await OpenTcp(oldClient, oldServer, token); + await connection.WriteAsync(new byte[] { 1, 2 }, token); + var original = await TcpDispatch(oldServer, token); + var read = connection.ReadAsync(token); + await oldClient.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + var (freshSide, server) = MemTransport.CreatePair(); + await using var fresh = AhpClient.Connect(freshSide); + await Assert.ThrowsAsync(() => fresh.ReconnectTcpConnectionsAsync( + new ReconnectParams { Channel = ProtocolVersion.RootResourceUri, ClientId = "other", LastSeenServerSeq = 20, Subscriptions = new() }, + new[] { connection }, token)); + var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 20, + Subscriptions = new() { "ahp-session:/s1" }, + }, new[] { connection }, token); + var request = await TcpRequest(server, "reconnect", token); + var parameters = Ser.Deserialize(request.Params!.Value); + Assert.Equal(0, parameters.LastSeenServerSeq); + Assert.Contains(connection.Resource, parameters.Subscriptions); + var actions = new System.Collections.Generic.List + { + new() { Channel = connection.Resource, ServerSeq = 1, Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), + Origin = new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq } }, + }; + if (acknowledged) + actions.Add(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 2, Action = original.Action, + Origin = new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq } }); + await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, Actions = actions, Missing = new(), + }), token); + await TcpPush(server, 3, original.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq }); + await TcpPush(server, 4, new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 2 }), token); + await reconnect.WaitAsync(token); + Assert.Equal(new byte[] { 7, 8 }, await read.WaitAsync(token)); + if (!acknowledged) + { + var resent = await TcpDispatch(server, token); + Assert.Equal(original.ClientSeq, resent.ClientSeq); + Assert.Equal(original.Action.Value, resent.Action.Value); + } + var credit = await TcpDispatch(server, token); + Assert.IsType(credit.Action.Value); + Assert.True(credit.ClientSeq > original.ClientSeq); + Assert.Null(await connection.ReadAsync(token)); + await CloseTcp(connection, server, token); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpAdapterReconnectSnapshotOrMissingFailsClosed(bool snapshot) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (oldSide, oldServer) = MemTransport.CreatePair(); + await using var oldClient = AhpClient.Connect(oldSide); + var connection = await OpenTcp(oldClient, oldServer, token); + var read = connection.ReadAsync(token); + await oldClient.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + var (freshSide, server) = MemTransport.CreatePair(); + await using var fresh = AhpClient.Connect(freshSide); + var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams + { + Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", Subscriptions = new(), LastSeenServerSeq = 0, + }, new[] { connection }, token); + var request = await TcpRequest(server, "reconnect", token); + var result = snapshot + ? new ReconnectResult(new ReconnectSnapshotResult { Type = ReconnectResultType.Snapshot, Snapshots = new() }) + : new ReconnectResult(new ReconnectReplayResult { Type = ReconnectResultType.Replay, Actions = new(), Missing = new() { connection.Resource } }); + await TcpResponse(server, request, result, token); + await TcpUnsubscribe(server, connection.Resource, token); + await reconnect.WaitAsync(token); + await Assert.ThrowsAsync(() => read); + Assert.Equal(0, fresh.EventListenerCount); + await connection.DisposeAsync(); + var open = fresh.OpenTcpConnectionAsync("ahp-session:/s1", TcpCreation(), token); + await TcpResponse(server, await TcpRequest(server, "subscribe", token), + new SubscribeResult { Snapshot = TcpSnapshot("ahp-tcp:/replacement") }, token); + var replacement = await open; + await replacement.DisposeAsync(); + await TcpUnsubscribe(server, replacement.Resource, token); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpClientDefaultShutdownDisposesEvenAfterPreservedTransportShutdown(bool preserved) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var connection = await OpenTcp(client, server, token); + var read = connection.ReadAsync(token); + var write = connection.WriteAsync(new byte[6], token); + _ = await TcpDispatch(server, token); + _ = await TcpDispatch(server, token); + var drain = connection.DrainAsync(token); + if (preserved) + { + await client.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); + Assert.False(read.IsCompleted); + Assert.False(write.IsCompleted); + Assert.False(drain.IsCompleted); + } + var shutdown = client.ShutdownAsync(token); + if (!preserved) await TcpUnsubscribe(server, connection.Resource, token); + await shutdown.WaitAsync(token); + await Assert.ThrowsAsync(() => read); + await Assert.ThrowsAsync(() => write); + await Assert.ThrowsAsync(() => drain); + Assert.Equal(0, client.EventListenerCount); + } + // ── Request round-trip ──────────────────────────────────────────────── + [Fact] + public async Task TcpAdapterLargeEncodingAndFinalClosePreserveCompletedDrainAndBufferedReads() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var token = timeout.Token; + var (side, server) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(side); + var bytes = new byte[4 * 1024 * 1024]; + bytes[0] = 1; + bytes[bytes.Length - 1] = 255; + var connection = await OpenTcp(client, server, token, maximumChunkSize: bytes.Length); + await connection.WriteAsync(bytes, token); + var sent = await TcpDispatch(server, token); + var input = Assert.IsType(sent.Action.Value); + Assert.Equal(0, input.Offset); + Assert.Equal(bytes, Convert.FromBase64String(input.Data)); + var drain = connection.DrainAsync(token); + Assert.False(drain.IsCompleted); + await TcpPush(server, 1, sent.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = sent.ClientSeq }); + await TcpPush(server, 2, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = bytes.Length }), token); + await TcpPush(server, 3, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), token); + await TcpPush(server, 4, new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }), token); + var close = await TcpDispatch(server, token); + Assert.IsType(close.Action.Value); + Assert.Equal(1, client.EventListenerCount); + await TcpPush(server, 5, close.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = close.ClientSeq }); + await drain.WaitAsync(token); + await connection.DrainAsync(token); + Assert.Equal(new byte[] { 7, 8 }, await connection.ReadAsync(token)); + var credit = await TcpDispatch(server, token); + Assert.IsType(credit.Action.Value); + await TcpPush(server, 6, credit.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = credit.ClientSeq }); + await TcpUnsubscribe(server, connection.Resource, token); + Assert.Null(await connection.ReadAsync(token)); + await connection.DisposeAsync(); + } + + [Theory] + [InlineData("{", false)] + [InlineData("{", true)] + [InlineData("""{"jsonrpc":"2.0","id":1}""", false)] + [InlineData("""{"jsonrpc":"2.0","method":"action"}""", false)] + [InlineData("""{"jsonrpc":"2.0","method":"action","params":null}""", false)] + [InlineData("""{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/child","serverSeq":2,"action":{"type":"tcp/dataEof","finalOffset":0.5}}}""", false)] + [InlineData("""{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/child","serverSeq":2,"action":{"type":"tcp/inputConsumed","consumedBytes":0.5}}}""", false)] + [InlineData("""{"jsonrpc":"2.0","method":"root/sessionAdded","params":[]}""", false)] + [InlineData("""{"jsonrpc":"2.0","method":"root/sessionAdded"}""", false)] + public async Task StrictEventsFailOnMalformedFramesAndNotificationPayloads(string wire, bool binary) + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var client = AhpClient.Connect(clientSide); + using var strict = client.CreateEventStream(failOnOverflow: true); + using var ordinary = client.CreateEventStream(); + using var barrier = client.AttachSubscription("ahp-session:/barrier"); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", 1, "prefix"), cts.Token); + await serverSide.SendAsync(binary + ? TransportMessage.FromBinary(System.Text.Encoding.UTF8.GetBytes(wire)) + : TransportMessage.FromText(wire), cts.Token); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", 2, "later"), cts.Token); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/barrier", 99, "barrier"), cts.Token); + _ = await barrier.Events.ReadAsync(cts.Token); + Assert.Equal(1, client.EventListenerCount); + var prefix = await strict.Events.ReadAsync(cts.Token); + Assert.Equal(1, Assert.IsType(prefix.Event).Envelope.ServerSeq); + var error = await Assert.ThrowsAsync(async () => + { + await foreach (var item in strict.Events.ReadAllAsync(cts.Token)) + Assert.Fail($"unexpected event after decode loss: {item.Channel}"); + }); + Assert.Equal("protocol", error.Kind); + Assert.False(strict.Events.TryRead(out _), "a decode-failed receiver must never resume"); + foreach (long expected in new[] { 1L, 2L, 99L }) + { + var item = await ordinary.Events.ReadAsync(cts.Token); + Assert.Equal(expected, Assert.IsType(item.Event).Envelope.ServerSeq); + } + } + + [Fact] + public async Task StrictEventsAllowUnknownNotificationsAndActions() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var client = AhpClient.Connect(clientSide); + using var events = client.CreateEventStream(failOnOverflow: true); + await serverSide.SendAsync(TransportMessage.FromText("""{"jsonrpc":"2.0","method":"future/notification"}"""), cts.Token); + await serverSide.SendAsync(TransportMessage.FromText( + """{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/child","serverSeq":1,"action":{"type":"tcp/future"}}}"""), cts.Token); + var item = await events.Events.ReadAsync(cts.Token); + var envelope = Assert.IsType(item.Event).Envelope; + Assert.Equal(1, envelope.ServerSeq); + Assert.Equal("ahp-tcp:/child", envelope.Channel); + } + + [Fact] + public async Task StrictDecodeFailureWakesBlockedReaderAndUnregisters() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var client = AhpClient.Connect(clientSide); + using var strict = client.CreateEventStream(failOnOverflow: true); + using var ordinary = client.CreateEventStream(); + var pending = strict.Events.WaitToReadAsync(cts.Token).AsTask(); + Assert.False(pending.IsCompleted); + await serverSide.SendAsync(TransportMessage.FromText("{"), cts.Token); + var error = await Assert.ThrowsAsync(async () => await pending); + Assert.Equal("protocol", error.Kind); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", 1, "still connected"), cts.Token); + var item = await ordinary.Events.ReadAsync(cts.Token); + Assert.Equal(1, Assert.IsType(item.Event).Envelope.ServerSeq); + Assert.Equal(1, client.EventListenerCount); + Assert.False(strict.Events.TryRead(out _)); + await Assert.ThrowsAsync(async () => await strict.Events.Completion); + } + + [Fact] + public async Task StrictEventsPreserveFirstTcpActionBeforeCreateReturns() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var client = AhpClient.Connect(clientSide); + using var events = client.CreateEventStream(failOnOverflow: true); + using var barrier = client.AttachSubscription("ahp-session:/barrier"); + var server = Task.Run(async () => + { + var message = Ser.DecodeMessage(await serverSide.ReceiveAsync(cts.Token)); + var request = Assert.IsType(message.Request); + Assert.Equal("subscribe", request.Method); + Assert.Equal("tcpConnection", request.Params!.Value.GetProperty("create").GetProperty("type").GetString()); + var parameters = Ser.Deserialize(request.Params!.Value); + Assert.Equal("ahp-session:/s1", parameters.Channel); + Assert.Equal("localhost", parameters.Create!.Host); + var direction = new FlowControlledByteDirectionState { WindowBytes = 8, MaximumChunkSize = 8 }; + var result = new SubscribeResult + { + Snapshot = new Snapshot + { + Resource = "ahp-tcp:/created", + State = new SnapshotState { Tcp = new TcpConnectionState + { + Session = parameters.Channel, + Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Encoding = TcpDataEncoding.Base64, Input = direction, Output = direction, + } }, + FromSeq = 0, + }, + }; + await serverSide.SendAsync(Ser.EncodeMessage(new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse { Id = request.Id, Result = Ser.SerializeToElement(result) }, + }), cts.Token); + await serverSide.SendAsync(Ser.EncodeMessage(new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "action", + Params = Ser.SerializeToElement(new ActionEnvelope + { + Channel = "ahp-tcp:/created", ServerSeq = 1, + Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "AA==" }), + }), + }, + }), cts.Token); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/barrier", 2, "barrier"), cts.Token); + }, cts.Token); + var result = await client.RequestAsync("subscribe", new SubscribeParams + { + Channel = "ahp-session:/s1", + Create = new TcpConnectionSubscription + { + Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 8, MaximumChunkSize = 8, + }, + }, cts.Token); + await server; + _ = await barrier.Events.ReadAsync(cts.Token); + var snapshot = Assert.IsType(result!.Snapshot); + var first = await events.Events.ReadAsync(cts.Token); + Assert.Equal(snapshot.Resource, first.Channel); + var envelope = Assert.IsType(first.Event).Envelope; + var initial = Assert.IsType(snapshot.State.Tcp); + Assert.Equal(1, Reducers.TcpReducer(initial, envelope.Action).Output.ReceivedBytes); + } + + [Fact] + public async Task StrictEventsOverflowIsTerminalAndOrdinaryEventsStillDropOldest() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var client = AhpClient.Connect(clientSide, new ClientConfig { SubscriptionBufferCapacity = 2 }); + using var strict = client.CreateEventStream(failOnOverflow: true); + using var ordinary = client.CreateEventStream(); + using var barrier = client.AttachSubscription("ahp-session:/barrier"); + for (long seq = 1; seq <= 3; seq++) + await serverSide.SendAsync(BuildActionNotification("ahp-session:/s1", seq, $"e{seq}"), cts.Token); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/barrier", 99, "barrier"), cts.Token); + _ = await barrier.Events.ReadAsync(cts.Token); + Assert.Equal(1, client.EventListenerCount); + for (long expected = 1; expected <= 2; expected++) + { + var item = await strict.Events.ReadAsync(cts.Token); + Assert.Equal(expected, Assert.IsType(item.Event).Envelope.ServerSeq); + } + var error = await Assert.ThrowsAsync(async () => + { + await foreach (var item in strict.Events.ReadAllAsync(cts.Token)) + Assert.Fail($"unexpected event after overflow: {item.Channel}"); + }); + Assert.Equal(2, error.Capacity); + var closed = await Assert.ThrowsAsync( + async () => await strict.Events.ReadAsync(cts.Token)); + Assert.IsType(closed.InnerException); + foreach (long expected in new[] { 3L, 99L }) + { + var item = await ordinary.Events.ReadAsync(cts.Token); + Assert.Equal(expected, Assert.IsType(item.Event).Envelope.ServerSeq); + } + using var healthy = client.CreateEventStream(failOnOverflow: true); + await serverSide.SendAsync(BuildActionNotification("ahp-session:/barrier", 100, "later"), cts.Token); + _ = await barrier.Events.ReadAsync(cts.Token); + Assert.False(strict.Events.TryRead(out _), "an overflowed receiver must never resume"); + await Assert.ThrowsAsync(async () => await strict.Events.Completion); + var later = await healthy.Events.ReadAsync(cts.Token); + Assert.Equal(100, Assert.IsType(later.Event).Envelope.ServerSeq); + healthy.Dispose(); + Assert.False(await healthy.Events.WaitToReadAsync(cts.Token)); + } + [Fact] public async Task RequestRoundTrip_InitializeReturnsProtocolVersion() { diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs index 4bf9390e4..3b3caaed5 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixtureDrivenReducerTests.cs @@ -49,40 +49,38 @@ public void ReducerMatchesFixture(string name, string path) string reducer = root.GetProperty("reducer").GetString()!; JsonElement initial = root.GetProperty("initial"); JsonElement expected = root.GetProperty("expected"); - var actions = new List(); - foreach (JsonElement actionElement in root.GetProperty("actions").EnumerateArray()) - { - actions.Add(actionElement.Deserialize(Options)!); - } + JsonElement actions = root.GetProperty("actions"); + string? expectedError = root.TryGetProperty("expectedError", out var error) ? error.GetString() : null; switch (reducer) { case "root": - RunFixture(initial, expected, actions, Reducers.ApplyToRoot); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToRoot); break; case "session": - RunFixture(initial, expected, actions, Reducers.ApplyToSession); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToSession); break; case "terminal": - RunFixture(initial, expected, actions, Reducers.ApplyToTerminal); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToTerminal); break; case "changeset": - RunFixture(initial, expected, actions, Reducers.ApplyToChangeset); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToChangeset); break; case "resourceWatch": - RunFixture(initial, expected, actions, Reducers.ApplyToResourceWatch); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToResourceWatch); break; case "annotations": - RunFixture(initial, expected, actions, Reducers.ApplyToAnnotations); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToAnnotations); break; case "chat": - RunFixture(initial, expected, actions, Reducers.ApplyToChat); + RunFixture(initial, expected, actions, expectedError, Reducers.ApplyToChat); break; case "automation": RunFixture( initial, expected, actions, + expectedError, Reducers.ApplyToAutomation); break; case "automationRun": @@ -90,8 +88,12 @@ public void ReducerMatchesFixture(string name, string path) initial, expected, actions, + expectedError, Reducers.ApplyToAutomationRun); break; + case "tcp": + RunFixture(initial, expected, actions, expectedError, Reducers.TcpReducer); + break; default: throw new Xunit.Sdk.XunitException($"unknown reducer kind '{reducer}'"); } @@ -105,9 +107,23 @@ public void ReducerMatchesFixture(string name, string path) private static void RunFixture( JsonElement initial, JsonElement expected, - List actions, + JsonElement actions, + string? expectedError, Func apply) where T : class + => RunFixture(initial, expected, actions, expectedError, (state, action) => + { + apply(state, action); + return state; + }); + + private static void RunFixture( + JsonElement initial, + JsonElement expected, + JsonElement actions, + string? expectedError, + Func apply) + where T : class { T state = initial.Deserialize(Options)!; @@ -123,9 +139,33 @@ private static void RunFixture( $"initial state did not survive round-trip:\nre-serialized: {actual}\noriginal: {original}"); } - foreach (StateAction action in actions) + if (expectedError is not null) Assert.True(actions.GetArrayLength() > 0, "expectedError requires a final action"); + int index = 0; + foreach (JsonElement raw in actions.EnumerateArray()) { - apply(state, action); + bool mustFail = expectedError is not null && index++ == actions.GetArrayLength() - 1; + string before = JsonSerializer.Serialize(state, Options); + if (mustFail && typeof(T) == typeof(TcpConnectionState) + && raw.TryGetProperty("offset", out var offset) + && offset.GetDouble() % 1 != 0) + { + Assert.Equal("Invalid TCP action: offset must be a nonnegative safe integer", expectedError); + Assert.Throws(() => raw.Deserialize(Options)); + } + else + { + StateAction action = raw.Deserialize(Options)!; + if (mustFail) + { + var failure = Assert.Throws(() => apply(state, action)); + Assert.Equal(expectedError, failure.Message); + } + else + { + state = apply(state, action); + } + } + if (mustFail) Assert.Equal(before, JsonSerializer.Serialize(state, Options)); } string got = Canon(JsonSerializer.SerializeToElement(state, Options)); diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs index ad32be82f..5edcae7da 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs @@ -10,6 +10,7 @@ // generated union + serializer's [WireValue] mapping, not a hand-typed literal. #nullable enable +using System; using System.Collections.Generic; using System.Linq; using Microsoft.AgentHostProtocol; @@ -19,6 +20,80 @@ namespace Microsoft.AgentHostProtocol.Tests; public sealed class NativeReducerTests { + private static TcpConnectionState TcpState(long size = 8) => new() + { + Session = "ahp-session:/test", + Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Encoding = TcpDataEncoding.Base64, + Input = new FlowControlledByteDirectionState { WindowBytes = size, MaximumChunkSize = size }, + Output = new FlowControlledByteDirectionState { WindowBytes = size, MaximumChunkSize = size }, + }; + + [Fact] + public void TcpFourMiBChunkDoesNotAllocateDecodedPayload() + { + const int size = 4 * 1024 * 1024; + string data = new string('A', size / 3 * 4) + "AA=="; + var before = TcpState(size); + var action = new StateAction(new TcpInputAction { Type = ActionType.TcpInput, Offset = 0, Data = data }); + _ = Reducers.TcpReducer(before, action); + long allocated = GC.GetAllocatedBytesForCurrentThread(); + var after = Reducers.TcpReducer(before, action); + allocated = GC.GetAllocatedBytesForCurrentThread() - allocated; + Assert.True(allocated < 64 * 1024, $"Reducer allocated {allocated} bytes for a 4 MiB chunk"); + Assert.Equal(size, after.Input.ReceivedBytes); + Assert.Equal(0, before.Input.ReceivedBytes); + Assert.Same(after, Reducers.TcpReducer(after, action)); + Assert.Same(before.Output, after.Output); + var error = Assert.Throws(() => Reducers.TcpReducer(after, + new StateAction(new TcpInputAction { Type = ActionType.TcpInput, Offset = size, Data = "AA==" }))); + Assert.Equal("Invalid TCP action: receive window exceeded", error.Message); + Assert.Equal(size, after.Input.ReceivedBytes); + } + + [Fact] + public void TcpSafeIntegerCounters() + { + const long max = 9007199254740991L; + var before = TcpState(); + before = before with { Input = before.Input with { ReceivedBytes = max - 1, ConsumedBytes = max - 1 } }; + var state = Reducers.TcpReducer(before, new StateAction(new TcpInputAction { Type = ActionType.TcpInput, Offset = max - 1, Data = "AA==" })); + state = Reducers.TcpReducer(state, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = max })); + state = Reducers.TcpReducer(state, new StateAction(new TcpInputEofAction { Type = ActionType.TcpInputEof, FinalOffset = max })); + Assert.Equal(max, state.Input.ReceivedBytes); + Assert.Equal(max, state.Input.ConsumedBytes); + Assert.Equal(max, state.Input.EofAtBytes); + foreach (long value in new[] { -1L, max + 1, long.MaxValue }) + { + StateAction[] actions = + { + new(new TcpInputAction { Type = ActionType.TcpInput, Offset = value, Data = "AA==" }), + new(new TcpDataAction { Type = ActionType.TcpData, Offset = value, Data = "AA==" }), + new(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = value }), + new(new TcpDataConsumedAction { Type = ActionType.TcpDataConsumed, ConsumedBytes = value }), + new(new TcpInputEofAction { Type = ActionType.TcpInputEof, FinalOffset = value }), + new(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = value }), + }; + foreach (StateAction action in actions) + { + var error = Assert.Throws(() => Reducers.TcpReducer(state, action)); + Assert.Equal("Invalid TCP action: offset must be a nonnegative safe integer", error.Message); + Assert.Equal(max, state.Input.ReceivedBytes); + } + } + } + + [Fact] + public void TcpRejectsWhitespaceAndUnicodeBase64() + { + foreach (string data in new[] { "AAA\n", "AAA\r", "AAA\t", "AAA ", "AAA\u00e9", "AA\U0001f600" }) + { + var error = Assert.Throws(() => Reducers.TcpReducer(TcpState(), + new StateAction(new TcpInputAction { Type = ActionType.TcpInput, Offset = 0, Data = data }))); + Assert.Equal("Invalid TCP action: base64 encoding", error.Message); + } + } + // #338: an open request is an UNRESOLVED InputRequestResponsePart living in the // active turn's response stream — there is no longer a separate live surface, so // without an active turn there is nowhere for an open request to exist at all. diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs index 9aefa4c93..ed20b2f5c 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs @@ -72,6 +72,46 @@ await Assert.ThrowsAsync( async () => await b.ReceiveAsync(cts.Token)); } + // Graceful close preserves frames whose sends have completed, even if the + // receiver does not start reading until after close. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InMemoryTransport_Close_DrainsAlreadySentFrames(bool closePeer) + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + for (int i = 0; i < 2; i++) + { + await a.SendAsync(TransportMessage.FromText($"a-{i}"), cts.Token); + await b.SendAsync(TransportMessage.FromText($"b-{i}"), cts.Token); + } + await (closePeer ? b : a).CloseAsync(cts.Token); + for (int i = 0; i < 2; i++) + { + Assert.Equal($"a-{i}", (await b.ReceiveAsync(cts.Token)).Text); + Assert.Equal($"b-{i}", (await a.ReceiveAsync(cts.Token)).Text); + } + await Assert.ThrowsAsync(async () => await a.ReceiveAsync(cts.Token)); + await Assert.ThrowsAsync(async () => await b.ReceiveAsync(cts.Token)); + } + + [Fact] + public async Task InMemoryTransport_Close_UnblocksBackpressuredSendWithoutLosingAcceptedFrames() + { + var (a, b) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + for (int i = 0; i < 16; i++) + await a.SendAsync(TransportMessage.FromText($"frame-{i}"), cts.Token); + var blocked = a.SendAsync(TransportMessage.FromText("not-accepted"), cts.Token).AsTask(); + Assert.False(blocked.IsCompleted); + await b.CloseAsync(cts.Token); + await Assert.ThrowsAsync(() => blocked); + for (int i = 0; i < 16; i++) + Assert.Equal($"frame-{i}", (await b.ReceiveAsync(cts.Token)).Text); + await Assert.ThrowsAsync(async () => await b.ReceiveAsync(cts.Token)); + } + // ── E: send after close throws ──────────────────────────────────────── [Fact] public async Task InMemoryTransport_SendAfterClose_Throws() diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs index d0fa9c1d6..91a1b0b87 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -164,6 +164,12 @@ private static (object decoded, string reencoded) DecodeAndReencode(string type, return Wrap(Ser.Deserialize(inputJson)); case "InitializeResult": return Wrap(Ser.Deserialize(inputJson)); + case "SubscribeParams": + return Wrap(Ser.Deserialize(inputJson)); + case "ReconnectResult": + return Wrap(Ser.Deserialize(inputJson)); + case "TcpConnectionOpenErrorData": + return Wrap(Ser.Deserialize(inputJson)); case "Snapshot": return Wrap(Ser.Deserialize(inputJson)); default: diff --git a/clients/go/README.md b/clients/go/README.md index 13562b870..8e06632c7 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -60,6 +60,72 @@ for evt := range sub.Events() { } ``` +## Owned TCP byte streams + +After `Initialize` advertises `tcpConnections` with `base64` support, create a +stream atomically through its parent session: + +```go +connection, err := client.OpenTCPConnection(ctx, sessionURI, ahptypes.TcpConnectionSubscription{ + Type: "tcpConnection", Host: "localhost", Port: 3000, + Encoding: ahptypes.TcpDataEncodingBase64, + ReceiveWindowBytes: 256 * 1024, MaximumChunkSize: 64 * 1024, +}) +if err != nil { + return err +} +defer connection.Dispose(context.Background()) + +if _, err := connection.Write(ctx, requestBytes); err != nil { + return err +} +if err := connection.End(ctx); err != nil { // Input EOF; output remains readable. + return err +} +chunk, err := connection.Read(ctx) // io.EOF only after buffered output is drained. +``` + +The SDK owns buffering, flow control, and replay. `Read` releases receive credit; +`Drain` waits for destination consumption. Only one writer may run at a time; +`Write` returns the accepted prefix on cancellation. Finish the writer before +calling `Close`, and keep reading until EOF while the close handshake drains. +Cancellation stops waiting, not the handshake. `Dispose` aborts without draining. + +Transport loss suspends the same handles. For deliberate transport replacement, +use `ShutdownPreservingTCP(ctx)`; normal shutdown disposes streams. Create a fresh +`Client` and resume instead of initializing again: + +```go +result, err := freshClient.ReconnectTCPConnections(ctx, ahptypes.ReconnectParams{ + ClientId: "my-client", // Same ID used by the original Initialize. + LastSeenServerSeq: lastSeenServerSeq, + Subscriptions: []string{sessionURI}, +}, []*ahp.TCPConnection{connection}) +``` + +Continue using `connection`; apply the returned result only to non-TCP +subscriptions. Snapshot fallback or missing resources fail streams rather than +creating new sockets. + +For a managed host, call `hostClientHandle.OpenTCPConnection(ctx, sessionURI, create)` +instead of opening through its borrowed `Client()`. The manager automatically +resumes streams across reconnects and disposes them on host removal or shutdown. +Transport/retry policy, connection limits, and native socket bridges remain +application-owned. See the [TCP channel contract](../../docs/specification/tcp-channel.md). + +## Loss-sensitive raw events (advanced) + +Ordinary `client.Events()` drops events when its bounded buffer is full. +For loss-sensitive consumers, attach `client.EventsStrict()` before sending +requests. Drain `events.Events()`, check `events.Err()` when it closes, and call +`events.Close()` when done. Overflow reports `*ahp.SubscriptionLagError`; decode +loss reports `*ahp.TransportError` (`Kind == "protocol"`). Both terminate the +receiver rather than skipping events. Capacity uses `Config.SubscriptionBuffer`; +ordinary receivers are unchanged. These raw receivers are global. The owned TCP +adapter instead registers a strict child-scoped receiver during creation reply +processing and reattaches it per child on reconnect. Unrelated traffic cannot +exhaust a TCP stream's event buffer. + ## Code generation The contents of `ahptypes/*.go` (except `common.go`) are auto-generated diff --git a/clients/go/ahp/client.go b/clients/go/ahp/client.go index 1938bf369..30d85f1d2 100644 --- a/clients/go/ahp/client.go +++ b/clients/go/ahp/client.go @@ -151,6 +151,11 @@ type pendingResult struct { err *ahptypes.JsonRpcError } +type pendingRequest struct { + result chan pendingResult + onResult func(json.RawMessage) +} + // outboundMsg is the writer goroutine's input queue payload. type outboundMsg struct { msg ahptypes.JsonRpcMessage @@ -159,36 +164,80 @@ type outboundMsg struct { } // EventStream is a top-level fan-in receiver over every inbound event -// from a [Client]. Returned by [Client.Events]. +// from a [Client]. Returned by [Client.Events] or [Client.EventsStrict]. type EventStream struct { - events chan ClientEvent - closeMu sync.Mutex - closed bool + events chan ClientEvent + closeMu sync.Mutex + closed bool + strict bool + err error + onClose func() + resource *string } // Events returns a receive-only channel of every [ClientEvent]. +// For a strict receiver, check [EventStream.Err] when this channel closes. func (s *EventStream) Events() <-chan ClientEvent { return s.events } +// Err returns the terminal error, if any. A strict receiver records a +// *SubscriptionLagError on overflow or a *TransportError for malformed input. +// Already-buffered events form a valid prefix and can still be drained, +// but no events after the gap are delivered. +func (s *EventStream) Err() error { + s.closeMu.Lock() + defer s.closeMu.Unlock() + return s.err +} + // Close stops the stream. Safe to call multiple times. func (s *EventStream) Close() { s.closeMu.Lock() - defer s.closeMu.Unlock() + onClose := s.closeLocked(nil) + s.closeMu.Unlock() + if onClose != nil { + onClose() + } +} + +func (s *EventStream) closeLocked(err error) func() { if s.closed { - return + return nil } + s.err = err s.closed = true close(s.events) + onClose := s.onClose + s.onClose = nil + return onClose } func (s *EventStream) trySend(ev ClientEvent) { + var onClose func() s.closeMu.Lock() - defer s.closeMu.Unlock() - if s.closed { - return + if !s.closed { + select { + case s.events <- ev: + default: + if s.strict { + onClose = s.closeLocked(&SubscriptionLagError{Capacity: cap(s.events)}) + } + } } - select { - case s.events <- ev: - default: + s.closeMu.Unlock() + if onClose != nil { + onClose() + } +} + +func (s *EventStream) fail(err error) { + var onClose func() + s.closeMu.Lock() + if s.strict { + onClose = s.closeLocked(err) + } + s.closeMu.Unlock() + if onClose != nil { + onClose() } } @@ -229,19 +278,25 @@ type Client struct { // pending is the request-correlation map keyed by JSON-RPC id. pendingMu sync.Mutex - pending map[uint64]chan pendingResult + pending map[uint64]pendingRequest // subscriptionsMu guards subscriptions and the all-events // fan-out registry. - subscriptionsMu sync.Mutex - subscriptions map[string][]*Subscription - eventListeners []*EventStream + subscriptionsMu sync.Mutex + subscriptions map[string][]*Subscription + eventListeners []*EventStream + resourceEventListeners map[string][]*EventStream serverRequestMu sync.Mutex serverRequestHandler ServerRequestHandler nextID atomic.Uint64 nextClientSeq atomic.Int64 + tcpMu sync.Mutex + tcpClientID string + tcpCapability *ahptypes.TcpConnectionsCapability + tcpStreams map[*TCPConnection]struct{} + tcpDisposed bool // done closes once the client has begun teardown. Subsequent // sends fail with [ErrShutdown]. @@ -268,7 +323,7 @@ func Connect(_ context.Context, transport Transport, cfg Config) (*Client, error cfg: cfg, transport: transport, outbound: make(chan outboundMsg, 64), - pending: make(map[uint64]chan pendingResult), + pending: make(map[uint64]pendingRequest), subscriptions: make(map[string][]*Subscription), done: make(chan struct{}), } @@ -302,6 +357,14 @@ func (c *Client) Err() error { // // Safe to call multiple times. func (c *Client) Shutdown(ctx context.Context) error { + err := c.disposeTCPStreams(ctx) + return errors.Join(err, c.ShutdownPreservingTCP(ctx)) +} + +// ShutdownPreservingTCP closes this transport but retains owned TCP handles for +// ReconnectTCPConnections. Ordinary Shutdown permanently disposes the handles, +// including when the transport has already failed. +func (c *Client) ShutdownPreservingTCP(ctx context.Context) error { c.shutdownWithError(nil) doneCh := make(chan struct{}) go func() { c.wg.Wait(); close(doneCh) }() @@ -334,7 +397,8 @@ func (c *Client) shutdownWithError(err error) { failErr.Message = fmt.Sprintf("client shut down: %v", err) } c.pendingMu.Lock() - for id, ch := range c.pending { + for id, request := range c.pending { + ch := request.result select { case ch <- pendingResult{err: failErr}: default: @@ -348,6 +412,10 @@ func (c *Client) shutdownWithError(err error) { c.subscriptionsMu.Lock() subs := c.subscriptions listeners := c.eventListeners + for _, list := range c.resourceEventListeners { + listeners = append(listeners, list...) + } + c.resourceEventListeners = nil c.subscriptions = map[string][]*Subscription{} c.eventListeners = nil c.subscriptionsMu.Unlock() @@ -412,11 +480,13 @@ func (c *Client) runReader() { msg, err := c.transport.Recv(ctx) cancel() if err != nil { + c.failStrictEvents(err) c.shutdownWithError(fmt.Errorf("ahp: transport recv: %w", err)) return } parsed, perr := msg.IntoParsed() if perr != nil { + c.failStrictEvents(perr) // Skip malformed frames; protocol resync is the server's // responsibility. continue @@ -461,7 +531,7 @@ func (c *Client) dispatch(msg ahptypes.JsonRpcMessage) { func (c *Client) deliver(id uint64, r pendingResult) { c.pendingMu.Lock() - ch, ok := c.pending[id] + request, ok := c.pending[id] if ok { delete(c.pending, id) } @@ -469,6 +539,10 @@ func (c *Client) deliver(id uint64, r pendingResult) { if !ok { return } + if r.err == nil && request.onResult != nil { + request.onResult(r.value) + } + ch := request.result ch <- r close(ch) } @@ -478,40 +552,58 @@ func (c *Client) handleNotification(n ahptypes.JsonRpcNotification) { case "action": var env ahptypes.ActionEnvelope if err := json.Unmarshal(n.Params, &env); err != nil { + c.failStrictEvents(&TransportError{Kind: "protocol", Err: err}) return } c.fanOut(env.Channel, SubscriptionEventAction{Envelope: env}) case "root/sessionAdded": var p ahptypes.SessionAddedParams if err := json.Unmarshal(n.Params, &p); err != nil { + c.failStrictEvents(&TransportError{Kind: "protocol", Err: err}) return } c.fanOut(p.Channel, SubscriptionEventSessionAdded{Params: p}) case "root/sessionRemoved": var p ahptypes.SessionRemovedParams if err := json.Unmarshal(n.Params, &p); err != nil { + c.failStrictEvents(&TransportError{Kind: "protocol", Err: err}) return } c.fanOut(p.Channel, SubscriptionEventSessionRemoved{Params: p}) case "root/sessionSummaryChanged": var p ahptypes.SessionSummaryChangedParams if err := json.Unmarshal(n.Params, &p); err != nil { + c.failStrictEvents(&TransportError{Kind: "protocol", Err: err}) return } c.fanOut(p.Channel, SubscriptionEventSessionSummaryChanged{Params: p}) case "auth/required": var p ahptypes.AuthRequiredParams if err := json.Unmarshal(n.Params, &p); err != nil { + c.failStrictEvents(&TransportError{Kind: "protocol", Err: err}) return } c.fanOut(p.Channel, SubscriptionEventAuthRequired{Params: p}) } } +func (c *Client) failStrictEvents(err error) { + c.subscriptionsMu.Lock() + listeners := append([]*EventStream(nil), c.eventListeners...) + for _, list := range c.resourceEventListeners { + listeners = append(listeners, list...) + } + c.subscriptionsMu.Unlock() + for _, listener := range listeners { + listener.fail(err) + } +} + func (c *Client) fanOut(channel string, ev SubscriptionEvent) { c.subscriptionsMu.Lock() subs := append([]*Subscription(nil), c.subscriptions[channel]...) listeners := append([]*EventStream(nil), c.eventListeners...) + listeners = append(listeners, c.resourceEventListeners[channel]...) c.subscriptionsMu.Unlock() for _, s := range subs { s.trySend(ev) @@ -638,6 +730,10 @@ func (c *Client) handleServerRequest(req ahptypes.JsonRpcRequest) { // Request sends a JSON-RPC request and decodes the response into out. // If out is nil, the result is discarded. func (c *Client) Request(ctx context.Context, method string, params any, out any) error { + return c.requestWithLateResult(ctx, method, params, out, nil, nil) +} + +func (c *Client) requestWithLateResult(ctx context.Context, method string, params any, out any, onLate func(json.RawMessage), onResult func(json.RawMessage)) error { select { case <-c.done: return ErrShutdown @@ -651,7 +747,8 @@ func (c *Client) Request(ctx context.Context, method string, params any, out any resultCh := make(chan pendingResult, 1) c.pendingMu.Lock() - c.pending[id] = resultCh + request := pendingRequest{result: resultCh, onResult: onResult} + c.pending[id] = request c.pendingMu.Unlock() req := ahptypes.JsonRpcMessage{Request: &ahptypes.JsonRpcRequest{ @@ -661,9 +758,11 @@ func (c *Client) Request(ctx context.Context, method string, params any, out any Params: rawParams, }} if err := c.send(ctx, req); err != nil { - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + c.abandonRequest(id, resultCh, onLate) + } else { + c.abandonRequest(id, resultCh, nil) + } return err } @@ -688,15 +787,36 @@ func (c *Client) Request(ctx context.Context, method string, params any, out any } return nil case <-ctx.Done(): - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() + c.abandonRequest(id, resultCh, onLate) return ctx.Err() case <-c.done: return ErrShutdown } } +func (c *Client) abandonRequest(id uint64, resultCh <-chan pendingResult, onLate func(json.RawMessage)) { + if onLate == nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return + } + // Send may already have reached the host when its context expires. Retain + // correlation until response or shutdown to release any late-created child. + go func() { + select { + case result, ok := <-resultCh: + if ok && result.err == nil { + onLate(result.value) + } + case <-c.done: + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + } + }() +} + // Notify sends a JSON-RPC notification (fire-and-forget). func (c *Client) Notify(ctx context.Context, method string, params any) error { select { @@ -774,6 +894,15 @@ func (c *Client) Initialize(ctx context.Context, clientID string, protocolVersio if err := c.Request(ctx, "initialize", params, &out); err != nil { return nil, err } + c.tcpMu.Lock() + c.tcpClientID = clientID + c.tcpCapability = nil + if out.TcpConnections != nil { + capability := *out.TcpConnections + capability.Encodings = append([]ahptypes.TcpDataEncoding(nil), capability.Encodings...) + c.tcpCapability = &capability + } + c.tcpMu.Unlock() return &out, nil } @@ -1003,9 +1132,87 @@ func (c *Client) SessionConfigCompletions(ctx context.Context, params ahptypes.S // inbound event from this client, tagged with the channel URI it was // scoped to. Multiple streams may exist concurrently. func (c *Client) Events() *EventStream { + return c.events(false) +} + +// EventsStrict attaches a bounded global receiver that permanently closes on +// overflow. Check its Err after draining Events; a *SubscriptionLagError means +// affected TCP channels must be reset/unsubscribed, never resumed past the gap. +// Malformed input terminates strict receivers with a *TransportError instead +// of silently dropping the frame or notification. +// +// Attach before requesting subscribe(create), then retain the receiver and +// filter by the returned child URI. Reconnect replay and credits remain the +// caller's responsibility. +func (c *Client) EventsStrict() *EventStream { + return c.events(true) +} + +func (c *Client) events(strict bool, resource ...string) *EventStream { c.subscriptionsMu.Lock() defer c.subscriptionsMu.Unlock() - s := &EventStream{events: make(chan ClientEvent, c.cfg.SubscriptionBuffer)} - c.eventListeners = append(c.eventListeners, s) + s := &EventStream{events: make(chan ClientEvent, c.cfg.SubscriptionBuffer), strict: strict} + if len(resource) != 0 { + s.resource = &resource[0] + } + if strict { + select { + case <-c.done: + s.Close() + return s + default: + } + s.onClose = func() { + c.subscriptionsMu.Lock() + defer c.subscriptionsMu.Unlock() + c.removeEventStream(s) + } + } + c.addEventStream(s) return s } + +func (c *Client) addEventStream(s *EventStream) { + if s.resource == nil { + c.eventListeners = append(c.eventListeners, s) + } else { + if c.resourceEventListeners == nil { + c.resourceEventListeners = make(map[string][]*EventStream) + } + c.resourceEventListeners[*s.resource] = append(c.resourceEventListeners[*s.resource], s) + } +} + +func (c *Client) removeEventStream(s *EventStream) { + list := c.eventListeners + if s.resource != nil { + list = c.resourceEventListeners[*s.resource] + } + for i, listener := range list { + if listener == s { + copy(list[i:], list[i+1:]) + list[len(list)-1] = nil + list = list[:len(list)-1] + break + } + } + if s.resource == nil { + c.eventListeners = list + } else if len(list) == 0 { + delete(c.resourceEventListeners, *s.resource) + } else { + c.resourceEventListeners[*s.resource] = list + } +} + +func (c *Client) bindEventStream(s *EventStream, resource string) { + c.subscriptionsMu.Lock() + defer c.subscriptionsMu.Unlock() + s.closeMu.Lock() + defer s.closeMu.Unlock() + c.removeEventStream(s) + s.resource = &resource + if !s.closed { + c.addEventStream(s) + } +} diff --git a/clients/go/ahp/client_test.go b/clients/go/ahp/client_test.go index 485adaf12..d6943da3b 100644 --- a/clients/go/ahp/client_test.go +++ b/clients/go/ahp/client_test.go @@ -680,6 +680,314 @@ func TestClientAutomationCatalogueAction(t *testing.T) { } } +func TestStrictEventsCaptureAtomicTCPCreateFirstAction(t *testing.T) { + clientSide, serverSide := newMemTransportPair() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + client, err := Connect(ctx, clientSide, DefaultConfig()) + if err != nil { + t.Fatal(err) + } + defer client.Shutdown(context.Background()) + events := client.EventsStrict() + defer events.Close() + state := tcpTestState() + snapshotResult, err := json.Marshal(ahptypes.SubscribeResult{Snapshot: &ahptypes.Snapshot{ + Resource: "ahp-tcp:/created", State: ahptypes.SnapshotState{Tcp: &state}, + }}) + if err != nil { + t.Fatal(err) + } + serverErr := make(chan error, 1) + go func() { + serverErr <- func() error { + message, err := serverSide.Recv(ctx) + if err != nil { + return err + } + parsed, err := message.IntoParsed() + if err != nil { + return err + } + if parsed.Request == nil || parsed.Request.Method != "subscribe" { + return errors.New("expected subscribe request") + } + var params ahptypes.SubscribeParams + if err := json.Unmarshal(parsed.Request.Params, ¶ms); err != nil { + return err + } + if params.Channel != "ahp-session:/s1" || params.Create == nil || params.Create.Type != "tcpConnection" { + return fmt.Errorf("unexpected create parameters: %+v", params) + } + if err := serverSide.Send(ctx, NewTextMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","id":%d,"result":%s}`, parsed.Request.ID, snapshotResult, + ))); err != nil { + return err + } + if err := serverSide.Send(ctx, NewTextMessage(`{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"origin":null,"action":{"type":"tcp/data","offset":0,"data":"AA=="}}}`)); err != nil { + return err + } + message, err = serverSide.Recv(ctx) + if err != nil { + return err + } + parsed, err = message.IntoParsed() + if err != nil { + return err + } + if parsed.Request == nil || parsed.Request.Method != "ping" { + return errors.New("expected ping barrier") + } + return serverSide.Send(ctx, NewTextMessage(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":null}`, parsed.Request.ID))) + }() + }() + var result ahptypes.SubscribeResult + err = client.Request(ctx, "subscribe", ahptypes.SubscribeParams{ + Channel: "ahp-session:/s1", + Create: &ahptypes.TcpConnectionSubscription{ + Type: "tcpConnection", Host: "localhost", Port: 3000, + Encoding: ahptypes.TcpDataEncodingBase64, ReceiveWindowBytes: 8, MaximumChunkSize: 6, + }, + }, &result) + if err != nil { + t.Fatal(err) + } + // Ensure the first action has been processed before draining this receiver. + if err := client.Ping(ctx); err != nil { + t.Fatal(err) + } + select { + case event, ok := <-events.Events(): + if !ok { + t.Fatalf("receiver closed: %v", events.Err()) + } + if result.Snapshot == nil || result.Snapshot.State.Tcp == nil || event.Channel != result.Snapshot.Resource { + t.Fatalf("event did not match created TCP snapshot: %+v", event) + } + action, ok := event.Event.(SubscriptionEventAction) + if !ok || action.Envelope.ServerSeq != 1 { + t.Fatalf("unexpected first action: %+v", event) + } + outcome, err := ApplyActionToTCP(result.Snapshot.State.Tcp, action.Envelope.Action) + if err != nil || outcome != ReduceOutcomeApplied || result.Snapshot.State.Tcp.Output.ReceivedBytes != 1 { + t.Fatalf("first action reduction: %v, %v", outcome, err) + } + case <-ctx.Done(): + t.Fatal("missing first action") + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestStrictEventsOverflowIsTerminalAndOtherReceiversContinue(t *testing.T) { + clientSide, _ := newMemTransportPair() + ctx := context.Background() + config := DefaultConfig() + config.SubscriptionBuffer = 1 + client, err := Connect(ctx, clientSide, config) + if err != nil { + t.Fatal(err) + } + defer client.Shutdown(ctx) + strict := client.EventsStrict() + ordinary := client.Events() + send := func(seq int64) { + client.fanOut("ahp-tcp:/created", SubscriptionEventAction{Envelope: ahptypes.ActionEnvelope{ServerSeq: seq}}) + } + send(1) + send(2) + var lag *SubscriptionLagError + if !errors.As(strict.Err(), &lag) || lag.Capacity != 1 { + t.Fatalf("expected typed overflow error, got %v", strict.Err()) + } + if event := <-strict.Events(); event.Event.(SubscriptionEventAction).Envelope.ServerSeq != 1 { + t.Fatal("strict receiver lost its valid buffered prefix") + } + if event := <-ordinary.Events(); event.Event.(SubscriptionEventAction).Envelope.ServerSeq != 1 { + t.Fatal("ordinary receiver's drop-newest behavior changed") + } + send(3) + select { + case _, open := <-strict.Events(): + if open { + t.Fatal("strict receiver resumed after overflow") + } + default: + t.Fatal("strict receiver did not terminate") + } + if event := <-ordinary.Events(); event.Event.(SubscriptionEventAction).Envelope.ServerSeq != 3 { + t.Fatal("ordinary receiver did not continue") + } + strict.Close() + if !errors.As(strict.Err(), &lag) { + t.Fatal("close erased the lag error") + } + if err := client.Shutdown(ctx); err != nil { + t.Fatal(err) + } + select { + case _, open := <-client.EventsStrict().Events(): + if open { + t.Fatal("receiver created after shutdown remained open") + } + default: + t.Fatal("receiver created after shutdown did not terminate") + } +} + +func TestStrictEventsDecodeLossIsTerminalButFutureActionsAreAllowed(t *testing.T) { + for _, test := range []struct { + name, wire string + fails bool + }{ + {"malformedJSON", "{", true}, + {"invalidEnvelope", `{"jsonrpc":"2.0","method":"action","params":{"channel":42,"serverSeq":1,"action":{"type":"tcp/dataEof","finalOffset":0}}}`, true}, + {"invalidEOF", `{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"action":{"type":"tcp/dataEof","finalOffset":"bad"}}}`, true}, + {"invalidCredit", `{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"action":{"type":"tcp/inputConsumed","consumedBytes":"bad"}}}`, true}, + {"futureAction", `{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"action":{"type":"tcp/futureControl"}}}`, false}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + clientSide, serverSide := newMemTransportPair() + client, err := Connect(ctx, clientSide, DefaultConfig()) + if err != nil { + t.Fatal(err) + } + defer client.Shutdown(context.Background()) + strict, ordinary := client.EventsStrict(), client.Events() + serverErr := make(chan error, 1) + go func() { + serverErr <- func() error { + message, err := serverSide.Recv(ctx) + if err != nil { + return err + } + request, err := message.IntoParsed() + if err != nil { + return err + } + if request.Request == nil || request.Request.Method != "ping" { + return errors.New("expected ping barrier") + } + for _, wire := range []string{ + test.wire, + `{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":2,"action":{"type":"tcp/data","offset":0,"data":"AA=="}}}`, + fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":null}`, request.Request.ID), + } { + if err := serverSide.Send(ctx, NewTextMessage(wire)); err != nil { + return err + } + } + return nil + }() + }() + if err := client.Ping(ctx); err != nil { + t.Fatal(err) + } + receive := func(stream *EventStream, seq int64) { + t.Helper() + select { + case event, ok := <-stream.Events(): + action, isAction := event.Event.(SubscriptionEventAction) + if !ok || !isAction || action.Envelope.ServerSeq != seq { + t.Fatalf("expected action %d, got %+v (error %v)", seq, event, stream.Err()) + } + case <-ctx.Done(): + t.Fatal("missing event") + } + } + if test.fails { + var protocolError *TransportError + if !errors.As(strict.Err(), &protocolError) || protocolError.Kind != "protocol" { + t.Fatalf("expected typed protocol error, got %v", strict.Err()) + } + select { + case _, open := <-strict.Events(): + if open { + t.Fatal("strict receiver resumed after decode loss") + } + default: + t.Fatal("strict receiver did not terminate") + } + } else { + receive(strict, 1) + receive(strict, 2) + receive(ordinary, 1) + if strict.Err() != nil { + t.Fatal(strict.Err()) + } + } + receive(ordinary, 2) + if err := <-serverErr; err != nil { + t.Fatal(err) + } + }) + } +} + +func TestStrictEventsUnregisterAndWakePendingReceive(t *testing.T) { + clientSide, _ := newMemTransportPair() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + config := DefaultConfig() + config.SubscriptionBuffer = 1 + client, err := Connect(ctx, clientSide, config) + if err != nil { + t.Fatal(err) + } + defer client.Shutdown(context.Background()) + ordinary := client.Events() + assertOnlyOrdinary := func() { + t.Helper() + client.subscriptionsMu.Lock() + defer client.subscriptionsMu.Unlock() + if len(client.eventListeners) != 1 || client.eventListeners[0] != ordinary { + t.Fatal("strict receiver remained registered or affected another receiver") + } + } + strict := client.EventsStrict() + ready := make(chan struct{}) + received := make(chan bool, 1) + go func() { + close(ready) + _, open := <-strict.Events() + received <- open + }() + <-ready + client.failStrictEvents(&TransportError{Kind: "protocol", Err: errors.New("malformed frame")}) + assertOnlyOrdinary() + select { + case open := <-received: + if open { + t.Fatal("failed receiver delivered an event") + } + case <-ctx.Done(): + t.Fatal("failure did not wake the pending receive") + } + var protocolError *TransportError + if !errors.As(strict.Err(), &protocolError) || protocolError.Kind != "protocol" { + t.Fatalf("missing protocol error: %v", strict.Err()) + } + closed := client.EventsStrict() + closed.Close() + closed.Close() + assertOnlyOrdinary() + overflowed := client.EventsStrict() + for i := int64(1); i <= 2; i++ { + client.fanOut("ahp-tcp:/created", SubscriptionEventAction{Envelope: ahptypes.ActionEnvelope{ServerSeq: i}}) + } + var lag *SubscriptionLagError + if !errors.As(overflowed.Err(), &lag) { + t.Fatalf("missing overflow error: %v", overflowed.Err()) + } + assertOnlyOrdinary() + if event := <-ordinary.Events(); event.Event.(SubscriptionEventAction).Envelope.ServerSeq != 1 { + t.Fatal("ordinary receiver was affected") + } +} + // TestClientShutdownFailsInFlightRequest confirms a Shutdown unblocks // any pending request with ErrShutdown. func TestClientShutdownFailsInFlightRequest(t *testing.T) { diff --git a/clients/go/ahp/error.go b/clients/go/ahp/error.go index ec97c2930..01eeb09f3 100644 --- a/clients/go/ahp/error.go +++ b/clients/go/ahp/error.go @@ -54,6 +54,16 @@ var ErrShutdown = errors.New("ahp: client shut down") // caller should resubscribe to recover. var ErrSequenceGap = errors.New("ahp: sequence gap detected; resubscribe required") +// SubscriptionLagError reports overflow of a strict event receiver. +// That receiver is permanently closed and will never resume past the gap. +type SubscriptionLagError struct { + Capacity int +} + +func (e *SubscriptionLagError) Error() string { + return fmt.Sprintf("ahp: subscription lag: event buffer capacity %d exceeded; receiver terminated", e.Capacity) +} + // TransportError wraps any error produced by an underlying [Transport] // implementation so that callers can distinguish transport faults from // protocol-level RPC errors via [errors.As]. diff --git a/clients/go/ahp/hosts/hosts.go b/clients/go/ahp/hosts/hosts.go index 65396c4c9..428f3e44d 100644 --- a/clients/go/ahp/hosts/hosts.go +++ b/clients/go/ahp/hosts/hosts.go @@ -19,8 +19,10 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" + "strings" "sync" "time" @@ -366,9 +368,13 @@ func (h *HostClientHandle) HostID() HostID { return h.host.id } // Client returns the live [ahp.Client] for the current generation, or // [ErrHostReconnected] if the connection has since been replaced. +// For managed TCP ownership use OpenTCPConnection on this handle instead. func (h *HostClientHandle) Client() (*ahp.Client, error) { h.host.mu.RLock() defer h.host.mu.RUnlock() + if h.host.removed { + return nil, ErrUnknownHost + } if h.host.generation != h.generation { return nil, ErrHostReconnected } @@ -378,6 +384,34 @@ func (h *HostClientHandle) Client() (*ahp.Client, error) { return h.host.client, nil } +// OpenTCPConnection creates a stream retained across the host's reconnects. +// The stream remains usable after this generation-checked handle becomes stale. +func (h *HostClientHandle) OpenTCPConnection(ctx context.Context, session string, create ahptypes.TcpConnectionSubscription) (*ahp.TCPConnection, error) { + client, err := h.Client() + if err != nil { + return nil, err + } + connection, err := client.OpenTCPConnection(ctx, session, create) + if err != nil { + return nil, err + } + h.host.mu.Lock() + if h.host.generation != h.generation || h.host.client != client || h.host.state.Kind != HostStateConnected || h.host.removed { + h.host.mu.Unlock() + return nil, errors.Join(ErrHostReconnected, connection.Dispose(ctx)) + } + live := h.host.tcpConnections[:0] + for _, retained := range h.host.tcpConnections { + if !retained.IsClosed() { + live = append(live, retained) + } + } + clear(h.host.tcpConnections[len(live):]) + h.host.tcpConnections = append(live, connection) + h.host.mu.Unlock() + return connection, nil +} + // ─── Errors ──────────────────────────────────────────────────────────── // ErrHostReconnected is returned by [HostClientHandle.Client] when @@ -401,22 +435,26 @@ var ErrDuplicateHost = errors.New("hosts: host id already registered") // hostState is the per-host bookkeeping the multi-host runtime owns. type hostState struct { - id HostID - label string - cfg HostConfig - mu sync.RWMutex - client *ahp.Client - state HostState - clientID string - protoVer string - automations *ahptypes.AutomationCapabilities - agents []ahptypes.AgentInfo - sessions []ahptypes.SessionSummary - terminals []ahptypes.TerminalInfo - updatedAt time.Time - generation uint64 - cancel context.CancelFunc - supervised sync.WaitGroup + id HostID + label string + cfg HostConfig + mu sync.RWMutex + client *ahp.Client + resumeClient *ahp.Client + state HostState + clientID string + protoVer string + automations *ahptypes.AutomationCapabilities + agents []ahptypes.AgentInfo + sessions []ahptypes.SessionSummary + terminals []ahptypes.TerminalInfo + updatedAt time.Time + generation uint64 + cancel context.CancelFunc + supervised sync.WaitGroup + tcpConnections []*ahp.TCPConnection + serverSeq int64 + removed bool } // MultiHostClient is the public multi-host registry + reconnect @@ -565,42 +603,123 @@ func (m *MultiHostClient) openHost(ctx context.Context, hs *hostState) error { if err != nil { return fmt.Errorf("hosts: connect: %w", err) } + hs.mu.Lock() + previous := hs.resumeClient + hs.resumeClient = client + hs.mu.Unlock() + if previous != nil { + if err := client.RestoreReconnectState(previous); err != nil { + return errors.Join(err, client.Shutdown(ctx)) + } + } - result, err := client.Initialize(ctx, hs.clientID, hs.cfg.ProtocolVersions, hs.cfg.InitialSubscriptions) + events := client.Events() + hs.mu.RLock() + retained := append([]*ahp.TCPConnection(nil), hs.tcpConnections...) + serverSeq := hs.serverSeq + hs.mu.RUnlock() + live := retained[:0] + for _, connection := range retained { + if !connection.IsClosed() { + live = append(live, connection) + } + } + subscriptions := make([]string, 0, len(hs.cfg.InitialSubscriptions)) + for _, resource := range hs.cfg.InitialSubscriptions { + if !strings.HasPrefix(resource, "ahp-tcp:") { + subscriptions = append(subscriptions, resource) + } + } + var result *ahptypes.InitializeResult + var replay *ahptypes.ReconnectResult + if len(live) != 0 { + replay, err = client.ReconnectTCPConnections(ctx, ahptypes.ReconnectParams{ + ClientId: hs.clientID, LastSeenServerSeq: serverSeq, Subscriptions: subscriptions, + }, live) + var rpc *ahp.RPCError + transportClosed := false + select { + case <-client.Done(): + transportClosed = true + default: + } + if errors.As(err, &rpc) && !transportClosed { + for _, connection := range live { + if cleanup := connection.Dispose(ctx); cleanup != nil { + log.Printf("hosts: TCP fallback cleanup: %v", cleanup) + } + } + result, err = client.Initialize(ctx, hs.clientID, hs.cfg.ProtocolVersions, subscriptions) + } + } else { + result, err = client.Initialize(ctx, hs.clientID, hs.cfg.ProtocolVersions, subscriptions) + } if err != nil { - _ = client.Shutdown(ctx) - return fmt.Errorf("hosts: initialize: %w", err) + events.Close() + return errors.Join(fmt.Errorf("hosts: handshake: %w", err), client.ShutdownPreservingTCP(ctx)) } hs.mu.Lock() + if hs.removed { + hs.mu.Unlock() + events.Close() + return errors.Join(ErrUnknownHost, client.Shutdown(ctx)) + } hs.client = client - hs.protoVer = result.ProtocolVersion - hs.automations = cloneAutomationCapabilities(result.Automations) + if result != nil { + hs.protoVer = result.ProtocolVersion + hs.automations = cloneAutomationCapabilities(result.Automations) + hs.serverSeq = result.ServerSeq + } hs.generation++ hs.mu.Unlock() + if replay != nil { + if actions, ok := replay.Value.(*ahptypes.ReconnectReplayResult); ok { + for _, envelope := range actions.Actions { + if !strings.HasPrefix(envelope.Channel, "ahp-tcp:") && envelope.ServerSeq <= serverSeq { + continue + } + m.publishHostEvent(hs, ahp.ClientEvent{Channel: envelope.Channel, Event: ahp.SubscriptionEventAction{Envelope: envelope}}) + } + } else if snapshots, ok := replay.Value.(*ahptypes.ReconnectSnapshotResult); ok { + hs.mu.Lock() + for _, snapshot := range snapshots.Snapshots { + hs.serverSeq = max(hs.serverSeq, snapshot.FromSeq) + } + hs.mu.Unlock() + } + } m.setHostState(hs, HostState{Kind: HostStateConnected}) // Fan inbound events out to subscribers. - go m.pumpEvents(hs, client) + go m.pumpEvents(hs, events) return nil } // pumpEvents drains the per-host [ahp.Client.Events] stream and // re-emits each event tagged with the host id. -func (m *MultiHostClient) pumpEvents(hs *hostState, client *ahp.Client) { - stream := client.Events() +func (m *MultiHostClient) pumpEvents(hs *hostState, stream *ahp.EventStream) { defer stream.Close() for ev := range stream.Events() { - m.subMu.Lock() - subs := append([]chan HostSubscriptionEvent(nil), m.subs...) - m.subMu.Unlock() - out := HostSubscriptionEvent{HostID: hs.id, Channel: ev.Channel, Event: ev.Event} - for _, ch := range subs { - select { - case ch <- out: - default: - } + m.publishHostEvent(hs, ev) + } +} + +func (m *MultiHostClient) publishHostEvent(hs *hostState, ev ahp.ClientEvent) { + if action, ok := ev.Event.(ahp.SubscriptionEventAction); ok { + hs.mu.Lock() + hs.serverSeq = max(hs.serverSeq, action.Envelope.ServerSeq) + hs.mu.Unlock() + } + m.subMu.Lock() + subs := append([]chan HostSubscriptionEvent(nil), m.subs...) + m.subMu.Unlock() + out := HostSubscriptionEvent{HostID: hs.id, Channel: ev.Channel, Event: ev.Event} + for _, ch := range subs { + select { + case ch <- out: + default: } } } @@ -633,6 +752,7 @@ func (m *MultiHostClient) supervise(ctx context.Context, hs *hostState) { default: } if policy.IsDisabled() { + m.disposeTCPConnections(ctx, hs) m.setHostState(hs, HostState{Kind: HostStateFailed, Err: errors.New("hosts: transport closed and reconnect disabled")}) return } @@ -640,7 +760,7 @@ func (m *MultiHostClient) supervise(ctx context.Context, hs *hostState) { // Ensure the old client is fully torn down before opening a // replacement (a no-op if Done fired from Shutdown already). shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) - _ = client.Shutdown(shutdownCtx) + _ = client.ShutdownPreservingTCP(shutdownCtx) shutdownCancel() var attempt uint32 = 1 @@ -661,9 +781,23 @@ func (m *MultiHostClient) supervise(ctx context.Context, hs *hostState) { } attempt++ if policy.MaxAttempts > 0 && attempt > policy.MaxAttempts { + m.disposeTCPConnections(ctx, hs) m.setHostState(hs, HostState{Kind: HostStateFailed, Err: fmt.Errorf("hosts: exceeded %d reconnect attempts", policy.MaxAttempts)}) return } + + } + } +} + +func (m *MultiHostClient) disposeTCPConnections(ctx context.Context, hs *hostState) { + hs.mu.Lock() + connections := hs.tcpConnections + hs.tcpConnections = nil + hs.mu.Unlock() + for _, connection := range connections { + if err := connection.Dispose(ctx); err != nil { + log.Printf("hosts: TCP disposal: %v", err) } } } @@ -782,6 +916,10 @@ func (m *MultiHostClient) RemoveHost(ctx context.Context, id HostID) error { return ErrUnknownHost } hs.cancel() + hs.mu.Lock() + hs.removed = true + hs.mu.Unlock() + m.disposeTCPConnections(ctx, hs) hs.mu.RLock() client := hs.client hs.mu.RUnlock() @@ -833,6 +971,10 @@ func (m *MultiHostClient) Shutdown(ctx context.Context) error { m.mu.Unlock() for _, hs := range hosts { hs.cancel() + hs.mu.Lock() + hs.removed = true + hs.mu.Unlock() + m.disposeTCPConnections(ctx, hs) hs.mu.RLock() client := hs.client hs.mu.RUnlock() diff --git a/clients/go/ahp/hosts/hosts_test.go b/clients/go/ahp/hosts/hosts_test.go index 8b35fd92f..2f8e29d52 100644 --- a/clients/go/ahp/hosts/hosts_test.go +++ b/clients/go/ahp/hosts/hosts_test.go @@ -3,6 +3,8 @@ package hosts import ( "context" "encoding/json" + "io" + "strings" "sync" "testing" "time" @@ -100,6 +102,280 @@ func runFakeServerWithInitializeResult(t *testing.T, serverSide *fakeTransport, } } +func TestManagedTCPReconnectAndShutdown(t *testing.T) { + for _, mode := range []string{"replay", "retry", "snapshot", "missing", "refused"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + multi := NewMultiHostClient() + events := multi.Subscriptions() + defer multi.Shutdown(context.Background()) + servers := make(chan *fakeTransport, 4) + reconnects := make(chan ahptypes.ReconnectParams, 4) + dispatches := make(chan ahptypes.DispatchActionParams, 32) + attempt := 0 + snapshot := func(resource string) map[string]any { + direction := map[string]any{"windowBytes": 4, "maximumChunkSize": 3, "receivedBytes": 0, "consumedBytes": 0, "eofAtBytes": nil} + return map[string]any{"snapshot": map[string]any{"resource": resource, "fromSeq": 10, + "state": map[string]any{"type": "tcp", "session": "ahp-session:/s1", "target": map[string]any{"host": "localhost", "port": 3000}, + "encoding": "base64", "input": direction, "output": direction, "clientClosed": false, "hostClosed": false, "reset": nil}}} + } + cfg := NewHostConfig("tcp", "TCP", func(_ context.Context, _ HostID) (ahp.Transport, error) { + attempt++ + current := attempt + client, server := newFakePair() + servers <- server + go func() { + send := func(value any) { + data, err := json.Marshal(value) + if err != nil { + t.Error(err) + return + } + if err := server.Send(ctx, ahp.NewTextMessage(string(data))); err != nil && ctx.Err() == nil { + t.Error(err) + } + } + for { + frame, err := server.Recv(ctx) + if err != nil { + return + } + message, err := frame.IntoParsed() + if err != nil { + t.Error(err) + return + } + if message.Notification != nil { + if message.Notification.Method == "dispatchAction" { + var params ahptypes.DispatchActionParams + if err := json.Unmarshal(message.Notification.Params, ¶ms); err != nil { + t.Error(err) + return + } + dispatches <- params + } + continue + } + if message.Request == nil { + continue + } + req := message.Request + var result any + switch req.Method { + case "initialize": + var params ahptypes.InitializeParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Error(err) + return + } + for _, resource := range params.InitialSubscriptions { + if strings.HasPrefix(resource, "ahp-tcp:") { + t.Error("TCP entered initialize fallback") + } + } + result = map[string]any{"protocolVersion": ahptypes.ProtocolVersion, "serverSeq": 10, "snapshots": []any{}, "tcpConnections": map[string]any{"encodings": []string{"base64"}}} + case "subscribe": + resource := "ahp-tcp:/owned" + if current > 1 { + resource = "ahp-tcp:/second" + } + result = snapshot(resource) + case "reconnect": + var params ahptypes.ReconnectParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Error(err) + return + } + reconnects <- params + if mode == "retry" && current == 2 { + server.Close(ctx) + return + } + if mode == "refused" { + send(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -32000, "message": "replay expired"}}) + continue + } + result = map[string]any{"type": "replay", "actions": []any{}, "missing": []string{}} + if mode == "replay" || mode == "retry" { + result = map[string]any{"type": "replay", "missing": []string{}, "actions": []any{ + map[string]any{"channel": "ahp-terminal:/t", "serverSeq": 20, "action": map[string]any{"type": "terminal/data", "data": "hello"}}, + map[string]any{"channel": "ahp-terminal:/t", "serverSeq": 21, "action": map[string]any{"type": "terminal/data", "data": "!"}}, + }} + } + if mode == "snapshot" { + result = map[string]any{"type": "snapshot", "snapshots": []any{}} + } + if mode == "missing" { + result = map[string]any{"type": "replay", "actions": []any{}, "missing": []string{"ahp-tcp:/owned"}} + } + default: + t.Errorf("unexpected request %s", req.Method) + return + } + send(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result}) + if req.Method == "subscribe" && current == 1 { + send(map[string]any{"jsonrpc": "2.0", "method": "action", "params": map[string]any{"channel": "ahp-tcp:/owned", "serverSeq": 11, "action": map[string]any{"type": "tcp/data", "offset": 0, "data": "eA=="}}}) + } + if req.Method == "reconnect" && (mode == "replay" || mode == "retry") { + send(map[string]any{"jsonrpc": "2.0", "method": "action", "params": map[string]any{"channel": "ahp-tcp:/owned", "serverSeq": 22, "action": map[string]any{"type": "tcp/dataEof", "finalOffset": 1}}}) + } + } + }() + return client, nil + }) + cfg.ClientID = "owner" + cfg.InitialSubscriptions = []string{ahptypes.RootResourceURI, "ahp-tcp:/must-not-initialize"} + cfg.ReconnectPolicy = ReconnectPolicy{MaxAttempts: 2, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond, BackoffMultiplier: 1, ResetOnSuccess: true} + if _, err := multi.AddHost(ctx, cfg); err != nil { + t.Fatal(err) + } + old, err := multi.ClientHandle(cfg.ID) + if err != nil { + t.Fatal(err) + } + create := ahptypes.TcpConnectionSubscription{Type: "tcpConnection", Host: "localhost", Port: 3000, Encoding: ahptypes.TcpDataEncodingBase64, ReceiveWindowBytes: 4, MaximumChunkSize: 3} + connection, err := old.OpenTCPConnection(ctx, "ahp-session:/s1", create) + if err != nil { + t.Fatal(err) + } + receive := func() ahptypes.DispatchActionParams { + select { + case p := <-dispatches: + return p + case <-ctx.Done(): + t.Fatal("dispatch timeout") + return ahptypes.DispatchActionParams{} + } + } + if data, err := connection.Read(ctx); err != nil || string(data) != "x" { + t.Fatalf("first data %q: %v", data, err) + } + credit := receive() + if _, err := connection.Write(ctx, []byte("ab")); err != nil { + t.Fatal(err) + } + input := receive() + raw, err := old.Client() + if err != nil { + t.Fatal(err) + } + ordinary, err := raw.Dispatch(ctx, "ahp-session:/s1", ahptypes.StateAction{Value: &ahptypes.SessionTitleChangedAction{Type: ahptypes.ActionTypeSessionTitleChanged, Title: "ordinary"}}) + if err != nil { + t.Fatal(err) + } + receive() + oldServer := <-servers + if err := oldServer.Send(ctx, ahp.NewTextMessage(`{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-terminal:/t","serverSeq":20,"action":{"type":"terminal/data","data":"hello"}}}`)); err != nil { + t.Fatal(err) + } + for { + select { + case event := <-events: + if event.Channel == "ahp-terminal:/t" { + goto ordinaryApplied + } + case <-ctx.Done(): + t.Fatal("ordinary event not delivered") + } + } + ordinaryApplied: + if err := oldServer.Close(ctx); err != nil { + t.Fatal(err) + } + var reconnect ahptypes.ReconnectParams + select { + case reconnect = <-reconnects: + case <-ctx.Done(): + t.Fatal("missing TCP reconnect") + } + found := false + for _, resource := range reconnect.Subscriptions { + found = found || resource == connection.Resource() + } + if !found || reconnect.ClientId != "owner" || reconnect.LastSeenServerSeq > 11 { + t.Fatalf("unsafe reconnect %+v", reconnect) + } + var fresh *HostClientHandle + for { + fresh, err = multi.ClientHandle(cfg.ID) + if err == nil && fresh.generation > old.generation && multi.Host(cfg.ID).State.Kind == HostStateConnected { + break + } + select { + case <-ctx.Done(): + t.Fatal("reconnect did not finish") + case <-time.After(time.Millisecond): + } + } + if mode == "replay" || mode == "retry" { + text := "hello" + replayed: + for { + select { + case event := <-events: + if action, ok := event.Event.(ahp.SubscriptionEventAction); ok { + if data, ok := action.Envelope.Action.Value.(*ahptypes.TerminalDataAction); ok { + text += data.Data + } + if action.Envelope.ServerSeq == 22 { + break replayed + } + } + case <-ctx.Done(): + t.Fatal("replay not delivered") + } + } + if text != "hello!" { + t.Fatalf("managed replay duplicated terminal output: %q", text) + } + if got := receive(); got.ClientSeq != credit.ClientSeq { + t.Fatal("credit was renumbered") + } + if got := receive(); got.ClientSeq != input.ClientSeq { + t.Fatal("input was renumbered") + } + if _, err := connection.Read(ctx); err != io.EOF { + t.Fatalf("replayed EOF: %v", err) + } + if _, err := connection.Write(ctx, []byte("c")); err != nil { + t.Fatal(err) + } + if got := receive(); got.ClientSeq <= ordinary.ClientSeq { + t.Fatal("ordinary sequence floor lost") + } + second, err := fresh.OpenTCPConnection(ctx, "ahp-session:/s1", create) + if err != nil { + t.Fatal(err) + } + waiter := make(chan error, 1) + go func() { _, err := second.Read(ctx); waiter <- err }() + if err := multi.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if !connection.IsClosed() || !second.IsClosed() { + t.Fatal("permanent shutdown retained live streams") + } + select { + case err := <-waiter: + if err == nil { + t.Fatal("shutdown did not fail blocked read") + } + case <-ctx.Done(): + t.Fatal("shutdown blocked read") + } + } else { + if !connection.IsClosed() { + t.Fatal("snapshot/missing/fallback revived TCP") + } + if _, err := connection.Read(ctx); err == nil { + t.Fatal("terminated stream read succeeded") + } + } + }) + } +} + func TestAutomationCapabilitiesUpdatedAcrossReconnect(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index ebef72956..84f2775e4 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -25,6 +25,175 @@ const ( ReduceOutcomeOutOfScope ) +// TcpReduceError reports a rejected TCP action. The reducer leaves state unchanged. +type TcpReduceError struct { + Reason string +} + +func (e *TcpReduceError) Error() string { + return "Invalid TCP action: " + e.Reason +} + +const tcpMaxSafeInteger int64 = 1<<53 - 1 + +func requireTCPOffset(value int64) error { + if value < 0 || value > tcpMaxSafeInteger { + return &TcpReduceError{Reason: "offset must be a nonnegative safe integer"} + } + return nil +} + +func tcpPayloadLength(data string, maximumChunkSize int64) (int64, error) { + // Match JavaScript string length for the size check, including invalid + // non-ASCII payloads, before validating the base64 alphabet. + encodedLength := 0 + for _, char := range data { + encodedLength++ + if char > 0xffff { + encodedLength++ + } + } + if maximumChunkSize < 0 || len(data) == 0 || + uint64(encodedLength) > 4*((uint64(maximumChunkSize)+2)/3) { + return 0, &TcpReduceError{Reason: "chunk size"} + } + padding := 0 + if data[len(data)-1] == '=' { + padding = 1 + if len(data) > 1 && data[len(data)-2] == '=' { + padding = 2 + } + } + if len(data)%4 != 0 { + return 0, &TcpReduceError{Reason: "base64 encoding"} + } + var last byte + for i := 0; i < len(data)-padding; i++ { + switch c := data[i]; { + case c >= 'A' && c <= 'Z': + last = c - 'A' + case c >= 'a' && c <= 'z': + last = c - 'a' + 26 + case c >= '0' && c <= '9': + last = c - '0' + 52 + case c == '+': + last = 62 + case c == '/': + last = 63 + default: + return 0, &TcpReduceError{Reason: "base64 encoding"} + } + } + if (padding == 2 && last%16 != 0) || (padding == 1 && last%4 != 0) { + return 0, &TcpReduceError{Reason: "noncanonical base64 padding bits"} + } + length := int64(len(data)/4)*3 - int64(padding) + if length > maximumChunkSize { + return 0, &TcpReduceError{Reason: "chunk size"} + } + return length, nil +} + +func receiveTCP(direction *ahptypes.FlowControlledByteDirectionState, offset int64, data string, senderClosed bool) (ReduceOutcome, error) { + if err := requireTCPOffset(offset); err != nil { + return ReduceOutcomeNoOp, err + } + length, err := tcpPayloadLength(data, direction.MaximumChunkSize) + if err != nil { + return ReduceOutcomeNoOp, err + } + if length > tcpMaxSafeInteger-offset { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "offset must be a nonnegative safe integer"} + } + end := offset + length + if end <= direction.ReceivedBytes { + return ReduceOutcomeNoOp, nil + } + if offset != direction.ReceivedBytes { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "gap or overlapping byte range"} + } + if senderClosed || direction.EofAtBytes != nil { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "data after EOF or sender close"} + } + if end-direction.ConsumedBytes > direction.WindowBytes { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "receive window exceeded"} + } + direction.ReceivedBytes = end + return ReduceOutcomeApplied, nil +} + +func consumeTCP(direction *ahptypes.FlowControlledByteDirectionState, consumedBytes int64) (ReduceOutcome, error) { + if err := requireTCPOffset(consumedBytes); err != nil { + return ReduceOutcomeNoOp, err + } + if consumedBytes > direction.ReceivedBytes { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "consuming bytes not received"} + } + if consumedBytes <= direction.ConsumedBytes { + return ReduceOutcomeNoOp, nil + } + direction.ConsumedBytes = consumedBytes + return ReduceOutcomeApplied, nil +} + +func eofTCP(direction *ahptypes.FlowControlledByteDirectionState, finalOffset int64, senderClosed bool) (ReduceOutcome, error) { + if err := requireTCPOffset(finalOffset); err != nil { + return ReduceOutcomeNoOp, err + } + if finalOffset != direction.ReceivedBytes { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "EOF offset"} + } + if direction.EofAtBytes != nil && *direction.EofAtBytes == finalOffset { + return ReduceOutcomeNoOp, nil + } + if senderClosed { + return ReduceOutcomeNoOp, &TcpReduceError{Reason: "EOF after sender close"} + } + direction.EofAtBytes = &finalOffset + return ReduceOutcomeApplied, nil +} + +// ApplyActionToTCP applies an action without retaining its payload. Invalid +// actions return a *TcpReduceError and leave state unchanged. Adapters must +// reset the channel on error and write bytes only when ReceivedBytes advances. +// This reducer is not a lossless stream subscription or a snapshot mirror. +func ApplyActionToTCP(state *ahptypes.TcpConnectionState, action ahptypes.StateAction) (ReduceOutcome, error) { + if state.Reset != nil { + return ReduceOutcomeNoOp, nil + } + switch a := action.Value.(type) { + case *ahptypes.TcpInputAction: + return receiveTCP(&state.Input, a.Offset, a.Data, state.ClientClosed) + case *ahptypes.TcpDataAction: + return receiveTCP(&state.Output, a.Offset, a.Data, state.HostClosed) + case *ahptypes.TcpInputConsumedAction: + return consumeTCP(&state.Input, a.ConsumedBytes) + case *ahptypes.TcpDataConsumedAction: + return consumeTCP(&state.Output, a.ConsumedBytes) + case *ahptypes.TcpInputEofAction: + return eofTCP(&state.Input, a.FinalOffset, state.ClientClosed) + case *ahptypes.TcpDataEofAction: + return eofTCP(&state.Output, a.FinalOffset, state.HostClosed) + case *ahptypes.TcpClientCloseAction: + if state.ClientClosed { + return ReduceOutcomeNoOp, nil + } + state.ClientClosed = true + case *ahptypes.TcpHostCloseAction: + if state.HostClosed { + return ReduceOutcomeNoOp, nil + } + state.HostClosed = true + case *ahptypes.TcpClientResetAction: + state.Reset = &ahptypes.TcpResetState{Source: ahptypes.TcpEndpointClient, Reason: a.Reason} + case *ahptypes.TcpHostResetAction: + state.Reset = &ahptypes.TcpResetState{Source: ahptypes.TcpEndpointHost, Reason: a.Reason} + default: + return ReduceOutcomeOutOfScope, nil + } + return ReduceOutcomeApplied, nil +} + func addMillisecondsToTimestamp(timestamp string, duration int64) string { start, err := time.Parse(time.RFC3339Nano, timestamp) if err != nil { diff --git a/clients/go/ahp/reducers_fixture_test.go b/clients/go/ahp/reducers_fixture_test.go index a1617d4b7..9c5ce9dd5 100644 --- a/clients/go/ahp/reducers_fixture_test.go +++ b/clients/go/ahp/reducers_fixture_test.go @@ -2,7 +2,9 @@ package ahp import ( "encoding/json" + "errors" "fmt" + "math" "os" "path/filepath" "reflect" @@ -83,6 +85,15 @@ var reducerFixturesSkipList = map[string]string{ // Add entries like "123-foo.json": "reason" when needed. } +type reducerFixture struct { + Description string `json:"description"` + Reducer string `json:"reducer"` + Initial json.RawMessage `json:"initial"` + Actions []json.RawMessage `json:"actions"` + Expected json.RawMessage `json:"expected"` + ExpectedError string `json:"expectedError"` +} + // TestFixtureDrivenReducerParity loads every fixture under // types/test-cases/reducers/*.json, applies the actions through the // matching Go reducer, and compares the resulting state with the @@ -117,13 +128,7 @@ func TestFixtureDrivenReducerParity(t *testing.T) { continue } - var fixture struct { - Description string `json:"description"` - Reducer string `json:"reducer"` - Initial json.RawMessage `json:"initial"` - Actions []json.RawMessage `json:"actions"` - Expected json.RawMessage `json:"expected"` - } + var fixture reducerFixture if err := json.Unmarshal(raw, &fixture); err != nil { t.Errorf("%s: parse fixture: %v", name, err) failed++ @@ -131,32 +136,27 @@ func TestFixtureDrivenReducerParity(t *testing.T) { } ok := t.Run(fmt.Sprintf("%s/%s", fixture.Reducer, name), func(tt *testing.T) { - actions := make([]ahptypes.StateAction, len(fixture.Actions)) - for i, raw := range fixture.Actions { - if err := json.Unmarshal(raw, &actions[i]); err != nil { - tt.Fatalf("decode action %d: %v", i, err) - } - } - switch fixture.Reducer { case "root": - runFixture[ahptypes.RootState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToRoot) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToRoot)) case "session": - runFixture[ahptypes.SessionState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToSession) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToSession)) case "chat": - runFixture[ahptypes.ChatState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToChat) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToChat)) case "terminal": - runFixture[ahptypes.TerminalState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToTerminal) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToTerminal)) case "changeset": - runFixture[ahptypes.ChangesetState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToChangeset) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToChangeset)) case "annotations": - runFixture[ahptypes.AnnotationsState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAnnotations) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToAnnotations)) case "resourceWatch": - runFixture[ahptypes.ResourceWatchState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToResourceWatch) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToResourceWatch)) case "automation": - runFixture[ahptypes.AutomationState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAutomation) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToAutomation)) case "automationRun": - runFixture[ahptypes.AutomationRunState](tt, fixture.Initial, fixture.Expected, actions, ApplyActionToAutomationRun) + runFixture(tt, fixture, reducerWithoutError(ApplyActionToAutomationRun)) + case "tcp": + runFixture(tt, fixture, ApplyActionToTCP) default: tt.Fatalf("unknown reducer kind %q", fixture.Reducer) } @@ -171,28 +171,61 @@ func TestFixtureDrivenReducerParity(t *testing.T) { t.Logf("Fixture results: %d passed, %d skipped, %d failed (of %d total)", passed, skipped, failed, passed+skipped+failed) } -func runFixture[T any](t *testing.T, initial, expected json.RawMessage, actions []ahptypes.StateAction, apply func(*T, ahptypes.StateAction) ReduceOutcome) { +func reducerWithoutError[T any](apply func(*T, ahptypes.StateAction) ReduceOutcome) func(*T, ahptypes.StateAction) (ReduceOutcome, error) { + return func(state *T, action ahptypes.StateAction) (ReduceOutcome, error) { + return apply(state, action), nil + } +} + +func runFixture[T any](t *testing.T, fixture reducerFixture, apply func(*T, ahptypes.StateAction) (ReduceOutcome, error)) { t.Helper() var state T - if err := json.Unmarshal(initial, &state); err != nil { + if err := json.Unmarshal(fixture.Initial, &state); err != nil { t.Fatalf("decode initial state: %v", err) } // Round-trip the initial state through marshal/unmarshal to catch // any data loss in the generated types before we mutate. roundTripped := stripNulls(reMarshal(t, &state)) - originalParsed := stripNulls(parseJSON(t, initial)) + originalParsed := stripNulls(parseJSON(t, fixture.Initial)) if !reflect.DeepEqual(roundTripped, originalParsed) { t.Fatalf("initial state did not survive round-trip:\nre-serialized: %s\noriginal: %s", mustPretty(roundTripped), mustPretty(originalParsed)) } - for i, action := range actions { - _ = apply(&state, action) - _ = i + if fixture.ExpectedError != "" && len(fixture.Actions) == 0 { + t.Fatal("expectedError requires a final action") + } + for i, raw := range fixture.Actions { + expectError := fixture.ExpectedError != "" && i == len(fixture.Actions)-1 + before := reMarshal(t, &state) + var action ahptypes.StateAction + if err := json.Unmarshal(raw, &action); err != nil { + var typeError *json.UnmarshalTypeError + offset, fractional := parseJSON(t, raw).(map[string]any)["offset"].(float64) + if !expectError || fixture.Reducer != "tcp" || + fixture.ExpectedError != "Invalid TCP action: offset must be a nonnegative safe integer" || + !fractional || math.Mod(offset, 1) == 0 || + !errors.As(err, &typeError) || typeError.Type.Kind() != reflect.Int64 || typeError.Field != "offset" { + t.Fatalf("decode action %d: %v", i, err) + } + t.Logf("final fractional action rejected by native int64 deserializer: %v", err) + } else { + _, err := apply(&state, action) + if expectError { + if err == nil || err.Error() != fixture.ExpectedError { + t.Fatalf("action %d: expected error %q, got %v", i, fixture.ExpectedError, err) + } + } else if err != nil { + t.Fatalf("action %d: unexpected reducer error: %v", i, err) + } + } + if expectError && !reflect.DeepEqual(before, reMarshal(t, &state)) { + t.Fatalf("rejected action %d mutated state", i) + } } actual := stripNulls(reMarshal(t, &state)) - want := stripNulls(parseJSON(t, expected)) + want := stripNulls(parseJSON(t, fixture.Expected)) if !reflect.DeepEqual(actual, want) { t.Fatalf("state mismatch:\nactual: %s\nexpected: %s", mustPretty(actual), mustPretty(want)) @@ -215,3 +248,123 @@ func mustPretty(v any) string { } return string(b) } + +func tcpTestState() ahptypes.TcpConnectionState { + direction := ahptypes.FlowControlledByteDirectionState{WindowBytes: 8, MaximumChunkSize: 6} + return ahptypes.TcpConnectionState{ + Session: "ahp-session:/s1", Target: ahptypes.TcpTarget{Host: "localhost", Port: 3000}, + Encoding: ahptypes.TcpDataEncodingBase64, Input: direction, Output: direction, + } +} + +func TestTCPReducerLargePayload(t *testing.T) { + const size = 4 * 1024 * 1024 + data := strings.Repeat("A", 4*((size+2)/3)-2) + "==" + for _, actionType := range []string{"tcp/input", "tcp/data"} { + t.Run(actionType, func(t *testing.T) { + state := tcpTestState() + state.Input.WindowBytes, state.Output.WindowBytes = size, size + state.Input.MaximumChunkSize, state.Output.MaximumChunkSize = size, size + var action ahptypes.StateAction + raw, err := json.Marshal(map[string]any{"type": actionType, "offset": 0, "data": data}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &action); err != nil { + t.Fatal(err) + } + if outcome, err := ApplyActionToTCP(&state, action); outcome != ReduceOutcomeApplied || err != nil { + t.Fatalf("large chunk: %v, %v", outcome, err) + } + direction := state.Input + if actionType == "tcp/data" { + direction = state.Output + } + if direction.ReceivedBytes != size { + t.Fatalf("received %d bytes, want %d", direction.ReceivedBytes, size) + } + before := reMarshal(t, state) + if outcome, err := ApplyActionToTCP(&state, action); outcome != ReduceOutcomeNoOp || err != nil { + t.Fatalf("duplicate: %v, %v", outcome, err) + } + // Even a fully duplicated range must pass canonical encoding validation. + switch a := action.Value.(type) { + case *ahptypes.TcpInputAction: + a.Data = data[:len(data)-3] + "B==" + case *ahptypes.TcpDataAction: + a.Data = data[:len(data)-3] + "B==" + } + _, err = ApplyActionToTCP(&state, action) + var tcpError *TcpReduceError + if !errors.As(err, &tcpError) || tcpError.Reason != "noncanonical base64 padding bits" { + t.Fatalf("invalid padding: %v", err) + } + if !reflect.DeepEqual(before, reMarshal(t, state)) { + t.Fatal("duplicate or rejected chunk mutated state") + } + }) + } +} + +func TestTCPReducerNativeIntegerBounds(t *testing.T) { + for _, actionType := range []string{"tcp/input", "tcp/data", "tcp/inputConsumed", "tcp/dataConsumed", "tcp/inputEof", "tcp/dataEof"} { + t.Run(actionType, func(t *testing.T) { + field := "offset" + if strings.HasSuffix(actionType, "Consumed") { + field = "consumedBytes" + } else if strings.HasSuffix(actionType, "Eof") { + field = "finalOffset" + } + + for _, value := range []int64{math.MinInt64, -1, tcpMaxSafeInteger + 1, math.MaxInt64} { + state := tcpTestState() + before := reMarshal(t, state) + raw, err := json.Marshal(map[string]any{"type": actionType, field: value, "data": "AA=="}) + if err != nil { + t.Fatal(err) + } + var action ahptypes.StateAction + if err := json.Unmarshal(raw, &action); err != nil { + t.Fatal(err) + } + outcome, err := ApplyActionToTCP(&state, action) + var tcpError *TcpReduceError + if outcome != ReduceOutcomeNoOp || !errors.As(err, &tcpError) || + tcpError.Reason != "offset must be a nonnegative safe integer" { + t.Fatalf("%d: %v, %v", value, outcome, err) + } + if !reflect.DeepEqual(before, reMarshal(t, state)) { + t.Fatal("invalid integer mutated state") + } + } + }) + } + state := tcpTestState() + state.Input.ReceivedBytes, state.Input.ConsumedBytes = tcpMaxSafeInteger-1, tcpMaxSafeInteger-1 + action := ahptypes.StateAction{Value: &ahptypes.TcpInputAction{Offset: tcpMaxSafeInteger - 1, Data: "AA=="}} + if outcome, err := ApplyActionToTCP(&state, action); outcome != ReduceOutcomeApplied || err != nil || state.Input.ReceivedBytes != tcpMaxSafeInteger { + t.Fatalf("last safe byte: %v, %v", outcome, err) + } +} + +func TestTCPReducerNonASCIIErrorOrder(t *testing.T) { + for _, test := range []struct { + data, reason string + }{ + {"\u00e9\u00e9\u00e9", "base64 encoding"}, + {"\U0001f600\U0001f600", "base64 encoding"}, + {"\U0001f600\U0001f600A", "chunk size"}, + } { + state := tcpTestState() + state.Input.MaximumChunkSize = 1 + before := reMarshal(t, state) + _, err := ApplyActionToTCP(&state, ahptypes.StateAction{Value: &ahptypes.TcpInputAction{Offset: 0, Data: test.data}}) + var tcpError *TcpReduceError + if !errors.As(err, &tcpError) || tcpError.Reason != test.reason { + t.Fatalf("%q: got %v, want %s", test.data, err, test.reason) + } + if !reflect.DeepEqual(before, reMarshal(t, state)) { + t.Fatal("invalid encoding mutated state") + } + } +} diff --git a/clients/go/ahp/tcp.go b/clients/go/ahp/tcp.go new file mode 100644 index 000000000..f096def65 --- /dev/null +++ b/clients/go/ahp/tcp.go @@ -0,0 +1,1048 @@ +package ahp + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "sort" + "strings" + "sync" + + "github.com/microsoft/agent-host-protocol/clients/go/ahptypes" +) + +const tcpMaxWindowBytes int64 = (1 << 32) - 1 + +// TCPConnectionError is a terminal stream or invalid-operation error. +type TCPConnectionError struct { + Reason string + Cause error +} + +func (e *TCPConnectionError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("ahp: TCP %s: %v", e.Reason, e.Cause) + } + return "ahp: TCP " + e.Reason +} + +func (e *TCPConnectionError) Unwrap() error { return e.Cause } + +type tcpPending struct { + action ahptypes.StateAction + sent uint64 +} + +// TCPConnection owns one protocol byte stream, not a native socket. +// Write accepts bytes into bounded protocol credit; Drain waits for consumption +// at the host. Transport loss suspends the same handle until explicitly resumed. +type TCPConnection struct { + mu sync.Mutex + sendMu sync.Mutex + resumeMu sync.Mutex + client *Client + events *EventStream + resource string + owner string + state ahptypes.TcpConnectionState + checkpoint int64 + lastSeq int64 + epoch uint64 + online bool + resuming bool + writing bool + ending bool + closing bool + closeQueued bool + terminal bool + err error + pending map[int64]tcpPending + received [][]byte + sentBytes int64 + consumed int64 + changed chan struct{} + cleanup chan struct{} + cleanupErr error +} + +// Resource is the host-assigned child channel URI. +func (t *TCPConnection) Resource() string { return t.resource } + +// IsClosed reports final close/reset/disposal, not a resumable transport loss. +func (t *TCPConnection) IsClosed() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.terminal +} + +func (c *Client) registerTCPStream(connection *TCPConnection) bool { + c.tcpMu.Lock() + defer c.tcpMu.Unlock() + if c.tcpDisposed { + return false + } + if c.tcpStreams == nil { + c.tcpStreams = make(map[*TCPConnection]struct{}) + } + c.tcpStreams[connection] = struct{}{} + return true +} + +func (c *Client) disposeTCPStreams(ctx context.Context) error { + c.tcpMu.Lock() + c.tcpDisposed = true + streams := make([]*TCPConnection, 0, len(c.tcpStreams)) + for connection := range c.tcpStreams { + streams = append(streams, connection) + } + c.tcpMu.Unlock() + var err error + for _, connection := range streams { + if connection.finishForClient(tcpConnectionError("disposed", "client shut down"), + tcpResetAction(ahptypes.TcpResetReasonConnectionAborted), false, c) { + err = errors.Join(err, connection.waitCleanup(ctx)) + } + } + return err +} + +// RestoreReconnectState carries negotiated TCP support and the complete client +// sequence allocator to a fresh transport. Host runtimes call this before their +// reconnect handshake; it does not initialize or subscribe the new transport. +func (c *Client) RestoreReconnectState(previous *Client) error { + if previous == nil || previous == c { + return tcpConnectionError("resume", "a previous client on a different transport is required") + } + previous.tcpMu.Lock() + owner, capability := previous.tcpClientID, previous.tcpCapability + previous.tcpMu.Unlock() + c.tcpMu.Lock() + if c.tcpClientID != "" && c.tcpClientID != owner { + c.tcpMu.Unlock() + return tcpConnectionError("resume", "previous transport belongs to a different clientId") + } + c.tcpClientID, c.tcpCapability = owner, capability + c.tcpMu.Unlock() + floor := previous.nextClientSeq.Load() + for { + next := c.nextClientSeq.Load() + if next >= floor || c.nextClientSeq.CompareAndSwap(next, floor) { + return nil + } + } +} + +func tcpConnectionError(reason, message string) error { + return &TCPConnectionError{Reason: reason, Cause: errors.New(message)} +} + +func tcpResetAction(reason ahptypes.TcpResetReason) ahptypes.StateAction { + return ahptypes.StateAction{Value: &ahptypes.TcpClientResetAction{Type: ahptypes.ActionTypeTcpClientReset, Reason: reason}} +} + +func validTCPDirection(d ahptypes.FlowControlledByteDirectionState) bool { + return d.WindowBytes > 0 && d.WindowBytes <= tcpMaxWindowBytes && + d.MaximumChunkSize > 0 && d.MaximumChunkSize <= d.WindowBytes && + d.ReceivedBytes == 0 && d.ConsumedBytes == 0 && d.EofAtBytes == nil +} + +func validateTCPOpen(session string, create ahptypes.TcpConnectionSubscription) error { + if !strings.HasPrefix(session, "ahp-session:") || len(session) == len("ahp-session:") || + create.Type != "tcpConnection" || create.Host == "" || strings.ContainsAny(create.Host, "/\\\x00 \t\r\n") || + create.Port < 1 || create.Port > 65535 || create.Encoding != ahptypes.TcpDataEncodingBase64 || + create.ReceiveWindowBytes < 1 || create.ReceiveWindowBytes > tcpMaxWindowBytes || + create.MaximumChunkSize < 1 || create.MaximumChunkSize > create.ReceiveWindowBytes { + return tcpConnectionError("invalidOpen", "invalid session, target, encoding, or byte limits") + } + return nil +} + +func tcpContext(c *Client) (context.Context, context.CancelFunc) { + if c.cfg.DefaultRequestTimeout > 0 { + return context.WithTimeout(context.Background(), c.cfg.DefaultRequestTimeout) + } + return context.WithCancel(context.Background()) +} + +// OpenTCPConnection atomically creates a child channel after Initialize has +// advertised base64 TCP support. Cancellation still cleans up a late creation +// response, including after the request timeout; the parent is never unsubscribed. +// For managed hosts use hosts.HostClientHandle.OpenTCPConnection so the runtime +// retains the stream across reconnects. +func (c *Client) OpenTCPConnection(ctx context.Context, session string, create ahptypes.TcpConnectionSubscription) (*TCPConnection, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := validateTCPOpen(session, create); err != nil { + return nil, err + } + c.tcpMu.Lock() + owner, capability := c.tcpClientID, c.tcpCapability + supported := false + if capability != nil { + for _, encoding := range capability.Encodings { + supported = supported || encoding == create.Encoding + } + } + c.tcpMu.Unlock() + if owner == "" || !supported { + return nil, tcpConnectionError("unsupported", "Initialize must advertise the requested TCP encoding") + } + events := c.events(true, "") + type opened struct { + connection *TCPConnection + err error + } + result := make(chan opened) + go func() { + requestCtx, cancel := tcpContext(c) + defer cancel() + var response ahptypes.SubscribeResult + err := c.requestWithLateResult(requestCtx, "subscribe", ahptypes.SubscribeParams{Channel: session, Create: &create}, &response, c.cleanupLateTCPCreation, func(raw json.RawMessage) { + var identity tcpCreationIdentity + if json.Unmarshal(raw, &identity) == nil && identity.Snapshot != nil { + c.bindEventStream(events, identity.Snapshot.Resource) + } + }) + var connection *TCPConnection + if err == nil { + snapshot := response.Snapshot + if snapshot == nil || !strings.HasPrefix(snapshot.Resource, "ahp-tcp:") || snapshot.Resource == "ahp-tcp:" || + snapshot.FromSeq < 0 || snapshot.FromSeq > tcpMaxSafeInteger || snapshot.State.Tcp == nil { + err = tcpConnectionError("protocol", "creation did not return a TCP snapshot") + } else { + state := snapshot.State.Tcp + if state.Session != session || state.Target.Host != create.Host || state.Target.Port != create.Port || + state.Encoding != create.Encoding || !validTCPDirection(state.Input) || !validTCPDirection(state.Output) || + state.Output.WindowBytes > create.ReceiveWindowBytes || state.Output.MaximumChunkSize > create.MaximumChunkSize || + state.ClientClosed || state.HostClosed || state.Reset != nil { + err = tcpConnectionError("protocol", "creation snapshot is not fresh or does not match the request") + } else { + connection = &TCPConnection{ + client: c, events: events, resource: snapshot.Resource, owner: owner, state: *state, + checkpoint: snapshot.FromSeq, epoch: 1, online: true, + pending: make(map[int64]tcpPending), changed: make(chan struct{}), + } + } + } + } + if err == nil && !c.registerTCPStream(connection) { + err = ErrShutdown + } + if err == nil && events.Err() != nil { + err = events.Err() + } + if err != nil { + events.Close() + if connection != nil { + connection.finish(err, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + err = errors.Join(err, connection.waitCleanup(requestCtx)) + connection = nil + } else if response.Snapshot != nil && strings.HasPrefix(response.Snapshot.Resource, "ahp-tcp:") && response.Snapshot.Resource != "ahp-tcp:" { + err = errors.Join(err, c.Unsubscribe(requestCtx, response.Snapshot.Resource)) + } + } + select { + case result <- opened{connection, err}: + if err == nil { + connection.start(1, events) + } + case <-ctx.Done(): + events.Close() + if connection != nil && err == nil { + if err := connection.Dispose(requestCtx); err != nil { + log.Printf("ahp: cancelled TCP creation cleanup failed: %v", err) + } + } + } + }() + select { + case out := <-result: + return out.connection, out.err + case <-ctx.Done(): + events.Close() + return nil, ctx.Err() + } +} + +func (t *TCPConnection) wakeLocked() { + close(t.changed) + t.changed = make(chan struct{}) +} + +// Cleanup needs the child identity even when the abandoned state cannot decode. +type tcpCreationIdentity struct { + Snapshot *tcpSnapshotIdentity `json:"snapshot"` +} + +type tcpSnapshotIdentity struct { + Resource string `json:"resource"` +} + +func (c *Client) cleanupLateTCPCreation(raw json.RawMessage) { + var response tcpCreationIdentity + if err := json.Unmarshal(raw, &response); err != nil { + log.Printf("ahp: invalid late TCP creation response: %v", err) + return + } + if response.Snapshot == nil || !strings.HasPrefix(response.Snapshot.Resource, "ahp-tcp:") || response.Snapshot.Resource == "ahp-tcp:" { + log.Printf("ahp: late TCP creation response omitted a valid child resource") + return + } + ctx, cancel := tcpContext(c) + defer cancel() + seq := c.nextClientSeq.Add(1) - 1 + var err error + if seq < 1 || seq > tcpMaxSafeInteger { + err = tcpConnectionError("protocol", "client sequence exhausted") + } else { + err = c.Notify(ctx, "dispatchAction", ahptypes.DispatchActionParams{ + Channel: response.Snapshot.Resource, ClientSeq: seq, + Action: tcpResetAction(ahptypes.TcpResetReasonConnectionAborted), + }) + } + err = errors.Join(err, c.Unsubscribe(ctx, response.Snapshot.Resource)) + if err != nil { + log.Printf("ahp: late TCP creation cleanup failed: %v", err) + } +} + +func (t *TCPConnection) queueLocked(action ahptypes.StateAction) error { + seq := t.client.nextClientSeq.Add(1) - 1 + if seq < 1 || seq > tcpMaxSafeInteger { + return tcpConnectionError("protocol", "client sequence exhausted") + } + t.lastSeq = seq + t.pending[seq] = tcpPending{action: action} + t.wakeLocked() + return nil +} + +func waitTCP(ctx context.Context, changed <-chan struct{}) error { + select { + case <-changed: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Read delivers one decoded chunk. Delivery, not arrival, releases receive credit. +// EOF is returned only after buffered output has been delivered. +func (t *TCPConnection) Read(ctx context.Context) ([]byte, error) { + for { + t.mu.Lock() + if t.err != nil { + err := t.err + t.mu.Unlock() + return nil, err + } + if !t.resuming && len(t.received) > 0 { + data := t.received[0] + consumed := t.consumed + int64(len(data)) + var err error + if !t.terminal { + err = t.queueLocked(ahptypes.StateAction{Value: &ahptypes.TcpDataConsumedAction{Type: ahptypes.ActionTypeTcpDataConsumed, ConsumedBytes: consumed}}) + } + if err == nil { + t.received[0] = nil + t.received = t.received[1:] + t.consumed = consumed + } + t.mu.Unlock() + if err != nil { + t.finish(err, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + return nil, err + } + return data, nil + } + if t.terminal || (!t.resuming && (t.state.Output.EofAtBytes != nil || t.state.HostClosed)) { + t.mu.Unlock() + return nil, io.EOF + } + changed := t.changed + t.mu.Unlock() + if err := waitTCP(ctx, changed); err != nil { + return nil, err + } + } +} + +// Write accepts bytes into the negotiated input window. One concurrent writer +// is permitted; partial progress is returned on cancellation. Accepted bytes are +// retained across disconnect, including actions not yet echoed by the host. +func (t *TCPConnection) Write(ctx context.Context, data []byte) (int, error) { + t.mu.Lock() + if t.writing || t.ending || t.terminal { + t.mu.Unlock() + return 0, tcpConnectionError("closed", "write requires an open, idle writer") + } + t.writing = true + t.mu.Unlock() + defer func() { + t.mu.Lock() + t.writing = false + t.wakeLocked() + t.mu.Unlock() + }() + written := 0 + for written < len(data) { + if err := ctx.Err(); err != nil { + return written, err + } + t.mu.Lock() + if t.err != nil || t.terminal || t.ending { + err := t.err + if err == nil { + err = tcpConnectionError("closed", "connection closed during write") + } + t.mu.Unlock() + return written, err + } + credit := t.state.Input.WindowBytes - (t.sentBytes - t.state.Input.ConsumedBytes) + if !t.online || t.resuming || credit == 0 { + changed := t.changed + t.mu.Unlock() + if err := waitTCP(ctx, changed); err != nil { + return written, err + } + continue + } + length := min(int64(len(data)-written), credit, t.state.Input.MaximumChunkSize) + if length <= 0 || t.sentBytes > tcpMaxSafeInteger-length { + t.mu.Unlock() + err := tcpConnectionError("protocol", "invalid input credit or byte offset exhaustion") + t.finish(err, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + return written, err + } + action := ahptypes.StateAction{Value: &ahptypes.TcpInputAction{ + Type: ahptypes.ActionTypeTcpInput, Offset: t.sentBytes, Data: base64.StdEncoding.EncodeToString(data[written : written+int(length)]), + }} + if err := t.queueLocked(action); err != nil { + t.mu.Unlock() + return written, err + } + t.sentBytes += length + written += int(length) + t.mu.Unlock() + } + return written, nil +} + +// Drain waits for all accepted input to be consumed at the host. +func (t *TCPConnection) Drain(ctx context.Context) error { + for { + t.mu.Lock() + if t.err != nil || (t.terminal && t.state.Input.ConsumedBytes < t.sentBytes) { + err := t.err + if err == nil { + err = tcpConnectionError("closed", "connection closed before drain") + } + t.mu.Unlock() + return err + } + if t.state.Input.ConsumedBytes >= t.sentBytes { + t.mu.Unlock() + return nil + } + changed := t.changed + t.mu.Unlock() + if err := waitTCP(ctx, changed); err != nil { + return err + } + } +} + +// End half-closes input, preserving output reads. Finish a concurrent Write first. +func (t *TCPConnection) End(ctx context.Context) error { + for { + t.mu.Lock() + if t.terminal || t.writing { + t.mu.Unlock() + return tcpConnectionError("closed", "end requires an open, idle writer") + } + if !t.resuming { + var err error + if !t.ending { + err = t.queueLocked(ahptypes.StateAction{Value: &ahptypes.TcpInputEofAction{Type: ahptypes.ActionTypeTcpInputEof, FinalOffset: t.sentBytes}}) + if err == nil { + t.ending = true + } + } + t.mu.Unlock() + return err + } + changed := t.changed + t.mu.Unlock() + if err := waitTCP(ctx, changed); err != nil { + return err + } + } +} + +// Close stops writes and awaits both close acknowledgements and consumed bytes. +// Continue reading concurrently to drain output. Dispose aborts without draining. +func (t *TCPConnection) Close(ctx context.Context) error { + t.mu.Lock() + if t.terminal { + t.mu.Unlock() + return t.waitCleanup(ctx) + } + if t.writing { + t.mu.Unlock() + return tcpConnectionError("busy", "close requires an idle writer") + } + t.ending, t.closing = true, true + t.wakeLocked() + t.mu.Unlock() + t.advanceClose() + for { + t.mu.Lock() + err, terminal, changed := t.err, t.terminal, t.changed + t.mu.Unlock() + if err != nil { + return err + } + if terminal { + return t.waitCleanup(ctx) + } + if err := waitTCP(ctx, changed); err != nil { + return err + } + } +} + +func (t *TCPConnection) advanceClose() { + t.mu.Lock() + if t.terminal || t.resuming || !t.closing { + t.mu.Unlock() + return + } + var err error + if !t.closeQueued && (t.state.HostClosed || t.state.Input.ConsumedBytes == t.sentBytes) { + err = t.queueLocked(ahptypes.StateAction{Value: &ahptypes.TcpClientCloseAction{Type: ahptypes.ActionTypeTcpClientClose}}) + t.closeQueued = err == nil + } + complete := t.state.ClientClosed && t.state.HostClosed && + t.state.Input.ConsumedBytes == t.sentBytes && + t.consumed == t.state.Output.ReceivedBytes && + t.state.Output.ConsumedBytes == t.consumed && len(t.pending) == 0 + t.mu.Unlock() + if err != nil { + t.finish(err, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + } else if complete { + t.finish(nil, ahptypes.StateAction{}, true) + } +} + +// Dispose aborts the stream and discards buffered output. Cleanup is exactly once. +func (t *TCPConnection) Dispose(ctx context.Context) error { + t.finish(tcpConnectionError("disposed", "connection disposed"), tcpResetAction(ahptypes.TcpResetReasonConnectionAborted), false) + return t.waitCleanup(ctx) +} + +func (t *TCPConnection) waitCleanup(ctx context.Context) error { + t.mu.Lock() + done := t.cleanup + t.mu.Unlock() + select { + case <-done: + t.mu.Lock() + err := t.cleanupErr + t.mu.Unlock() + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (t *TCPConnection) finish(reason error, action ahptypes.StateAction, preserve bool) { + t.finishForClient(reason, action, preserve, nil) +} + +func (t *TCPConnection) finishForClient(reason error, action ahptypes.StateAction, preserve bool, owner *Client) bool { + t.mu.Lock() + if owner != nil && t.client != owner { + t.mu.Unlock() + return false + } + if t.terminal { + t.mu.Unlock() + return true + } + t.terminal, t.online, t.resuming, t.err = true, false, false, reason + t.pending = nil + if !preserve { + t.received = nil + } + t.cleanup = make(chan struct{}) + client, events := t.client, t.events + t.wakeLocked() + t.mu.Unlock() + events.Close() + go func() { + t.sendMu.Lock() + defer t.sendMu.Unlock() + ctx, cancel := tcpContext(client) + defer cancel() + var err error + closed := false + select { + case <-client.Done(): + closed = true + default: + } + if !closed && action.Value != nil { + seq := client.nextClientSeq.Add(1) - 1 + if seq < 1 || seq > tcpMaxSafeInteger { + err = tcpConnectionError("protocol", "client sequence exhausted") + } else { + err = client.Notify(ctx, "dispatchAction", ahptypes.DispatchActionParams{Channel: t.resource, ClientSeq: seq, Action: action}) + } + } + if !closed { + err = errors.Join(err, client.Unsubscribe(ctx, t.resource)) + } + t.mu.Lock() + t.cleanupErr = err + if t.err != nil && err != nil { + t.err = errors.Join(t.err, err) + } + close(t.cleanup) + t.mu.Unlock() + client.tcpMu.Lock() + delete(client.tcpStreams, t) + client.tcpMu.Unlock() + }() + return true +} + +func (t *TCPConnection) suspend(epoch uint64) { + t.mu.Lock() + if t.epoch != epoch || t.terminal { + t.mu.Unlock() + return + } + t.online, t.resuming = false, false + events := t.events + t.wakeLocked() + t.mu.Unlock() + events.Close() +} + +func tcpEchoMatches(expected, actual ahptypes.StateAction) bool { + switch action := actual.Value.(type) { + case *ahptypes.TcpInputAction: + pending, ok := expected.Value.(*ahptypes.TcpInputAction) + return ok && *pending == *action + case *ahptypes.TcpDataConsumedAction: + pending, ok := expected.Value.(*ahptypes.TcpDataConsumedAction) + return ok && *pending == *action + case *ahptypes.TcpInputEofAction: + pending, ok := expected.Value.(*ahptypes.TcpInputEofAction) + return ok && *pending == *action + case *ahptypes.TcpClientCloseAction: + pending, ok := expected.Value.(*ahptypes.TcpClientCloseAction) + return ok && *pending == *action + case *ahptypes.TcpClientResetAction: + pending, ok := expected.Value.(*ahptypes.TcpClientResetAction) + return ok && *pending == *action + default: + return false + } +} + +func (t *TCPConnection) accept(envelope ahptypes.ActionEnvelope, epoch uint64) { + t.mu.Lock() + if t.epoch != epoch || t.terminal || envelope.Channel != t.resource { + t.mu.Unlock() + return + } + var err error + clientEcho := false + switch envelope.Action.Value.(type) { + case *ahptypes.TcpInputAction, *ahptypes.TcpDataConsumedAction, *ahptypes.TcpInputEofAction, + *ahptypes.TcpClientCloseAction, *ahptypes.TcpClientResetAction: + clientEcho = true + } + var pending tcpPending + var pendingExists bool + if clientEcho { + origin := envelope.Origin + if origin == nil || origin.ClientId != t.owner || origin.ClientSeq < 1 || + origin.ClientSeq > tcpMaxSafeInteger || origin.ClientSeq > t.lastSeq { + err = tcpConnectionError("protocol", "invalid client TCP echo origin") + } else { + pending, pendingExists = t.pending[origin.ClientSeq] + if pendingExists && !tcpEchoMatches(pending.action, envelope.Action) { + err = tcpConnectionError("protocol", "TCP echo does not match pending action") + } + } + } + if err == nil && envelope.ServerSeq <= t.checkpoint { + t.mu.Unlock() + return + } + before := t.state.Output.ReceivedBytes + next := t.state + if envelope.RejectionReason != nil { + err = tcpConnectionError("rejected", *envelope.RejectionReason) + } else if envelope.ServerSeq > tcpMaxSafeInteger { + err = tcpConnectionError("protocol", "invalid server sequence") + } else if err == nil { + var outcome ReduceOutcome + outcome, err = ApplyActionToTCP(&next, envelope.Action) + if err == nil && clientEcho && !pendingExists && outcome == ReduceOutcomeApplied { + err = tcpConnectionError("protocol", "unacknowledged TCP state advanced without a matching pending action") + } + } + if err == nil && (next.Input.ReceivedBytes > t.sentBytes || next.Output.ConsumedBytes > t.consumed || + next.Output.ReceivedBytes-t.consumed > next.Output.WindowBytes) { + err = tcpConnectionError("protocol", "host exceeded owned byte counters") + } + if err == nil && next.Output.ReceivedBytes > before { + if action, ok := envelope.Action.Value.(*ahptypes.TcpDataAction); ok { + var bytes []byte + bytes, err = base64.StdEncoding.Strict().DecodeString(action.Data) + if err == nil { + t.received = append(t.received, bytes) + } + } + } + if err == nil { + t.state = next + t.checkpoint = envelope.ServerSeq + if clientEcho { + delete(t.pending, envelope.Origin.ClientSeq) + } + } + reset := t.state.Reset + if t.state.HostClosed { + t.ending, t.closing = true, true + } + t.wakeLocked() + t.mu.Unlock() + if err != nil { + t.finish(err, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + } else if reset != nil { + t.finish(tcpConnectionError("reset", string(reset.Reason)), ahptypes.StateAction{}, false) + } else { + t.advanceClose() + } +} + +func (t *TCPConnection) start(epoch uint64, events *EventStream) { + go func() { + for event := range events.Events() { + t.mu.Lock() + current := t.epoch == epoch && !t.terminal + t.mu.Unlock() + if !current { + return + } + if action, ok := event.Event.(SubscriptionEventAction); ok { + t.accept(action.Envelope, epoch) + } + } + t.mu.Lock() + current := t.epoch == epoch && !t.terminal + t.mu.Unlock() + if !current { + return + } + var lag *SubscriptionLagError + var protocol *TransportError + err := events.Err() + if errors.As(err, &lag) || (errors.As(err, &protocol) && protocol.Kind == "protocol") { + t.finish(err, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + } else { + t.suspend(epoch) + } + }() + go t.sendPending(epoch) +} + +func (t *TCPConnection) sendPending(epoch uint64) { + for { + t.mu.Lock() + if t.epoch != epoch || t.terminal || !t.online || t.resuming { + t.mu.Unlock() + return + } + var seq int64 + for candidate, pending := range t.pending { + if pending.sent != epoch && (seq == 0 || candidate < seq) { + seq = candidate + } + } + pending, client, changed := t.pending[seq], t.client, t.changed + t.mu.Unlock() + if seq == 0 { + select { + case <-changed: + continue + case <-client.Done(): + t.suspend(epoch) + return + } + } + t.sendMu.Lock() + t.mu.Lock() + current := t.epoch == epoch && !t.terminal && t.online + t.mu.Unlock() + var err error + if current { + ctx, cancel := tcpContext(client) + err = client.Notify(ctx, "dispatchAction", ahptypes.DispatchActionParams{Channel: t.resource, ClientSeq: seq, Action: pending.action}) + cancel() + } + t.sendMu.Unlock() + if !current { + return + } + if err != nil { + t.suspend(epoch) + return + } + t.mu.Lock() + if existing, ok := t.pending[seq]; ok && t.epoch == epoch { + existing.sent = epoch + t.pending[seq] = existing + } + t.mu.Unlock() + } +} + +// ReconnectTCPConnections resumes existing handles on a caller-provided fresh +// transport. It never creates replacements. Replay is applied before queued live +// events and remaining pending actions are resent with their original sequences. +func (c *Client) ReconnectTCPConnections(ctx context.Context, params ahptypes.ReconnectParams, connections []*TCPConnection) (*ahptypes.ReconnectResult, error) { + seen := make(map[*TCPConnection]bool) + params.Subscriptions = append([]string(nil), params.Subscriptions...) + c.tcpMu.Lock() + wrongIdentity := c.tcpClientID != "" && c.tcpClientID != params.ClientId + c.tcpMu.Unlock() + if wrongIdentity { + return nil, tcpConnectionError("resume", "new transport was initialized for a different clientId") + } + locked := make([]*TCPConnection, 0, len(connections)) + defer func() { + for _, connection := range locked { + connection.resumeMu.Unlock() + } + }() + for _, connection := range connections { + if connection == nil || seen[connection] || !connection.resumeMu.TryLock() { + return nil, tcpConnectionError("resume", "duplicate, nil, or concurrently resuming handle") + } + seen[connection] = true + locked = append(locked, connection) + } + for _, connection := range connections { + connection.mu.Lock() + } + unlock := func() { + for _, connection := range connections { + connection.mu.Unlock() + } + } + if params.ClientId == "" || params.LastSeenServerSeq < 0 || params.LastSeenServerSeq > tcpMaxSafeInteger { + unlock() + return nil, tcpConnectionError("resume", "invalid identity or checkpoint") + } + consumerCheckpoint := params.LastSeenServerSeq + highest := c.nextClientSeq.Load() - 1 + var capability *ahptypes.TcpConnectionsCapability + for _, connection := range connections { + select { + case <-connection.client.Done(): + connection.online = false + default: + } + if connection.owner != params.ClientId || connection.terminal || connection.online || connection.client == c { + unlock() + return nil, tcpConnectionError("resume", "handles must be suspended, live, and owned by the same clientId") + } + highest = max(highest, connection.lastSeq, connection.client.nextClientSeq.Load()-1) + connection.client.tcpMu.Lock() + capability = connection.client.tcpCapability + connection.client.tcpMu.Unlock() + params.LastSeenServerSeq = min(params.LastSeenServerSeq, connection.checkpoint) + found := false + for _, resource := range params.Subscriptions { + found = found || resource == connection.resource + } + if !found { + params.Subscriptions = append(params.Subscriptions, connection.resource) + } + } + for { + next := c.nextClientSeq.Load() + if next > highest || c.nextClientSeq.CompareAndSwap(next, highest+1) { + break + } + } + if len(connections) != 0 { + c.tcpMu.Lock() + c.tcpClientID, c.tcpCapability = params.ClientId, capability + c.tcpMu.Unlock() + } + epochs := make(map[*TCPConnection]uint64, len(connections)) + for _, connection := range connections { + connection.events.Close() + connection.client.tcpMu.Lock() + delete(connection.client.tcpStreams, connection) + connection.client.tcpMu.Unlock() + connection.client, connection.events = c, c.events(true, connection.resource) + connection.epoch++ + epochs[connection] = connection.epoch + connection.resuming, connection.online = true, false + connection.wakeLocked() + } + registrationFailed := false + for _, connection := range connections { + registrationFailed = !c.registerTCPStream(connection) || registrationFailed + } + unlock() + if registrationFailed { + for _, connection := range connections { + connection.finishForClient(tcpConnectionError("disposed", "client shut down"), ahptypes.StateAction{}, false, c) + } + return nil, ErrShutdown + } + params.Channel = ahptypes.RootResourceURI + var result ahptypes.ReconnectResult + var raw json.RawMessage + err := c.Request(ctx, "reconnect", params, &raw) + if err != nil { + for _, connection := range connections { + var lag *SubscriptionLagError + var protocol *TransportError + failure := connection.events.Err() + if errors.As(failure, &lag) || (errors.As(failure, &protocol) && protocol.Kind == "protocol") { + connection.finish(failure, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + } else { + connection.suspend(epochs[connection]) + } + } + return nil, err + } + if err := json.Unmarshal(raw, &result); err != nil { + protocol := &TransportError{Kind: "protocol", Err: err} + for _, connection := range connections { + connection.finish(protocol, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + } + return nil, protocol + } + replay, ok := result.Value.(*ahptypes.ReconnectReplayResult) + if !ok { + for _, connection := range connections { + connection.finish(tcpConnectionError("replayUnavailable", "TCP cannot recover from a snapshot"), ahptypes.StateAction{}, false) + } + return &result, nil + } + actions := replay.Actions[:0] + for _, action := range replay.Actions { + if strings.HasPrefix(action.Channel, "ahp-tcp:") || action.ServerSeq > consumerCheckpoint { + actions = append(actions, action) + } + } + clear(replay.Actions[len(actions):]) + replay.Actions = actions + for _, connection := range connections { + for _, missing := range replay.Missing { + if missing == connection.resource { + connection.finish(tcpConnectionError("replayUnavailable", "TCP channel is missing"), ahptypes.StateAction{}, false) + } + } + for _, action := range replay.Actions { + connection.mu.Lock() + epoch := connection.epoch + connection.mu.Unlock() + connection.accept(action, epoch) + } + } + type resend struct { + connection *TCPConnection + seq int64 + action ahptypes.StateAction + } + var pending []resend + for _, connection := range connections { + drain: + for { + select { + case event, open := <-connection.events.Events(): + if !open { + if failure := connection.events.Err(); failure != nil { + var lag *SubscriptionLagError + var protocol *TransportError + if errors.As(failure, &lag) || (errors.As(failure, &protocol) && protocol.Kind == "protocol") { + connection.finish(failure, tcpResetAction(ahptypes.TcpResetReasonProtocolError), false) + } else { + connection.suspend(epochs[connection]) + } + } else { + connection.suspend(epochs[connection]) + } + break drain + } + if action, ok := event.Event.(SubscriptionEventAction); ok { + connection.mu.Lock() + epoch := connection.epoch + connection.mu.Unlock() + connection.accept(action.Envelope, epoch) + } + default: + break drain + } + } + connection.mu.Lock() + if !connection.terminal && connection.resuming { + for seq, action := range connection.pending { + pending = append(pending, resend{connection, seq, action.action}) + } + } + connection.mu.Unlock() + } + sort.Slice(pending, func(i, j int) bool { return pending[i].seq < pending[j].seq }) + for _, item := range pending { + item.connection.sendMu.Lock() + item.connection.mu.Lock() + terminal := item.connection.terminal + item.connection.mu.Unlock() + if terminal { + item.connection.sendMu.Unlock() + continue + } + err := c.Notify(ctx, "dispatchAction", ahptypes.DispatchActionParams{Channel: item.connection.resource, ClientSeq: item.seq, Action: item.action}) + item.connection.sendMu.Unlock() + if err != nil { + for _, connection := range connections { + connection.suspend(epochs[connection]) + } + return nil, err + } + item.connection.mu.Lock() + if action, exists := item.connection.pending[item.seq]; exists { + action.sent = item.connection.epoch + item.connection.pending[item.seq] = action + } + item.connection.mu.Unlock() + } + for _, connection := range connections { + connection.mu.Lock() + if !connection.terminal && connection.resuming { + connection.online, connection.resuming = true, false + connection.wakeLocked() + connection.start(connection.epoch, connection.events) + } + connection.mu.Unlock() + connection.advanceClose() + } + return &result, nil +} diff --git a/clients/go/ahp/tcp_test.go b/clients/go/ahp/tcp_test.go new file mode 100644 index 000000000..20c8f54dc --- /dev/null +++ b/clients/go/ahp/tcp_test.go @@ -0,0 +1,1376 @@ +package ahp + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "runtime" + "sync" + "testing" + "time" + + "github.com/microsoft/agent-host-protocol/clients/go/ahptypes" +) + +type tcpTestHost struct { + t *testing.T + client *Client + server *memTransport + ctx context.Context + requests chan ahptypes.JsonRpcRequest + notifications chan ahptypes.JsonRpcNotification + mu sync.Mutex + seq int64 + first []byte + holdCreate bool +} + +type tcpHeldSubscribeTransport struct { + Transport + release <-chan struct{} +} + +func (t tcpHeldSubscribeTransport) Send(ctx context.Context, message TransportMessage) error { + if err := t.Transport.Send(ctx, message); err != nil { + return err + } + parsed, err := message.IntoParsed() + if err != nil { + return err + } + if parsed.Request != nil && parsed.Request.Method == "subscribe" { + select { + case <-t.release: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +func newTCPTestHost(t *testing.T, initialize bool, first []byte, holdCreate bool, sendGate ...<-chan struct{}) *tcpTestHost { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + a, b := newMemTransportPair() + var transport Transport = a + if len(sendGate) != 0 { + transport = tcpHeldSubscribeTransport{Transport: a, release: sendGate[0]} + } + client, err := Connect(ctx, transport, DefaultConfig()) + if err != nil { + t.Fatal(err) + } + h := &tcpTestHost{t: t, client: client, server: b, ctx: ctx, requests: make(chan ahptypes.JsonRpcRequest, 8), notifications: make(chan ahptypes.JsonRpcNotification, 64), first: first, holdCreate: holdCreate} + t.Cleanup(func() { client.Shutdown(context.Background()); cancel() }) + go func() { + for { + message, err := b.Recv(ctx) + if err != nil { + return + } + parsed, err := message.IntoParsed() + if err != nil { + t.Error(err) + return + } + if parsed.Notification != nil { + select { + case h.notifications <- *parsed.Notification: + case <-ctx.Done(): + return + } + } else if parsed.Request != nil { + request := *parsed.Request + switch request.Method { + case "initialize": + h.reply(request, map[string]any{"protocolVersion": ahptypes.ProtocolVersion, "serverSeq": 0, "snapshots": []any{}, "tcpConnections": map[string]any{"encodings": []string{"base64"}}}) + case "subscribe": + if holdCreate { + h.requests <- request + continue + } + h.reply(request, h.snapshot()) + if first != nil { + h.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 0, Data: base64.StdEncoding.EncodeToString(first)}, nil, nil) + } + case "ping": + h.reply(request, nil) + default: + h.requests <- request + } + } + } + }() + if initialize { + if _, err := client.Initialize(ctx, "owner", ahptypes.SupportedProtocolVersions(), nil); err != nil { + t.Fatal(err) + } + } + return h +} + +func (h *tcpTestHost) snapshot() ahptypes.SubscribeResult { + state := tcpTestState() + state.Input.WindowBytes, state.Input.MaximumChunkSize = 4, 3 + state.Output.WindowBytes, state.Output.MaximumChunkSize = 4, 3 + return ahptypes.SubscribeResult{Snapshot: &ahptypes.Snapshot{Resource: "ahp-tcp:/owned", State: ahptypes.SnapshotState{Tcp: &state}}} +} + +func (h *tcpTestHost) reply(request ahptypes.JsonRpcRequest, result any) { + h.t.Helper() + raw, err := json.Marshal(result) + if err != nil { + h.t.Error(err) + return + } + if err := h.server.Send(h.ctx, NewParsedMessage(ahptypes.JsonRpcMessage{SuccessResponse: &ahptypes.JsonRpcSuccessResponse{JsonRpc: ahptypes.JsonRpcV2, ID: request.ID, Result: raw}})); err != nil { + h.t.Error(err) + } +} + +func (h *tcpTestHost) emit(action any, origin *ahptypes.ActionOrigin, rejected *string) int64 { + return h.emitOn("ahp-tcp:/owned", action, origin, rejected) +} + +func (h *tcpTestHost) emitOn(resource string, action any, origin *ahptypes.ActionOrigin, rejected *string) int64 { + h.t.Helper() + raw, err := json.Marshal(action) + if err != nil { + h.t.Fatal(err) + } + var typed ahptypes.StateAction + if err := json.Unmarshal(raw, &typed); err != nil { + h.t.Fatal(err) + } + h.mu.Lock() + defer h.mu.Unlock() + h.seq++ + body, err := json.Marshal(ahptypes.ActionEnvelope{Channel: resource, ServerSeq: h.seq, Action: typed, Origin: origin, RejectionReason: rejected}) + if err != nil { + h.t.Fatal(err) + } + if err := h.server.Send(h.ctx, NewParsedMessage(ahptypes.JsonRpcMessage{Notification: &ahptypes.JsonRpcNotification{JsonRpc: ahptypes.JsonRpcV2, Method: "action", Params: body}})); err != nil { + h.t.Fatal(err) + } + return h.seq +} + +func (h *tcpTestHost) unrelatedBurst() { + h.t.Helper() + barrier := h.client.AttachSubscription("ahp-session:/barrier") + defer barrier.Close() + for i := 0; i < 16; i++ { + h.emitOn("ahp-session:/other", &ahptypes.SessionTitleChangedAction{Type: ahptypes.ActionTypeSessionTitleChanged, Title: "busy"}, nil, nil) + h.emitOn("ahp-tcp:/other", &ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: int64(i), Data: "AA=="}, nil, nil) + } + h.emitOn(barrier.URI(), &ahptypes.SessionTitleChangedAction{Type: ahptypes.ActionTypeSessionTitleChanged, Title: "barrier"}, nil, nil) + select { + case <-barrier.Events(): + case <-h.ctx.Done(): + h.t.Fatal("traffic barrier timed out") + } +} + +func TestOwnedTCPScopedCreationAndActiveTraffic(t *testing.T) { + release := make(chan struct{}) + h := newTCPTestHost(t, true, nil, true, release) + h.client.cfg.SubscriptionBuffer = 2 + type opened struct { + connection *TCPConnection + err error + } + result := make(chan opened, 1) + go func() { + connection, err := h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", tcpCreateParams()) + result <- opened{connection, err} + }() + request := <-h.requests + h.unrelatedBurst() + h.reply(request, h.snapshot()) + h.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 0, Data: "Bw=="}, nil, nil) + // The request writer is still blocked: the child route must already exist. + h.unrelatedBurst() + close(release) + out := <-result + if out.err != nil { + t.Fatal(out.err) + } + c := out.connection + c.mu.Lock() + h.unrelatedBurst() + c.mu.Unlock() + data, err := c.Read(h.ctx) + if err != nil || !bytes.Equal(data, []byte{7}) { + t.Fatalf("first child action lost: %v, %v", data, err) + } + credit := h.dispatch() + h.emit(credit.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: credit.ClientSeq}, nil) + h.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 1, Data: "CA=="}, nil, nil) + data, err = c.Read(h.ctx) + if err != nil || !bytes.Equal(data, []byte{8}) { + t.Fatalf("active stream failed after unrelated traffic: %v, %v", data, err) + } + h.dispatch() + if err := c.Dispose(h.ctx); err != nil { + t.Fatal(err) + } + h.dispatch() + h.notification("unsubscribe") +} + +func tcpCreateParams() ahptypes.TcpConnectionSubscription { + return ahptypes.TcpConnectionSubscription{Type: "tcpConnection", Host: "localhost", Port: 3000, Encoding: ahptypes.TcpDataEncodingBase64, ReceiveWindowBytes: 4, MaximumChunkSize: 3} +} + +func (h *tcpTestHost) open() *TCPConnection { + h.t.Helper() + connection, err := h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", tcpCreateParams()) + if err != nil { + h.t.Fatal(err) + } + return connection +} + +func (h *tcpTestHost) notification(method string) ahptypes.JsonRpcNotification { + h.t.Helper() + select { + case notification := <-h.notifications: + if notification.Method != method { + h.t.Fatalf("got %s, want %s", notification.Method, method) + } + return notification + case <-h.ctx.Done(): + h.t.Fatal("notification timeout") + return ahptypes.JsonRpcNotification{} + } +} + +func (h *tcpTestHost) dispatch() ahptypes.DispatchActionParams { + h.t.Helper() + n := h.notification("dispatchAction") + var params ahptypes.DispatchActionParams + if err := json.Unmarshal(n.Params, ¶ms); err != nil { + h.t.Fatal(err) + } + return params +} + +func (h *tcpTestHost) closeGracefully(c *TCPConnection) { + h.t.Helper() + done := make(chan error, 1) + go func() { done <- c.Close(h.ctx) }() + action := h.dispatch() + if _, ok := action.Action.Value.(*ahptypes.TcpClientCloseAction); !ok { + h.t.Fatal("missing client close") + } + h.emit(action.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: action.ClientSeq}, nil) + h.emit(&ahptypes.TcpHostCloseAction{Type: ahptypes.ActionTypeTcpHostClose}, nil, nil) + if err := <-done; err != nil { + h.t.Fatal(err) + } + h.notification("unsubscribe") +} + +func waitTCPState(t *testing.T, ctx context.Context, c *TCPConnection, predicate func(*TCPConnection) bool) { + t.Helper() + for { + c.mu.Lock() + ok := predicate(c) + changed := c.changed + c.mu.Unlock() + if ok { + return + } + if err := waitTCP(ctx, changed); err != nil { + t.Fatal(err) + } + } +} + +func TestOwnedTCPFlowControlAndHalfClose(t *testing.T) { + h := newTCPTestHost(t, true, []byte("abc"), false) + c := h.open() + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.state.Output.ReceivedBytes == 3 }) + select { + case event := <-h.notifications: + t.Fatalf("receipt released credit: %s", event.Method) + default: + } + data, err := c.Read(h.ctx) + if err != nil || string(data) != "abc" { + t.Fatalf("read %q %v", data, err) + } + credit := h.dispatch() + if a, ok := credit.Action.Value.(*ahptypes.TcpDataConsumedAction); !ok || a.ConsumedBytes != 3 { + t.Fatalf("bad delivered credit: %+v", credit) + } + done := make(chan error, 1) + go func() { + n, err := c.Write(h.ctx, []byte("abcdef")) + if err == nil && n != 6 { + err = errors.New("short write") + } + done <- err + }() + first, second := h.dispatch(), h.dispatch() + a, b := first.Action.Value.(*ahptypes.TcpInputAction), second.Action.Value.(*ahptypes.TcpInputAction) + if a.Offset != 0 || a.Data != "YWJj" || b.Offset != 3 || b.Data != "ZA==" { + t.Fatalf("chunks: %+v %+v", a, b) + } + c.mu.Lock() + received := c.state.Input.ReceivedBytes + c.mu.Unlock() + if received != 0 { + t.Fatal("optimistic reducer mutation") + } + select { + case err := <-done: + t.Fatalf("write did not block: %v", err) + default: + } + hostAction := h.emit(&ahptypes.TcpInputConsumedAction{Type: ahptypes.ActionTypeTcpInputConsumed, ConsumedBytes: 0}, + &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: first.ClientSeq}, nil) + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.checkpoint >= hostAction }) + c.mu.Lock() + _, retained := c.pending[first.ClientSeq] + c.mu.Unlock() + if !retained { + t.Fatal("host action cleared pending client action") + } + h.emit(a, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: first.ClientSeq}, nil) + h.emit(a, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: first.ClientSeq}, nil) + h.emit(b, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: second.ClientSeq}, nil) + h.emit(&ahptypes.TcpInputConsumedAction{Type: ahptypes.ActionTypeTcpInputConsumed, ConsumedBytes: 4}, nil, nil) + last := h.dispatch() + lastInput := last.Action.Value.(*ahptypes.TcpInputAction) + if lastInput.Offset != 4 || lastInput.Data != "ZWY=" { + t.Fatalf("resumed write %+v", lastInput) + } + if err := <-done; err != nil { + t.Fatal(err) + } + drained := make(chan error, 1) + go func() { drained <- c.Drain(h.ctx) }() + h.emit(lastInput, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: last.ClientSeq}, nil) + h.emit(&ahptypes.TcpInputConsumedAction{Type: ahptypes.ActionTypeTcpInputConsumed, ConsumedBytes: 6}, nil, nil) + if err := <-drained; err != nil { + t.Fatal(err) + } + h.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 0, Data: "YWJj"}, nil, nil) + h.emit(&ahptypes.TcpDataEofAction{Type: ahptypes.ActionTypeTcpDataEof, FinalOffset: 3}, nil, nil) + if _, err := c.Read(h.ctx); !errors.Is(err, io.EOF) { + t.Fatalf("duplicate data delivered or missing EOF: %v", err) + } + if err := c.End(h.ctx); err != nil { + t.Fatal(err) + } + end := h.dispatch() + if a, ok := end.Action.Value.(*ahptypes.TcpInputEofAction); !ok || a.FinalOffset != 6 { + t.Fatalf("bad EOF %+v", end) + } + h.emit(credit.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: credit.ClientSeq}, nil) + h.emit(end.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: end.ClientSeq}, nil) + h.closeGracefully(c) + if err := c.Dispose(h.ctx); err != nil { + t.Fatal(err) + } + if err := h.client.Ping(h.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-h.notifications: + t.Fatalf("duplicate cleanup %s", n.Method) + default: + } +} + +func TestOwnedTCPNegotiatedOutputLimits(t *testing.T) { + for _, limits := range []struct { + window, chunk int64 + valid bool + }{ + {2, 1, true}, {4, 2, true}, {0, 1, false}, {2, 0, false}, + {5, 1, false}, {4, 3, false}, {1, 2, false}, + } { + h := newTCPTestHost(t, true, nil, true) + create := tcpCreateParams() + create.MaximumChunkSize = 2 + var connection *TCPConnection + var err error + done := make(chan struct{}) + go func() { connection, err = h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", create); close(done) }() + req := <-h.requests + snapshot := h.snapshot() + snapshot.Snapshot.State.Tcp.Output.WindowBytes = limits.window + snapshot.Snapshot.State.Tcp.Output.MaximumChunkSize = limits.chunk + h.reply(req, snapshot) + <-done + if (err == nil) != limits.valid { + t.Fatalf("limits %d/%d valid=%v: %v", limits.window, limits.chunk, limits.valid, err) + } + if connection != nil { + if connection.state.Output.WindowBytes != limits.window || connection.state.Output.MaximumChunkSize != limits.chunk { + t.Fatal("negotiated limits not retained") + } + if err := connection.Dispose(h.ctx); err != nil { + t.Fatal(err) + } + } + } +} + +func TestOwnedTCPLimitsUInt32(t *testing.T) { + const limit int64 = 4_294_967_295 + for _, field := range []string{"boundary", "requestWindow", "requestChunk", "inputWindow", "inputChunk", "outputWindow", "outputChunk"} { + t.Run(field, func(t *testing.T) { + h := newTCPTestHost(t, true, nil, true) + create := tcpCreateParams() + create.ReceiveWindowBytes, create.MaximumChunkSize = limit, limit + snapshot := h.snapshot() + state := snapshot.Snapshot.State.Tcp + state.Input.WindowBytes, state.Input.MaximumChunkSize = limit, limit + state.Output.WindowBytes, state.Output.MaximumChunkSize = limit, limit + switch field { + case "requestWindow": + create.ReceiveWindowBytes++ + case "requestChunk": + create.MaximumChunkSize++ + case "inputWindow": + state.Input.WindowBytes++ + case "inputChunk": + state.Input.MaximumChunkSize++ + case "outputWindow": + state.Output.WindowBytes++ + case "outputChunk": + state.Output.MaximumChunkSize++ + } + var connection *TCPConnection + var err error + done := make(chan struct{}) + go func() { connection, err = h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", create); close(done) }() + select { + case req := <-h.requests: + if field == "requestWindow" || field == "requestChunk" { + t.Error("out-of-UInt32 request was sent") + } + h.reply(req, snapshot) + <-done + case <-done: + case <-h.ctx.Done(): + t.Fatal("creation hung") + } + if (err == nil) != (field == "boundary") { + t.Errorf("UInt32 validation result: %v", err) + } + if connection != nil { + if err := connection.Dispose(h.ctx); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestOwnedTCPReconnectFiltersOrdinaryReplay(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + if err := old.client.ShutdownPreservingTCP(old.ctx); err != nil { + t.Fatal(err) + } + fresh := newTCPTestHost(t, false, nil, false) + done := make(chan *ahptypes.ReconnectResult, 1) + go func() { + result, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "owner", LastSeenServerSeq: 20}, []*TCPConnection{c}) + if err != nil { + t.Error(err) + } + done <- result + }() + req := <-fresh.requests + var params ahptypes.ReconnectParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatal(err) + } + if params.LastSeenServerSeq != 0 { + t.Fatal("wire checkpoint not clamped for TCP") + } + fresh.reply(req, &ahptypes.ReconnectReplayResult{Missing: []string{}, Actions: []ahptypes.ActionEnvelope{ + {Channel: c.Resource(), ServerSeq: 11, Action: ahptypes.StateAction{Value: &ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Data: "eA=="}}}, + {Channel: "ahp-terminal:/t", ServerSeq: 15, Action: ahptypes.StateAction{Value: &ahptypes.TerminalDataAction{Type: ahptypes.ActionTypeTerminalData, Data: "hello"}}}, + {Channel: "ahp-terminal:/t", ServerSeq: 21, Action: ahptypes.StateAction{Value: &ahptypes.TerminalDataAction{Type: ahptypes.ActionTypeTerminalData, Data: "!"}}}, + }}) + result := <-done + if result == nil { + t.Fatal("reconnect failed") + } + text := "hello" + for _, action := range result.Value.(*ahptypes.ReconnectReplayResult).Actions { + if data, ok := action.Action.Value.(*ahptypes.TerminalDataAction); ok { + text += data.Data + } + } + if text != "hello!" { + t.Fatalf("ordinary replay duplicated output: %q", text) + } + if data, err := c.Read(fresh.ctx); err != nil || string(data) != "x" { + t.Fatalf("TCP replay lost: %q %v", data, err) + } + if err := c.Dispose(fresh.ctx); err != nil { + t.Fatal(err) + } +} + +func TestOwnedTCPCloseRetainsCrossingDataUntilAcknowledged(t *testing.T) { + h := newTCPTestHost(t, true, nil, false) + c := h.open() + done := make(chan error, 1) + go func() { done <- c.Close(h.ctx) }() + closeAction := h.dispatch() + if _, ok := closeAction.Action.Value.(*ahptypes.TcpClientCloseAction); !ok { + t.Fatal("missing client close") + } + if c.IsClosed() { + t.Fatal("local close released ownership before host close") + } + h.emit(closeAction.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: closeAction.ClientSeq}, nil) + h.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 0, Data: "YWI="}, nil, nil) + seq := h.emit(&ahptypes.TcpHostCloseAction{Type: ahptypes.ActionTypeTcpHostClose}, nil, nil) + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.checkpoint == seq }) + if err := h.client.Ping(h.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-h.notifications: + t.Fatalf("cleanup before output drain: %s", n.Method) + default: + } + data, err := c.Read(h.ctx) + if err != nil || string(data) != "ab" { + t.Fatalf("crossing data %q: %v", data, err) + } + credit := h.dispatch() + if a, ok := credit.Action.Value.(*ahptypes.TcpDataConsumedAction); !ok || a.ConsumedBytes != 2 { + t.Fatal("missing read credit") + } + if _, err := c.Read(h.ctx); !errors.Is(err, io.EOF) { + t.Fatal("host close did not finish output") + } + if c.IsClosed() { + t.Fatal("released before credit acknowledgement") + } + h.emit(credit.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: credit.ClientSeq}, nil) + if err := <-done; err != nil { + t.Fatal(err) + } + h.notification("unsubscribe") + if !c.IsClosed() { + t.Fatal("handshake did not release ownership") + } +} + +func TestOwnedTCPClosingResumesAndResetWakesClose(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + closed := make(chan error, 1) + go func() { closed <- c.Close(old.ctx) }() + original := old.dispatch() + if err := old.client.ShutdownPreservingTCP(old.ctx); err != nil { + t.Fatal(err) + } + fresh := newTCPTestHost(t, false, nil, false) + resumed := make(chan error, 1) + go func() { + _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "owner"}, []*TCPConnection{c}) + resumed <- err + }() + req := <-fresh.requests + fresh.reply(req, &ahptypes.ReconnectReplayResult{Actions: []ahptypes.ActionEnvelope{}, Missing: []string{}}) + if err := <-resumed; err != nil { + t.Fatal(err) + } + action := fresh.dispatch() + if _, ok := action.Action.Value.(*ahptypes.TcpClientCloseAction); !ok || action.ClientSeq != original.ClientSeq { + t.Fatal("closing resume replaced or renumbered client close") + } + select { + case err := <-closed: + t.Fatalf("close completed before handshake: %v", err) + default: + } + fresh.emit(&ahptypes.TcpHostResetAction{Type: ahptypes.ActionTypeTcpHostReset, Reason: ahptypes.TcpResetReasonConnectionReset}, nil, nil) + select { + case err := <-closed: + var tcpErr *TCPConnectionError + if !errors.As(err, &tcpErr) || tcpErr.Reason != "reset" { + t.Fatalf("reset close result: %v", err) + } + case <-fresh.ctx.Done(): + t.Fatal("reset left close blocked") + } + fresh.notification("unsubscribe") + if err := c.Dispose(fresh.ctx); err != nil { + t.Fatal(err) + } + if err := fresh.client.Ping(fresh.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-fresh.notifications: + t.Fatalf("duplicate cleanup: %s", n.Method) + default: + } +} + +func TestOwnedTCPResetAndRejectionWakeWaiters(t *testing.T) { + for _, reject := range []bool{false, true} { + t.Run(map[bool]string{false: "reset", true: "rejection"}[reject], func(t *testing.T) { + h := newTCPTestHost(t, true, nil, false) + c := h.open() + if _, err := c.Write(h.ctx, []byte("abcd")); err != nil { + t.Fatal(err) + } + first := h.dispatch() + h.dispatch() + results := make(chan error, 3) + go func() { _, err := c.Read(h.ctx); results <- err }() + go func() { _, err := c.Write(h.ctx, []byte("x")); results <- err }() + go func() { results <- c.Drain(h.ctx) }() + if reject { + reason := "" + h.emit(first.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: first.ClientSeq}, &reason) + } else { + h.emit(&ahptypes.TcpHostResetAction{Type: ahptypes.ActionTypeTcpHostReset, Reason: ahptypes.TcpResetReasonConnectionReset}, nil, nil) + } + for i := 0; i < 3; i++ { + select { + case err := <-results: + if err == nil { + t.Fatal("waiter succeeded after terminal failure") + } + case <-h.ctx.Done(): + t.Fatal("waiter hung") + } + } + if reject { + if a, ok := h.dispatch().Action.Value.(*ahptypes.TcpClientResetAction); !ok || a.Reason != ahptypes.TcpResetReasonProtocolError { + t.Fatal("missing protocol reset") + } + } + h.notification("unsubscribe") + }) + } +} + +func TestOwnedTCPReconnectRetainsHandleAndPendingSequences(t *testing.T) { + for _, acknowledged := range []bool{false, true} { + t.Run(map[bool]string{false: "resend", true: "acknowledged"}[acknowledged], func(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + if _, err := c.Write(old.ctx, []byte("ab")); err != nil { + t.Fatal(err) + } + original := old.dispatch() + seq := old.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 0, Data: "eHk="}, nil, nil) + waitTCPState(t, old.ctx, c, func(c *TCPConnection) bool { return c.checkpoint == seq }) + if err := old.client.ShutdownPreservingTCP(old.ctx); err != nil { + t.Fatal(err) + } + fresh := newTCPTestHost(t, false, nil, false) + done := make(chan error, 1) + go func() { + _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "owner", LastSeenServerSeq: 999, Subscriptions: []string{"ahp-session:/s1"}}, []*TCPConnection{c}) + done <- err + }() + var request ahptypes.JsonRpcRequest + select { + case request = <-fresh.requests: + case <-fresh.ctx.Done(): + t.Fatal("missing reconnect") + } + var params ahptypes.ReconnectParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + t.Fatal(err) + } + if request.Method != "reconnect" || params.LastSeenServerSeq != seq || len(params.Subscriptions) != 2 { + t.Fatalf("unsafe reconnect %+v", params) + } + actions := []ahptypes.ActionEnvelope{} + if acknowledged { + actions = append(actions, ahptypes.ActionEnvelope{Channel: c.Resource(), ServerSeq: 2, Action: original.Action, Origin: &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: original.ClientSeq}}) + } + fresh.reply(request, &ahptypes.ReconnectReplayResult{Actions: actions, Missing: []string{}}) + if err := <-done; err != nil { + t.Fatal(err) + } + if err := old.client.Shutdown(old.ctx); err != nil { + t.Fatal(err) + } + if c.IsClosed() { + t.Fatal("old client shutdown disposed rebound stream") + } + if !acknowledged { + replayed := fresh.dispatch() + if replayed.ClientSeq != original.ClientSeq || replayed.Channel != original.Channel { + t.Fatal("pending action was renumbered") + } + if a := replayed.Action.Value.(*ahptypes.TcpInputAction); a.Offset != 0 || a.Data != "YWI=" { + t.Fatal("pending bytes changed") + } + } + data, err := c.Read(fresh.ctx) + if err != nil || string(data) != "xy" { + t.Fatalf("retained read %q %v", data, err) + } + credit := fresh.dispatch() + if _, ok := credit.Action.Value.(*ahptypes.TcpDataConsumedAction); !ok || credit.ClientSeq <= original.ClientSeq { + t.Fatalf("ack replayed or sequence reused: %+v", credit) + } + fresh.client.tcpMu.Lock() + retainedIdentity := fresh.client.tcpClientID == "owner" && fresh.client.tcpCapability != nil + fresh.client.tcpMu.Unlock() + if !retainedIdentity { + t.Fatal("resume lost negotiated TCP capability") + } + if err := c.Dispose(fresh.ctx); err != nil { + t.Fatal(err) + } + fresh.dispatch() + fresh.notification("unsubscribe") + }) + } +} + +func TestOwnedTCPReconnectAppliesReplayBeforeQueuedLiveData(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + if _, err := c.Write(old.ctx, []byte("xy")); err != nil { + t.Fatal(err) + } + original := old.dispatch() + if err := old.client.ShutdownPreservingTCP(old.ctx); err != nil { + t.Fatal(err) + } + fresh := newTCPTestHost(t, false, nil, false) + fresh.client.cfg.SubscriptionBuffer = 2 + done := make(chan error, 1) + go func() { + _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, + ahptypes.ReconnectParams{ClientId: "owner", LastSeenServerSeq: 999}, []*TCPConnection{c}) + done <- err + }() + request := <-fresh.requests + fresh.mu.Lock() + fresh.seq = 1 + fresh.mu.Unlock() + fresh.unrelatedBurst() + func() { + // Hold application of replay while the receive loop queues later live frames. + c.mu.Lock() + defer c.mu.Unlock() + if !c.resuming || c.online { + t.Fatal("stream operations were released before replay") + } + fresh.reply(request, &ahptypes.ReconnectReplayResult{ + Actions: []ahptypes.ActionEnvelope{{ + Channel: c.resource, ServerSeq: 1, + Action: ahptypes.StateAction{Value: &ahptypes.TcpDataAction{ + Type: ahptypes.ActionTypeTcpData, Offset: 0, Data: "YWI=", + }}, + }}, + Missing: []string{}, + }) + fresh.unrelatedBurst() + fresh.emit(&ahptypes.TcpDataAction{Type: ahptypes.ActionTypeTcpData, Offset: 2, Data: "Y2Q="}, nil, nil) + fresh.emit(&ahptypes.TcpDataEofAction{Type: ahptypes.ActionTypeTcpDataEof, FinalOffset: 4}, nil, nil) + if err := fresh.client.Ping(fresh.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-fresh.notifications: + t.Fatalf("outgoing action before replay applied: %s", n.Method) + default: + } + }() + if err := <-done; err != nil { + t.Fatal(err) + } + resend := fresh.dispatch() + if resend.ClientSeq != original.ClientSeq { + t.Fatal("resume renumbered pending input") + } + if input, ok := resend.Action.Value.(*ahptypes.TcpInputAction); !ok || input.Offset != 0 || input.Data != "eHk=" { + t.Fatalf("incorrect resumed input: %+v", resend) + } + if err := fresh.client.Ping(fresh.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-fresh.notifications: + t.Fatalf("receipt released credit or duplicated input: %s", n.Method) + default: + } + for i, expected := range []string{"ab", "cd"} { + data, err := c.Read(fresh.ctx) + if err != nil || string(data) != expected { + t.Fatalf("read %q: %v", data, err) + } + credit := fresh.dispatch() + if action, ok := credit.Action.Value.(*ahptypes.TcpDataConsumedAction); !ok || + action.ConsumedBytes != int64((i+1)*2) || credit.ClientSeq <= original.ClientSeq { + t.Fatalf("incorrect resumed credit: %+v", credit) + } + } + if _, err := c.Read(fresh.ctx); !errors.Is(err, io.EOF) { + t.Fatalf("missing live EOF: %v", err) + } + if err := c.Dispose(fresh.ctx); err != nil { + t.Fatal(err) + } + fresh.dispatch() + fresh.notification("unsubscribe") +} + +func TestOwnedTCPReconnectSnapshotAndMissingAreTerminal(t *testing.T) { + for _, snapshot := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "snapshot"}[snapshot], func(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + old.client.ShutdownPreservingTCP(old.ctx) + fresh := newTCPTestHost(t, false, nil, false) + done := make(chan error, 1) + go func() { + _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "owner"}, []*TCPConnection{c}) + done <- err + }() + request := <-fresh.requests + if snapshot { + fresh.reply(request, &ahptypes.ReconnectSnapshotResult{Snapshots: []ahptypes.Snapshot{*fresh.snapshot().Snapshot}}) + } else { + fresh.reply(request, &ahptypes.ReconnectReplayResult{Actions: []ahptypes.ActionEnvelope{}, Missing: []string{c.Resource()}}) + } + if err := <-done; err != nil { + t.Fatal(err) + } + _, err := c.Read(fresh.ctx) + var tcpErr *TCPConnectionError + if !errors.As(err, &tcpErr) || tcpErr.Reason != "replayUnavailable" { + t.Fatalf("restored invalid stream: %v", err) + } + fresh.notification("unsubscribe") + next := fresh.open() + if err := next.Dispose(fresh.ctx); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestOwnedTCPCancelledCreationCleansKnownChild(t *testing.T) { + h := newTCPTestHost(t, true, nil, true) + ctx, cancel := context.WithCancel(h.ctx) + done := make(chan error, 1) + go func() { _, err := h.client.OpenTCPConnection(ctx, "ahp-session:/s1", tcpCreateParams()); done <- err }() + request := <-h.requests + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + h.reply(request, h.snapshot()) + h.dispatch() + n := h.notification("unsubscribe") + var params ahptypes.UnsubscribeParams + if err := json.Unmarshal(n.Params, ¶ms); err != nil { + t.Fatal(err) + } + if params.Channel != "ahp-tcp:/owned" { + t.Fatalf("unsubscribed parent: %s", params.Channel) + } +} + +func TestOwnedTCPClientShutdownDisposesLiveAndSuspendedStreams(t *testing.T) { + for _, suspended := range []bool{false, true} { + t.Run(map[bool]string{false: "live", true: "suspended"}[suspended], func(t *testing.T) { + h := newTCPTestHost(t, true, nil, false) + c := h.open() + if _, err := c.Write(h.ctx, []byte("abcd")); err != nil { + t.Fatal(err) + } + h.dispatch() + h.dispatch() + results := make(chan error, 3) + go func() { _, err := c.Read(h.ctx); results <- err }() + go func() { _, err := c.Write(h.ctx, []byte("x")); results <- err }() + go func() { results <- c.Drain(h.ctx) }() + for { + c.mu.Lock() + waiting := c.writing + c.mu.Unlock() + if waiting { + break + } + select { + case <-h.ctx.Done(): + t.Fatal("writer never blocked") + default: + runtime.Gosched() + } + } + if suspended { + if err := h.client.ShutdownPreservingTCP(h.ctx); err != nil { + t.Fatal(err) + } + if c.IsClosed() { + t.Fatal("preserving shutdown disposed stream") + } + } + if err := h.client.Shutdown(h.ctx); err != nil { + t.Fatal(err) + } + if !c.IsClosed() { + t.Fatal("shutdown left stream suspended") + } + if h.client.registerTCPStream(c) { + t.Fatal("shutdown permitted new stream registration") + } + for i := 0; i < 3; i++ { + select { + case err := <-results: + var tcpErr *TCPConnectionError + if !errors.As(err, &tcpErr) || tcpErr.Reason != "disposed" { + t.Fatalf("waiter: %v", err) + } + case <-h.ctx.Done(): + t.Fatal("shutdown left waiter blocked") + } + } + if err := h.client.Shutdown(h.ctx); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestOwnedTCPCloseWaitsForAcceptedInput(t *testing.T) { + h := newTCPTestHost(t, true, nil, false) + c := h.open() + if _, err := c.Write(h.ctx, []byte("ab")); err != nil { + t.Fatal(err) + } + input := h.dispatch() + done := make(chan error, 1) + go func() { done <- c.Close(h.ctx) }() + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.ending }) + select { + case err := <-done: + t.Fatalf("closed before drain: %v", err) + default: + } + select { + case n := <-h.notifications: + t.Fatalf("premature %s", n.Method) + default: + } + h.emit(input.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: input.ClientSeq}, nil) + h.emit(&ahptypes.TcpInputConsumedAction{Type: ahptypes.ActionTypeTcpInputConsumed, ConsumedBytes: 2}, nil, nil) + closeAction := h.dispatch() + if _, ok := closeAction.Action.Value.(*ahptypes.TcpClientCloseAction); !ok { + t.Fatal("missing final close") + } + h.emit(closeAction.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: closeAction.ClientSeq}, nil) + h.emit(&ahptypes.TcpHostCloseAction{Type: ahptypes.ActionTypeTcpHostClose}, nil, nil) + if err := <-done; err != nil { + t.Fatal(err) + } + h.notification("unsubscribe") +} + +func TestOwnedTCPLargePayloadEncoding(t *testing.T) { + h := newTCPTestHost(t, true, nil, true) + const size = 4 * 1024 * 1024 + params := tcpCreateParams() + params.ReceiveWindowBytes, params.MaximumChunkSize = size, size + var c *TCPConnection + var err error + done := make(chan struct{}) + go func() { c, err = h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", params); close(done) }() + req := <-h.requests + snapshot := h.snapshot() + snapshot.Snapshot.State.Tcp.Input.WindowBytes, snapshot.Snapshot.State.Tcp.Input.MaximumChunkSize = size, size + snapshot.Snapshot.State.Tcp.Output.WindowBytes, snapshot.Snapshot.State.Tcp.Output.MaximumChunkSize = size, size + h.reply(req, snapshot) + <-done + if err != nil { + t.Fatal(err) + } + payload := bytes.Repeat([]byte{0xab}, size) + if n, err := c.Write(h.ctx, payload); err != nil || n != size { + t.Fatalf("write %d: %v", n, err) + } + input := h.dispatch() + action := input.Action.Value.(*ahptypes.TcpInputAction) + decoded, err := base64.StdEncoding.Strict().DecodeString(action.Data) + if err != nil || !bytes.Equal(decoded, payload) || action.Offset != 0 { + t.Fatalf("incorrect large payload: %v", err) + } + h.emit(action, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: input.ClientSeq}, nil) + h.emit(&ahptypes.TcpInputConsumedAction{Type: ahptypes.ActionTypeTcpInputConsumed, ConsumedBytes: size}, nil, nil) + h.closeGracefully(c) +} +func TestOwnedTCPStrictLossWakesReader(t *testing.T) { + for _, overflow := range []bool{false, true} { + t.Run(map[bool]string{false: "decode", true: "overflow"}[overflow], func(t *testing.T) { + h := newTCPTestHost(t, true, nil, false) + c := h.open() + done := make(chan error, 1) + go func() { _, err := c.Read(h.ctx); done <- err }() + if overflow { + c.mu.Lock() + for i := 0; i < h.client.cfg.SubscriptionBuffer+2; i++ { + h.emit(map[string]any{"type": "future/tcp"}, nil, nil) + } + err := h.client.Ping(h.ctx) + c.mu.Unlock() + if err != nil { + t.Fatal(err) + } + } else if err := h.server.Send(h.ctx, NewTextMessage("{")); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + var lag *SubscriptionLagError + var protocol *TransportError + if overflow && !errors.As(err, &lag) || !overflow && (!errors.As(err, &protocol) || protocol.Kind != "protocol") { + t.Fatalf("loss was not surfaced: %v", err) + } + case <-h.ctx.Done(): + t.Fatal("loss did not wake reader") + } + if _, ok := h.dispatch().Action.Value.(*ahptypes.TcpClientResetAction); !ok { + t.Fatal("missing loss reset") + } + h.notification("unsubscribe") + if _, err := c.Read(h.ctx); err == nil { + t.Fatal("failed stream resumed") + } + }) + } +} + +func TestOwnedTCPValidationAndInvalidCreationCleanup(t *testing.T) { + h := newTCPTestHost(t, false, nil, true) + if c, err := h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", tcpCreateParams()); err == nil || c != nil { + t.Fatal("uninitialized creation accepted") + } + if _, err := h.client.Initialize(h.ctx, "owner", ahptypes.SupportedProtocolVersions(), nil); err != nil { + t.Fatal(err) + } + for _, change := range []func(*ahptypes.TcpConnectionSubscription){ + func(p *ahptypes.TcpConnectionSubscription) { p.Port = 0 }, + func(p *ahptypes.TcpConnectionSubscription) { p.Host = "http://localhost" }, + func(p *ahptypes.TcpConnectionSubscription) { p.MaximumChunkSize = 5 }, + func(p *ahptypes.TcpConnectionSubscription) { p.ReceiveWindowBytes = tcpMaxSafeInteger + 1 }, + func(p *ahptypes.TcpConnectionSubscription) { p.Encoding = "future" }, + } { + params := tcpCreateParams() + change(¶ms) + if c, err := h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", params); err == nil || c != nil { + t.Fatal("invalid creation accepted") + } + } + done := make(chan error, 1) + go func() { + c, err := h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", tcpCreateParams()) + if c != nil { + t.Error("invalid creation returned a handle") + } + done <- err + }() + request := <-h.requests + response := h.snapshot() + response.Snapshot.State.Tcp.Input.ReceivedBytes = 1 + h.reply(request, response) + if err := <-done; err == nil { + t.Fatal("non-fresh snapshot accepted") + } + h.notification("unsubscribe") +} + +func TestOwnedTCPTimeoutReleasesLateChildOnLiveTransport(t *testing.T) { + for _, mode := range []string{"valid", "malformedState", "heldSend"} { + t.Run(mode, func(t *testing.T) { + var gate chan struct{} + var gates []<-chan struct{} + if mode == "heldSend" { + gate = make(chan struct{}) + gates = append(gates, gate) + } + h := newTCPTestHost(t, true, nil, true, gates...) + h.client.cfg.DefaultRequestTimeout = 100 * time.Millisecond + done := make(chan error, 1) + go func() { + c, err := h.client.OpenTCPConnection(h.ctx, "ahp-session:/s1", tcpCreateParams()) + if c != nil { + t.Error("timeout returned a connection") + } + done <- err + }() + request := <-h.requests + if err := <-done; !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected timeout: %v", err) + } + if gate != nil { + close(gate) + } + if mode == "malformedState" { + h.reply(request, json.RawMessage(`{"snapshot":{"state":{"type":"tcp","input":{"windowBytes":0.5}},"resource":"ahp-tcp:/owned","fromSeq":0}}`)) + } else { + h.reply(request, h.snapshot()) + } + reset := h.dispatch() + if reset.Channel != "ahp-tcp:/owned" { + t.Fatal("reset targeted parent") + } + if action, ok := reset.Action.Value.(*ahptypes.TcpClientResetAction); !ok || action.Reason != ahptypes.TcpResetReasonConnectionAborted { + t.Fatal("missing late creation reset") + } + n := h.notification("unsubscribe") + var params ahptypes.UnsubscribeParams + if err := json.Unmarshal(n.Params, ¶ms); err != nil { + t.Fatal(err) + } + if params.Channel != "ahp-tcp:/owned" { + t.Fatal("unsubscribed parent") + } + h.reply(request, h.snapshot()) + if err := h.client.Ping(h.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-h.notifications: + t.Fatalf("duplicate cleanup: %s", n.Method) + default: + } + }) + } +} + +func TestOwnedTCPResumeDoesNotReuseFullyAckedSequence(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + if _, err := c.Write(old.ctx, []byte("ab")); err != nil { + t.Fatal(err) + } + original := old.dispatch() + old.emit(original.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: original.ClientSeq}, nil) + waitTCPState(t, old.ctx, c, func(c *TCPConnection) bool { return len(c.pending) == 0 }) + ordinary, err := old.client.Dispatch(old.ctx, "ahp-session:/s1", ahptypes.StateAction{ + Value: &ahptypes.SessionTitleChangedAction{Type: ahptypes.ActionTypeSessionTitleChanged, Title: "ordinary"}, + }) + if err != nil { + t.Fatal(err) + } + old.dispatch() + old.client.ShutdownPreservingTCP(old.ctx) + fresh := newTCPTestHost(t, false, nil, false) + done := make(chan error, 1) + go func() { + _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "owner", LastSeenServerSeq: 1}, []*TCPConnection{c}) + done <- err + }() + request := <-fresh.requests + fresh.reply(request, &ahptypes.ReconnectReplayResult{Actions: []ahptypes.ActionEnvelope{}, Missing: []string{}}) + if err := <-done; err != nil { + t.Fatal(err) + } + if _, err := c.Write(fresh.ctx, []byte("c")); err != nil { + t.Fatal(err) + } + next := fresh.dispatch() + if next.ClientSeq <= ordinary.ClientSeq { + t.Fatal("reused original client's ordinary action sequence") + } + if action, ok := next.Action.Value.(*ahptypes.TcpInputAction); !ok || action.Offset != 2 { + t.Fatal("lost retained input offset") + } + if err := c.Dispose(fresh.ctx); err != nil { + t.Fatal(err) + } + fresh.dispatch() + fresh.notification("unsubscribe") +} + +func TestOwnedTCPFinalCloseDrainsBufferedReads(t *testing.T) { + h := newTCPTestHost(t, true, []byte("abc"), false) + c := h.open() + if _, err := c.Write(h.ctx, []byte("abcd")); err != nil { + t.Fatal(err) + } + first, second := h.dispatch(), h.dispatch() + write := make(chan error, 1) + drain := make(chan error, 1) + go func() { _, err := c.Write(h.ctx, []byte("x")); write <- err }() + go func() { drain <- c.Drain(h.ctx) }() + h.emit(&ahptypes.TcpHostCloseAction{Type: ahptypes.ActionTypeTcpHostClose}, nil, nil) + if err := <-write; err == nil { + t.Fatal("host close did not stop new writes") + } + select { + case err := <-drain: + t.Fatalf("drain finished before input consumption: %v", err) + default: + } + closeAction := h.dispatch() + if _, ok := closeAction.Action.Value.(*ahptypes.TcpClientCloseAction); !ok { + t.Fatal("missing close response before input consumption") + } + if c.IsClosed() { + t.Fatal("close response disposed unconsumed input") + } + if data, err := c.Read(h.ctx); string(data) != "abc" || err != nil { + t.Fatalf("lost buffered close data %q: %v", data, err) + } + if _, err := c.Read(h.ctx); !errors.Is(err, io.EOF) { + t.Fatal(err) + } + credit := h.dispatch() + if _, ok := credit.Action.Value.(*ahptypes.TcpDataConsumedAction); !ok { + t.Fatal("missing read credit") + } + h.emit(first.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: first.ClientSeq}, nil) + h.emit(second.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: second.ClientSeq}, nil) + ack := h.emit(closeAction.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: closeAction.ClientSeq}, nil) + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.checkpoint == ack }) + if c.IsClosed() { + t.Fatal("two-sided close discarded unconsumed bytes") + } + if err := h.client.Ping(h.ctx); err != nil { + t.Fatal(err) + } + select { + case n := <-h.notifications: + t.Fatalf("cleanup before drain: %s", n.Method) + default: + } + h.emit(&ahptypes.TcpInputConsumedAction{Type: ahptypes.ActionTypeTcpInputConsumed, ConsumedBytes: 4}, nil, nil) + if err := <-drain; err != nil { + t.Fatal(err) + } + if c.IsClosed() { + t.Fatal("closed before credit ack") + } + h.emit(credit.Action.Value, &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: credit.ClientSeq}, nil) + h.notification("unsubscribe") + if err := c.Close(h.ctx); err != nil { + t.Fatal(err) + } +} + +func TestOwnedTCPInvalidEchoTerminatesWithoutRetainingPayload(t *testing.T) { + for _, mode := range []string{"missing", "foreign", "zero", "negative", "unsafe", "unassigned", + "mismatched", "reusedAck", "creditMissing", "eofMissing", "closeMissing", "resetMissing"} { + t.Run(mode, func(t *testing.T) { + h := newTCPTestHost(t, true, []byte("x"), false) + c := h.open() + if _, err := c.Write(h.ctx, []byte("ab")); err != nil { + t.Fatal(err) + } + original := h.dispatch() + action := original.Action.Value + origin := &ahptypes.ActionOrigin{ClientId: "owner", ClientSeq: original.ClientSeq} + switch mode { + case "missing": + origin = nil + case "foreign": + origin.ClientId = "another" + case "zero": + origin.ClientSeq = 0 + case "negative": + origin.ClientSeq = -1 + case "unsafe": + origin.ClientSeq = tcpMaxSafeInteger + 1 + case "unassigned": + origin.ClientSeq += 100 + case "mismatched": + action = &ahptypes.TcpInputAction{Type: ahptypes.ActionTypeTcpInput, Offset: 0, Data: "eHk="} + case "reusedAck": + seq := h.emit(original.Action.Value, origin, nil) + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.checkpoint == seq }) + if _, err := c.Write(h.ctx, []byte("c")); err != nil { + t.Fatal(err) + } + action = h.dispatch().Action.Value + case "creditMissing": + if _, err := c.Read(h.ctx); err != nil { + t.Fatal(err) + } + action = h.dispatch().Action.Value + origin = nil + case "eofMissing": + if err := c.End(h.ctx); err != nil { + t.Fatal(err) + } + action = h.dispatch().Action.Value + origin = nil + case "closeMissing": + action = &ahptypes.TcpClientCloseAction{Type: ahptypes.ActionTypeTcpClientClose} + origin = nil + case "resetMissing": + action = &ahptypes.TcpClientResetAction{Type: ahptypes.ActionTypeTcpClientReset, Reason: ahptypes.TcpResetReasonConnectionAborted} + origin = nil + } + c.mu.Lock() + beforeInput, beforeConsumed := c.state.Input.ReceivedBytes, c.state.Output.ConsumedBytes + c.mu.Unlock() + h.emit(action, origin, nil) + waitTCPState(t, h.ctx, c, func(c *TCPConnection) bool { return c.terminal }) + c.mu.Lock() + unchanged := c.state.Input.ReceivedBytes == beforeInput && c.state.Output.ConsumedBytes == beforeConsumed + released := len(c.pending) == 0 + c.mu.Unlock() + if !unchanged || !released { + t.Fatal("bad echo changed counters or retained payload") + } + if _, err := c.Read(h.ctx); err == nil { + t.Fatal("bad echo did not terminate reader") + } + if _, err := c.Write(h.ctx, []byte("z")); err == nil { + t.Fatal("bad echo did not terminate writer") + } + if reset, ok := h.dispatch().Action.Value.(*ahptypes.TcpClientResetAction); !ok || reset.Reason != ahptypes.TcpResetReasonProtocolError { + t.Fatal("missing protocol reset") + } + h.notification("unsubscribe") + }) + } +} + +func TestOwnedTCPMalformedReplayIsTerminal(t *testing.T) { + old := newTCPTestHost(t, true, nil, false) + c := old.open() + old.client.ShutdownPreservingTCP(old.ctx) + fresh := newTCPTestHost(t, false, nil, false) + if _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "different"}, []*TCPConnection{c}); err == nil { + t.Fatal("changed owner accepted") + } + done := make(chan error, 1) + go func() { + _, err := fresh.client.ReconnectTCPConnections(fresh.ctx, ahptypes.ReconnectParams{ClientId: "owner"}, []*TCPConnection{c}) + done <- err + }() + request := <-fresh.requests + fresh.reply(request, map[string]any{"type": "replay", "missing": []any{}, "actions": []any{ + map[string]any{"channel": c.Resource(), "serverSeq": 1, "action": map[string]any{"type": "tcp/dataEof", "finalOffset": 0.5}}, + }}) + if err := <-done; err == nil { + t.Fatal("malformed replay succeeded") + } + if _, err := c.Read(fresh.ctx); err == nil { + t.Fatal("malformed replay did not terminate stream") + } + fresh.dispatch() + fresh.notification("unsubscribe") +} diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index dc5e6f386..da5b8e221 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -122,6 +122,16 @@ const ( ActionTypeAutomationRunSessionRemoved ActionType = "automationRun/sessionRemoved" ActionTypeAutomationRunPrimarySessionChanged ActionType = "automationRun/primarySessionChanged" ActionTypeAutomationRunCancelRequested ActionType = "automationRun/cancelRequested" + ActionTypeTcpInput ActionType = "tcp/input" + ActionTypeTcpData ActionType = "tcp/data" + ActionTypeTcpInputConsumed ActionType = "tcp/inputConsumed" + ActionTypeTcpDataConsumed ActionType = "tcp/dataConsumed" + ActionTypeTcpInputEof ActionType = "tcp/inputEof" + ActionTypeTcpDataEof ActionType = "tcp/dataEof" + ActionTypeTcpClientClose ActionType = "tcp/clientClose" + ActionTypeTcpHostClose ActionType = "tcp/hostClose" + ActionTypeTcpClientReset ActionType = "tcp/clientReset" + ActionTypeTcpHostReset ActionType = "tcp/hostReset" ) // ─── Action Envelope ───────────────────────────────────────────────── @@ -1634,6 +1644,72 @@ type ResourceWatchChangedAction struct { Changes json.RawMessage `json:"changes"` } +// Client bytes. Never apply optimistically to the authoritative reducer. +// Write to the destination only when accepted input.receivedBytes advances. +type TcpInputAction struct { + Type ActionType `json:"type"` + // Absolute decoded-byte offset. + Offset int64 `json:"offset"` + // Nonempty canonical padded RFC 4648 base64; no whitespace. + Data string `json:"data"` +} + +// Host bytes. Deliver once, only when output.receivedBytes advances. +type TcpDataAction struct { + Type ActionType `json:"type"` + // Absolute decoded-byte offset. + Offset int64 `json:"offset"` + // Nonempty canonical padded RFC 4648 base64; no whitespace. + Data string `json:"data"` +} + +// Cumulative input bytes released from the host's bounded write buffer. +// Not an acknowledgment that the destination application processed the bytes. +type TcpInputConsumedAction struct { + Type ActionType `json:"type"` + ConsumedBytes int64 `json:"consumedBytes"` +} + +// Cumulative output bytes released by the client's bounded stream consumer. +type TcpDataConsumedAction struct { + Type ActionType `json:"type"` + ConsumedBytes int64 `json:"consumedBytes"` +} + +// Half-close client input after all preceding input bytes have been written. +type TcpInputEofAction struct { + Type ActionType `json:"type"` + FinalOffset int64 `json:"finalOffset"` +} + +// Half-close host output after all preceding output bytes have been delivered. +type TcpDataEofAction struct { + Type ActionType `json:"type"` + FinalOffset int64 `json:"finalOffset"` +} + +// Client's final close. Respond with hostClose if not already sent. +type TcpClientCloseAction struct { + Type ActionType `json:"type"` +} + +// Host's final close. Respond with clientClose if not already sent. +type TcpHostCloseAction struct { + Type ActionType `json:"type"` +} + +// Abort both directions and discard buffered payload. +type TcpClientResetAction struct { + Type ActionType `json:"type"` + Reason TcpResetReason `json:"reason"` +} + +// Abort both directions and discard buffered payload. +type TcpHostResetAction struct { + Type ActionType `json:"type"` + Reason TcpResetReason `json:"reason"` +} + // Ask the host to create a durable automation at a client-chosen resource. // // Clients may dispatch this action only when the host advertises its `create` @@ -1857,6 +1933,16 @@ func (*TerminalCommandDetectionAvailableAction) isStateAction() {} func (*TerminalCommandExecutedAction) isStateAction() {} func (*TerminalCommandFinishedAction) isStateAction() {} func (*ResourceWatchChangedAction) isStateAction() {} +func (*TcpInputAction) isStateAction() {} +func (*TcpDataAction) isStateAction() {} +func (*TcpInputConsumedAction) isStateAction() {} +func (*TcpDataConsumedAction) isStateAction() {} +func (*TcpInputEofAction) isStateAction() {} +func (*TcpDataEofAction) isStateAction() {} +func (*TcpClientCloseAction) isStateAction() {} +func (*TcpHostCloseAction) isStateAction() {} +func (*TcpClientResetAction) isStateAction() {} +func (*TcpHostResetAction) isStateAction() {} func (*AutomationCreateRequestedAction) isStateAction() {} func (*AutomationUpdateRequestedAction) isStateAction() {} func (*AutomationSetAction) isStateAction() {} @@ -2445,6 +2531,66 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "tcp/input": + var value TcpInputAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/data": + var value TcpDataAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/inputConsumed": + var value TcpInputConsumedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/dataConsumed": + var value TcpDataConsumedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/inputEof": + var value TcpInputEofAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/dataEof": + var value TcpDataEofAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/clientClose": + var value TcpClientCloseAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/hostClose": + var value TcpHostCloseAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/clientReset": + var value TcpClientResetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "tcp/hostReset": + var value TcpHostResetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "automation/createRequested": var value AutomationCreateRequestedAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index cd3b2a6ad..a1214a9ec 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -190,6 +190,8 @@ type InitializeResult struct { // `ahp-automations://` for {@link AutomationState}; absence means the // host does not expose an automation catalogue or automation commands. Automations *AutomationCapabilities `json:"automations,omitempty"` + // Enables atomic creation of session-scoped, replay-only TCP channels. + TcpConnections *TcpConnectionsCapability `json:"tcpConnections,omitempty"` } // Optional capabilities a client declares during `initialize`. @@ -317,6 +319,8 @@ type ReconnectParams struct { // // The server MUST include all replayed data in the response. type ReconnectReplayResult struct { + // Discriminant + Type ReconnectResultType `json:"type"` // Missed action envelopes since `lastSeenServerSeq` Actions []ActionEnvelope `json:"actions"` // URIs from `ReconnectParams.subscriptions` that the server cannot resume. @@ -328,8 +332,14 @@ type ReconnectReplayResult struct { // Reconnect result when the gap exceeds the replay buffer. type ReconnectSnapshotResult struct { + // Discriminant + Type ReconnectResultType `json:"type"` // Fresh snapshots for each subscription Snapshots []Snapshot `json:"snapshots"` + // Subscriptions that cannot be restored. Hosts supporting TCP MUST list all + // requested TCP channels here and dispose their sockets on snapshot fallback. + // Omitted by older hosts; absence does not authorize snapshot-restoring TCP. + Missing []URI `json:"missing,omitempty"` } // Subscribe to a URI-identified channel. @@ -355,6 +365,10 @@ type SubscribeParams struct { // Servers that do not understand a requested view ignore it and return their // default snapshot. Clients MUST tolerate receiving more state than requested. View *SubscribeView `json:"view,omitempty"` + // Atomically create a private child channel and subscribe to it. + // Requires the advertised tcpConnections capability. channel identifies + // the parent session; snapshot.resource identifies the created TCP channel. + Create *TcpConnectionSubscription `json:"create,omitempty"` } // Optional client-requested shape for a subscription snapshot. @@ -388,6 +402,26 @@ type SubscribeResult struct { Snapshot *Snapshot `json:"snapshot,omitempty"` } +// Creates and exclusively subscribes to one TCP connection. +// +// SubscribeParams.channel MUST identify the parent `ahp-session:` channel. +// The host returns the new `ahp-tcp:` URI in snapshot.resource, not the parent. +// It installs the subscription and sends the response before any TCP actions. +// Unknown creation kinds MUST be rejected, never treated as normal subscribe. +type TcpConnectionSubscription struct { + Type string `json:"type"` + // DNS name or IP literal, not a URL. + Host string `json:"host"` + // Destination port. + Port int64 `json:"port"` + // Selected from InitializeResult.tcpConnections.encodings. + Encoding TcpDataEncoding `json:"encoding"` + // Client receive window in decoded bytes. + ReceiveWindowBytes int64 `json:"receiveWindowBytes"` + // Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes. + MaximumChunkSize int64 `json:"maximumChunkSize"` +} + // Creates a new session with the specified agent provider. // // If the session URI already exists, the server MUST return an error with code @@ -1512,6 +1546,62 @@ func (v ChatMoveToNewSessionDestination) MarshalJSON() ([]byte, error) { return json.Marshal(raw) } +func (v *ReconnectReplayResult) UnmarshalJSON(data []byte) error { + disc, ok, err := readDiscriminator(data, "type") + if err != nil { + return err + } + if !ok { + return missingDiscriminatorError("ReconnectReplayResult", "type") + } + if disc != "replay" { + return unknownDiscriminatorError("ReconnectReplayResult", "type", disc) + } + type wire ReconnectReplayResult + var raw wire + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *v = ReconnectReplayResult(raw) + v.Type = ReconnectResultTypeReplay + return nil +} + +func (v ReconnectReplayResult) MarshalJSON() ([]byte, error) { + type wire ReconnectReplayResult + raw := wire(v) + raw.Type = ReconnectResultTypeReplay + return json.Marshal(raw) +} + +func (v *ReconnectSnapshotResult) UnmarshalJSON(data []byte) error { + disc, ok, err := readDiscriminator(data, "type") + if err != nil { + return err + } + if !ok { + return missingDiscriminatorError("ReconnectSnapshotResult", "type") + } + if disc != "snapshot" { + return unknownDiscriminatorError("ReconnectSnapshotResult", "type", disc) + } + type wire ReconnectSnapshotResult + var raw wire + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *v = ReconnectSnapshotResult(raw) + v.Type = ReconnectResultTypeSnapshot + return nil +} + +func (v ReconnectSnapshotResult) MarshalJSON() ([]byte, error) { + type wire ReconnectSnapshotResult + raw := wire(v) + raw.Type = ReconnectResultTypeSnapshot + return json.Marshal(raw) +} + // ─── ChatSource Union ───────────────────────────────────────────────── // ChatSource identifies how a new chat uses a source chat. diff --git a/clients/go/ahptypes/errors.generated.go b/clients/go/ahptypes/errors.generated.go index e6a4fd395..f1d1463ba 100644 --- a/clients/go/ahptypes/errors.generated.go +++ b/clients/go/ahptypes/errors.generated.go @@ -32,10 +32,11 @@ const ( ErrorCodeTurnInProgress int32 = -32004 ErrorCodeUnsupportedProtocolVersion int32 = -32005 // -32006 is intentionally reserved and unassigned. - ErrorCodeAuthRequired int32 = -32007 - ErrorCodeNotFound int32 = -32008 - ErrorCodePermissionDenied int32 = -32009 - ErrorCodeAlreadyExists int32 = -32010 + ErrorCodeAuthRequired int32 = -32007 + ErrorCodeNotFound int32 = -32008 + ErrorCodePermissionDenied int32 = -32009 + ErrorCodeAlreadyExists int32 = -32010 + ErrorCodeTcpConnectionOpenFailed int32 = -32012 ) // AhpErrorCode is the type alias used by AHP application error codes. diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index 0f7181385..b96ee7795 100644 --- a/clients/go/ahptypes/roundtrip_fixture_test.go +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -242,6 +242,18 @@ func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string { var v InitializeResult dec(&v) return enc(&v) + case "SubscribeParams": + var v SubscribeParams + dec(&v) + return enc(&v) + case "ReconnectResult": + var v ReconnectResult + dec(&v) + return enc(&v) + case "TcpConnectionOpenErrorData": + var v TcpConnectionOpenErrorData + dec(&v) + return enc(&v) case "ChatSource": var v ChatSource dec(&v) diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 4cc70c783..681827353 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -15,6 +15,44 @@ var _ = json.RawMessage(nil) // ─── Enums ──────────────────────────────────────────────────────────── +// Payload encodings advertised by the host. +type TcpDataEncoding string + +const ( + TcpDataEncodingBase64 TcpDataEncoding = "base64" +) + +// Endpoint that closes or resets a connection. +type TcpEndpoint string + +const ( + TcpEndpointClient TcpEndpoint = "client" + TcpEndpointHost TcpEndpoint = "host" +) + +// Why a connection was aborted. +type TcpResetReason string + +const ( + TcpResetReasonConnectionReset TcpResetReason = "connectionReset" + TcpResetReasonConnectionAborted TcpResetReason = "connectionAborted" + TcpResetReasonProtocolError TcpResetReason = "protocolError" + TcpResetReasonReplayUnavailable TcpResetReason = "replayUnavailable" + TcpResetReasonPolicyRevoked TcpResetReason = "policyRevoked" + TcpResetReasonSessionDisposed TcpResetReason = "sessionDisposed" + TcpResetReasonInternalError TcpResetReason = "internalError" +) + +// Expected connection establishment failures. +type TcpConnectionOpenFailureReason string + +const ( + TcpConnectionOpenFailureReasonConnectionFailed TcpConnectionOpenFailureReason = "connectionFailed" + TcpConnectionOpenFailureReasonNameResolutionFailed TcpConnectionOpenFailureReason = "nameResolutionFailed" + TcpConnectionOpenFailureReasonResourceShortage TcpConnectionOpenFailureReason = "resourceShortage" + TcpConnectionOpenFailureReasonSessionNotReady TcpConnectionOpenFailureReason = "sessionNotReady" +) + // Policy configuration state for a model. type PolicyState string @@ -3923,6 +3961,74 @@ type ResourceWatchState struct { Includes *json.RawMessage `json:"includes,omitempty"` } +// State of one host-assigned `ahp-tcp:` channel. +// +// Payload is never stored in this state. Only the creating authenticated +// logical client may observe or dispatch to the channel. Reconnect requires +// the original sockets, local stream state, and complete action replay; +// a snapshot cannot restore this channel. +// +// Close flags record the two-sided handshake. Either flag means closing; +// both mean closed. A present reset terminates the connection immediately, +// independently of the close history. +type TcpConnectionState struct { + Session URI `json:"session"` + Target TcpTarget `json:"target"` + Encoding TcpDataEncoding `json:"encoding"` + // Client to destination socket. + Input FlowControlledByteDirectionState `json:"input"` + // Destination socket to client. + Output FlowControlledByteDirectionState `json:"output"` + ClientClosed bool `json:"clientClosed"` + HostClosed bool `json:"hostClosed"` + Reset *TcpResetState `json:"reset,omitempty"` +} + +type TcpTarget struct { + // DNS name or IP literal, resolved and connected in the host endpoint's network. + Host string `json:"host"` + // Destination port. + Port int64 `json:"port"` +} + +type TcpResetState struct { + Source TcpEndpoint `json:"source"` + Reason TcpResetReason `json:"reason"` +} + +// Bounded byte credit in one direction of a stream. +// All counters are nonnegative safe integers (at most 2^53 - 1). +// 0 <= consumedBytes <= receivedBytes and +// receivedBytes - consumedBytes <= windowBytes. +type FlowControlledByteDirectionState struct { + // Maximum accepted-but-not-consumed decoded bytes. + WindowBytes int64 `json:"windowBytes"` + // Maximum decoded bytes per chunk; MUST NOT exceed windowBytes. + MaximumChunkSize int64 `json:"maximumChunkSize"` + // Cumulative accepted bytes. + ReceivedBytes int64 `json:"receivedBytes"` + // Cumulative bytes released by the bounded consumer. + ConsumedBytes int64 `json:"consumedBytes"` + // Present after EOF; equals receivedBytes permanently. + EofAtBytes *int64 `json:"eofAtBytes,omitempty"` +} + +// Host support for private, session-scoped TCP channels. +// Presence on initialize is required before using subscribe.create. +type TcpConnectionsCapability struct { + // Supported encodings. The base64 profile MUST be supported. + Encodings []TcpDataEncoding `json:"encodings"` + // Informational limit; runtime policy may impose a lower limit. + MaximumConnectionsPerClient *int64 `json:"maximumConnectionsPerClient,omitempty"` +} + +// Required detail for TcpConnectionOpenFailed (-32012). +// Policy denial and malformed requests use PermissionDenied and InvalidParams. +type TcpConnectionOpenErrorData struct { + Reason TcpConnectionOpenFailureReason `json:"reason"` + Retryable *bool `json:"retryable,omitempty"` +} + // A single change observed by a resource watcher. type ResourceChange struct { // The URI of the resource that changed. @@ -6261,12 +6367,13 @@ func (o ChatOrigin) MarshalJSON() ([]byte, error) { // SnapshotState is the state payload of a snapshot — root, session, // chat, terminal, changeset, resource-watch, annotations, automation catalogue, -// or automation-run state. The active +// automation-run, or TCP state. The active // variant is chosen by which pointer field is non-nil; UnmarshalJSON probes // for required fields in the canonical order -// (automationRun → automations → session → chat → terminal → changeset → +// (tcp → automationRun → automations → session → chat → terminal → changeset → // resourceWatch → annotations → root). type SnapshotState struct { + Tcp *TcpConnectionState `json:"-"` Root *RootState `json:"-"` Session *SessionState `json:"-"` Chat *ChatState `json:"-"` @@ -6281,6 +6388,8 @@ type SnapshotState struct { // MarshalJSON encodes whichever variant is currently populated. func (s SnapshotState) MarshalJSON() ([]byte, error) { switch { + case s.Tcp != nil: + return json.Marshal(s.Tcp) case s.AutomationRun != nil: return json.Marshal(s.AutomationRun) case s.Automations != nil: @@ -6313,6 +6422,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { return err } switch { + case containsAll(probe, "input", "output", "target"): + var v TcpConnectionState + if err := json.Unmarshal(data, &v); err != nil { + return err + } + s.Tcp = &v case containsAll(probe, "automation", "origin", "sessions"): var v AutomationRunState if err := json.Unmarshal(data, &v); err != nil { diff --git a/clients/kotlin/README.md b/clients/kotlin/README.md index cae975d5c..4bbdd0ca4 100644 --- a/clients/kotlin/README.md +++ b/clients/kotlin/README.md @@ -110,6 +110,12 @@ behavior. This is a client API migration, not a new JSON protocol. state from the current state and an applied action. Behavior parity with the canonical TypeScript reducers is verified against the shared `types/test-cases/reducers/` fixture corpus. +- **TCP accounting** — `tcpReducer(state, action)` and `TcpReducer.reduce` + return the next `TcpConnectionState` without retaining payloads. Invalid + actions throw `IllegalArgumentException` and leave the original state unchanged. +- **Portable TCP streams** — `TcpConnection` owns buffering, flow control, and + reconnect replay. It uses JVM + `CompletableFuture` operations without adding a coroutine or network runtime. - **Channel-scoped notification params** — `SessionAddedParams`, `SessionRemovedParams`, `SessionSummaryChangedParams`, `AuthRequiredParams`, `OtlpExportLogsParams`, etc. Notifications are routed by their JSON-RPC @@ -124,6 +130,48 @@ behavior. This is a client API migration, not a new JSON protocol. - An example Android client — see the Swift `AHPClient` example for the architecture pattern; a Kotlin/Android equivalent is planned for a follow-up release. +### Portable TCP adapter + +Inject `TcpConnectionTransport(clientId, nextSequence, advanceSequencePast, send, +unsubscribe, lastAssignedSequence)`. The identity must be the one used to initialize/reconnect. +Callbacks enqueue without blocking; the sequence allocator is shared with all +actions sent by that logical client. `send` receives the child resource, original +client sequence, and typed action. A send exception suspends the handle and is +available as `lastTransportFailure`; retained actions reconcile on resume. +`lastAssignedSequence` reads the global allocator, including acknowledged and +non-TCP actions (use -1 if nothing has been assigned). This prevents a replacement +allocator from reusing identities even when the TCP pending set is empty. +1. Construct `TcpConnectionCreation(session, create, initialized, transport)`. + Set `create.type` to `"tcpConnection"`. Its `parameters` validate the + request before the transport sends `subscribe`. +2. Attach a strict bounded event receiver **before** that request. Buffer events + while the child URI is unknown; overflow and decode loss must be explicit. +3. Forward the definitive subscribe reply to `creation.accept(result)`, and + request errors/timeouts to `creation.fail(error)`. Await `creation.completion` + for the connection, then forward buffered/live envelopes to its `accept`. + The transport must still forward a late reply after cancellation/timeout: + the creation helper releases its child once, never the parent. Cleanup + failures throw to the transport callback. +4. Use `write(ByteArray)`, `read()`, `drain()`, and `end()` futures. One reader and + one writer may run concurrently; overlapping writers are rejected. Do not + mutate write input before completion. Reading releases cumulative credit; + `read()` returns null only after buffered EOF drains. +5. Call `suspend(cause)` on disconnect. Before the replacement transport's + reconnect request, call `TcpConnection.reconnectParameters(params, handles)`. + Buffer live delivery until `TcpConnection.resume(adjustedParams, result, + handles, replacementTransport)` returns. This helper verifies identity, + reconciles replay, and resends pending actions. Snapshot/missing resources + fail closed. +6. Forward strict receiver lag/decode loss to `fail(error)`, never `suspend`. + `close()` stops new input but retains crossing host data and buffered reads. + Keep reading during graceful close; `dispose()` aborts without draining. + +The application supplies typed transport/event delivery and chooses reconnect +timing, connection-count limits, and native socket bridges. It does not implement +TCP reduction, credit, acknowledgement bookkeeping, or replay. TCP streams do not +belong in ordinary state mirrors. +See the [TCP channel contract](../../docs/specification/tcp-channel.md). + ## Protocol version mapping Two constants in `com.microsoft.agenthostprotocol.generated` track which diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 8fd22a745..c4031d9bb 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -85,6 +85,121 @@ public object AutomationRunReducer : Reducer { automationRunReducer(state, action) } +/** Pure TCP reducer. Invalid actions throw before changing state; payloads are never retained. */ +public object TcpReducer : Reducer { + override fun reduce(state: TcpConnectionState, action: StateAction): TcpConnectionState = + tcpReducer(state, action) +} + +private const val TCP_MAX_SAFE_INTEGER = 9007199254740991L + +private fun requireTcp(condition: Boolean, message: String) { + require(condition) { "Invalid TCP action: $message" } +} + +private fun requireTcpOffset(value: Long) { + requireTcp(value in 0..TCP_MAX_SAFE_INTEGER, "offset must be a nonnegative safe integer") +} + +private fun tcpBase64Value(char: Char): Int = when (char) { + in 'A'..'Z' -> char - 'A' + in 'a'..'z' -> char - 'a' + 26 + in '0'..'9' -> char - '0' + 52 + '+' -> 62 + '/' -> 63 + else -> -1 +} + +private fun tcpPayloadLength(data: String, maximumChunkSize: Long): Long { + requireTcp( + data.isNotEmpty() && data.length.toLong() <= 4 * (maximumChunkSize / 3 + if (maximumChunkSize % 3 > 0) 1 else 0), + "chunk size", + ) + val padding = if (data.endsWith("==")) 2 else if (data.endsWith("=")) 1 else 0 + requireTcp(data.length % 4 == 0, "base64 encoding") + var last = 0 + for (index in 0 until data.length - padding) { + last = tcpBase64Value(data[index]) + requireTcp(last >= 0, "base64 encoding") + } + if (padding > 0) { + requireTcp(last % (if (padding == 2) 16 else 4) == 0, "noncanonical base64 padding bits") + } + val length = data.length.toLong() / 4 * 3 - padding + requireTcp(length <= maximumChunkSize, "chunk size") + return length +} + +private fun tcpReceive( + direction: FlowControlledByteDirectionState, offset: Long, data: String, senderClosed: Boolean, +): FlowControlledByteDirectionState { + requireTcpOffset(offset) + val end = offset + tcpPayloadLength(data, direction.maximumChunkSize) + requireTcpOffset(end) + if (end <= direction.receivedBytes) return direction + requireTcp(offset == direction.receivedBytes, "gap or overlapping byte range") + requireTcp(!senderClosed && direction.eofAtBytes == null, "data after EOF or sender close") + requireTcp(end - direction.consumedBytes <= direction.windowBytes, "receive window exceeded") + return direction.copy(receivedBytes = end) +} + +private fun tcpConsume(direction: FlowControlledByteDirectionState, consumedBytes: Long): FlowControlledByteDirectionState { + requireTcpOffset(consumedBytes) + requireTcp(consumedBytes <= direction.receivedBytes, "consuming bytes not received") + return if (consumedBytes <= direction.consumedBytes) direction else direction.copy(consumedBytes = consumedBytes) +} + +private fun tcpEof(direction: FlowControlledByteDirectionState, finalOffset: Long, senderClosed: Boolean): FlowControlledByteDirectionState { + requireTcpOffset(finalOffset) + requireTcp(finalOffset == direction.receivedBytes, "EOF offset") + if (direction.eofAtBytes == finalOffset) return direction + requireTcp(!senderClosed, "EOF after sender close") + return direction.copy(eofAtBytes = finalOffset) +} + +/** + * Reduces TCP accounting only, without restoring streams or performing socket I/O. + * Callers must reset/close the channel on [IllegalArgumentException] and must not + * write rejected or duplicate data (only write when receivedBytes advances). + */ +public fun tcpReducer(state: TcpConnectionState, action: StateAction): TcpConnectionState { + if (state.reset != null) return state + val input: FlowControlledByteDirectionState + val output: FlowControlledByteDirectionState + when (action) { + is StateActionTcpInput -> { + input = tcpReceive(state.input, action.value.offset, action.value.data, state.clientClosed) + output = state.output + } + is StateActionTcpData -> { + input = state.input + output = tcpReceive(state.output, action.value.offset, action.value.data, state.hostClosed) + } + is StateActionTcpInputConsumed -> { + input = tcpConsume(state.input, action.value.consumedBytes) + output = state.output + } + is StateActionTcpDataConsumed -> { + input = state.input + output = tcpConsume(state.output, action.value.consumedBytes) + } + is StateActionTcpInputEof -> { + input = tcpEof(state.input, action.value.finalOffset, state.clientClosed) + output = state.output + } + is StateActionTcpDataEof -> { + input = state.input + output = tcpEof(state.output, action.value.finalOffset, state.hostClosed) + } + is StateActionTcpClientClose -> return if (state.clientClosed) state else state.copy(clientClosed = true) + is StateActionTcpHostClose -> return if (state.hostClosed) state else state.copy(hostClosed = true) + is StateActionTcpClientReset -> return state.copy(reset = TcpResetState(TcpEndpoint.CLIENT, action.value.reason)) + is StateActionTcpHostReset -> return state.copy(reset = TcpResetState(TcpEndpoint.HOST, action.value.reason)) + else -> return state + } + return if (input === state.input && output === state.output) state else state.copy(input = input, output = output) +} + private val isoTimestampFormatter = DateTimeFormatterBuilder().appendInstant(3).toFormatter() private fun addMillisecondsToTimestamp(timestamp: String, duration: Long): String = diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/TcpConnection.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/TcpConnection.kt new file mode 100644 index 000000000..afedecd7f --- /dev/null +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/TcpConnection.kt @@ -0,0 +1,433 @@ +package com.microsoft.agenthostprotocol + +import com.microsoft.agenthostprotocol.generated.* +import java.util.ArrayDeque +import java.util.Base64 +import java.util.concurrent.CompletableFuture + +/** + * Transport boundary for the portable TCP adapter. Callbacks must enqueue without + * blocking. Sequence allocation is shared with all actions of this logical client. + * Throwing from send suspends the handle; retained actions are reconciled on resume. + */ +public class TcpConnectionTransport( + public val clientId: String, + public val nextSequence: () -> Long, + public val advanceSequencePast: (Long) -> Unit, + public val send: (String, Long, StateAction) -> Unit, + public val unsubscribe: (String) -> Unit, + public val lastAssignedSequence: () -> Long, +) + +/** + * Portable subscribe/create lifecycle. The transport sends [parameters] and + * forwards its definitive reply to [accept], even after a timeout/cancellation. + * [fail] reports request failure; a late private child is released by the SDK. + */ +public class TcpConnectionCreation( + private val session: String, + private val create: TcpConnectionSubscription, + private val initialized: InitializeResult, + private val transport: TcpConnectionTransport, +) { + public val parameters: SubscribeParams = TcpConnection.creationParameters(session, create, initialized) + public val completion: CompletableFuture = CompletableFuture() + private val lock = Any() + private var received = false + + public fun fail(error: Throwable) { completion.completeExceptionally(error) } + + /** Cleanup failures throw to the transport caller instead of disappearing in a detached task. */ + public fun accept(result: SubscribeResult): Unit = synchronized(lock) { + if (received) return@synchronized + received = true + if (completion.isDone) { + result.snapshot?.resource?.takeIf { it.startsWith("ahp-tcp:") }?.let(transport.unsubscribe) + return@synchronized + } + try { + val connection = TcpConnection.open(session, create, initialized, result, transport) + if (!completion.complete(connection)) connection.dispose() + } catch (error: Exception) { + if (!completion.completeExceptionally(error)) throw error + } + } +} + +/** + * A transport-independent, bounded TCP consumer. No worker threads or network + * runtime are created. Feed accepted envelopes through [accept], call [suspend] + * on disconnect, and [fail] on strict receiver lag or decode loss. + */ +public class TcpConnection private constructor( + public val resource: String, + initial: TcpConnectionState, + checkpoint: Long, + private var transport: TcpConnectionTransport, +) : AutoCloseable { + public val clientId: String = transport.clientId + private val lock = Any() + private var current = initial + private var checkpoint = checkpoint + private val received = ArrayDeque() + private val pending = sortedMapOf() + private var sentBytes = initial.input.receivedBytes + private var consumedBytes = initial.output.consumedBytes + private var lastSequence = -1L + private var suspended = false + private var closing = false + private var closed = false + private var released = false + private var ending = false + private var failure: Throwable? = null + private var transportFailure: Throwable? = null + private var reader: CompletableFuture? = null + private var writer: Write? = null + private var endResult: CompletableFuture? = null + private val drains = mutableListOf>() + private var progressing = false + private var dirty = false + + private class Write(val data: ByteArray, val result: CompletableFuture, var offset: Int = 0) + + public val state: TcpConnectionState get() = synchronized(lock) { current } + public val appliedCheckpoint: Long get() = synchronized(lock) { checkpoint } + public val isSuspended: Boolean get() = synchronized(lock) { suspended } + public val isClosed: Boolean get() = synchronized(lock) { closed } + public val lastTransportFailure: Throwable? get() = synchronized(lock) { transportFailure } + + /** Pulls a chunk and releases its credit. Null is returned only after buffered EOF drains. */ + public fun read(): CompletableFuture = synchronized(lock) { + check(reader == null) { "TCP permits one reader at a time" } + val result = CompletableFuture() + reader = result + result.whenComplete { _, _ -> synchronized(lock) { if (reader === result && result.isCancelled) reader = null } } + progress() + result + } + + /** One writer at a time. The caller must not mutate data until the result completes. */ + public fun write(data: ByteArray): CompletableFuture = synchronized(lock) { + check(writer == null && !ending && !closing && !closed) { "TCP write requires an open, idle writer" } + val result = CompletableFuture() + val operation = Write(data, result) + writer = operation + result.whenComplete { _, _ -> synchronized(lock) { if (writer === operation && result.isCancelled) writer = null } } + progress() + result + } + + /** Waits until all reserved input bytes are consumed by the destination. */ + public fun drain(): CompletableFuture = synchronized(lock) { + val result = CompletableFuture() + drains.add(result) + result.whenComplete { _, _ -> synchronized(lock) { drains.remove(result) } } + progress() + result + } + + /** Half-closes input. Finish an active write first. */ + public fun end(): CompletableFuture = synchronized(lock) { + check(writer == null && !closing && !closed) { "TCP end requires an open, idle writer" } + if (ending) return@synchronized endResult ?: CompletableFuture.completedFuture(Unit) + ending = true + val result = CompletableFuture() + endResult = result + result.whenComplete { _, _ -> synchronized(lock) { + if (endResult === result && result.isCancelled) { endResult = null; ending = false } + } } + progress() + result + } + + /** Stops input; retains crossing output and ownership until both sides close and drain. */ + override fun close(): Unit = synchronized(lock) { + if (closing || closed) return@synchronized + closing = true + dispatch(StateActionTcpClientClose(TcpClientCloseAction(ActionType.TCP_CLIENT_CLOSE))) + progress() + failure?.let { throw it } + } + + /** Disposal terminates every pending operation, discarding unread bytes. */ + public fun dispose(): Unit = fail(IllegalStateException("TCP connection disposed")) + + public fun suspend(cause: Throwable? = null): Unit = synchronized(lock) { + if (!closed) { + suspended = true + transportFailure = cause + } + } + + /** Lag/decode loss is terminal, not a resumable transport interruption. */ + public fun fail(error: Throwable): Unit = synchronized(lock) { failLocked(error, reset = true) } + + public fun accept(envelope: ActionEnvelope): Unit = synchronized(lock) { + if (!suspended) apply(envelope) + } + + private fun apply(envelope: ActionEnvelope) { + if (closed || envelope.channel != resource) return + try { + safe(envelope.serverSeq) + val clientEcho = when (envelope.action) { + is StateActionTcpInput, is StateActionTcpDataConsumed, is StateActionTcpInputEof, + is StateActionTcpClientClose, is StateActionTcpClientReset -> true + else -> false + } + val sequence = if (clientEcho) { + val origin = envelope.origin + check(origin != null && origin.clientId == clientId) { "TCP client echo requires the owning client origin" } + safe(origin.clientSeq) + check(origin.clientSeq <= lastSequence) { "TCP client echo has an unassigned sequence" } + origin.clientSeq + } else null + if (envelope.serverSeq <= checkpoint) return + check(envelope.rejectionReason == null) { envelope.rejectionReason ?: "Rejected TCP action" } + val next = tcpReducer(current, envelope.action) + if (sequence != null) { + val expected = pending[sequence] + if (expected != null) check(expected == envelope.action) { "TCP client echo does not match its pending action" } + else check(next == current) { "TCP advancing client echo has no pending action" } + } + check(next.output.receivedBytes - consumedBytes <= next.output.windowBytes) { "TCP output exceeds locally released credit" } + val action = envelope.action + if (action is StateActionTcpData && next.output.receivedBytes > current.output.receivedBytes) { + received.addLast(Base64.getDecoder().decode(action.value.data)) + } + current = next + checkpoint = envelope.serverSeq + if (sequence != null) pending.remove(sequence) + } catch (error: IllegalArgumentException) { + failLocked(error, reset = true) + return + } catch (error: IllegalStateException) { + failLocked(error, reset = true) + return + } + if (current.reset != null) failLocked(IllegalStateException("TCP reset: ${current.reset?.reason}"), reset = false) + else if (current.hostClosed) close() + progress() + } + + private fun dispatch(action: StateAction) { + val sequence = transport.nextSequence() + safe(sequence) + check(sequence > lastSequence) { "TCP sequence allocator did not advance" } + lastSequence = sequence + pending[sequence] = action + if (!suspended) send(sequence, action) + } + + private fun send(sequence: Long, action: StateAction) { + try { + transport.send(resource, sequence, action) + } catch (error: Exception) { + transportFailure = error + suspended = true + if (closed) { + val previous = failure + if (previous == null) failure = error else previous.addSuppressed(error) + } + } + } + + private fun failLocked(error: Throwable, reset: Boolean) { + if (closed) return + failure = error + closed = true + try { + if (reset && !suspended) dispatch(StateActionTcpClientReset(TcpClientResetAction(ActionType.TCP_CLIENT_RESET, TcpResetReason.PROTOCOL_ERROR))) + } catch (sendError: Exception) { error.addSuppressed(sendError) } + received.clear() + release() + progress() + } + + private fun release() { + if (released) return + released = true + pending.clear() + try { + transport.unsubscribe(resource) + } catch (error: Exception) { + val previous = failure + if (previous == null) failure = error else previous.addSuppressed(error) + } + } + + private fun progress() { + dirty = true + if (progressing) return + progressing = true + try { + while (dirty) { + dirty = false + val error = failure + if (error != null) { + val read = reader; reader = null; read?.completeExceptionally(error) + val write = writer; writer = null; write?.result?.completeExceptionally(error) + endResult?.completeExceptionally(error); endResult = null + for (drain in drains.toList()) drain.completeExceptionally(error) + continue + } + if (!suspended || closed) { + reader?.let { result -> + if (received.isNotEmpty()) { + val bytes = received.removeFirst() + reader = null + consumedBytes += bytes.size + if (!closed) dispatch(StateActionTcpDataConsumed(TcpDataConsumedAction(ActionType.TCP_DATA_CONSUMED, consumedBytes))) + result.complete(bytes) + } else if (closed || current.hostClosed || current.output.eofAtBytes != null) { + reader = null + result.complete(null) + } + } + } + while (!suspended && !closing && !closed) { + val write = writer ?: break + if (write.offset == write.data.size) { + writer = null + write.result.complete(Unit) + break + } + val credit = current.input.windowBytes - (sentBytes - current.input.consumedBytes) + if (credit == 0L) break + val count = minOf(credit, current.input.maximumChunkSize, (write.data.size - write.offset).toLong()).toInt() + safe(sentBytes + count) + val action = StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, sentBytes, + Base64.getEncoder().encodeToString(write.data.copyOfRange(write.offset, write.offset + count)))) + sentBytes += count + write.offset += count + dispatch(action) + } + if (!suspended && !closing && !closed) { + endResult?.let { result -> + endResult = null + dispatch(StateActionTcpInputEof(TcpInputEofAction(ActionType.TCP_INPUT_EOF, sentBytes))) + result.complete(Unit) + } + } + if (!closed && closing && current.clientClosed && current.hostClosed + && current.input.consumedBytes >= sentBytes + && current.output.consumedBytes >= current.output.receivedBytes + && received.isEmpty() && pending.isEmpty()) { + closed = true + release() + dirty = true + } + if (closing || closed) { + val errorClosed = IllegalStateException("TCP connection closed") + val write = writer; writer = null; write?.result?.completeExceptionally(errorClosed) + endResult?.completeExceptionally(errorClosed); endResult = null + } + if (current.input.consumedBytes >= sentBytes) { + for (drain in drains.toList()) drain.complete(Unit) + } + } + } catch (error: Exception) { + failLocked(error, reset = false) + } finally { + progressing = false + } + if (dirty) progress() + } + + public companion object { + private fun safe(value: Long) { require(value in 0..9007199254740991L) { "TCP counter must be a nonnegative safe integer" } } + + private fun validateLimits(windowBytes: Long, maximumChunkSize: Long) { + require(windowBytes in 1..4294967295L && maximumChunkSize in 1..windowBytes) { + "TCP window and chunk limits must be positive UInt32 values, with chunk no larger than window" + } + } + + /** Validates capability and flow limits before the injected transport sends subscribe. */ + public fun creationParameters(session: String, create: TcpConnectionSubscription, initialized: InitializeResult): SubscribeParams { + require(session.startsWith("ahp-session:") && create.type == "tcpConnection" && create.host.isNotBlank() + && create.port in 1..65535 && create.encoding == TcpDataEncoding.BASE64 + && initialized.tcpConnections?.encodings?.contains(create.encoding) == true) { "Invalid or unsupported TCP creation request" } + validateLimits(create.receiveWindowBytes, create.maximumChunkSize) + return SubscribeParams(channel = session, create = create) + } + + /** Seeds only a validated, freshly created stream; never a reconnect snapshot. */ + public fun open(session: String, create: TcpConnectionSubscription, initialized: InitializeResult, + result: SubscribeResult, transport: TcpConnectionTransport): TcpConnection { + val snapshot = requireNotNull(result.snapshot) { "Missing TCP creation snapshot" } + try { + creationParameters(session, create, initialized) + val state = (snapshot.state as? SnapshotState.Tcp)?.value + ?: throw IllegalArgumentException("Invalid TCP creation snapshot") + require(snapshot.resource.startsWith("ahp-tcp:") && state.session == session && state.target.host == create.host + && state.target.port == create.port && state.encoding == create.encoding + && !state.clientClosed && !state.hostClosed && state.reset == null) { "Invalid TCP creation snapshot" } + safe(snapshot.fromSeq) + for (direction in listOf(state.input, state.output)) { + validateLimits(direction.windowBytes, direction.maximumChunkSize) + require(direction.receivedBytes == 0L && direction.consumedBytes == 0L && direction.eofAtBytes == null) { "TCP creation requires fresh directions" } + } + require(state.output.windowBytes <= create.receiveWindowBytes && state.output.maximumChunkSize <= create.maximumChunkSize) + return TcpConnection(snapshot.resource, state, snapshot.fromSeq, transport) + } catch (error: IllegalArgumentException) { + if (snapshot.resource.startsWith("ahp-tcp:")) transport.unsubscribe(snapshot.resource) + throw error + } + } + + /** Call before sending reconnect; the transport must buffer subsequent live events until resume returns. */ + public fun reconnectParameters(parameters: ReconnectParams, connections: List): ReconnectParams { + safe(parameters.lastSeenServerSeq) + require(parameters.channel == "ahp-root://") + require(connections.map { it.resource }.distinct().size == connections.size) + var checkpoint = parameters.lastSeenServerSeq + for (connection in connections) synchronized(connection.lock) { + require(connection.clientId == parameters.clientId && connection.suspended && !connection.closed) { "TCP reconnect requires suspended handles owned by the same client" } + checkpoint = minOf(checkpoint, connection.checkpoint) + } + return parameters.copy(lastSeenServerSeq = checkpoint, subscriptions = (parameters.subscriptions + connections.map { it.resource }).distinct()) + } + + /** Rebinds, applies replay, cleans acknowledgements, then resends original pending identities. */ + public fun resume(parameters: ReconnectParams, result: ReconnectResult, connections: List, transport: TcpConnectionTransport) { + require(transport.clientId == parameters.clientId) + val checked = reconnectParameters(parameters, connections) + require(checked.lastSeenServerSeq == parameters.lastSeenServerSeq && parameters.subscriptions.containsAll(checked.subscriptions)) { + "Use reconnectParameters before sending reconnect" + } + val lastSequence = connections.maxOfOrNull { synchronized(it.lock) { + val assigned = it.transport.lastAssignedSequence() + if (assigned != -1L) safe(assigned) + it.lastSequence = maxOf(it.lastSequence, assigned) + it.lastSequence + } } + if (lastSequence != null && lastSequence >= 0) transport.advanceSequencePast(lastSequence) + if (result is ReconnectResultReplay) { + var previous = parameters.lastSeenServerSeq + for (envelope in result.value.actions) { + safe(envelope.serverSeq) + require(envelope.serverSeq > previous) { "TCP replay is not ordered" } + previous = envelope.serverSeq + } + } + for (connection in connections) synchronized(connection.lock) { + connection.transport = transport + if (result !is ReconnectResultReplay || connection.resource in result.value.missing) { + connection.failLocked(IllegalStateException("TCP cannot resume missing resources or snapshot fallback"), reset = false) + } else { + for (envelope in result.value.actions) connection.apply(envelope) + if (!connection.closed) { + connection.suspended = false + connection.transportFailure = null + for ((sequence, action) in connection.pending.toMap()) { + if (connection.suspended) break + connection.send(sequence, action) + } + connection.progress() + } + } + } + } + } +} diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index 3607b366b..dc4c6e8ca 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -132,6 +132,16 @@ value class ActionType(val rawValue: String) { val AUTOMATION_RUN_SESSION_REMOVED: ActionType = ActionType("automationRun/sessionRemoved") val AUTOMATION_RUN_PRIMARY_SESSION_CHANGED: ActionType = ActionType("automationRun/primarySessionChanged") val AUTOMATION_RUN_CANCEL_REQUESTED: ActionType = ActionType("automationRun/cancelRequested") + val TCP_INPUT: ActionType = ActionType("tcp/input") + val TCP_DATA: ActionType = ActionType("tcp/data") + val TCP_INPUT_CONSUMED: ActionType = ActionType("tcp/inputConsumed") + val TCP_DATA_CONSUMED: ActionType = ActionType("tcp/dataConsumed") + val TCP_INPUT_EOF: ActionType = ActionType("tcp/inputEof") + val TCP_DATA_EOF: ActionType = ActionType("tcp/dataEof") + val TCP_CLIENT_CLOSE: ActionType = ActionType("tcp/clientClose") + val TCP_HOST_CLOSE: ActionType = ActionType("tcp/hostClose") + val TCP_CLIENT_RESET: ActionType = ActionType("tcp/clientReset") + val TCP_HOST_RESET: ActionType = ActionType("tcp/hostReset") } } @@ -1507,6 +1517,78 @@ data class ResourceWatchChangedAction( val changes: JsonElement ) +@Serializable +data class TcpInputAction( + val type: ActionType, + /** + * Absolute decoded-byte offset. + */ + val offset: Long, + /** + * Nonempty canonical padded RFC 4648 base64; no whitespace. + */ + val data: String +) + +@Serializable +data class TcpDataAction( + val type: ActionType, + /** + * Absolute decoded-byte offset. + */ + val offset: Long, + /** + * Nonempty canonical padded RFC 4648 base64; no whitespace. + */ + val data: String +) + +@Serializable +data class TcpInputConsumedAction( + val type: ActionType, + val consumedBytes: Long +) + +@Serializable +data class TcpDataConsumedAction( + val type: ActionType, + val consumedBytes: Long +) + +@Serializable +data class TcpInputEofAction( + val type: ActionType, + val finalOffset: Long +) + +@Serializable +data class TcpDataEofAction( + val type: ActionType, + val finalOffset: Long +) + +@Serializable +data class TcpClientCloseAction( + val type: ActionType +) + +@Serializable +data class TcpHostCloseAction( + val type: ActionType +) + +@Serializable +data class TcpClientResetAction( + val type: ActionType, + val reason: TcpResetReason +) + +@Serializable +data class TcpHostResetAction( + val type: ActionType, + val reason: TcpResetReason +) + @Serializable data class AutomationCreateRequestedAction( val type: ActionType, @@ -1756,6 +1838,16 @@ sealed interface StateAction @JvmInline value class StateActionTerminalCommandExecuted(val value: TerminalCommandExecutedAction) : StateAction @JvmInline value class StateActionTerminalCommandFinished(val value: TerminalCommandFinishedAction) : StateAction @JvmInline value class StateActionResourceWatchChanged(val value: ResourceWatchChangedAction) : StateAction +@JvmInline value class StateActionTcpInput(val value: TcpInputAction) : StateAction +@JvmInline value class StateActionTcpData(val value: TcpDataAction) : StateAction +@JvmInline value class StateActionTcpInputConsumed(val value: TcpInputConsumedAction) : StateAction +@JvmInline value class StateActionTcpDataConsumed(val value: TcpDataConsumedAction) : StateAction +@JvmInline value class StateActionTcpInputEof(val value: TcpInputEofAction) : StateAction +@JvmInline value class StateActionTcpDataEof(val value: TcpDataEofAction) : StateAction +@JvmInline value class StateActionTcpClientClose(val value: TcpClientCloseAction) : StateAction +@JvmInline value class StateActionTcpHostClose(val value: TcpHostCloseAction) : StateAction +@JvmInline value class StateActionTcpClientReset(val value: TcpClientResetAction) : StateAction +@JvmInline value class StateActionTcpHostReset(val value: TcpHostResetAction) : StateAction @JvmInline value class StateActionAutomationCreateRequested(val value: AutomationCreateRequestedAction) : StateAction @JvmInline value class StateActionAutomationUpdateRequested(val value: AutomationUpdateRequestedAction) : StateAction @JvmInline value class StateActionAutomationSet(val value: AutomationSetAction) : StateAction @@ -1874,6 +1966,16 @@ internal object StateActionSerializer : KSerializer { "terminal/commandExecuted" -> StateActionTerminalCommandExecuted(input.json.decodeFromJsonElement(TerminalCommandExecutedAction.serializer(), element)) "terminal/commandFinished" -> StateActionTerminalCommandFinished(input.json.decodeFromJsonElement(TerminalCommandFinishedAction.serializer(), element)) "resourceWatch/changed" -> StateActionResourceWatchChanged(input.json.decodeFromJsonElement(ResourceWatchChangedAction.serializer(), element)) + "tcp/input" -> StateActionTcpInput(input.json.decodeFromJsonElement(TcpInputAction.serializer(), element)) + "tcp/data" -> StateActionTcpData(input.json.decodeFromJsonElement(TcpDataAction.serializer(), element)) + "tcp/inputConsumed" -> StateActionTcpInputConsumed(input.json.decodeFromJsonElement(TcpInputConsumedAction.serializer(), element)) + "tcp/dataConsumed" -> StateActionTcpDataConsumed(input.json.decodeFromJsonElement(TcpDataConsumedAction.serializer(), element)) + "tcp/inputEof" -> StateActionTcpInputEof(input.json.decodeFromJsonElement(TcpInputEofAction.serializer(), element)) + "tcp/dataEof" -> StateActionTcpDataEof(input.json.decodeFromJsonElement(TcpDataEofAction.serializer(), element)) + "tcp/clientClose" -> StateActionTcpClientClose(input.json.decodeFromJsonElement(TcpClientCloseAction.serializer(), element)) + "tcp/hostClose" -> StateActionTcpHostClose(input.json.decodeFromJsonElement(TcpHostCloseAction.serializer(), element)) + "tcp/clientReset" -> StateActionTcpClientReset(input.json.decodeFromJsonElement(TcpClientResetAction.serializer(), element)) + "tcp/hostReset" -> StateActionTcpHostReset(input.json.decodeFromJsonElement(TcpHostResetAction.serializer(), element)) "automation/createRequested" -> StateActionAutomationCreateRequested(input.json.decodeFromJsonElement(AutomationCreateRequestedAction.serializer(), element)) "automation/updateRequested" -> StateActionAutomationUpdateRequested(input.json.decodeFromJsonElement(AutomationUpdateRequestedAction.serializer(), element)) "automation/set" -> StateActionAutomationSet(input.json.decodeFromJsonElement(AutomationSetAction.serializer(), element)) @@ -1985,6 +2087,16 @@ internal object StateActionSerializer : KSerializer { is StateActionTerminalCommandExecuted -> output.json.encodeToJsonElement(TerminalCommandExecutedAction.serializer(), value.value) is StateActionTerminalCommandFinished -> output.json.encodeToJsonElement(TerminalCommandFinishedAction.serializer(), value.value) is StateActionResourceWatchChanged -> output.json.encodeToJsonElement(ResourceWatchChangedAction.serializer(), value.value) + is StateActionTcpInput -> output.json.encodeToJsonElement(TcpInputAction.serializer(), value.value) + is StateActionTcpData -> output.json.encodeToJsonElement(TcpDataAction.serializer(), value.value) + is StateActionTcpInputConsumed -> output.json.encodeToJsonElement(TcpInputConsumedAction.serializer(), value.value) + is StateActionTcpDataConsumed -> output.json.encodeToJsonElement(TcpDataConsumedAction.serializer(), value.value) + is StateActionTcpInputEof -> output.json.encodeToJsonElement(TcpInputEofAction.serializer(), value.value) + is StateActionTcpDataEof -> output.json.encodeToJsonElement(TcpDataEofAction.serializer(), value.value) + is StateActionTcpClientClose -> output.json.encodeToJsonElement(TcpClientCloseAction.serializer(), value.value) + is StateActionTcpHostClose -> output.json.encodeToJsonElement(TcpHostCloseAction.serializer(), value.value) + is StateActionTcpClientReset -> output.json.encodeToJsonElement(TcpClientResetAction.serializer(), value.value) + is StateActionTcpHostReset -> output.json.encodeToJsonElement(TcpHostResetAction.serializer(), value.value) is StateActionAutomationCreateRequested -> output.json.encodeToJsonElement(AutomationCreateRequestedAction.serializer(), value.value) is StateActionAutomationUpdateRequested -> output.json.encodeToJsonElement(AutomationUpdateRequestedAction.serializer(), value.value) is StateActionAutomationSet -> output.json.encodeToJsonElement(AutomationSetAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 48ed5f7b7..e85766d4d 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -418,7 +418,11 @@ data class InitializeResult( * `ahp-automations://` for {@link AutomationState}; absence means the * host does not expose an automation catalogue or automation commands. */ - val automations: AutomationCapabilities? = null + val automations: AutomationCapabilities? = null, + /** + * Enables atomic creation of session-scoped, replay-only TCP channels. + */ + val tcpConnections: TcpConnectionsCapability? = null ) @Serializable @@ -557,7 +561,13 @@ data class ReconnectSnapshotResult( /** * Fresh snapshots for each subscription */ - val snapshots: List + val snapshots: List, + /** + * Subscriptions that cannot be restored. Hosts supporting TCP MUST list all + * requested TCP channels here and dispose their sockets on snapshot fallback. + * Omitted by older hosts; absence does not authorize snapshot-restoring TCP. + */ + val missing: List? = null ) @Serializable @@ -586,7 +596,13 @@ data class SubscribeParams( * Servers that do not understand a requested view ignore it and return their * default snapshot. Clients MUST tolerate receiving more state than requested. */ - val view: SubscribeView? = null + val view: SubscribeView? = null, + /** + * Atomically create a private child channel and subscribe to it. + * Requires the advertised tcpConnections capability. channel identifies + * the parent session; snapshot.resource identifies the created TCP channel. + */ + val create: TcpConnectionSubscription? = null ) @Serializable @@ -623,6 +639,31 @@ data class SubscribeResult( val snapshot: Snapshot? = null ) +@Serializable +data class TcpConnectionSubscription( + val type: String, + /** + * DNS name or IP literal, not a URL. + */ + val host: String, + /** + * Destination port. + */ + val port: Long, + /** + * Selected from InitializeResult.tcpConnections.encodings. + */ + val encoding: TcpDataEncoding, + /** + * Client receive window in decoded bytes. + */ + val receiveWindowBytes: Long, + /** + * Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes. + */ + val maximumChunkSize: Long +) + @Serializable data class CreateSessionParams( /** diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt index c1ba5c42b..ffe6fa9d4 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Errors.generated.kt @@ -57,6 +57,7 @@ object AhpErrorCodes { const val PERMISSION_DENIED: Int = -32009 /** The target resource already exists and the operation does not allow overwriting */ const val ALREADY_EXISTS: Int = -32010 + const val TCP_CONNECTION_OPEN_FAILED: Int = -32012 } // ─── Error Detail Payloads ────────────────────────────────────────────────── diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index 87ba426a8..3f7cdbc74 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -72,6 +72,89 @@ internal object StringOrMarkdownSerializer : KSerializer { // ─── Enums ────────────────────────────────────────────────────────────────── +/** + * Payload encodings advertised by the host. + */ +@Serializable(with = TcpDataEncodingSerializer::class) +@JvmInline +value class TcpDataEncoding(val rawValue: String) { + companion object { + val BASE64: TcpDataEncoding = TcpDataEncoding("base64") + } +} + +internal object TcpDataEncodingSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TcpDataEncoding", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: TcpDataEncoding) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): TcpDataEncoding = + TcpDataEncoding(decoder.decodeString()) +} + +/** + * Endpoint that closes or resets a connection. + */ +@Serializable +enum class TcpEndpoint { + @SerialName("client") + CLIENT, + @SerialName("host") + HOST +} + +/** + * Why a connection was aborted. + */ +@Serializable(with = TcpResetReasonSerializer::class) +@JvmInline +value class TcpResetReason(val rawValue: String) { + companion object { + val CONNECTION_RESET: TcpResetReason = TcpResetReason("connectionReset") + val CONNECTION_ABORTED: TcpResetReason = TcpResetReason("connectionAborted") + val PROTOCOL_ERROR: TcpResetReason = TcpResetReason("protocolError") + val REPLAY_UNAVAILABLE: TcpResetReason = TcpResetReason("replayUnavailable") + val POLICY_REVOKED: TcpResetReason = TcpResetReason("policyRevoked") + val SESSION_DISPOSED: TcpResetReason = TcpResetReason("sessionDisposed") + val INTERNAL_ERROR: TcpResetReason = TcpResetReason("internalError") + } +} + +internal object TcpResetReasonSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TcpResetReason", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: TcpResetReason) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): TcpResetReason = + TcpResetReason(decoder.decodeString()) +} + +/** + * Expected connection establishment failures. + */ +@Serializable(with = TcpConnectionOpenFailureReasonSerializer::class) +@JvmInline +value class TcpConnectionOpenFailureReason(val rawValue: String) { + companion object { + val CONNECTION_FAILED: TcpConnectionOpenFailureReason = TcpConnectionOpenFailureReason("connectionFailed") + val NAME_RESOLUTION_FAILED: TcpConnectionOpenFailureReason = TcpConnectionOpenFailureReason("nameResolutionFailed") + val RESOURCE_SHORTAGE: TcpConnectionOpenFailureReason = TcpConnectionOpenFailureReason("resourceShortage") + val SESSION_NOT_READY: TcpConnectionOpenFailureReason = TcpConnectionOpenFailureReason("sessionNotReady") + } +} + +internal object TcpConnectionOpenFailureReasonSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TcpConnectionOpenFailureReason", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: TcpConnectionOpenFailureReason) { + encoder.encodeString(value.rawValue) + } + override fun deserialize(decoder: Decoder): TcpConnectionOpenFailureReason = + TcpConnectionOpenFailureReason(decoder.decodeString()) +} + /** * Policy configuration state for a model. */ @@ -5427,6 +5510,84 @@ data class ResourceChange( val type: ResourceChangeType ) +@Serializable +data class TcpConnectionState( + val session: String, + val target: TcpTarget, + val encoding: TcpDataEncoding, + /** + * Client to destination socket. + */ + val input: FlowControlledByteDirectionState, + /** + * Destination socket to client. + */ + val output: FlowControlledByteDirectionState, + val clientClosed: Boolean, + val hostClosed: Boolean, + val reset: TcpResetState? = null +) + +@Serializable +data class TcpTarget( + /** + * DNS name or IP literal, resolved and connected in the host endpoint's network. + */ + val host: String, + /** + * Destination port. + */ + val port: Long +) + +@Serializable +data class TcpResetState( + val source: TcpEndpoint, + val reason: TcpResetReason +) + +@Serializable +data class FlowControlledByteDirectionState( + /** + * Maximum accepted-but-not-consumed decoded bytes. + */ + val windowBytes: Long, + /** + * Maximum decoded bytes per chunk; MUST NOT exceed windowBytes. + */ + val maximumChunkSize: Long, + /** + * Cumulative accepted bytes. + */ + val receivedBytes: Long, + /** + * Cumulative bytes released by the bounded consumer. + */ + val consumedBytes: Long, + /** + * Present after EOF; equals receivedBytes permanently. + */ + val eofAtBytes: Long? = null +) + +@Serializable +data class TcpConnectionsCapability( + /** + * Supported encodings. The base64 profile MUST be supported. + */ + val encodings: List, + /** + * Informational limit; runtime policy may impose a lower limit. + */ + val maximumConnectionsPerClient: Long? = null +) + +@Serializable +data class TcpConnectionOpenErrorData( + val reason: TcpConnectionOpenFailureReason, + val retryable: Boolean? = null +) + @Serializable data class AutomationSessionOrigin( val kind: SessionOriginKind, @@ -7420,6 +7581,7 @@ internal object ToolResultContentSerializer : KSerializer { */ @Serializable(with = SnapshotStateSerializer::class) sealed interface SnapshotState { + @JvmInline value class Tcp(val value: TcpConnectionState) : SnapshotState @JvmInline value class Root(val value: RootState) : SnapshotState @JvmInline value class Session(val value: SessionState) : SnapshotState @JvmInline value class Chat(val value: ChatState) : SnapshotState @@ -7441,7 +7603,8 @@ internal object SnapshotStateSerializer : KSerializer { val element = input.decodeJsonElement() val obj = element as? JsonObject ?: error("Expected JsonObject for SnapshotState") - // Try the most distinctive shape first. AutomationRunState has required + // Try the most distinctive shape first. TcpConnectionState has required + // `input`, `output`, and `target`; AutomationRunState has required // `automation`, `origin`, and `sessions`; AutomationState has // required `entries`; SessionState has required // `lifecycle`; ChatState has required `turns`; ChangesetState has @@ -7451,6 +7614,8 @@ internal object SnapshotStateSerializer : KSerializer { // key); TerminalState has required `content`; RootState is the // catch-all. return when { + obj.containsKey("input") && obj.containsKey("output") && obj.containsKey("target") -> + SnapshotState.Tcp(input.json.decodeFromJsonElement(TcpConnectionState.serializer(), element)) obj.containsKey("automation") && obj.containsKey("origin") && obj.containsKey("sessions") -> SnapshotState.AutomationRun(input.json.decodeFromJsonElement(AutomationRunState.serializer(), element)) obj.containsKey("entries") -> @@ -7473,6 +7638,7 @@ internal object SnapshotStateSerializer : KSerializer { val output = encoder as? JsonEncoder ?: error("SnapshotState can only be serialized to JSON") val element: JsonElement = when (value) { + is SnapshotState.Tcp -> output.json.encodeToJsonElement(TcpConnectionState.serializer(), value.value) is SnapshotState.Root -> output.json.encodeToJsonElement(RootState.serializer(), value.value) is SnapshotState.Session -> output.json.encodeToJsonElement(SessionState.serializer(), value.value) is SnapshotState.Chat -> output.json.encodeToJsonElement(ChatState.serializer(), value.value) diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt index c3f4675eb..1d286b55a 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/FixtureDrivenReducerTest.kt @@ -10,10 +10,14 @@ import com.microsoft.agenthostprotocol.generated.RootState import com.microsoft.agenthostprotocol.generated.SessionState import com.microsoft.agenthostprotocol.generated.StateAction import com.microsoft.agenthostprotocol.generated.TerminalState +import com.microsoft.agenthostprotocol.generated.TcpConnectionState import java.io.File -import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.DynamicTest import org.junit.jupiter.api.Test @@ -21,6 +25,8 @@ import org.junit.jupiter.api.TestFactory import org.junit.jupiter.api.assertAll import org.junit.jupiter.api.fail import kotlin.test.assertTrue +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith /** * JSON-fixture-driven reducer tests for cross-language parity. @@ -110,10 +116,33 @@ class FixtureDrivenReducerTest { ) } - val actions = Ahp.json.decodeFromJsonElement( - ListSerializer(StateAction.serializer()), - actionsArr, - ) + val actions = actionsArr.jsonArray + val expectedError = fixture["expectedError"]?.jsonPrimitiveContent() + if (expectedError != null) assertTrue(actions.isNotEmpty(), "expectedError requires a final action") + + fun runActions(initialState: S, reduce: (S, StateAction) -> S): S { + var state = initialState + for ((index, raw) in actions.withIndex()) { + val mustFail = expectedError != null && index == actions.lastIndex + val fractionalOffset = raw.jsonObject["offset"]?.jsonPrimitive?.doubleOrNull + ?.let { it % 1.0 != 0.0 } == true + if (mustFail && reducer == "tcp" && fractionalOffset) { + assertEquals("Invalid TCP action: offset must be a nonnegative safe integer", expectedError) + assertFailsWith { + Ahp.json.decodeFromJsonElement(StateAction.serializer(), raw) + } + } else { + val action = Ahp.json.decodeFromJsonElement(StateAction.serializer(), raw) + if (mustFail) { + val error = assertFailsWith { reduce(state, action) } + assertEquals(expectedError, error.message, file.name) + } else { + state = reduce(state, action) + } + } + } + return state + } when (reducer) { "root" -> compareFixture( @@ -122,9 +151,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = RootState.serializer(), run = { state -> - var s = state - for (action in actions) s = rootReducer(s, action) - s + runActions(state, ::rootReducer) }, ) @@ -134,9 +161,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = SessionState.serializer(), run = { state -> - var s = state - for (action in actions) s = sessionReducer(s, action) - s + runActions(state, ::sessionReducer) }, ) @@ -146,9 +171,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = ChatState.serializer(), run = { state -> - var s = state - for (action in actions) s = chatReducer(s, action) - s + runActions(state, ::chatReducer) }, ) @@ -158,9 +181,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = TerminalState.serializer(), run = { state -> - var s = state - for (action in actions) s = terminalReducer(s, action) - s + runActions(state, ::terminalReducer) }, ) @@ -170,9 +191,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = ChangesetState.serializer(), run = { state -> - var s = state - for (action in actions) s = changesetReducer(s, action) - s + runActions(state, ::changesetReducer) }, ) @@ -182,9 +201,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = AnnotationsState.serializer(), run = { state -> - var s = state - for (action in actions) s = annotationsReducer(s, action) - s + runActions(state, ::annotationsReducer) }, ) @@ -194,9 +211,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = ResourceWatchState.serializer(), run = { state -> - var s = state - for (action in actions) s = resourceWatchReducer(s, action) - s + runActions(state, ::resourceWatchReducer) }, ) @@ -206,9 +221,7 @@ class FixtureDrivenReducerTest { expected = expected, serializer = AutomationState.serializer(), run = { state -> - var s = state - for (action in actions) s = automationReducer(s, action) - s + runActions(state, ::automationReducer) }, ) @@ -218,12 +231,18 @@ class FixtureDrivenReducerTest { expected = expected, serializer = AutomationRunState.serializer(), run = { state -> - var s = state - for (action in actions) s = automationRunReducer(s, action) - s + runActions(state, ::automationRunReducer) }, ) + "tcp" -> compareFixture( + file = file, + initial = initial, + expected = expected, + serializer = TcpConnectionState.serializer(), + run = { state -> runActions(state, ::tcpReducer) }, + ) + else -> fail("${file.name}: unsupported reducer '$reducer'") } } diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt index f792a4ff2..954eb9cef 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/GeneratedStructsTest.kt @@ -9,6 +9,9 @@ import com.microsoft.agenthostprotocol.generated.ProtectedResourceMetadata import com.microsoft.agenthostprotocol.generated.SessionAddedParams import com.microsoft.agenthostprotocol.generated.SessionModelInfo import com.microsoft.agenthostprotocol.generated.SessionStatus +import com.microsoft.agenthostprotocol.generated.SubscribeParams +import com.microsoft.agenthostprotocol.generated.SubscribeView +import com.microsoft.agenthostprotocol.generated.SubscriptionDeliveryOptions import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -32,6 +35,22 @@ import kotlin.test.assertTrue class GeneratedStructsTest { private val json: Json = Ahp.json + @Test + fun `subscribe preserves existing positional constructor copy and component arguments`() { + val delivery = SubscriptionDeliveryOptions(0L) + val view = SubscribeView(10L) + val params = SubscribeParams("ahp-session:/s1", null, delivery, view) + assertEquals(delivery, params.delivery) + assertEquals(view, params.view) + val copy = params.copy("ahp-session:/s2", null, delivery, view) + val (channel, meta, copiedDelivery, copiedView) = copy + assertEquals("ahp-session:/s2", channel) + assertEquals(null, meta) + assertEquals(delivery, copiedDelivery) + assertEquals(view, copiedView) + assertEquals(null, copy.create) + } + @Test fun `plain enum encodes wire string and decodes back`() { val encoded = json.encodeToString(PolicyState.serializer(), PolicyState.UNCONFIGURED) diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt index 78f73c2a5..d59f29fb4 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/ReducersTest.kt @@ -11,6 +11,7 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue +import kotlin.test.assertFailsWith /** * Focused unit tests covering the reducer module's public surface, the @@ -21,6 +22,471 @@ import kotlin.test.assertTrue */ class ReducersTest { + @Test + fun `TCP creation requires canonical discriminator`() { + val create = TcpConnectionSubscription("tcpConnection", "localhost", 3000, TcpDataEncoding.BASE64, 4, 2) + val initialized = InitializeResult(protocolVersion = "1", snapshots = emptyList(), serverSeq = 0, + tcpConnections = TcpConnectionsCapability(listOf(TcpDataEncoding.BASE64))) + val creation = TcpConnectionCreation("ahp-session:/test", create, initialized, TcpHarness().transport) + val wire = Ahp.json.encodeToJsonElement(SubscribeParams.serializer(), creation.parameters) as JsonObject + assertEquals(JsonPrimitive("tcpConnection"), (wire["create"] as JsonObject)["type"]) + assertFailsWith { + TcpConnection.creationParameters("ahp-session:/test", create.copy(type = "tcp"), initialized) + } + } + + @Test + fun `TCP local close retains crossing traffic until both directions drain`() { + val harness = TcpHarness() + val connection = harness.open() + connection.write(byteArrayOf(1, 2)).join() + val input = harness.sent.last() + val drain = connection.drain() + connection.close() + val close = harness.sent.last() + assertIs(close.second) + assertTrue(!connection.isClosed) + assertTrue(harness.unsubscribed.isEmpty()) + val read = connection.read() + assertTrue(!read.isDone) + connection.accept(tcpEnvelope(1, input.second, ActionOrigin("owner", input.first))) + connection.accept(tcpEnvelope(2, close.second, ActionOrigin("owner", close.first))) + connection.accept(tcpEnvelope(3, StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg=")))) + assertTrue(byteArrayOf(7, 8).contentEquals(read.join())) + val credit = harness.sent.last() + assertEquals(2, assertIs(credit.second).value.consumedBytes) + connection.accept(tcpEnvelope(4, StateActionTcpHostClose(TcpHostCloseAction(ActionType.TCP_HOST_CLOSE)))) + assertTrue(!connection.isClosed && !drain.isDone && harness.unsubscribed.isEmpty()) + connection.accept(tcpEnvelope(5, credit.second, ActionOrigin("owner", credit.first))) + assertTrue(!connection.isClosed && harness.unsubscribed.isEmpty()) + connection.accept(tcpEnvelope(6, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, 2)))) + drain.join() + assertNull(connection.read().join()) + assertTrue(connection.isClosed) + connection.close() + connection.dispose() + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + + @Test + fun `TCP creation and snapshot limits use UInt32 range`() { + val initialized = InitializeResult(protocolVersion = "1", snapshots = emptyList(), serverSeq = 0, + tcpConnections = TcpConnectionsCapability(listOf(TcpDataEncoding.BASE64))) + val request = TcpConnectionSubscription("tcpConnection", "localhost", 3000, TcpDataEncoding.BASE64, 4294967295L, 4294967295L) + for (limit in listOf(1L, 4294967295L, 0L, -1L, 4294967296L, 9007199254740991L)) { + val valid = limit in 1..4294967295L + for (chunk in listOf(false, true)) { + val create = request.copy(receiveWindowBytes = limit, maximumChunkSize = if (chunk) limit else 1) + if (valid) TcpConnection.creationParameters("ahp-session:/test", create, initialized) + else assertFailsWith("creation $limit") { + TcpConnection.creationParameters("ahp-session:/test", create, initialized) + } + for (input in listOf(false, true)) { + val direction = FlowControlledByteDirectionState(limit, if (chunk) limit else 1, 0, 0) + val state = if (input) tcpState().copy(input = direction) else tcpState().copy(output = direction) + val result = SubscribeResult(Snapshot("ahp-tcp:/created", SnapshotState.Tcp(state), 0)) + val harness = TcpHarness() + if (valid) { + val connection = TcpConnection.open("ahp-session:/test", request, initialized, result, harness.transport) + connection.dispose() + } else assertFailsWith("snapshot $limit, input $input") { + TcpConnection.open("ahp-session:/test", request, initialized, result, harness.transport) + } + assertEquals(listOf("ahp-tcp:/created"), harness.unsubscribed) + } + } + } + } + + @Test + fun `TCP peer close responds without waiting for credit or unread output`() { + val harness = TcpHarness() + val connection = harness.open() + val write = connection.write(ByteArray(5)) + val drain = connection.drain() + harness.sent.toList().forEachIndexed { index, item -> + connection.accept(tcpEnvelope(index + 1L, item.second, ActionOrigin("owner", item.first))) + } + connection.accept(tcpEnvelope(3, StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg=")))) + connection.accept(tcpEnvelope(4, StateActionTcpHostClose(TcpHostCloseAction(ActionType.TCP_HOST_CLOSE)))) + val close = harness.sent.last() + assertIs(close.second) + assertEquals(0, connection.state.input.consumedBytes) + assertEquals(0, connection.state.output.consumedBytes) + assertTrue(write.isCompletedExceptionally && !drain.isDone && harness.unsubscribed.isEmpty()) + connection.accept(tcpEnvelope(5, close.second, ActionOrigin("owner", close.first))) + connection.accept(tcpEnvelope(6, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, 4)))) + drain.join() + assertTrue(byteArrayOf(7, 8).contentEquals(connection.read().join())) + val credit = harness.sent.last() + assertIs(credit.second) + connection.accept(tcpEnvelope(7, credit.second, ActionOrigin("owner", credit.first))) + assertNull(connection.read().join()) + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + + @Test + fun `TCP creation releases late children after timeout or cancellation but never the parent`() { + for (cancel in listOf(false, true)) for (resource in listOf("ahp-tcp:/late", "ahp-session:/test")) { + val harness = TcpHarness() + val creation = TcpConnectionCreation("ahp-session:/test", + TcpConnectionSubscription("tcpConnection", "localhost", 3000, TcpDataEncoding.BASE64, 4, 4), + InitializeResult(protocolVersion = "1", snapshots = emptyList(), serverSeq = 0, + tcpConnections = TcpConnectionsCapability(listOf(TcpDataEncoding.BASE64))), harness.transport) + if (cancel) creation.completion.cancel(false) + else creation.fail(java.util.concurrent.TimeoutException("create timeout")) + val result = SubscribeResult(Snapshot(resource, SnapshotState.Tcp(tcpState(4)), 0)) + creation.accept(result) + creation.accept(result) + assertTrue(creation.completion.isCompletedExceptionally) + assertEquals(if (resource.startsWith("ahp-tcp:")) listOf(resource) else emptyList(), harness.unsubscribed) + assertTrue(harness.sent.isEmpty()) + } + } + + private class TcpHarness { + val sent = mutableListOf>() + val unsubscribed = mutableListOf() + var sequence = 1L + val transport = TcpConnectionTransport("owner", { sequence++ }, { sequence = maxOf(sequence, it + 1) }, + { _, seq, action -> sent.add(seq to action) }, { unsubscribed.add(it) }, { sequence - 1 }) + fun open(maximumChunkSize: Long = 2): TcpConnection { + val window = maxOf(4, maximumChunkSize) + val direction = FlowControlledByteDirectionState(window, maximumChunkSize, 0, 0) + val state = TcpConnectionState("ahp-session:/test", TcpTarget("localhost", 3000), TcpDataEncoding.BASE64, + direction, direction, false, false) + val creation = TcpConnectionCreation("ahp-session:/test", + TcpConnectionSubscription("tcpConnection", "localhost", 3000, TcpDataEncoding.BASE64, window, maximumChunkSize), + InitializeResult(protocolVersion = "1", snapshots = emptyList(), serverSeq = 0, tcpConnections = TcpConnectionsCapability(listOf(TcpDataEncoding.BASE64))), + transport) + val wire = Ahp.json.encodeToJsonElement(SubscribeParams.serializer(), creation.parameters) as JsonObject + assertEquals(JsonPrimitive("tcpConnection"), (wire["create"] as JsonObject)["type"]) + creation.accept(SubscribeResult(Snapshot("ahp-tcp:/created", SnapshotState.Tcp(state), 0))) + return creation.completion.join() + } + } + + @Test + fun `TCP reset or dispose terminates a closing stream`() { + for (reset in listOf(false, true)) { + val harness = TcpHarness() + val connection = harness.open() + val write = connection.write(ByteArray(5)) + val drain = connection.drain() + connection.close() + assertTrue(write.isCompletedExceptionally) + assertTrue(!drain.isDone) + if (reset) { + connection.accept(tcpEnvelope(1, StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg=")))) + connection.accept(tcpEnvelope(2, StateActionTcpHostReset(TcpHostResetAction(ActionType.TCP_HOST_RESET, TcpResetReason.PROTOCOL_ERROR)))) + assertTrue(connection.read().isCompletedExceptionally) + } else { + val read = connection.read() + assertTrue(!read.isDone) + connection.dispose() + assertTrue(read.isCompletedExceptionally) + } + assertTrue(drain.isCompletedExceptionally) + connection.dispose() + connection.close() + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + } + + @Test + fun `TCP close while suspended replays and drains before release`() { + val old = TcpHarness() + val connection = old.open() + connection.suspend() + connection.close() + assertTrue(!connection.isClosed && old.sent.isEmpty() && old.unsubscribed.isEmpty()) + val read = connection.read() + assertTrue(!read.isDone) + val request = TcpConnection.reconnectParameters(ReconnectParams("ahp-root://", clientId = "owner", + lastSeenServerSeq = 0, subscriptions = emptyList()), listOf(connection)) + assertTrue(connection.resource in request.subscriptions) + val fresh = TcpHarness() + TcpConnection.resume(request, ReconnectResultReplay(ReconnectReplayResult(ReconnectResultType.REPLAY, listOf( + tcpEnvelope(1, StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg="))), + tcpEnvelope(2, StateActionTcpHostClose(TcpHostCloseAction(ActionType.TCP_HOST_CLOSE))), + ), emptyList())), listOf(connection), fresh.transport) + val close = fresh.sent.first() + assertIs(close.second) + assertTrue(byteArrayOf(7, 8).contentEquals(read.join())) + val credit = fresh.sent.last() + assertIs(credit.second) + connection.accept(tcpEnvelope(3, close.second, ActionOrigin("owner", close.first))) + assertTrue(fresh.unsubscribed.isEmpty()) + connection.accept(tcpEnvelope(4, credit.second, ActionOrigin("owner", credit.first))) + assertNull(connection.read().join()) + assertEquals(listOf(connection.resource), fresh.unsubscribed) + assertTrue(old.unsubscribed.isEmpty()) + } + + private fun tcpEnvelope(sequence: Long, action: StateAction, origin: ActionOrigin? = null) = + ActionEnvelope(channel = "ahp-tcp:/created", action = action, serverSeq = sequence, origin = origin) + + @Test + fun `TCP adapter encodes 4 MiB and final close preserves drain and buffered reads`() { + val harness = TcpHarness() + val bytes = ByteArray(4 * 1024 * 1024) + bytes[0] = 1 + bytes[bytes.lastIndex] = -1 + val connection = harness.open(bytes.size.toLong()) + connection.write(bytes).join() + val sent = harness.sent.single() + val input = assertIs(sent.second).value + assertEquals(0L, input.offset) + assertTrue(bytes.contentEquals(java.util.Base64.getDecoder().decode(input.data))) + val drain = connection.drain() + assertTrue(!drain.isDone) + connection.accept(tcpEnvelope(1, sent.second, ActionOrigin("owner", sent.first))) + connection.accept(tcpEnvelope(2, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, bytes.size.toLong())))) + connection.accept(tcpEnvelope(3, StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg=")))) + connection.accept(tcpEnvelope(4, StateActionTcpHostClose(TcpHostCloseAction(ActionType.TCP_HOST_CLOSE)))) + val close = harness.sent.last() + assertIs(close.second) + connection.accept(tcpEnvelope(5, close.second, ActionOrigin("owner", close.first))) + assertTrue(harness.unsubscribed.isEmpty()) + drain.join() + connection.drain().join() + assertTrue(byteArrayOf(7, 8).contentEquals(connection.read().join())) + val credit = harness.sent.last() + assertIs(credit.second) + assertTrue(harness.unsubscribed.isEmpty()) + connection.accept(tcpEnvelope(6, credit.second, ActionOrigin("owner", credit.first))) + assertNull(connection.read().join()) + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + + @Test + fun `TCP adapter rejects malformed client echoes before advancing or releasing payloads`() { + for (malformed in listOf("missing", "owner", "negative", "unsafe", "unassigned", "wrong-pending", "reused", "payload", "eof", "credit", "close", "reset", "rejected-empty")) { + val harness = TcpHarness() + val connection = harness.open() + val read = connection.read() + val write = connection.write(byteArrayOf(1, 2, 3, 4, 5)) + val drain = connection.drain() + val first = harness.sent[0] + val second = harness.sent[1] + var origin: ActionOrigin? = ActionOrigin("owner", first.first) + var action = first.second + when (malformed) { + "missing" -> origin = null + "owner" -> origin = ActionOrigin("other", first.first) + "negative" -> origin = ActionOrigin("owner", -1) + "unsafe" -> origin = ActionOrigin("owner", 9007199254740992) + "unassigned" -> origin = ActionOrigin("owner", second.first + 1) + "wrong-pending" -> origin = ActionOrigin("owner", second.first) + "reused" -> { connection.accept(tcpEnvelope(1, first.second, origin)); action = second.second } + "payload" -> action = StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, 0, "AgE=")) + "eof" -> { origin = null; action = StateActionTcpInputEof(TcpInputEofAction(ActionType.TCP_INPUT_EOF, 0)) } + "credit" -> { origin = null; action = StateActionTcpDataConsumed(TcpDataConsumedAction(ActionType.TCP_DATA_CONSUMED, 0)) } + "close" -> { origin = null; action = StateActionTcpClientClose(TcpClientCloseAction(ActionType.TCP_CLIENT_CLOSE)) } + "reset" -> { origin = null; action = StateActionTcpClientReset(TcpClientResetAction(ActionType.TCP_CLIENT_RESET, TcpResetReason.PROTOCOL_ERROR)) } + } + connection.accept(tcpEnvelope(2, action, origin).copy(rejectionReason = if (malformed == "rejected-empty") "" else null)) + for (future in listOf(read, write, drain)) assertTrue(future.isCompletedExceptionally, malformed) + assertEquals(if (malformed == "reused") 2L else 0L, connection.state.input.receivedBytes) + assertEquals(0, connection.state.input.consumedBytes) + assertIs(harness.sent.last().second) + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + } + + @Test + fun `TCP adapter reserves credit chunks reads duplicates and half closes`() { + val harness = TcpHarness() + val connection = harness.open() + val write = connection.write(byteArrayOf(1, 2, 3, 4, 5)) + assertTrue(!write.isDone) + assertEquals(2, harness.sent.size) + assertFailsWith { connection.write(byteArrayOf(9)) } + for ((index, item) in harness.sent.toList().withIndex()) { + assertEquals(2, java.util.Base64.getDecoder().decode(assertIs(item.second).value.data).size) + connection.accept(tcpEnvelope(index + 1L, item.second, ActionOrigin("owner", item.first))) + } + connection.accept(tcpEnvelope(3, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, 2)))) + write.get(1, java.util.concurrent.TimeUnit.SECONDS) + val last = harness.sent.last() + assertEquals(4, assertIs(last.second).value.offset) + val drain = connection.drain() + assertTrue(!drain.isDone) + connection.accept(tcpEnvelope(4, last.second, ActionOrigin("owner", last.first))) + connection.accept(tcpEnvelope(5, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, 5)))) + drain.get(1, java.util.concurrent.TimeUnit.SECONDS) + val data = StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg=")) + connection.accept(tcpEnvelope(6, data)) + connection.accept(tcpEnvelope(7, data)) + connection.accept(tcpEnvelope(8, StateActionTcpDataEof(TcpDataEofAction(ActionType.TCP_DATA_EOF, 2)))) + assertTrue(byteArrayOf(7, 8).contentEquals(connection.read().get())) + assertEquals(2, assertIs(harness.sent.last().second).value.consumedBytes) + assertNull(connection.read().get()) + connection.end().get() + assertEquals(5, assertIs(harness.sent.last().second).value.finalOffset) + connection.close() + connection.dispose() + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + + @Test + fun `TCP adapter resumes retained readers and only resends unacknowledged identities`() { + val first = TcpHarness() + val connection = first.open() + connection.write(byteArrayOf(1, 2)).get() + connection.end().get() + connection.accept(tcpEnvelope(1, StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, 0, "Bwg=")), ActionOrigin("owner", first.sent[0].first))) + connection.suspend() + val reader = connection.read() + assertTrue(!reader.isDone) + val request = TcpConnection.reconnectParameters(ReconnectParams("ahp-root://", clientId = "owner", + lastSeenServerSeq = 20, subscriptions = listOf("ahp-session:/test")), listOf(connection)) + assertEquals(1, request.lastSeenServerSeq) + assertTrue(connection.resource in request.subscriptions) + val fresh = TcpHarness() + TcpConnection.resume(request, ReconnectResultReplay(ReconnectReplayResult(ReconnectResultType.REPLAY, emptyList(), emptyList())), + listOf(connection), fresh.transport) + assertEquals(first.sent.take(2), fresh.sent.take(2)) + assertEquals(3, fresh.sent.last().first) + assertTrue(byteArrayOf(7, 8).contentEquals(reader.get())) + connection.suspend() + val again = TcpHarness() + val replay = tcpEnvelope(3, first.sent[0].second, ActionOrigin("owner", first.sent[0].first)) + TcpConnection.resume(request, ReconnectResultReplay(ReconnectReplayResult(ReconnectResultType.REPLAY, listOf(replay), emptyList())), + listOf(connection), again.transport) + assertEquals(listOf(2L, 3L), again.sent.map { it.first }) + connection.accept(tcpEnvelope(4, first.sent[0].second, ActionOrigin("owner", first.sent[0].first))) + assertTrue(!connection.isClosed) + connection.dispose() + } + + @Test + fun `TCP adapter continues a blocked writer after replay releases credit`() { + val first = TcpHarness() + val connection = first.open() + val write = connection.write(ByteArray(6)) + assertTrue(!write.isDone) + first.sent.forEachIndexed { index, item -> connection.accept(tcpEnvelope(index + 1L, item.second, ActionOrigin("owner", item.first))) } + val unrelatedSequence = first.transport.nextSequence() + connection.suspend() + val request = TcpConnection.reconnectParameters(ReconnectParams("ahp-root://", clientId = "owner", + lastSeenServerSeq = 20, subscriptions = emptyList()), listOf(connection)) + assertEquals(2, request.lastSeenServerSeq) + val replay = listOf(tcpEnvelope(3, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, 2)))) + val fresh = TcpHarness() + TcpConnection.resume(request, ReconnectResultReplay(ReconnectReplayResult(ReconnectResultType.REPLAY, replay, emptyList())), + listOf(connection), fresh.transport) + write.get(1, java.util.concurrent.TimeUnit.SECONDS) + assertEquals(1, fresh.sent.size) + assertEquals(4, assertIs(fresh.sent.single().second).value.offset) + assertTrue(fresh.sent.single().first > unrelatedSequence) + connection.dispose() + } + + @Test + fun `TCP adapter snapshot missing and strict loss terminate pending operations`() { + val results = listOf( + ReconnectResultSnapshot(ReconnectSnapshotResult(ReconnectResultType.SNAPSHOT, emptyList())), + ReconnectResultReplay(ReconnectReplayResult(ReconnectResultType.REPLAY, emptyList(), listOf("ahp-tcp:/created"))), + ) + for (result in results) { + val harness = TcpHarness() + val connection = harness.open() + val read = connection.read() + val write = connection.write(ByteArray(5)) + val drain = connection.drain() + connection.suspend() + val request = TcpConnection.reconnectParameters(ReconnectParams("ahp-root://", clientId = "owner", lastSeenServerSeq = 0, + subscriptions = emptyList()), listOf(connection)) + TcpConnection.resume(request, result, listOf(connection), harness.transport) + for (future in listOf(read, write, drain)) assertTrue(future.isCompletedExceptionally) + assertEquals(listOf(connection.resource), harness.unsubscribed) + } + val harness = TcpHarness() + val connection = harness.open() + val read = connection.read() + val write = connection.write(ByteArray(5)) + connection.fail(IllegalStateException("strict decode loss")) + assertTrue(read.isCompletedExceptionally && write.isCompletedExceptionally) + assertIs(harness.sent.last().second) + connection.dispose() + assertEquals(1, harness.unsubscribed.size) + val closing = TcpHarness().open() + val pendingRead = closing.read() + val pendingWrite = closing.write(ByteArray(5)) + val pendingDrain = closing.drain() + closing.close() + assertTrue(!pendingRead.isDone && !pendingDrain.isDone) + assertTrue(pendingWrite.isCompletedExceptionally) + closing.dispose() + assertTrue(pendingRead.isCompletedExceptionally && pendingDrain.isCompletedExceptionally) + } + + private fun tcpState(size: Long = 8): TcpConnectionState = TcpConnectionState( + session = "ahp-session:/test", + target = TcpTarget(host = "localhost", port = 3000), + encoding = TcpDataEncoding.BASE64, + input = FlowControlledByteDirectionState(size, size, 0, 0), + output = FlowControlledByteDirectionState(size, size, 0, 0), + clientClosed = false, + hostClosed = false, + ) + + @Test + fun `TCP validates a four MiB chunk without decoding or retaining payload`() { + val size = 4 * 1024 * 1024 + val data = "AAAA".repeat(size / 3) + "AA==" + val before = tcpState(size.toLong()) + val action = StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, 0, data)) + val after = TcpReducer.reduce(before, action) + assertEquals(size.toLong(), after.input.receivedBytes) + assertEquals(0L, before.input.receivedBytes) + assertSame(after, tcpReducer(after, action)) + assertSame(before.output, after.output) + val error = assertFailsWith { + tcpReducer(after, StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, size.toLong(), "AA=="))) + } + assertEquals("Invalid TCP action: receive window exceeded", error.message) + assertEquals(size.toLong(), after.input.receivedBytes) + } + + @Test + fun `TCP checks all integer counters and accepts the safe boundary`() { + val max = 9007199254740991L + val before = tcpState().let { it.copy(input = it.input.copy(receivedBytes = max - 1, consumedBytes = max - 1)) } + var state = tcpReducer(before, StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, max - 1, "AA=="))) + state = tcpReducer(state, StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, max))) + state = tcpReducer(state, StateActionTcpInputEof(TcpInputEofAction(ActionType.TCP_INPUT_EOF, max))) + assertEquals(max, state.input.receivedBytes) + assertEquals(max, state.input.consumedBytes) + assertEquals(max, state.input.eofAtBytes) + for (value in listOf(-1L, max + 1, Long.MAX_VALUE)) { + val actions = listOf( + StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, value, "AA==")), + StateActionTcpData(TcpDataAction(ActionType.TCP_DATA, value, "AA==")), + StateActionTcpInputConsumed(TcpInputConsumedAction(ActionType.TCP_INPUT_CONSUMED, value)), + StateActionTcpDataConsumed(TcpDataConsumedAction(ActionType.TCP_DATA_CONSUMED, value)), + StateActionTcpInputEof(TcpInputEofAction(ActionType.TCP_INPUT_EOF, value)), + StateActionTcpDataEof(TcpDataEofAction(ActionType.TCP_DATA_EOF, value)), + ) + for (action in actions) { + val error = assertFailsWith { tcpReducer(state, action) } + assertEquals("Invalid TCP action: offset must be a nonnegative safe integer", error.message) + assertEquals(max, state.input.receivedBytes) + } + } + } + + @Test + fun `TCP rejects actual whitespace and Unicode base64`() { + for (data in listOf("AAA\n", "AAA\r", "AAA\t", "AAA ", "AAA\u00e9", "AA\uD83D\uDE00")) { + val error = assertFailsWith { + tcpReducer(tcpState(), StateActionTcpInput(TcpInputAction(ActionType.TCP_INPUT, 0, data))) + } + assertEquals("Invalid TCP action: base64 encoding", error.message) + } + } + @Test fun `Reducer object wrappers delegate to free functions`() { // RootReducer diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt index 7af1319f9..4fed08a31 100644 --- a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/RoundTripCorpusTest.kt @@ -39,6 +39,7 @@ import com.microsoft.agenthostprotocol.generated.JsonRpcNotification import com.microsoft.agenthostprotocol.generated.JsonRpcRequest import com.microsoft.agenthostprotocol.generated.JsonRpcSuccessResponse import com.microsoft.agenthostprotocol.generated.PartialSessionSummary +import com.microsoft.agenthostprotocol.generated.ReconnectResult import com.microsoft.agenthostprotocol.generated.SessionAddedParams import com.microsoft.agenthostprotocol.generated.ChatInputQuestion import com.microsoft.agenthostprotocol.generated.SessionStatus @@ -46,6 +47,8 @@ import com.microsoft.agenthostprotocol.generated.SessionSummary import com.microsoft.agenthostprotocol.generated.Snapshot import com.microsoft.agenthostprotocol.generated.StateAction import com.microsoft.agenthostprotocol.generated.StringOrMarkdown +import com.microsoft.agenthostprotocol.generated.SubscribeParams +import com.microsoft.agenthostprotocol.generated.TcpConnectionOpenErrorData import java.io.File import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json @@ -253,6 +256,9 @@ class RoundTripCorpusTest { "PartialSessionSummary" -> rt(PartialSessionSummary.serializer()) "Implementation" -> rt(Implementation.serializer()) "InitializeResult" -> rt(InitializeResult.serializer()) + "SubscribeParams" -> rt(SubscribeParams.serializer()) + "ReconnectResult" -> rt(ReconnectResult.serializer()) + "TcpConnectionOpenErrorData" -> rt(TcpConnectionOpenErrorData.serializer()) "ChatSource" -> rt(ChatSource.serializer()) "Snapshot" -> rt(Snapshot.serializer()) else -> fail( diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock index 2b4da79a4..306c1f701 100644 --- a/clients/rust/Cargo.lock +++ b/clients/rust/Cargo.lock @@ -8,6 +8,7 @@ version = "0.9.0" dependencies = [ "ahp-types", "ahp-ws", + "base64", "jiff", "serde", "serde_json", @@ -46,6 +47,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 776a1ed7e..3eec46de7 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; #[allow(unused_imports)] use serde_repr::{Deserialize_repr, Serialize_repr}; +use crate::state::TcpResetReason; #[allow(unused_imports)] use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, @@ -133,6 +134,16 @@ pub enum ActionType { AutomationRunSessionRemoved, AutomationRunPrimarySessionChanged, AutomationRunCancelRequested, + TcpInput, + TcpData, + TcpInputConsumed, + TcpDataConsumed, + TcpInputEof, + TcpDataEof, + TcpClientClose, + TcpHostClose, + TcpClientReset, + TcpHostReset, /// Unknown raw value from a newer protocol version, preserved verbatim. Unknown(String), } @@ -310,6 +321,16 @@ impl serde::Serialize for ActionType { Self::AutomationRunCancelRequested => { serializer.serialize_str("automationRun/cancelRequested") } + Self::TcpInput => serializer.serialize_str("tcp/input"), + Self::TcpData => serializer.serialize_str("tcp/data"), + Self::TcpInputConsumed => serializer.serialize_str("tcp/inputConsumed"), + Self::TcpDataConsumed => serializer.serialize_str("tcp/dataConsumed"), + Self::TcpInputEof => serializer.serialize_str("tcp/inputEof"), + Self::TcpDataEof => serializer.serialize_str("tcp/dataEof"), + Self::TcpClientClose => serializer.serialize_str("tcp/clientClose"), + Self::TcpHostClose => serializer.serialize_str("tcp/hostClose"), + Self::TcpClientReset => serializer.serialize_str("tcp/clientReset"), + Self::TcpHostReset => serializer.serialize_str("tcp/hostReset"), Self::Unknown(value) => serializer.serialize_str(value), } } @@ -425,6 +446,16 @@ impl<'de> serde::Deserialize<'de> for ActionType { "automationRun/sessionRemoved" => Self::AutomationRunSessionRemoved, "automationRun/primarySessionChanged" => Self::AutomationRunPrimarySessionChanged, "automationRun/cancelRequested" => Self::AutomationRunCancelRequested, + "tcp/input" => Self::TcpInput, + "tcp/data" => Self::TcpData, + "tcp/inputConsumed" => Self::TcpInputConsumed, + "tcp/dataConsumed" => Self::TcpDataConsumed, + "tcp/inputEof" => Self::TcpInputEof, + "tcp/dataEof" => Self::TcpDataEof, + "tcp/clientClose" => Self::TcpClientClose, + "tcp/hostClose" => Self::TcpHostClose, + "tcp/clientReset" => Self::TcpClientReset, + "tcp/hostReset" => Self::TcpHostReset, _ => Self::Unknown(raw), }) } @@ -2146,6 +2177,80 @@ pub struct ResourceWatchChangedAction { pub changes: AnyValue, } +/// Client bytes. Never apply optimistically to the authoritative reducer. +/// Write to the destination only when accepted input.receivedBytes advances. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpInputAction { + /// Absolute decoded-byte offset. + pub offset: i64, + /// Nonempty canonical padded RFC 4648 base64; no whitespace. + pub data: String, +} + +/// Host bytes. Deliver once, only when output.receivedBytes advances. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpDataAction { + /// Absolute decoded-byte offset. + pub offset: i64, + /// Nonempty canonical padded RFC 4648 base64; no whitespace. + pub data: String, +} + +/// Cumulative input bytes released from the host's bounded write buffer. +/// Not an acknowledgment that the destination application processed the bytes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpInputConsumedAction { + pub consumed_bytes: i64, +} + +/// Cumulative output bytes released by the client's bounded stream consumer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpDataConsumedAction { + pub consumed_bytes: i64, +} + +/// Half-close client input after all preceding input bytes have been written. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpInputEofAction { + pub final_offset: i64, +} + +/// Half-close host output after all preceding output bytes have been delivered. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpDataEofAction { + pub final_offset: i64, +} + +/// Client's final close. Respond with hostClose if not already sent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpClientCloseAction {} + +/// Host's final close. Respond with clientClose if not already sent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpHostCloseAction {} + +/// Abort both directions and discard buffered payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpClientResetAction { + pub reason: TcpResetReason, +} + +/// Abort both directions and discard buffered payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpHostResetAction { + pub reason: TcpResetReason, +} + /// Ask the host to create a durable automation at a client-chosen resource. /// /// Clients may dispatch this action only when the host advertises its `create` @@ -2515,6 +2620,26 @@ pub enum StateAction { TerminalCommandFinished(TerminalCommandFinishedAction), #[serde(rename = "resourceWatch/changed")] ResourceWatchChanged(ResourceWatchChangedAction), + #[serde(rename = "tcp/input")] + TcpInput(TcpInputAction), + #[serde(rename = "tcp/data")] + TcpData(TcpDataAction), + #[serde(rename = "tcp/inputConsumed")] + TcpInputConsumed(TcpInputConsumedAction), + #[serde(rename = "tcp/dataConsumed")] + TcpDataConsumed(TcpDataConsumedAction), + #[serde(rename = "tcp/inputEof")] + TcpInputEof(TcpInputEofAction), + #[serde(rename = "tcp/dataEof")] + TcpDataEof(TcpDataEofAction), + #[serde(rename = "tcp/clientClose")] + TcpClientClose(TcpClientCloseAction), + #[serde(rename = "tcp/hostClose")] + TcpHostClose(TcpHostCloseAction), + #[serde(rename = "tcp/clientReset")] + TcpClientReset(TcpClientResetAction), + #[serde(rename = "tcp/hostReset")] + TcpHostReset(TcpHostResetAction), #[serde(rename = "automation/createRequested")] AutomationCreateRequested(Box), #[serde(rename = "automation/updateRequested")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 37e8955b5..ebfc0dd9d 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -18,7 +18,8 @@ use crate::state::{ AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, - Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn, + Snapshot, SnapshotState, TcpConnectionsCapability, TcpDataEncoding, TelemetryCapabilities, + TerminalClaim, TextRange, Turn, }; // ─── Enums ──────────────────────────────────────────────────────────── @@ -330,6 +331,9 @@ pub struct InitializeResult { /// host does not expose an automation catalogue or automation commands. #[serde(default, skip_serializing_if = "Option::is_none")] pub automations: Option, + /// Enables atomic creation of session-scoped, replay-only TCP channels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tcp_connections: Option, } /// Optional capabilities a client declares during `initialize`. @@ -497,6 +501,11 @@ pub struct ReconnectReplayResult { pub struct ReconnectSnapshotResult { /// Fresh snapshots for each subscription pub snapshots: Vec, + /// Subscriptions that cannot be restored. Hosts supporting TCP MUST list all + /// requested TCP channels here and dispose their sockets on snapshot fallback. + /// Omitted by older hosts; absence does not authorize snapshot-restoring TCP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub missing: Option>, } /// Subscribe to a URI-identified channel. @@ -527,6 +536,11 @@ pub struct SubscribeParams { /// default snapshot. Clients MUST tolerate receiving more state than requested. #[serde(default, skip_serializing_if = "Option::is_none")] pub view: Option, + /// Atomically create a private child channel and subscribe to it. + /// Requires the advertised tcpConnections capability. channel identifies + /// the parent session; snapshot.resource identifies the created TCP channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub create: Option, } impl SubscribeParams { @@ -537,6 +551,7 @@ impl SubscribeParams { meta: None, delivery: None, view: None, + create: None, } } @@ -547,6 +562,7 @@ impl SubscribeParams { meta: None, delivery: Some(delivery), view: None, + create: None, } } @@ -557,6 +573,7 @@ impl SubscribeParams { meta: None, delivery: None, view: Some(view), + create: None, } } } @@ -601,6 +618,28 @@ pub struct SubscribeResult { pub snapshot: Option, } +/// Creates and exclusively subscribes to one TCP connection. +/// +/// SubscribeParams.channel MUST identify the parent `ahp-session:` channel. +/// The host returns the new `ahp-tcp:` URI in snapshot.resource, not the parent. +/// It installs the subscription and sends the response before any TCP actions. +/// Unknown creation kinds MUST be rejected, never treated as normal subscribe. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpConnectionSubscription { + pub r#type: String, + /// DNS name or IP literal, not a URL. + pub host: String, + /// Destination port. + pub port: i64, + /// Selected from InitializeResult.tcpConnections.encodings. + pub encoding: TcpDataEncoding, + /// Client receive window in decoded bytes. + pub receive_window_bytes: i64, + /// Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes. + pub maximum_chunk_size: i64, +} + /// Creates a new session with the specified agent provider. /// /// If the session URI already exists, the server MUST return an error with code diff --git a/clients/rust/crates/ahp-types/src/errors.rs b/clients/rust/crates/ahp-types/src/errors.rs index 3c181c259..2cbf5fb49 100644 --- a/clients/rust/crates/ahp-types/src/errors.rs +++ b/clients/rust/crates/ahp-types/src/errors.rs @@ -55,6 +55,8 @@ pub mod ahp_error_codes { pub const ALREADY_EXISTS: i32 = -32010; /// An optimistic-concurrency precondition failed: a request's precondition token (e.g. `ResourceWriteParams.if_match`) no longer matches the resource's current state. pub const CONFLICT: i32 = -32011; + /// TCP connection creation failed. Data carries TcpConnectionOpenErrorData. + pub const TCP_CONNECTION_OPEN_FAILED: i32 = -32012; } /// Type alias: AHP application error code. diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 06fc17955..4942f17e3 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -13,6 +13,141 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; // ─── Enums ──────────────────────────────────────────────────────────── +/// Payload encodings advertised by the host. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TcpDataEncoding { + Base64, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for TcpDataEncoding { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Base64 => serializer.serialize_str("base64"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for TcpDataEncoding { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "base64" => Self::Base64, + _ => Self::Unknown(raw), + }) + } +} + +/// Endpoint that closes or resets a connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TcpEndpoint { + #[serde(rename = "client")] + Client, + #[serde(rename = "host")] + Host, +} + +/// Why a connection was aborted. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TcpResetReason { + ConnectionReset, + ConnectionAborted, + ProtocolError, + ReplayUnavailable, + PolicyRevoked, + SessionDisposed, + InternalError, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for TcpResetReason { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::ConnectionReset => serializer.serialize_str("connectionReset"), + Self::ConnectionAborted => serializer.serialize_str("connectionAborted"), + Self::ProtocolError => serializer.serialize_str("protocolError"), + Self::ReplayUnavailable => serializer.serialize_str("replayUnavailable"), + Self::PolicyRevoked => serializer.serialize_str("policyRevoked"), + Self::SessionDisposed => serializer.serialize_str("sessionDisposed"), + Self::InternalError => serializer.serialize_str("internalError"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for TcpResetReason { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "connectionReset" => Self::ConnectionReset, + "connectionAborted" => Self::ConnectionAborted, + "protocolError" => Self::ProtocolError, + "replayUnavailable" => Self::ReplayUnavailable, + "policyRevoked" => Self::PolicyRevoked, + "sessionDisposed" => Self::SessionDisposed, + "internalError" => Self::InternalError, + _ => Self::Unknown(raw), + }) + } +} + +/// Expected connection establishment failures. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TcpConnectionOpenFailureReason { + ConnectionFailed, + NameResolutionFailed, + ResourceShortage, + SessionNotReady, + /// Unknown raw value from a newer protocol version, preserved verbatim. + Unknown(String), +} + +impl serde::Serialize for TcpConnectionOpenFailureReason { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::ConnectionFailed => serializer.serialize_str("connectionFailed"), + Self::NameResolutionFailed => serializer.serialize_str("nameResolutionFailed"), + Self::ResourceShortage => serializer.serialize_str("resourceShortage"), + Self::SessionNotReady => serializer.serialize_str("sessionNotReady"), + Self::Unknown(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> serde::Deserialize<'de> for TcpConnectionOpenFailureReason { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + Ok(match raw.as_str() { + "connectionFailed" => Self::ConnectionFailed, + "nameResolutionFailed" => Self::NameResolutionFailed, + "resourceShortage" => Self::ResourceShortage, + "sessionNotReady" => Self::SessionNotReady, + _ => Self::Unknown(raw), + }) + } +} + /// Policy configuration state for a model. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PolicyState { @@ -5473,6 +5608,90 @@ pub struct ResourceWatchState { pub includes: Option, } +/// State of one host-assigned `ahp-tcp:` channel. +/// +/// Payload is never stored in this state. Only the creating authenticated +/// logical client may observe or dispatch to the channel. Reconnect requires +/// the original sockets, local stream state, and complete action replay; +/// a snapshot cannot restore this channel. +/// +/// Close flags record the two-sided handshake. Either flag means closing; +/// both mean closed. A present reset terminates the connection immediately, +/// independently of the close history. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpConnectionState { + pub session: Uri, + pub target: TcpTarget, + pub encoding: TcpDataEncoding, + /// Client to destination socket. + pub input: FlowControlledByteDirectionState, + /// Destination socket to client. + pub output: FlowControlledByteDirectionState, + pub client_closed: bool, + pub host_closed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reset: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpTarget { + /// DNS name or IP literal, resolved and connected in the host endpoint's network. + pub host: String, + /// Destination port. + pub port: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpResetState { + pub source: TcpEndpoint, + pub reason: TcpResetReason, +} + +/// Bounded byte credit in one direction of a stream. +/// All counters are nonnegative safe integers (at most 2^53 - 1). +/// 0 <= consumedBytes <= receivedBytes and +/// receivedBytes - consumedBytes <= windowBytes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FlowControlledByteDirectionState { + /// Maximum accepted-but-not-consumed decoded bytes. + pub window_bytes: i64, + /// Maximum decoded bytes per chunk; MUST NOT exceed windowBytes. + pub maximum_chunk_size: i64, + /// Cumulative accepted bytes. + pub received_bytes: i64, + /// Cumulative bytes released by the bounded consumer. + pub consumed_bytes: i64, + /// Present after EOF; equals receivedBytes permanently. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eof_at_bytes: Option, +} + +/// Host support for private, session-scoped TCP channels. +/// Presence on initialize is required before using subscribe.create. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpConnectionsCapability { + /// Supported encodings. The base64 profile MUST be supported. + pub encodings: Vec, + /// Informational limit; runtime policy may impose a lower limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_connections_per_client: Option, +} + +/// Required detail for TcpConnectionOpenFailed (-32012). +/// Policy denial and malformed requests use PermissionDenied and InvalidParams. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TcpConnectionOpenErrorData { + pub reason: TcpConnectionOpenFailureReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retryable: Option, +} + /// A single change observed by a resource watcher. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -6500,7 +6719,8 @@ pub enum AutomationRunLifecycle { /// The state payload of a snapshot. /// -/// Deserialized by trying session first (has required `lifecycle`), then +/// Deserialized by trying TCP first (has required `input`, `output`, and +/// `target`), then session (has required `lifecycle`), then /// chat (has required `turns`), then terminal (has required `content`), /// then changeset (has required `status` and `files`), then resource-watch /// (has required `root` and `recursive`), then annotations (has required @@ -6509,6 +6729,7 @@ pub enum AutomationRunLifecycle { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SnapshotState { + Tcp(Box), Session(Box), Chat(Box), Terminal(Box), diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index 1b81a9caf..b8d55ab79 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -28,11 +28,17 @@ use ahp_types::{ actions::{ActionEnvelope, StateAction}, - commands::{ChangesetOperationTarget, ChatSource, Implementation, InitializeResult}, + commands::{ + ChangesetOperationTarget, ChatSource, Implementation, InitializeResult, ReconnectResult, + SubscribeParams, + }, common::StringOrMarkdown, messages::JsonRpcMessage, notifications::{PartialSessionSummary, SessionAddedParams}, - state::{ChatInputQuestion, Customization, SessionStatus, SessionSummary, Snapshot}, + state::{ + ChatInputQuestion, Customization, SessionStatus, SessionSummary, Snapshot, + TcpConnectionOpenErrorData, + }, version::{PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}, }; use serde_json::{Number, Value}; @@ -223,6 +229,9 @@ fn decode_and_reencode(file: &str, type_name: &str, input_json: &str) -> Result< "PartialSessionSummary" => round_trip!(PartialSessionSummary), "Implementation" => round_trip!(Implementation), "InitializeResult" => round_trip!(InitializeResult), + "SubscribeParams" => round_trip!(SubscribeParams), + "ReconnectResult" => round_trip!(ReconnectResult), + "TcpConnectionOpenErrorData" => round_trip!(TcpConnectionOpenErrorData), "ChatSource" => round_trip!(ChatSource), "Snapshot" => round_trip!(Snapshot), other => Err(format!( diff --git a/clients/rust/crates/ahp/Cargo.toml b/clients/rust/crates/ahp/Cargo.toml index c4f310848..c0b994a87 100644 --- a/clients/rust/crates/ahp/Cargo.toml +++ b/clients/rust/crates/ahp/Cargo.toml @@ -27,6 +27,7 @@ thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } jiff = { workspace = true } +base64 = "0.22" [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/clients/rust/crates/ahp/README.md b/clients/rust/crates/ahp/README.md index 343027901..3a5319478 100644 --- a/clients/rust/crates/ahp/README.md +++ b/clients/rust/crates/ahp/README.md @@ -42,6 +42,81 @@ async fn main() -> anyhow::Result<()> { } ``` +## Owned TCP byte streams + +After `initialize` advertises `tcpConnections` with `base64` support, create a +stream atomically through its parent session: + +```rust +use ahp_types::state::TcpDataEncoding; +use ahp_types::commands::TcpConnectionSubscription; + +let connection = client.open_tcp_connection(session_uri.clone(), TcpConnectionSubscription { + r#type: "tcpConnection".into(), + host: "localhost".into(), + port: 3000, + encoding: TcpDataEncoding::Base64, + receive_window_bytes: 256 * 1024, + maximum_chunk_size: 64 * 1024, +}).await?; +connection.write_all(&request_bytes).await?; +connection.end().await?; // Input EOF; output remains readable. +while let Some(chunk) = connection.read().await? { + // Consume this chunk. +} +connection.dispose().await?; +``` + +The SDK owns buffering, flow control, and replay. `read` releases receive credit; +`drain` waits for destination consumption. `write` accepts one chunk and returns +its byte count; `write_all` loops under one writer permit. Concurrent writers +are rejected. Cancelling `write_all` can leave an accepted prefix, so do not +blindly retry the whole buffer. + +Finish the writer before calling `close`, and keep reading until EOF while the +close handshake drains. Cancelling the future stops waiting, not the handshake. +`dispose` aborts without draining; call it on error/cancellation too. Clones share +ownership; last-handle drop attempts best-effort cleanup. + +Transport loss suspends the same handles. For deliberate transport replacement, +use `shutdown_preserving_tcp().await`; normal shutdown disposes streams. Create a +fresh `Client` and resume instead of initializing again: + +```rust +use ahp_types::commands::ReconnectParams; + +let result = fresh_client.reconnect_tcp_connections(ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), + meta: None, + client_id: "my-client".into(), // Same ID used by the original initialize. + last_seen_server_seq, + subscriptions: vec![session_uri], +}, &[connection.clone()]).await?; +``` + +Continue using the same handle; apply the returned result only to non-TCP +subscriptions. Snapshot fallback or missing resources fail streams rather than +creating new sockets. + +For managed hosts, call `HostClientHandle::open_tcp_connection(session_uri, create)` +instead of opening through `raw_client()`. The runtime automatically resumes +streams across reconnects and disposes them on host removal or shutdown. +Transport/retry policy, connection limits, and native socket bridges remain +application-owned. See the [TCP channel contract](../../../../docs/specification/tcp-channel.md). + +## Loss-sensitive raw events (advanced) + +Ordinary `client.events()` skips events when its bounded buffer overflows. +For loss-sensitive consumers, attach `client.events_strict()` before sending +requests. Its `recv().await` returns `Result, ClientError>`: +overflow reports `ClientError::SubscriptionLag`, and decode loss reports +`ClientError::Transport(TransportError::Protocol(...))`. Both terminate the +receiver rather than skipping events. Capacity uses +`ClientConfig::subscription_buffer`; ordinary receivers are unchanged. +These raw receivers are global. The owned TCP adapter instead registers a strict +child-scoped receiver during creation reply processing and reattaches it per +child on reconnect. Unrelated traffic cannot exhaust a TCP stream's event buffer. + ## Custom transport Implement `ahp::Transport` for any framed byte stream: diff --git a/clients/rust/crates/ahp/src/client.rs b/clients/rust/crates/ahp/src/client.rs index 4b9e5f05e..832dbf653 100644 --- a/clients/rust/crates/ahp/src/client.rs +++ b/clients/rust/crates/ahp/src/client.rs @@ -25,12 +25,12 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }; use std::time::Duration; -use ahp_types::actions::{ActionEnvelope, StateAction}; +use ahp_types::actions::{ActionEnvelope, ActionType, StateAction}; use ahp_types::commands::{ CompletionsParams, CompletionsResult, CreateResourceWatchParams, CreateResourceWatchResult, DispatchActionParams, InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, @@ -56,7 +56,7 @@ use serde_json::Value; use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; use tokio::task::JoinHandle; -use crate::error::ClientError; +use crate::error::{ClientError, SubscriptionLagError, TransportError}; use crate::transport::{Transport, TransportMessage}; /// Default size of a per-subscription broadcast channel. Consumers that @@ -144,6 +144,127 @@ impl ClientEventStream { } } +/// A bounded global event receiver that terminates on the first delivery gap. +/// +/// Created by [`Client::events_strict`]. Unlike [`ClientEventStream`], this +/// receiver reports lag rather than silently continuing past missing events. +pub struct StrictClientEventStream { + rx: Option>>, + on_drop: Option>, +} + +impl Drop for StrictClientEventStream { + fn drop(&mut self) { + self.rx.take(); + if let Some(on_drop) = self.on_drop.take() { + on_drop(); + } + } +} + +impl StrictClientEventStream { + pub(crate) fn try_recv(&mut self) -> Result, ClientError> { + let Some(rx) = self.rx.as_mut() else { + return Err(ClientError::Shutdown); + }; + match rx.try_recv() { + Ok(Ok(event)) => Ok(Some(event)), + Ok(Err(error)) => { + self.rx = None; + Err(error.into()) + } + Err(broadcast::error::TryRecvError::Empty) => Ok(None), + Err(broadcast::error::TryRecvError::Closed) => { + self.rx = None; + Err(ClientError::Shutdown) + } + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + self.rx = None; + Err(SubscriptionLagError { skipped }.into()) + } + } + } + + /// Receive an event, or report delivery loss and terminate this receiver. + /// + /// Overflow returns [`ClientError::SubscriptionLag`]; decoding or transport + /// failures return [`ClientError::Transport`]. Subsequent calls return + /// `Ok(None)`. Stream owners must reset/unsubscribe on errors, not resume. + pub async fn recv(&mut self) -> Result, ClientError> { + let Some(rx) = self.rx.as_mut() else { + return Ok(None); + }; + match rx.recv().await { + Ok(Ok(event)) => Ok(Some(event)), + Ok(Err(error)) => { + self.rx = None; + Err(error.into()) + } + Err(broadcast::error::RecvError::Closed) => { + self.rx = None; + Ok(None) + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + self.rx = None; + Err(SubscriptionLagError { skipped }.into()) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod strict_event_tests { + use super::*; + + #[tokio::test] + async fn protocol_failure_wakes_and_unregisters_strict_receiver() { + let (sender, rx) = broadcast::channel(1); + let mut stream = StrictClientEventStream { + rx: Some(rx), + on_drop: None, + }; + assert_eq!(sender.receiver_count(), 1); + let (result, ()) = tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!( + biased; + stream.recv(), + async { + sender.send(Err(TransportError::Protocol("malformed frame".into()))).unwrap(); + }, + ) + }) + .await + .unwrap(); + assert!(matches!( + result, + Err(ClientError::Transport(TransportError::Protocol(_))) + )); + assert_eq!(sender.receiver_count(), 0); + assert!(stream.recv().await.unwrap().is_none()); + } + + #[tokio::test] + async fn lag_unregisters_strict_receiver() { + let (sender, rx) = broadcast::channel(1); + let mut stream = StrictClientEventStream { + rx: Some(rx), + on_drop: None, + }; + for _ in 0..2 { + sender + .send(Err(TransportError::Protocol("malformed frame".into()))) + .unwrap(); + } + assert!(matches!( + stream.recv().await, + Err(ClientError::SubscriptionLag(_)) + )); + assert_eq!(sender.receiver_count(), 0); + assert!(stream.recv().await.unwrap().is_none()); + } +} + /// Handle to a single resource subscription. Drop to stop receiving /// events. The underlying server subscription is released when the last /// handle for that URI is dropped and [`Client::unsubscribe`] is called. @@ -187,7 +308,14 @@ pub struct DispatchHandle { // ─── Internal plumbing ─────────────────────────────────────────────────────── -type PendingMap = HashMap>>; +type ResultHandler = Box; +type PendingMap = HashMap< + u64, + ( + oneshot::Sender>, + Option, + ), +>; struct Shared { pending: Mutex, @@ -200,9 +328,16 @@ struct Shared { /// after the underlying transport closes (the `Sender` would stay /// alive inside the still-`Arc`-held `Shared`). all_events: std::sync::Mutex>>, + strict_events: std::sync::Mutex>>>, + resource_events: + std::sync::Mutex>>>, outbound: mpsc::Sender, next_id: AtomicU64, next_client_seq: AtomicU64, + tcp_identity: Mutex)>>, + tcp_closed: AtomicBool, + tcp_disposed: AtomicBool, + tcp_streams: Mutex>, config: ClientConfig, /// Handler for inbound server-initiated requests (the symmetrical /// `resource*` family). `None` → the client replies `MethodNotFound`. @@ -375,13 +510,20 @@ impl Client { ) -> Result { let (outbound_tx, outbound_rx) = mpsc::channel::(64); let (all_events_tx, _) = broadcast::channel::(config.subscription_buffer); + let (strict_events_tx, _) = broadcast::channel(config.subscription_buffer); let shared = Arc::new(Shared { pending: Mutex::new(HashMap::new()), subscriptions: Mutex::new(HashMap::new()), all_events: std::sync::Mutex::new(Some(all_events_tx)), + strict_events: std::sync::Mutex::new(Some(strict_events_tx)), + resource_events: std::sync::Mutex::new(HashMap::new()), outbound: outbound_tx, next_id: AtomicU64::new(1), next_client_seq: AtomicU64::new(1), + tcp_identity: Mutex::new(None), + tcp_closed: AtomicBool::new(false), + tcp_disposed: AtomicBool::new(false), + tcp_streams: Mutex::new(Vec::new()), config, server_request_handler: std::sync::Mutex::new(None), }); @@ -398,10 +540,31 @@ impl Client { /// Gracefully shut down the client, aborting any in-flight requests /// with [`ClientError::Shutdown`]. pub async fn shutdown(&self) { + self.shared.tcp_disposed.store(true, Ordering::Release); + let streams: Vec<_> = self + .shared + .tcp_streams + .lock() + .await + .iter() + .filter_map(crate::tcp::WeakTcpConnection::upgrade) + .collect(); + for connection in streams { + if let Err(error) = connection.dispose_for_client(self).await { + tracing::warn!(?error, "client TCP shutdown cleanup failed"); + } + } + self.shutdown_preserving_tcp().await; + } + + /// Close this transport while retaining TCP handles for explicit reconnection. + /// Ordinary `shutdown` disposes handles even after a transport failure. + pub async fn shutdown_preserving_tcp(&self) { + self.shared.tcp_closed.store(true, Ordering::Release); let _ = self.shared.outbound.send(Outbound::Shutdown).await; // Fail any pending in-flight requests. let mut pending = self.shared.pending.lock().await; - for (_, tx) in pending.drain() { + for (_, (tx, _)) in pending.drain() { let _ = tx.send(Err(JsonRpcError { code: -32000, message: "client shut down".into(), @@ -412,6 +575,21 @@ impl Client { /// Send a JSON-RPC request and await its result. pub async fn request(&self, method: &str, params: P) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + self.request_with_late_result(method, params, None, None) + .await + } + + pub(crate) async fn request_with_late_result( + &self, + method: &str, + params: P, + on_late: Option>, + on_result: Option, + ) -> Result where P: Serialize, R: DeserializeOwned, @@ -430,10 +608,10 @@ impl Client { params: params_any, }); - let (tx, rx) = oneshot::channel(); + let (tx, mut rx) = oneshot::channel(); { let mut pending = self.shared.pending.lock().await; - pending.insert(id, tx); + pending.insert(id, (tx, on_result)); } if self @@ -448,10 +626,20 @@ impl Client { } let result = match self.shared.config.default_request_timeout { - Some(dur) => match tokio::time::timeout(dur, rx).await { + Some(dur) => match tokio::time::timeout(dur, &mut rx).await { Ok(r) => r, Err(_) => { - self.shared.pending.lock().await.remove(&id); + if let Some(on_late) = on_late { + // Retain correlation until reply or shutdown to release + // resources created after this request's deadline. + tokio::spawn(async move { + if let Ok(Ok(value)) = rx.await { + on_late(value); + } + }); + } else { + self.shared.pending.lock().await.remove(&id); + } return Err(ClientError::Cancelled); } }, @@ -500,7 +688,7 @@ impl Client { channel: ROOT_RESOURCE_URI.to_string(), meta: None, protocol_versions, - client_id, + client_id: client_id.clone(), initial_subscriptions: if initial_subscriptions.is_empty() { None } else { @@ -510,7 +698,9 @@ impl Client { capabilities: None, client_info: None, }; - self.request("initialize", params).await + let result: InitializeResult = self.request("initialize", params).await?; + *self.shared.tcp_identity.lock().await = Some((client_id, result.tcp_connections.clone())); + Ok(result) } /// Re-establish a dropped connection with `reconnect`. @@ -530,6 +720,59 @@ impl Client { self.request("reconnect", params).await } + pub(crate) async fn tcp_identity( + &self, + ) -> Option<(String, Option)> { + self.shared.tcp_identity.lock().await.clone() + } + + pub(crate) async fn tcp_register(&self, connection: &crate::TcpConnection) -> bool { + let mut streams = self.shared.tcp_streams.lock().await; + if self.shared.tcp_disposed.load(Ordering::Acquire) { + return false; + } + streams.retain(|stream| stream.upgrade().is_some()); + streams.push(connection.downgrade()); + true + } + + pub(crate) async fn tcp_unregister(&self, connection: &crate::TcpConnection) { + self.shared + .tcp_streams + .lock() + .await + .retain(|stream| !stream.matches(connection)); + } + + pub(crate) async fn tcp_restore_identity( + &self, + identity: (String, Option), + ) { + *self.shared.tcp_identity.lock().await = Some(identity); + } + + pub(crate) fn tcp_next_sequence(&self) -> u64 { + self.shared.next_client_seq.fetch_add(1, Ordering::Relaxed) + } + + pub(crate) fn tcp_sequence_floor(&self) -> u64 { + self.shared.next_client_seq.load(Ordering::Relaxed) + } + + pub(crate) fn tcp_advance_sequence(&self, next: u64) { + self.shared + .next_client_seq + .fetch_max(next, Ordering::Relaxed); + } + + pub(crate) fn tcp_is_closed(&self) -> bool { + self.shared.tcp_closed.load(Ordering::Acquire) + } + + pub(crate) fn tcp_same_transport(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.shared, &other.shared) + } + /// Protocol-level liveness `ping`. /// /// Verifies the connection is still alive and keeps it from being closed @@ -586,6 +829,7 @@ impl Client { SubscribeParams { channel: uri, meta: None, + create: None, delivery, view, }, @@ -627,6 +871,8 @@ impl Client { /// independently. Useful for the multi-host runtime in /// [`crate::hosts`], or any consumer that needs a single fan-in feed /// rather than per-URI subscriptions. + /// This receiver skips gaps on overflow; use [`Client::events_strict`] + /// when losing an event must terminate the receiver instead. /// /// Every event carries its channel URI in [`ClientEvent::channel`] — /// action envelopes from the envelope's `channel` field, protocol @@ -652,6 +898,58 @@ impl Client { ClientEventStream { rx } } + /// Attach a bounded global receiver that reports overflow as + /// [`ClientError::SubscriptionLag`] and never resumes past a gap. + /// Malformed inbound frames or notification payloads terminate it with + /// [`ClientError::Transport`] instead of silently losing those events. + /// + /// Attach before an atomic `subscribe(create)` request and retain this + /// receiver while awaiting the returned child URI. Reconnect results and + /// stream credits remain the caller's responsibility. + pub fn events_strict(&self) -> StrictClientEventStream { + let rx = match self.shared.strict_events.lock() { + Ok(guard) => guard.as_ref().map(|sender| sender.subscribe()), + Err(_) => None, + }; + StrictClientEventStream { rx, on_drop: None } + } + + pub(crate) fn resource_events_strict(&self, resource: String) -> StrictClientEventStream { + let mut routes = self + .shared + .resource_events + .lock() + .expect("resource events lock poisoned"); + if self.shared.tcp_closed.load(Ordering::Acquire) { + return StrictClientEventStream { + rx: None, + on_drop: None, + }; + } + let sender = routes + .entry(resource.clone()) + .or_insert_with(|| broadcast::channel(self.shared.config.subscription_buffer).0); + let rx = Some(sender.subscribe()); + let shared = Arc::downgrade(&self.shared); + StrictClientEventStream { + rx, + on_drop: Some(Box::new(move || { + if let Some(shared) = shared.upgrade() { + let mut routes = shared + .resource_events + .lock() + .expect("resource events lock poisoned"); + if routes + .get(&resource) + .is_some_and(|sender| sender.receiver_count() == 0) + { + routes.remove(&resource); + } + } + })), + } + } + /// Fire a write-ahead `dispatchAction` notification with a /// client-assigned sequence number. pub async fn dispatch( @@ -850,6 +1148,7 @@ async fn drive_transport( if let Ok(wire) = TransportMessage::encode(&msg) { if let Err(err) = transport.send(wire).await { tracing::warn!(?err, "transport send failed"); + fan_out_strict(&shared, Err(err)); break; } } @@ -865,12 +1164,16 @@ async fn drive_transport( Ok(Some(wire)) => { match wire.into_parsed() { Ok(msg) => dispatch_inbound(&shared, msg).await, - Err(err) => tracing::warn!(?err, "malformed frame"), + Err(err) => { + tracing::warn!(?err, "malformed frame"); + fan_out_strict(&shared, Err(err)); + }, } } Ok(None) => break, Err(err) => { tracing::warn!(?err, "transport recv error"); + fan_out_strict(&shared, Err(err)); break; } } @@ -878,9 +1181,10 @@ async fn drive_transport( } } + shared.tcp_closed.store(true, Ordering::Release); // Teardown: close everything so waiters see Shutdown. let mut pending = shared.pending.lock().await; - for (_, tx) in pending.drain() { + for (_, (tx, _)) in pending.drain() { let _ = tx.send(Err(JsonRpcError { code: -32000, message: "transport closed".into(), @@ -896,17 +1200,28 @@ async fn drive_transport( if let Ok(mut guard) = shared.all_events.lock() { guard.take(); } + if let Ok(mut guard) = shared.strict_events.lock() { + guard.take(); + } + shared + .resource_events + .lock() + .expect("resource events lock poisoned") + .clear(); } async fn dispatch_inbound(shared: &Arc, msg: JsonRpcMessage) { match msg { JsonRpcMessage::SuccessResponse(r) => { - if let Some(tx) = shared.pending.lock().await.remove(&r.id) { + if let Some((tx, on_result)) = shared.pending.lock().await.remove(&r.id) { + if let Some(on_result) = on_result { + on_result(&r.result); + } let _ = tx.send(Ok(r.result)); } } JsonRpcMessage::ErrorResponse(r) => { - if let Some(tx) = shared.pending.lock().await.remove(&r.id) { + if let Some((tx, _)) = shared.pending.lock().await.remove(&r.id) { let _ = tx.send(Err(r.error)); } } @@ -957,30 +1272,44 @@ async fn handle_server_request(shared: &Shared, req: JsonRpcRequest) { let _ = shared.outbound.send(Outbound::Message(response)).await; } +fn decode_notification(shared: &Shared, value: Value) -> Option { + match serde_json::from_value(value) { + Ok(decoded) => Some(decoded), + Err(error) => { + fan_out_strict(shared, Err(TransportError::Protocol(error.to_string()))); + None + } + } +} + async fn handle_notification(shared: &Shared, n: JsonRpcNotification) { let params_val: Value = n.params.unwrap_or(Value::Null); match n.method.as_str() { "action" => { - if let Ok(envelope) = serde_json::from_value::(params_val) { + if let Some(envelope) = + decode_notification::(shared, params_val) + { let channel = envelope.channel.clone(); fan_out(shared, &channel, SubscriptionEvent::Action(envelope)).await; } } "root/sessionAdded" => { - if let Ok(params) = serde_json::from_value::(params_val) { + if let Some(params) = decode_notification::(shared, params_val) { let channel = params.channel.clone(); fan_out(shared, &channel, SubscriptionEvent::SessionAdded(params)).await; } } "root/sessionRemoved" => { - if let Ok(params) = serde_json::from_value::(params_val) { + if let Some(params) = decode_notification::(shared, params_val) { let channel = params.channel.clone(); fan_out(shared, &channel, SubscriptionEvent::SessionRemoved(params)).await; } } "root/sessionSummaryChanged" => { - if let Ok(params) = serde_json::from_value::(params_val) { + if let Some(params) = + decode_notification::(shared, params_val) + { let channel = params.channel.clone(); fan_out( shared, @@ -991,7 +1320,7 @@ async fn handle_notification(shared: &Shared, n: JsonRpcNotification) { } } "auth/required" => { - if let Ok(params) = serde_json::from_value::(params_val) { + if let Some(params) = decode_notification::(shared, params_val) { let channel = params.channel.clone(); fan_out(shared, &channel, SubscriptionEvent::AuthRequired(params)).await; } @@ -1002,6 +1331,32 @@ async fn handle_notification(shared: &Shared, n: JsonRpcNotification) { } } +fn fan_out_strict(shared: &Shared, event: Result) { + { + let routes = shared + .resource_events + .lock() + .expect("resource events lock poisoned"); + match &event { + Ok(event) => { + if let Some(sender) = routes.get(&event.channel) { + let _ = sender.send(Ok(event.clone())); + } + } + Err(error) => { + for sender in routes.values() { + let _ = sender.send(Err(error.clone())); + } + } + } + } + if let Ok(guard) = shared.strict_events.lock() { + if let Some(sender) = guard.as_ref() { + let _ = sender.send(event); + } + } +} + /// Dispatch an inbound event to the matching per-URI subscription (if /// any) and to the top-level fan-in stream. async fn fan_out(shared: &Shared, channel: &Uri, event: SubscriptionEvent) { @@ -1015,8 +1370,36 @@ async fn fan_out(shared: &Shared, channel: &Uri, event: SubscriptionEvent) { if let Some(tx) = guard.as_ref() { let _ = tx.send(ClientEvent { channel: channel.clone(), - event, + event: event.clone(), }); } } + // The generated union preserves malformed known actions as Unknown, too. + // Strict readers may ignore future kinds, but cannot safely ignore those. + if let SubscriptionEvent::Action(envelope) = &event { + if let Some(error) = strict_action_error(&envelope.action) { + fan_out_strict(shared, Err(error)); + return; + } + } + fan_out_strict( + shared, + Ok(ClientEvent { + channel: channel.clone(), + event, + }), + ); +} + +pub(crate) fn strict_action_error(action: &StateAction) -> Option { + if let StateAction::Unknown(raw) = action { + let action_type = serde_json::from_value::(raw["type"].clone()); + if !matches!(action_type, Ok(ActionType::Unknown(_))) { + return Some(TransportError::Protocol(format!( + "malformed action: {}", + raw["type"] + ))); + } + } + None } diff --git a/clients/rust/crates/ahp/src/error.rs b/clients/rust/crates/ahp/src/error.rs index a84245ff0..ee05e8750 100644 --- a/clients/rust/crates/ahp/src/error.rs +++ b/clients/rust/crates/ahp/src/error.rs @@ -1,6 +1,6 @@ //! Error types used across the SDK. //! -//! Two error families are exposed: +//! Error types exposed by the SDK: //! //! - [`TransportError`] — failures of an underlying [`crate::Transport`] //! implementation (closed connection, framing/IO errors). @@ -8,6 +8,7 @@ //! API: transport errors, JSON-RPC error responses, deserialization //! problems, shutdown, cancellation, missing subscriptions, and //! sequence gaps that require resubscribing. +//! - [`SubscriptionLagError`] — terminal overflow of a strict event receiver. //! //! `ClientError` implements `From` and //! `From` so client code can use `?` freely. @@ -15,8 +16,16 @@ use ahp_types::messages::JsonRpcError; use thiserror::Error; +/// A strict event receiver lost events and has permanently terminated. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("subscription lag: {skipped} events lost; receiver terminated")] +pub struct SubscriptionLagError { + /// Number of events evicted before the receiver detected the gap. + pub skipped: u64, +} + /// Errors raised by a [`crate::Transport`] implementation. -#[derive(Debug, Error)] +#[derive(Debug, Clone, Error)] pub enum TransportError { /// The connection was closed by the remote peer or the transport /// reached end-of-stream. @@ -36,10 +45,17 @@ pub enum TransportError { /// Errors produced by the SDK client. #[derive(Debug, Error)] pub enum ClientError { + /// An owned TCP operation failed while integrating with a client runtime. + #[error(transparent)] + Tcp(#[from] crate::tcp::TcpError), /// A transport-level error prevented the request from completing. #[error("transport error: {0}")] Transport(#[from] TransportError), + /// A strict event receiver overflowed and permanently terminated. + #[error(transparent)] + SubscriptionLag(#[from] SubscriptionLagError), + /// The server returned a JSON-RPC error response. #[error("rpc error {}: {}", .0.code, .0.message)] Rpc(JsonRpcError), diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs index 6bfa33d7d..aa36c6425 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -11,7 +11,8 @@ use std::time::SystemTime; use ahp_types::actions::{ActionEnvelope, StateAction}; use ahp_types::commands::{ - ListSessionsParams, ListSessionsResult, ReconnectResult, SubscribeParams, SubscribeResult, + ListSessionsParams, ListSessionsResult, ReconnectParams, ReconnectResult, SubscribeParams, + SubscribeResult, }; use ahp_types::common::{Uri, ROOT_RESOURCE_URI}; use ahp_types::state::{RootState, SessionSummary, SnapshotState}; @@ -106,6 +107,7 @@ pub(super) fn spawn( session_summaries: BTreeMap::new(), generation: 0, current_client: None, + tcp_connections: vec![], }; let shared = HostShared::new(initial); let shutdown_signal = Arc::new(Notify::new()); @@ -119,6 +121,8 @@ pub(super) fn spawn( fan_out, host_events, shutdown_signal: shutdown_signal.clone(), + connecting_client: None, + prior_client: None, }; let join = tokio::spawn(runtime.run()); @@ -138,6 +142,8 @@ struct HostRuntime { fan_out: broadcast::Sender, host_events: broadcast::Sender, shutdown_signal: Arc, + connecting_client: Option, + prior_client: Option, } enum InnerOutcome { @@ -194,7 +200,10 @@ impl HostRuntime { attempt = 0; } let outcome = self.run_connection(events).await; - self.tear_down_client().await; + if matches!(outcome, InnerOutcome::Shutdown) { + break; + } + self.tear_down_client(true).await; match outcome { InnerOutcome::Shutdown => break, InnerOutcome::ManualReconnect => { @@ -209,6 +218,10 @@ impl HostRuntime { } } Err(err) => { + if let Some(client) = self.connecting_client.take() { + self.prior_client = Some(client.clone()); + client.shutdown_preserving_tcp().await; + } let arc_err = Arc::new(err); tracing::warn!( host_id = %self.config.id, @@ -251,6 +264,12 @@ impl HostRuntime { break; } } + self.set_state(HostState::Disconnected, None).await; + self.dispose_tcp_connections().await; + self.tear_down_client(false).await; + if let Some(client) = self.connecting_client.take() { + client.shutdown().await; + } } async fn connect_once(&mut self) -> Result { @@ -261,30 +280,69 @@ impl HostRuntime { .await?; let client = Client::connect(transport, self.config.client_config.clone()).await?; + self.connecting_client = Some(client.clone()); + if let Some(prior) = &self.prior_client { + client.tcp_advance_sequence(prior.tcp_sequence_floor()); + if let Some(identity) = prior.tcp_identity().await { + client.tcp_restore_identity(identity).await; + } + } // Attach the events receiver BEFORE the initialize/reconnect // handshake so any notifications the server pushes between the // handshake response and the moment we enter `run_connection` // are captured rather than dropped. let events = client.events(); + let tcp_connections = self.tcp_connections().await; // Decide between initialize and reconnect based on prior state. let (subscriptions, server_seq_after, init_result, reconnect_result) = { let snapshot = self.shared.lock().await; - let can_reconnect = snapshot.server_seq > 0 && !snapshot.subscriptions.is_empty(); - let subscriptions = snapshot.subscriptions.clone(); + let can_reconnect = !tcp_connections.is_empty() + || (snapshot.server_seq > 0 && !snapshot.subscriptions.is_empty()); + let subscriptions: Vec = snapshot + .subscriptions + .iter() + .filter(|resource| !resource.starts_with("ahp-tcp:")) + .cloned() + .collect(); let server_seq = snapshot.server_seq; drop(snapshot); if can_reconnect { - match client - .reconnect(self.client_id.clone(), server_seq, subscriptions.clone()) - .await - { + let reconnect = if tcp_connections.is_empty() { + client + .reconnect(self.client_id.clone(), server_seq, subscriptions.clone()) + .await + } else { + client + .reconnect_tcp_connections( + ReconnectParams { + channel: ROOT_RESOURCE_URI.into(), + meta: None, + client_id: self.client_id.clone(), + last_seen_server_seq: server_seq, + subscriptions: subscriptions.clone(), + }, + &tcp_connections, + ) + .await + .map_err(ClientError::from) + }; + match reconnect { Ok(result) => (subscriptions, server_seq, None, Some(result)), - Err(ClientError::Rpc(_)) => { + Err(error) + if !client.tcp_is_closed() + && (matches!(&error, ClientError::Rpc(_)) + || matches!(&error, ClientError::Tcp(crate::TcpError::Client(source)) if matches!(source.as_ref(), ClientError::Rpc(_)))) => + { // Server refused reconnect (likely too much state has // elapsed); fall back to initialize. + for connection in &tcp_connections { + if let Err(error) = connection.dispose().await { + tracing::warn!(?error, "TCP initialize fallback cleanup failed"); + } + } let init = client .initialize( self.client_id.clone(), @@ -363,6 +421,7 @@ impl HostRuntime { } state.generation }; + self.connecting_client = None; // Apply the reconnect response (if this was a reconnect rather // than a fresh initialize). Replayed actions must be fanned out @@ -370,7 +429,8 @@ impl HostRuntime { // mirrors and aggregated views stay correct; missing // subscriptions must be dropped from the cache. if let Some(result) = reconnect_result { - self.apply_reconnect_result(result, &subscriptions).await; + self.apply_reconnect_result(result, &subscriptions, server_seq_after) + .await; } self.set_state(HostState::Connected, None).await; @@ -404,10 +464,16 @@ impl HostRuntime { &self, result: ReconnectResult, prior_subscriptions: &[String], + consumer_checkpoint: i64, ) { match result { ReconnectResult::Replay(replay) => { for envelope in replay.actions { + if !envelope.channel.starts_with("ahp-tcp:") + && envelope.server_seq <= consumer_checkpoint as u64 + { + continue; + } let channel = envelope.channel.clone(); self.apply_action(&envelope).await; let host_event = HostSubscriptionEvent { @@ -627,6 +693,7 @@ impl HostRuntime { SubscribeParams { channel: uri.clone(), meta: None, + create: None, delivery: None, view: None, }, @@ -676,13 +743,47 @@ impl HostRuntime { .map_err(HostError::Client) } - async fn tear_down_client(&self) { + async fn tcp_connections(&self) -> Vec { + let candidates: Vec<_> = { + let mut state = self.shared.lock().await; + state + .tcp_connections + .retain(|connection| connection.upgrade().is_some()); + state + .tcp_connections + .iter() + .filter_map(|connection| connection.upgrade()) + .collect() + }; + let mut live = Vec::new(); + for connection in candidates { + if !connection.is_closed().await { + live.push(connection); + } + } + live + } + + async fn dispose_tcp_connections(&self) { + for connection in self.tcp_connections().await { + if let Err(error) = connection.dispose().await { + tracing::warn!(?error, "managed TCP disposal failed"); + } + } + } + + async fn tear_down_client(&mut self, preserve_tcp: bool) { let prev = { let mut state = self.shared.lock().await; state.current_client.take() }; if let Some(client) = prev { - client.shutdown().await; + self.prior_client = Some(client.clone()); + if preserve_tcp { + client.shutdown_preserving_tcp().await; + } else { + client.shutdown().await; + } } } diff --git a/clients/rust/crates/ahp/src/hosts/types.rs b/clients/rust/crates/ahp/src/hosts/types.rs index ec47aaa12..9320ab669 100644 --- a/clients/rust/crates/ahp/src/hosts/types.rs +++ b/clients/rust/crates/ahp/src/hosts/types.rs @@ -374,9 +374,46 @@ impl HostClientHandle { /// Borrow the underlying [`Client`] for advanced use. The caller is /// responsible for not holding it past the next reconnect. + /// + /// Use [`Self::open_tcp_connection`] for managed TCP ownership rather than + /// creating a stream directly through this borrowed client. pub fn raw_client(&self) -> &Client { &self.client } + + /// Create a byte stream retained across this host's automatic reconnects. + /// The stream remains usable after this generation-checked handle goes stale. + pub async fn open_tcp_connection( + &self, + session: String, + create: ahp_types::commands::TcpConnectionSubscription, + ) -> Result { + self.check_alive().await?; + let connection = self + .client + .open_tcp_connection(session, create) + .await + .map_err(ClientError::from)?; + let mut state = self.shared.lock().await; + if state.generation != self.generation + || !matches!(state.state, HostState::Connected) + || !state + .current_client + .as_ref() + .is_some_and(|client| client.tcp_same_transport(&self.client)) + { + drop(state); + if let Err(error) = connection.dispose().await { + tracing::warn!(?error, "stale managed TCP creation cleanup failed"); + } + return Err(HostError::HostShutDown(self.host_id.clone())); + } + state + .tcp_connections + .retain(|connection| connection.upgrade().is_some()); + state.tcp_connections.push(connection.downgrade()); + Ok(connection) + } } impl std::fmt::Debug for HostClientHandle { @@ -554,6 +591,7 @@ pub(super) struct HostInternal { pub(super) session_summaries: std::collections::BTreeMap, pub(super) generation: u64, pub(super) current_client: Option, + pub(super) tcp_connections: Vec, } impl HostInternal { diff --git a/clients/rust/crates/ahp/src/lib.rs b/clients/rust/crates/ahp/src/lib.rs index 013229405..534aba17f 100644 --- a/clients/rust/crates/ahp/src/lib.rs +++ b/clients/rust/crates/ahp/src/lib.rs @@ -150,18 +150,22 @@ pub mod error; pub mod hosts; pub mod multi_host_state_mirror; pub mod reducers; +pub mod tcp; pub mod transport; pub use ahp_types; pub use client::{ Client, ClientConfig, ClientEvent, ClientEventStream, DispatchHandle, ResourceRequestHandlers, - ServerRequestFuture, ServerRequestHandler, SessionSubscription, SubscriptionEvent, + ServerRequestFuture, ServerRequestHandler, SessionSubscription, StrictClientEventStream, + SubscriptionEvent, }; -pub use error::{ClientError, TransportError}; +pub use error::{ClientError, SubscriptionLagError, TransportError}; pub use multi_host_state_mirror::{HostedResourceKey, MultiHostStateMirror}; pub use reducers::{ apply_action_to_automation, apply_action_to_automation_run, apply_action_to_root, - apply_action_to_session, apply_action_to_terminal, ReduceError, ReduceOutcome, + apply_action_to_session, apply_action_to_tcp, apply_action_to_terminal, ReduceError, + ReduceOutcome, }; +pub use tcp::{TcpConnection, TcpError}; pub use transport::{BoxedTransport, DynTransport, Transport, TransportMessage}; diff --git a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs index c581bd26d..a3160ef39 100644 --- a/clients/rust/crates/ahp/src/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/src/multi_host_state_mirror.rs @@ -226,6 +226,8 @@ impl MultiHostStateMirror { pub fn apply_snapshot(&mut self, host: &HostId, snapshot: &ahp_types::state::Snapshot) { let key = HostedResourceKey::new(host.clone(), snapshot.resource.clone()); match &snapshot.state { + // TCP belongs to a lossless stream adapter, not a snapshot-restored mirror. + SnapshotState::Tcp(_) => {} SnapshotState::Root(state) => { self.root_states .insert(host.clone(), state.as_ref().clone()); diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 76602c966..4d3646241 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -14,6 +14,9 @@ //! makes them safe to run inside a UI render loop or a snapshot //! reconciler. //! +//! [`apply_action_to_tcp`] validates TCP byte-stream metadata without retaining +//! payloads. It must be paired with a lossless stream owner, not a snapshot mirror. +//! //! # Example //! //! ``` @@ -60,15 +63,17 @@ use ahp_types::state::{ ActiveTurn, AnnotationsState, AutomationRunState, AutomationState, BackgroundWork, ChangesetOperationStatus, ChangesetState, ChangesetStatus, ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, CustomizationEnablement, - ErrorResponsePart, InputRequestResponsePart, McpServerCustomization, McpServerStartingState, - McpServerState, McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, - ResponsePart, RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, - TerminalCommandPart, TerminalContentPart, TerminalExitedLifecycleState, TerminalLifecycleState, - TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, ToolCallCancellationReason, - ToolCallCancelledState, ToolCallCompletedState, ToolCallConfirmationReason, - ToolCallContributor, ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, - ToolCallResponsePart, ToolCallRunningState, ToolCallState, ToolCallStatus, - ToolCallStreamingState, ToolInput, Turn, TurnState, + ErrorResponsePart, FlowControlledByteDirectionState, InputRequestResponsePart, + McpServerCustomization, McpServerStartingState, McpServerState, McpServerStoppedState, + PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, + SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TcpConnectionState, + TcpEndpoint, TcpResetState, TerminalCommandPart, TerminalContentPart, + TerminalExitedLifecycleState, TerminalLifecycleState, TerminalState, TerminalUnclassifiedPart, + ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, + ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, + ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, + ToolCallRunningState, ToolCallState, ToolCallStatus, ToolCallStreamingState, ToolInput, Turn, + TurnState, }; use jiff::{SignedDuration, Timestamp}; @@ -91,6 +96,12 @@ pub enum ReduceOutcome { /// Why an action could not be reduced safely. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ReduceError { + /// A TCP action violated byte ordering, credit, or encoding requirements. + #[error("Invalid TCP action: {reason}")] + InvalidTcp { + /// Canonical protocol validation diagnostic. + reason: &'static str, + }, /// A turn carried a start timestamp that was not valid RFC 3339. #[error("invalid RFC 3339 timestamp {timestamp:?}: {reason}")] InvalidTimestamp { @@ -112,6 +123,184 @@ pub enum ReduceError { }, } +const TCP_MAX_SAFE_INTEGER: i64 = (1 << 53) - 1; + +fn require_tcp(condition: bool, reason: &'static str) -> Result<(), ReduceError> { + if condition { + Ok(()) + } else { + Err(ReduceError::InvalidTcp { reason }) + } +} + +fn require_tcp_offset(value: i64) -> Result<(), ReduceError> { + require_tcp( + (0..=TCP_MAX_SAFE_INTEGER).contains(&value), + "offset must be a nonnegative safe integer", + ) +} + +fn tcp_payload_length(data: &str, maximum_chunk_size: i64) -> Result { + // JavaScript measures string length in UTF-16 code units, even for + // invalid non-ASCII payloads rejected by the following alphabet check. + let encoded_length = data.encode_utf16().count(); + require_tcp( + maximum_chunk_size >= 0 + && !data.is_empty() + && encoded_length as u64 <= 4 * (maximum_chunk_size as u64).div_ceil(3), + "chunk size", + )?; + let padding = if data.ends_with("==") { + 2 + } else if data.ends_with('=') { + 1 + } else { + 0 + }; + require_tcp(data.len() % 4 == 0, "base64 encoding")?; + let mut last = 0; + for &byte in &data.as_bytes()[..data.len() - padding] { + last = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => { + return Err(ReduceError::InvalidTcp { + reason: "base64 encoding", + }) + } + }; + } + require_tcp( + (padding != 2 || last % 16 == 0) && (padding != 1 || last % 4 == 0), + "noncanonical base64 padding bits", + )?; + let length = (data.len() / 4 * 3 - padding) as i64; + require_tcp(length <= maximum_chunk_size, "chunk size")?; + Ok(length) +} + +fn receive_tcp( + direction: &mut FlowControlledByteDirectionState, + offset: i64, + data: &str, + sender_closed: bool, +) -> Result { + require_tcp_offset(offset)?; + let length = tcp_payload_length(data, direction.maximum_chunk_size)?; + require_tcp( + length <= TCP_MAX_SAFE_INTEGER - offset, + "offset must be a nonnegative safe integer", + )?; + let end = offset + length; + if end <= direction.received_bytes { + return Ok(ReduceOutcome::NoOp); + } + require_tcp( + offset == direction.received_bytes, + "gap or overlapping byte range", + )?; + require_tcp( + !sender_closed && direction.eof_at_bytes.is_none(), + "data after EOF or sender close", + )?; + require_tcp( + end - direction.consumed_bytes <= direction.window_bytes, + "receive window exceeded", + )?; + direction.received_bytes = end; + Ok(ReduceOutcome::Applied) +} + +fn consume_tcp( + direction: &mut FlowControlledByteDirectionState, + consumed_bytes: i64, +) -> Result { + require_tcp_offset(consumed_bytes)?; + require_tcp( + consumed_bytes <= direction.received_bytes, + "consuming bytes not received", + )?; + if consumed_bytes <= direction.consumed_bytes { + return Ok(ReduceOutcome::NoOp); + } + direction.consumed_bytes = consumed_bytes; + Ok(ReduceOutcome::Applied) +} + +fn eof_tcp( + direction: &mut FlowControlledByteDirectionState, + final_offset: i64, + sender_closed: bool, +) -> Result { + require_tcp_offset(final_offset)?; + require_tcp(final_offset == direction.received_bytes, "EOF offset")?; + if direction.eof_at_bytes == Some(final_offset) { + return Ok(ReduceOutcome::NoOp); + } + require_tcp(!sender_closed, "EOF after sender close")?; + direction.eof_at_bytes = Some(final_offset); + Ok(ReduceOutcome::Applied) +} + +/// Apply a TCP action without retaining its payload. +/// +/// Invalid actions return [`ReduceOutcome::Invalid`] without mutating state. +/// Adapters must reset the channel on error and write bytes only when +/// `received_bytes` advances. This does not provide a lossless subscription +/// and is intentionally not integrated with snapshot state mirrors. +pub fn apply_action_to_tcp(state: &mut TcpConnectionState, action: &StateAction) -> ReduceOutcome { + if state.reset.is_some() { + return ReduceOutcome::NoOp; + } + let result = match action { + StateAction::TcpInput(a) => { + receive_tcp(&mut state.input, a.offset, &a.data, state.client_closed) + } + StateAction::TcpData(a) => { + receive_tcp(&mut state.output, a.offset, &a.data, state.host_closed) + } + StateAction::TcpInputConsumed(a) => consume_tcp(&mut state.input, a.consumed_bytes), + StateAction::TcpDataConsumed(a) => consume_tcp(&mut state.output, a.consumed_bytes), + StateAction::TcpInputEof(a) => { + eof_tcp(&mut state.input, a.final_offset, state.client_closed) + } + StateAction::TcpDataEof(a) => eof_tcp(&mut state.output, a.final_offset, state.host_closed), + StateAction::TcpClientClose(_) => { + if state.client_closed { + return ReduceOutcome::NoOp; + } + state.client_closed = true; + Ok(ReduceOutcome::Applied) + } + StateAction::TcpHostClose(_) => { + if state.host_closed { + return ReduceOutcome::NoOp; + } + state.host_closed = true; + Ok(ReduceOutcome::Applied) + } + StateAction::TcpClientReset(a) => { + state.reset = Some(TcpResetState { + source: TcpEndpoint::Client, + reason: a.reason.clone(), + }); + Ok(ReduceOutcome::Applied) + } + StateAction::TcpHostReset(a) => { + state.reset = Some(TcpResetState { + source: TcpEndpoint::Host, + reason: a.reason.clone(), + }); + Ok(ReduceOutcome::Applied) + } + _ => return ReduceOutcome::OutOfScope, + }; + result.unwrap_or_else(ReduceOutcome::Invalid) +} + fn add_milliseconds_to_timestamp(timestamp: &str, duration: i64) -> Result { let start: Timestamp = timestamp @@ -2559,6 +2748,142 @@ mod tests { // ─── Fixture-Driven Tests ───────────────────────────────────────── + fn tcp_test_state() -> TcpConnectionState { + let direction = FlowControlledByteDirectionState { + window_bytes: 8, + maximum_chunk_size: 6, + received_bytes: 0, + consumed_bytes: 0, + eof_at_bytes: None, + }; + TcpConnectionState { + session: "ahp-session:/s1".into(), + target: ahp_types::state::TcpTarget { + host: "localhost".into(), + port: 3000, + }, + encoding: ahp_types::state::TcpDataEncoding::Base64, + input: direction.clone(), + output: direction, + client_closed: false, + host_closed: false, + reset: None, + } + } + + #[test] + fn tcp_large_payload() { + let size: usize = 4 * 1024 * 1024; + let data = "A".repeat(4 * size.div_ceil(3) - 2) + "=="; + for action_type in ["tcp/input", "tcp/data"] { + let mut state = tcp_test_state(); + state.input.window_bytes = size as i64; + state.output.window_bytes = size as i64; + state.input.maximum_chunk_size = size as i64; + state.output.maximum_chunk_size = size as i64; + let action = serde_json::from_value(serde_json::json!({ + "type": action_type, "offset": 0, "data": data, + })) + .unwrap(); + assert_eq!( + apply_action_to_tcp(&mut state, &action), + ReduceOutcome::Applied + ); + let direction = if action_type == "tcp/input" { + &state.input + } else { + &state.output + }; + assert_eq!(direction.received_bytes, size as i64); + let before = state.clone(); + assert_eq!( + apply_action_to_tcp(&mut state, &action), + ReduceOutcome::NoOp + ); + let invalid = serde_json::from_value(serde_json::json!({ + "type": action_type, "offset": 0, "data": data[..data.len()-3].to_owned() + "B==", + })) + .unwrap(); + assert_eq!( + apply_action_to_tcp(&mut state, &invalid), + ReduceOutcome::Invalid(ReduceError::InvalidTcp { + reason: "noncanonical base64 padding bits" + }), + ); + assert_eq!(state, before); + } + } + + #[test] + fn tcp_native_integer_bounds() { + for action_type in [ + "tcp/input", + "tcp/data", + "tcp/inputConsumed", + "tcp/dataConsumed", + "tcp/inputEof", + "tcp/dataEof", + ] { + let field = if action_type.ends_with("Consumed") { + "consumedBytes" + } else if action_type.ends_with("Eof") { + "finalOffset" + } else { + "offset" + }; + for value in [i64::MIN, -1, TCP_MAX_SAFE_INTEGER + 1, i64::MAX] { + let mut state = tcp_test_state(); + let before = state.clone(); + let action = serde_json::from_value(serde_json::json!({ + "type": action_type, (field): value, "data": "AA==", + })) + .unwrap(); + assert_eq!( + apply_action_to_tcp(&mut state, &action), + ReduceOutcome::Invalid(ReduceError::InvalidTcp { + reason: "offset must be a nonnegative safe integer" + }), + "{action_type}: {value}", + ); + assert_eq!(state, before); + } + } + let mut state = tcp_test_state(); + state.input.received_bytes = TCP_MAX_SAFE_INTEGER - 1; + state.input.consumed_bytes = TCP_MAX_SAFE_INTEGER - 1; + let action = serde_json::from_value(serde_json::json!({ + "type": "tcp/input", "offset": TCP_MAX_SAFE_INTEGER - 1, "data": "AA==", + })) + .unwrap(); + assert_eq!( + apply_action_to_tcp(&mut state, &action), + ReduceOutcome::Applied + ); + assert_eq!(state.input.received_bytes, TCP_MAX_SAFE_INTEGER); + } + + #[test] + fn tcp_non_ascii_payload_errors_match_utf16_size_check() { + for (data, reason) in [ + ("\u{e9}\u{e9}\u{e9}", "base64 encoding"), + ("\u{1f600}\u{1f600}", "base64 encoding"), + ("\u{1f600}\u{1f600}A", "chunk size"), + ] { + let mut state = tcp_test_state(); + state.input.maximum_chunk_size = 1; + let before = state.clone(); + let action = StateAction::TcpInput(ahp_types::actions::TcpInputAction { + offset: 0, + data: data.into(), + }); + assert_eq!( + apply_action_to_tcp(&mut state, &action), + ReduceOutcome::Invalid(ReduceError::InvalidTcp { reason }), + ); + assert_eq!(state, before); + } + } + /// Recursively strip JSON `null` values from objects so that absent /// optional fields (which Rust omits via `skip_serializing_if`) match /// the fixture expectations that spell them out as `null`. @@ -2639,15 +2964,7 @@ mod tests { let initial = raw["initial"].clone(); let actions = raw["actions"].as_array().expect("actions must be an array"); let expected = raw["expected"].clone(); - - let parsed_actions: Vec = actions - .iter() - .map(|v| { - serde_json::from_value::(v.clone()).unwrap_or_else(|e| { - panic!("{file_name} ({description}): failed to deserialize action: {e}") - }) - }) - .collect(); + let expected_error = raw["expectedError"].as_str(); /// Deserialize initial state, apply actions, compare result. /// Also checks that initial state round-trips through Rust types, @@ -2655,10 +2972,11 @@ mod tests { fn run_fixture( initial: serde_json::Value, expected: serde_json::Value, - actions: &[StateAction], + actions: &[serde_json::Value], apply: fn(&mut S, &StateAction) -> ReduceOutcome, file_name: &str, description: &str, + expected_error: Option<&str>, ) where S: serde::de::DeserializeOwned + serde::Serialize, { @@ -2676,13 +2994,68 @@ mod tests { serde_json::to_string_pretty(&initial_normalized).unwrap(), ); let mut state = state; - for action in actions { - apply(&mut state, action); + assert!( + expected_error.is_none() || !actions.is_empty(), + "{file_name}: expectedError requires a final action" + ); + for (i, raw_action) in actions.iter().enumerate() { + let expect_error = expected_error.filter(|_| i == actions.len() - 1); + let before = serde_json::to_value(&state).unwrap(); + // The union's unknown fallback also accepts malformed known + // variants. Decode input directly to expose native i64 rejection. + let decoded = if raw_action["type"] == "tcp/input" { + serde_json::from_value::( + raw_action.clone(), + ) + .map(StateAction::TcpInput) + } else { + serde_json::from_value::(raw_action.clone()) + }; + match decoded { + Ok(action) => match (apply(&mut state, &action), expect_error) { + (ReduceOutcome::Invalid(error), Some(expected)) => { + assert_eq!(error.to_string(), expected, "{file_name}: action {i}"); + } + (ReduceOutcome::Invalid(error), None) => { + panic!("{file_name}: action {i}: unexpected error: {error}"); + } + (outcome, Some(expected)) => { + panic!( + "{file_name}: action {i}: expected {expected}, got {outcome:?}" + ); + } + (_, None) => {} + }, + Err(error) => { + assert!( + expect_error + == Some( + "Invalid TCP action: offset must be a nonnegative safe integer" + ) + && raw_action["type"] == "tcp/input" + && raw_action["offset"] + .as_f64() + .is_some_and(|offset| offset.fract() != 0.0) + && error.is_data() + && error.to_string().contains("expected i64"), + "{file_name}: action {i}: unexpected deserialization error: {error}" + ); + eprintln!("{file_name}: final fractional action rejected by native i64 deserializer: {error}"); + } + } + if expect_error.is_some() { + assert_eq!( + serde_json::to_value(&state).unwrap(), + before, + "{file_name}: rejected action {i} mutated state" + ); + } } let actual = strip_nulls(serde_json::to_value(&state).unwrap()); let expected = strip_nulls(expected); assert_eq!( - actual, expected, + actual, + expected, "\n=== FIXTURE FAILED: {file_name} ({description}) ===\nactual: {}\nexpected: {}", serde_json::to_string_pretty(&actual).unwrap(), serde_json::to_string_pretty(&expected).unwrap(), @@ -2693,74 +3066,92 @@ mod tests { "root" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_root, &file_name, description, + expected_error, ), "session" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_session, &file_name, description, + expected_error, ), "chat" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_chat, &file_name, description, + expected_error, ), "terminal" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_terminal, &file_name, description, + expected_error, ), "changeset" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_changeset, &file_name, description, + expected_error, ), "annotations" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_annotations, &file_name, description, + expected_error, ), "resourceWatch" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_resource_watch, &file_name, description, + expected_error, ), "automation" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_automation, &file_name, description, + expected_error, ), "automationRun" => run_fixture::( initial, expected, - &parsed_actions, + actions, apply_action_to_automation_run, &file_name, description, + expected_error, + ), + "tcp" => run_fixture::( + initial, + expected, + actions, + apply_action_to_tcp, + &file_name, + description, + expected_error, ), other => { panic!("{file_name}: unknown reducer type '{other}'"); diff --git a/clients/rust/crates/ahp/src/tcp.rs b/clients/rust/crates/ahp/src/tcp.rs new file mode 100644 index 000000000..a59449b62 --- /dev/null +++ b/clients/rust/crates/ahp/src/tcp.rs @@ -0,0 +1,2768 @@ +//! Owned, flow-controlled protocol byte streams. Native sockets and transport +//! reconnection policy belong to the consumer, not this module. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Weak}; + +use ahp_types::actions::*; +use ahp_types::commands::{ + DispatchActionParams, ReconnectParams, ReconnectResult, SubscribeParams, SubscribeResult, + TcpConnectionSubscription, +}; +use ahp_types::state::{ + FlowControlledByteDirectionState, SnapshotState, TcpConnectionState, TcpDataEncoding, + TcpResetReason, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use tokio::sync::{oneshot, watch, Mutex}; + +use crate::{ + apply_action_to_tcp, Client, ClientError, ReduceOutcome, StrictClientEventStream, + SubscriptionEvent, TransportError, +}; + +const MAX_SAFE: i64 = (1 << 53) - 1; +const MAX_WINDOW_BYTES: i64 = (1 << 32) - 1; + +/// Errors from an owned TCP protocol stream. +#[derive(Debug, Clone, thiserror::Error)] +pub enum TcpError { + /// The operation's arguments or the negotiated capability are invalid. + #[error("invalid TCP operation: {0}")] + Invalid(&'static str), + /// The host violated the stream protocol. + #[error("TCP protocol error: {0}")] + Protocol(String), + /// An underlying client operation failed. + #[error("TCP client error: {0}")] + Client(#[source] Arc), + /// An endpoint reset the stream. + #[error("TCP reset: {0:?}")] + Reset(TcpResetReason), + /// Final close terminated the stream. + #[error("TCP connection closed")] + Closed, + /// The owner explicitly disposed the stream. + #[error("TCP connection disposed")] + Disposed, + /// A snapshot or missing resource cannot restore retained byte ownership. + #[error("TCP replay unavailable")] + ReplayUnavailable, +} + +impl From for TcpError { + fn from(error: ClientError) -> Self { + Self::Client(Arc::new(error)) + } +} + +struct Pending { + action: StateAction, + sent: u64, +} + +struct Inner { + client: Client, + state: TcpConnectionState, + checkpoint: i64, + last_seq: u64, + epoch: u64, + online: bool, + resuming: bool, + ending: bool, + closing: bool, + close_queued: bool, + terminal: bool, + error: Option, + cleanup: Option>, + pending: BTreeMap, + received: VecDeque>, + sent_bytes: i64, + consumed: i64, +} + +struct Shared { + resource: String, + owner: String, + inner: Mutex, + changed: watch::Sender, + writer: Arc>, + resume: Arc>, + send: Mutex<()>, +} + +impl Shared { + fn wake(&self) { + self.changed + .send_modify(|value| *value = value.wrapping_add(1)); + } +} + +impl Drop for Shared { + fn drop(&mut self) { + let inner = self.inner.get_mut(); + if inner.terminal { + return; + } + inner.terminal = true; + let client = inner.client.clone(); + let resource = self.resource.clone(); + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + runtime.spawn(async move { + let seq = client.tcp_next_sequence(); + if seq <= MAX_SAFE as u64 { + if let Err(error) = client + .notify( + "dispatchAction", + DispatchActionParams { + channel: resource.clone(), + client_seq: seq as i64, + action: StateAction::TcpClientReset(TcpClientResetAction { + reason: TcpResetReason::ConnectionAborted, + }), + }, + ) + .await + { + tracing::warn!(?error, "TCP drop reset failed"); + } + } + if let Err(error) = client.unsubscribe(resource).await { + tracing::warn!(?error, "TCP drop unsubscribe failed"); + } + }); + } + Err(error) => tracing::warn!( + ?error, + "TCP handle dropped outside runtime; use dispose before runtime shutdown" + ), + } + } +} + +/// An owned protocol stream. Clones share one reader queue and one writer permit. +/// Dropping the last handle aborts it; `dispose` awaits explicit cleanup. +#[derive(Clone)] +pub struct TcpConnection { + shared: Arc, +} + +pub(crate) struct WeakTcpConnection(Weak); + +impl WeakTcpConnection { + pub(crate) fn matches(&self, connection: &TcpConnection) -> bool { + self.0.ptr_eq(&Arc::downgrade(&connection.shared)) + } + pub(crate) fn upgrade(&self) -> Option { + self.0.upgrade().map(|shared| TcpConnection { shared }) + } +} + +fn reset_action(reason: TcpResetReason) -> StateAction { + StateAction::TcpClientReset(TcpClientResetAction { reason }) +} + +fn close_action() -> StateAction { + StateAction::TcpClientClose(TcpClientCloseAction {}) +} + +fn valid_direction(direction: &FlowControlledByteDirectionState) -> bool { + (1..=MAX_WINDOW_BYTES).contains(&direction.window_bytes) + && (1..=direction.window_bytes).contains(&direction.maximum_chunk_size) + && direction.received_bytes == 0 + && direction.consumed_bytes == 0 + && direction.eof_at_bytes.is_none() +} + +fn queue(inner: &mut Inner, action: StateAction) -> Result<(), TcpError> { + let seq = inner.client.tcp_next_sequence(); + if seq == 0 || seq > MAX_SAFE as u64 { + return Err(TcpError::Protocol("client sequence exhausted".into())); + } + inner.last_seq = seq; + inner + .pending + .insert(seq as i64, Pending { action, sent: 0 }); + Ok(()) +} + +impl TcpConnection { + pub(crate) fn downgrade(&self) -> WeakTcpConnection { + WeakTcpConnection(Arc::downgrade(&self.shared)) + } + + /// Whether the stream has finally closed, as opposed to transport suspension. + pub async fn is_closed(&self) -> bool { + self.shared.inner.lock().await.terminal + } + /// Host-assigned child channel URI. + pub fn resource(&self) -> &str { + &self.shared.resource + } + + /// Deliver one chunk and release its receive credit. EOF follows buffered data. + pub async fn read(&self) -> Result>, TcpError> { + let mut changed = self.shared.changed.subscribe(); + loop { + let mut inner = self.shared.inner.lock().await; + if let Some(error) = &inner.error { + return Err(error.clone()); + } + if !inner.resuming { + if let Some(data) = inner.received.front() { + let consumed_bytes = inner.consumed + data.len() as i64; + if !inner.terminal { + queue( + &mut inner, + StateAction::TcpDataConsumed(TcpDataConsumedAction { consumed_bytes }), + )?; + } + inner.consumed = consumed_bytes; + let data = inner.received.pop_front(); + self.shared.wake(); + return Ok(data); + } + if inner.state.output.eof_at_bytes.is_some() || inner.state.host_closed { + return Ok(None); + } + } + if inner.terminal { + return Ok(None); + } + drop(inner); + changed.changed().await.map_err(|_| TcpError::Closed)?; + } + } + + /// Accept up to one negotiated chunk. A cancelled wait accepts no bytes. + /// Only one writer is allowed. Use `write_all` to submit a complete slice. + pub async fn write(&self, data: &[u8]) -> Result { + let _writer = self + .shared + .writer + .clone() + .try_lock_owned() + .map_err(|_| TcpError::Invalid("writer is busy"))?; + self.write_chunk(data).await + } + + /// Submit a complete slice with bounded credit. Like standard `write_all`, + /// cancellation may leave a prefix accepted; do not blindly retry the slice. + pub async fn write_all(&self, data: &[u8]) -> Result<(), TcpError> { + let _writer = self + .shared + .writer + .clone() + .try_lock_owned() + .map_err(|_| TcpError::Invalid("writer is busy"))?; + let mut offset = 0; + while offset < data.len() { + offset += self.write_chunk(&data[offset..]).await?; + } + Ok(()) + } + + async fn write_chunk(&self, data: &[u8]) -> Result { + let mut changed = self.shared.changed.subscribe(); + loop { + let mut inner = self.shared.inner.lock().await; + if let Some(error) = &inner.error { + return Err(error.clone()); + } + if inner.terminal || inner.ending { + return Err(TcpError::Closed); + } + if data.is_empty() { + return Ok(0); + } + let credit = inner.state.input.window_bytes + - (inner.sent_bytes - inner.state.input.consumed_bytes); + if inner.online && !inner.resuming && credit > 0 { + let length = + (credit.min(inner.state.input.maximum_chunk_size) as usize).min(data.len()); + if inner.sent_bytes > MAX_SAFE - length as i64 { + drop(inner); + let error = TcpError::Protocol("byte offset exhausted".into()); + self.finish( + Some(error.clone()), + Some(reset_action(TcpResetReason::ProtocolError)), + false, + ) + .await; + return Err(error); + } + let action = StateAction::TcpInput(TcpInputAction { + offset: inner.sent_bytes, + data: STANDARD.encode(&data[..length]), + }); + queue(&mut inner, action)?; + inner.sent_bytes += length as i64; + self.shared.wake(); + return Ok(length); + } + drop(inner); + changed.changed().await.map_err(|_| TcpError::Closed)?; + } + } + + /// Wait until all accepted input is consumed by the host's destination buffer. + pub async fn drain(&self) -> Result<(), TcpError> { + let mut changed = self.shared.changed.subscribe(); + loop { + let inner = self.shared.inner.lock().await; + if let Some(error) = &inner.error { + return Err(error.clone()); + } + if inner.state.input.consumed_bytes >= inner.sent_bytes { + return Ok(()); + } + if inner.terminal { + return Err(TcpError::Closed); + } + drop(inner); + changed.changed().await.map_err(|_| TcpError::Closed)?; + } + } + + /// Half-close input after preceding writes; output remains readable. + pub async fn end(&self) -> Result<(), TcpError> { + let _writer = self + .shared + .writer + .clone() + .try_lock_owned() + .map_err(|_| TcpError::Invalid("writer is busy"))?; + let mut changed = self.shared.changed.subscribe(); + loop { + let mut inner = self.shared.inner.lock().await; + if let Some(error) = &inner.error { + return Err(error.clone()); + } + if inner.terminal { + return Err(TcpError::Closed); + } + if !inner.resuming { + if !inner.ending { + let final_offset = inner.sent_bytes; + queue( + &mut inner, + StateAction::TcpInputEof(TcpInputEofAction { final_offset }), + )?; + inner.ending = true; + self.shared.wake(); + } + return Ok(()); + } + drop(inner); + changed.changed().await.map_err(|_| TcpError::Closed)?; + } + } + + /// Stop writes and await both close acknowledgements and consumed bytes. + /// Continue reading concurrently to drain output. `dispose` aborts immediately. + pub async fn close(&self) -> Result<(), TcpError> { + if self.is_closed().await { + return self.wait_cleanup().await; + } + let _writer = self + .shared + .writer + .clone() + .try_lock_owned() + .map_err(|_| TcpError::Invalid("writer is busy"))?; + { + let mut inner = self.shared.inner.lock().await; + inner.ending = true; + inner.closing = true; + } + drop(_writer); + self.shared.wake(); + self.advance_close().await; + let mut changed = self.shared.changed.subscribe(); + loop { + let inner = self.shared.inner.lock().await; + if let Some(error) = &inner.error { + return Err(error.clone()); + } + if inner.terminal { + drop(inner); + return self.wait_cleanup().await; + } + drop(inner); + changed.changed().await.map_err(|_| TcpError::Closed)?; + } + } + + async fn advance_close(&self) { + let mut inner = self.shared.inner.lock().await; + if inner.terminal || inner.resuming || !inner.closing { + return; + } + let mut error = None; + if !inner.close_queued + && (inner.state.host_closed || inner.state.input.consumed_bytes == inner.sent_bytes) + { + match queue(&mut inner, close_action()) { + Ok(()) => inner.close_queued = true, + Err(failure) => error = Some(failure), + } + self.shared.wake(); + } + let complete = inner.state.client_closed + && inner.state.host_closed + && inner.state.input.consumed_bytes == inner.sent_bytes + && inner.consumed == inner.state.output.received_bytes + && inner.state.output.consumed_bytes == inner.consumed + && inner.pending.is_empty(); + drop(inner); + if let Some(error) = error { + self.finish( + Some(error), + Some(reset_action(TcpResetReason::ProtocolError)), + false, + ) + .await; + } else if complete { + self.finish(None, None, true).await; + } + } + + /// Abort, discard buffered bytes, and await exactly-once unsubscribe. + pub async fn dispose(&self) -> Result<(), TcpError> { + self.finish( + Some(TcpError::Disposed), + Some(reset_action(TcpResetReason::ConnectionAborted)), + false, + ) + .await; + self.wait_cleanup().await + } + + async fn wait_cleanup(&self) -> Result<(), TcpError> { + let mut changed = self.shared.changed.subscribe(); + loop { + if let Some(result) = &self.shared.inner.lock().await.cleanup { + return result.clone(); + } + changed.changed().await.map_err(|_| TcpError::Closed)?; + } + } + + pub(crate) async fn dispose_for_client(&self, client: &Client) -> Result<(), TcpError> { + if self + .finish_for_client( + Some(TcpError::Disposed), + Some(reset_action(TcpResetReason::ConnectionAborted)), + false, + Some(client), + ) + .await + { + self.wait_cleanup().await + } else { + Ok(()) + } + } + + async fn finish(&self, error: Option, action: Option, preserve: bool) { + self.finish_for_client(error, action, preserve, None).await; + } + + async fn finish_for_client( + &self, + error: Option, + action: Option, + preserve: bool, + owner: Option<&Client>, + ) -> bool { + let mut inner = self.shared.inner.lock().await; + if owner.is_some_and(|owner| !inner.client.tcp_same_transport(owner)) { + return false; + } + if inner.terminal { + return true; + } + inner.terminal = true; + inner.online = false; + inner.resuming = false; + inner.error = error; + inner.pending.clear(); + if !preserve { + inner.received.clear(); + } + let client = inner.client.clone(); + drop(inner); + self.shared.wake(); + let shared = self.shared.clone(); + tokio::spawn(async move { + let _send = shared.send.lock().await; + let mut error = None; + if let Some(action) = action.filter(|_| !client.tcp_is_closed()) { + let seq = client.tcp_next_sequence(); + if seq > MAX_SAFE as u64 { + error = Some(TcpError::Protocol("client sequence exhausted".into())); + } else if let Err(failure) = client + .notify( + "dispatchAction", + DispatchActionParams { + channel: shared.resource.clone(), + client_seq: seq as i64, + action, + }, + ) + .await + { + error = Some(failure.into()); + } + } + if !client.tcp_is_closed() { + if let Err(failure) = client.unsubscribe(shared.resource.clone()).await { + if error.is_none() { + error = Some(failure.into()); + } else { + tracing::warn!(?failure, "TCP unsubscribe failed after close failure"); + } + } + } + let mut inner = shared.inner.lock().await; + inner.cleanup = Some(error.map_or(Ok(()), Err)); + shared.wake(); + drop(inner); + drop(_send); + client.tcp_unregister(&TcpConnection { shared }).await; + }); + true + } + + async fn suspend(&self, epoch: u64) { + let mut inner = self.shared.inner.lock().await; + if inner.epoch != epoch || inner.terminal { + return; + } + inner.online = false; + inner.resuming = false; + self.shared.wake(); + } + + async fn accept(&self, envelope: ActionEnvelope, epoch: u64) { + let mut inner = self.shared.inner.lock().await; + if inner.epoch != epoch || inner.terminal || envelope.channel != self.shared.resource { + return; + } + let malformed = crate::client::strict_action_error(&envelope.action); + let mut next = inner.state.clone(); + let mut error = malformed + .map(|error| TcpError::Client(Arc::new(ClientError::Transport(error)))) + .or_else(|| envelope.rejection_reason.map(TcpError::Protocol)); + let client_echo = matches!( + &envelope.action, + StateAction::TcpInput(_) + | StateAction::TcpDataConsumed(_) + | StateAction::TcpInputEof(_) + | StateAction::TcpClientClose(_) + | StateAction::TcpClientReset(_) + ); + let mut pending_exists = false; + if client_echo { + match &envelope.origin { + Some(origin) + if origin.client_id == self.shared.owner + && (1..=MAX_SAFE).contains(&origin.client_seq) + && origin.client_seq as u64 <= inner.last_seq => + { + if let Some(pending) = inner.pending.get(&origin.client_seq) { + pending_exists = true; + if pending.action != envelope.action { + error = Some(TcpError::Protocol( + "TCP echo does not match pending action".into(), + )); + } + } + } + _ => error = Some(TcpError::Protocol("invalid client TCP echo origin".into())), + } + } + if envelope.server_seq <= inner.checkpoint as u64 && error.is_none() { + return; + } + if envelope.server_seq > MAX_SAFE as u64 { + error = Some(TcpError::Protocol("invalid server sequence".into())); + } + if error.is_none() { + match apply_action_to_tcp(&mut next, &envelope.action) { + ReduceOutcome::Invalid(failure) => { + error = Some(TcpError::Protocol(failure.to_string())) + } + ReduceOutcome::Applied if client_echo && !pending_exists => { + error = Some(TcpError::Protocol( + "unacknowledged TCP state advanced without a matching pending action" + .into(), + )); + } + _ => {} + } + } + if error.is_none() + && (next.input.received_bytes > inner.sent_bytes + || next.output.consumed_bytes > inner.consumed + || next.output.received_bytes - inner.consumed > next.output.window_bytes) + { + error = Some(TcpError::Protocol( + "host exceeded owned byte counters".into(), + )); + } + if error.is_none() && next.output.received_bytes > inner.state.output.received_bytes { + if let StateAction::TcpData(action) = &envelope.action { + match STANDARD.decode(&action.data) { + Ok(bytes) => inner.received.push_back(bytes), + Err(failure) => error = Some(TcpError::Protocol(failure.to_string())), + } + } + } + if error.is_none() { + inner.state = next; + inner.checkpoint = envelope.server_seq as i64; + if client_echo { + if let Some(origin) = envelope.origin { + inner.pending.remove(&origin.client_seq); + } + } + } + let reset = inner.state.reset.clone(); + if inner.state.host_closed { + inner.ending = true; + inner.closing = true; + } + drop(inner); + self.shared.wake(); + if let Some(error) = error { + self.finish( + Some(error), + Some(reset_action(TcpResetReason::ProtocolError)), + false, + ) + .await; + } else if let Some(reset) = reset { + self.finish(Some(TcpError::Reset(reset.reason)), None, false) + .await; + } else { + self.advance_close().await; + } + } + + fn start(&self, epoch: u64, mut events: StrictClientEventStream) { + let weak = Arc::downgrade(&self.shared); + let mut changed = self.shared.changed.subscribe(); + tokio::spawn(async move { + loop { + let Some(shared) = weak.upgrade() else { return }; + let inner = shared.inner.lock().await; + if inner.epoch != epoch || inner.terminal || !inner.online { + return; + } + drop(inner); + drop(shared); + tokio::select! { + event = events.recv() => { + let Some(shared) = weak.upgrade() else { return }; + let connection = TcpConnection { shared }; + match event { + Ok(Some(event)) => if let SubscriptionEvent::Action(action) = event.event { connection.accept(action, epoch).await; }, + Ok(None) => { connection.suspend(epoch).await; return; }, + Err(error) => { + if matches!(error, ClientError::SubscriptionLag(_) | ClientError::Transport(TransportError::Protocol(_))) { + connection.finish(Some(error.into()), Some(reset_action(TcpResetReason::ProtocolError)), false).await; + } else { connection.suspend(epoch).await; } + return; + } + } + } + _ = changed.changed() => { + // Closing remains owned if the caller cancels its close future. + let Some(shared) = weak.upgrade() else { return }; + TcpConnection { shared }.advance_close().await; + } + } + } + }); + tokio::spawn(send_pending(Arc::downgrade(&self.shared), epoch)); + } +} + +async fn send_pending(weak: Weak, epoch: u64) { + let Some(initial) = weak.upgrade() else { + return; + }; + let mut changed = initial.changed.subscribe(); + drop(initial); + loop { + let Some(shared) = weak.upgrade() else { return }; + let inner = shared.inner.lock().await; + if inner.epoch != epoch || inner.terminal || !inner.online || inner.resuming { + return; + } + let next = inner + .pending + .iter() + .find(|(_, action)| action.sent != epoch) + .map(|(seq, action)| (*seq, action.action.clone())); + let client = inner.client.clone(); + drop(inner); + if let Some((seq, action)) = next { + let send = shared.send.lock().await; + let inner = shared.inner.lock().await; + if inner.epoch != epoch || inner.terminal || !inner.online { + return; + } + drop(inner); + let result = client + .notify( + "dispatchAction", + DispatchActionParams { + channel: shared.resource.clone(), + client_seq: seq, + action, + }, + ) + .await; + drop(send); + if result.is_err() { + TcpConnection { shared }.suspend(epoch).await; + return; + } + let mut inner = shared.inner.lock().await; + if inner.epoch == epoch { + if let Some(pending) = inner.pending.get_mut(&seq) { + pending.sent = epoch; + } + } + } else { + drop(shared); + if changed.changed().await.is_err() { + return; + } + } + } +} + +impl Client { + /// Atomically create an owned TCP stream after `initialize` advertised support. + /// Cancellation and request timeout clean up late successful creation responses. + /// For managed hosts, use [`crate::hosts::HostClientHandle::open_tcp_connection`] + /// so the runtime retains this stream across reconnects. + pub async fn open_tcp_connection( + &self, + session: String, + create: TcpConnectionSubscription, + ) -> Result { + if !session.starts_with("ahp-session:") + || session == "ahp-session:" + || create.r#type != "tcpConnection" + || create.host.is_empty() + || create + .host + .chars() + .any(|c| c.is_whitespace() || "/\\\0".contains(c)) + || !(1..=65535).contains(&create.port) + || create.encoding != TcpDataEncoding::Base64 + || !(1..=MAX_WINDOW_BYTES).contains(&create.receive_window_bytes) + || !(1..=create.receive_window_bytes).contains(&create.maximum_chunk_size) + { + return Err(TcpError::Invalid( + "invalid session, target, encoding, or limits", + )); + } + let Some((owner, Some(capability))) = self.tcp_identity().await else { + return Err(TcpError::Invalid("initialize must advertise TCP support")); + }; + if owner.is_empty() || !capability.encodings.contains(&create.encoding) { + return Err(TcpError::Invalid("requested TCP encoding is not supported")); + } + let client = self.clone(); + let (sender, receiver) = oneshot::channel(); + tokio::spawn(async move { + let result = create_connection(client, owner, session, create).await; + if let Err(Ok(connection)) = sender.send(result) { + if let Err(error) = connection.dispose().await { + tracing::warn!(?error, "cancelled TCP creation cleanup failed"); + } + } + }); + receiver.await.map_err(|_| TcpError::Closed)? + } + + /// Resume retained handles on a fresh transport using replay, never creation. + /// Checkpoints are clamped to retained state. Cancellation does not abandon + /// an in-flight reconciliation; the owned task finishes or suspends handles. + pub async fn reconnect_tcp_connections( + &self, + params: ReconnectParams, + connections: &[TcpConnection], + ) -> Result { + let client = self.clone(); + let connections = connections.to_vec(); + let (sender, receiver) = oneshot::channel(); + tokio::spawn(async move { + let _ = sender.send(resume_connections(client, params, connections).await); + }); + receiver.await.map_err(|_| TcpError::Closed)? + } +} + +async fn create_connection( + client: Client, + owner: String, + session: String, + create: TcpConnectionSubscription, +) -> Result { + let mut params = SubscribeParams::new(session.clone()); + params.create = Some(create.clone()); + let cleanup_client = client.clone(); + let events_slot = Arc::new(std::sync::Mutex::new(None)); + let result_events = events_slot.clone(); + let route_client = client.clone(); + let raw: serde_json::Value = client + .request_with_late_result( + "subscribe", + params, + Some(Box::new(move |result| { + tokio::spawn(cleanup_late_creation(cleanup_client, result)); + })), + Some(Box::new(move |result| { + if let Some(resource) = result["snapshot"]["resource"].as_str() { + *result_events.lock().expect("creation events lock poisoned") = + Some(route_client.resource_events_strict(resource.to_owned())); + } + })), + ) + .await?; + let result: SubscribeResult = match serde_json::from_value(raw.clone()) { + Ok(result) => result, + Err(error) => { + if let Some(resource) = raw["snapshot"]["resource"].as_str() { + if resource.starts_with("ahp-tcp:") && resource != "ahp-tcp:" { + if let Err(cleanup) = client.unsubscribe(resource.to_owned()).await { + tracing::warn!(?cleanup, "invalid TCP creation cleanup failed"); + } + } + } + return Err(TcpError::Protocol(format!( + "invalid creation response: {error}" + ))); + } + }; + let Some(snapshot) = result.snapshot else { + return Err(TcpError::Protocol("creation omitted snapshot".into())); + }; + let valid_resource = + snapshot.resource.starts_with("ahp-tcp:") && snapshot.resource != "ahp-tcp:"; + let valid = if let SnapshotState::Tcp(state) = &snapshot.state { + valid_resource + && (0..=MAX_SAFE).contains(&snapshot.from_seq) + && state.session == session + && state.target.host == create.host + && state.target.port == create.port + && state.encoding == create.encoding + && valid_direction(&state.input) + && valid_direction(&state.output) + && state.output.window_bytes <= create.receive_window_bytes + && state.output.maximum_chunk_size <= create.maximum_chunk_size + && !state.client_closed + && !state.host_closed + && state.reset.is_none() + } else { + false + }; + if !valid { + if valid_resource { + client.unsubscribe(snapshot.resource).await?; + } + return Err(TcpError::Protocol( + "creation snapshot is not fresh or does not match request".into(), + )); + } + let SnapshotState::Tcp(state) = snapshot.state else { + return Err(TcpError::Protocol("expected TCP snapshot".into())); + }; + let mut events = events_slot + .lock() + .expect("creation events lock poisoned") + .take() + .ok_or_else(|| TcpError::Protocol("creation omitted resource route".into()))?; + let (changed, _) = watch::channel(0); + let connection = TcpConnection { + shared: Arc::new(Shared { + resource: snapshot.resource, + owner, + inner: Mutex::new(Inner { + client, + state: *state, + checkpoint: snapshot.from_seq, + last_seq: 0, + epoch: 1, + online: true, + resuming: false, + ending: false, + closing: false, + close_queued: false, + terminal: false, + error: None, + cleanup: None, + pending: BTreeMap::new(), + received: VecDeque::new(), + sent_bytes: 0, + consumed: 0, + }), + changed, + writer: Arc::new(Mutex::new(())), + resume: Arc::new(Mutex::new(())), + send: Mutex::new(()), + }), + }; + let owner = connection.shared.inner.lock().await.client.clone(); + if !owner.tcp_register(&connection).await { + connection.dispose().await?; + return Err(ClientError::Shutdown.into()); + } + if drain_events(&connection, 1, &mut events).await { + connection.start(1, events); + } + if let Some(error) = &connection.shared.inner.lock().await.error { + return Err(error.clone()); + } + Ok(connection) +} + +async fn cleanup_late_creation(client: Client, result: serde_json::Value) { + let Some(resource) = result["snapshot"]["resource"].as_str() else { + tracing::warn!("late TCP creation response omitted its child resource"); + return; + }; + if !resource.starts_with("ahp-tcp:") || resource == "ahp-tcp:" { + tracing::warn!("late TCP creation response has an invalid child resource"); + return; + } + let seq = client.tcp_next_sequence(); + if seq == 0 || seq > MAX_SAFE as u64 { + tracing::warn!("late TCP creation reset failed: client sequence exhausted"); + } else if let Err(error) = client + .notify( + "dispatchAction", + DispatchActionParams { + channel: resource.into(), + client_seq: seq as i64, + action: reset_action(TcpResetReason::ConnectionAborted), + }, + ) + .await + { + tracing::warn!(?error, "late TCP creation reset failed"); + } + if let Err(error) = client.unsubscribe(resource.into()).await { + tracing::warn!(?error, "late TCP creation unsubscribe failed"); + } +} + +async fn resume_connections( + client: Client, + mut params: ReconnectParams, + connections: Vec, +) -> Result { + let consumer_checkpoint = params.last_seen_server_seq; + let mut identity = client.tcp_identity().await; + if identity + .as_ref() + .is_some_and(|(owner, _)| owner != ¶ms.client_id) + { + return Err(TcpError::Invalid("new client has a different clientId")); + } + let mut permits = Vec::new(); + for connection in &connections { + permits.push( + connection + .shared + .resume + .clone() + .try_lock_owned() + .map_err(|_| TcpError::Invalid("duplicate or concurrently resuming handle"))?, + ); + } + if params.client_id.is_empty() || !(0..=MAX_SAFE).contains(¶ms.last_seen_server_seq) { + return Err(TcpError::Invalid( + "invalid reconnect identity or checkpoint", + )); + } + let mut inners = Vec::new(); + for connection in &connections { + inners.push(connection.shared.inner.lock().await); + } + let mut next = client.tcp_sequence_floor(); + for (connection, inner) in connections.iter().zip(inners.iter()) { + if connection.shared.owner != params.client_id + || inner.terminal + || (inner.online && !inner.client.tcp_is_closed()) + || inner.client.tcp_same_transport(&client) + { + return Err(TcpError::Invalid( + "handles must be suspended, live, and owned by the same clientId", + )); + } + next = next + .max(inner.last_seq + 1) + .max(inner.client.tcp_sequence_floor()); + identity = inner.client.tcp_identity().await; + params.last_seen_server_seq = params.last_seen_server_seq.min(inner.checkpoint); + if !params.subscriptions.contains(&connection.shared.resource) { + params + .subscriptions + .push(connection.shared.resource.clone()); + } + } + client.tcp_advance_sequence(next); + if let Some(identity) = identity { + client.tcp_restore_identity(identity).await; + } + let mut bindings = Vec::new(); + for (connection, inner) in connections.iter().zip(inners.iter_mut()) { + inner.client.tcp_unregister(connection).await; + inner.client = client.clone(); + inner.epoch += 1; + inner.online = false; + inner.resuming = true; + bindings.push(( + inner.epoch, + client.resource_events_strict(connection.shared.resource.clone()), + )); + connection.shared.wake(); + } + let mut registration_failed = false; + for connection in &connections { + registration_failed |= !client.tcp_register(connection).await; + } + drop(inners); + if registration_failed { + for connection in &connections { + connection.dispose_for_client(&client).await?; + } + return Err(ClientError::Shutdown.into()); + } + params.channel = ahp_types::ROOT_RESOURCE_URI.into(); + let mut result: ReconnectResult = match client.request("reconnect", params).await { + Ok(result) => result, + Err(error) => { + for (connection, (epoch, events)) in connections.iter().zip(bindings.iter_mut()) { + let mut failure = if matches!(error, ClientError::Deserialization(_)) { + Some(TcpError::Protocol(error.to_string())) + } else { + None + }; + loop { + match events.try_recv() { + Ok(Some(_)) => continue, + Err( + loss @ (ClientError::SubscriptionLag(_) + | ClientError::Transport(TransportError::Protocol(_))), + ) => { + failure = Some(loss.into()); + break; + } + _ => break, + } + } + if let Some(failure) = failure { + connection + .finish( + Some(failure), + Some(reset_action(TcpResetReason::ProtocolError)), + false, + ) + .await; + } else { + connection.suspend(*epoch).await; + } + } + return Err(error.into()); + } + }; + let ReconnectResult::Replay(replay) = &mut result else { + for connection in &connections { + connection + .finish(Some(TcpError::ReplayUnavailable), None, false) + .await; + } + return Ok(result); + }; + replay.actions.retain(|action| { + action.channel.starts_with("ahp-tcp:") || action.server_seq > consumer_checkpoint as u64 + }); + for connection in &connections { + if replay.missing.contains(&connection.shared.resource) { + connection + .finish(Some(TcpError::ReplayUnavailable), None, false) + .await; + } + for action in &replay.actions { + let epoch = connection.shared.inner.lock().await.epoch; + connection.accept(action.clone(), epoch).await; + } + } + let mut resend = Vec::new(); + for (index, connection) in connections.iter().enumerate() { + let (epoch, events) = &mut bindings[index]; + if !drain_events(connection, *epoch, events).await { + continue; + } + let inner = connection.shared.inner.lock().await; + if !inner.terminal && inner.resuming { + for (seq, pending) in &inner.pending { + resend.push((*seq, index, pending.action.clone())); + } + } + } + resend.sort_by_key(|(seq, _, _)| *seq); + for (seq, index, action) in resend { + let connection = &connections[index]; + let _send = connection.shared.send.lock().await; + if connection.shared.inner.lock().await.terminal { + continue; + } + if let Err(error) = client + .notify( + "dispatchAction", + DispatchActionParams { + channel: connection.shared.resource.clone(), + client_seq: seq, + action, + }, + ) + .await + { + for (connection, (epoch, _)) in connections.iter().zip(bindings.iter()) { + connection.suspend(*epoch).await; + } + return Err(error.into()); + } + let mut inner = connection.shared.inner.lock().await; + let epoch = inner.epoch; + if let Some(pending) = inner.pending.get_mut(&seq) { + pending.sent = epoch; + } + } + for (connection, (epoch, events)) in connections.iter().zip(bindings) { + let mut inner = connection.shared.inner.lock().await; + if !inner.terminal && inner.resuming { + inner.online = true; + inner.resuming = false; + connection.shared.wake(); + connection.start(epoch, events); + } + drop(inner); + connection.advance_close().await; + } + Ok(result) +} + +async fn drain_events( + connection: &TcpConnection, + epoch: u64, + events: &mut StrictClientEventStream, +) -> bool { + loop { + match events.try_recv() { + Ok(Some(event)) => { + if let SubscriptionEvent::Action(action) = event.event { + connection.accept(action, epoch).await; + } + } + Ok(None) => return true, + Err(error) => { + if matches!( + error, + ClientError::SubscriptionLag(_) + | ClientError::Transport(TransportError::Protocol(_)) + ) { + connection + .finish( + Some(error.into()), + Some(reset_action(TcpResetReason::ProtocolError)), + false, + ) + .await; + } else { + connection.suspend(epoch).await; + } + return false; + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::{ClientConfig, Transport, TransportMessage}; + use ahp_types::messages::{JsonRpcMessage, JsonRpcRequest}; + use serde_json::{json, Value}; + use std::time::Duration; + use tokio::sync::mpsc; + + struct Wire { + tx: mpsc::Sender, + rx: mpsc::Receiver, + } + impl Transport for Wire { + async fn send(&mut self, message: TransportMessage) -> Result<(), TransportError> { + self.tx + .send(message) + .await + .map_err(|_| TransportError::Closed) + } + async fn recv(&mut self) -> Result, TransportError> { + Ok(self.rx.recv().await) + } + } + fn pair() -> (Wire, Wire) { + let (a, b) = mpsc::channel(32); + let (c, d) = mpsc::channel(32); + (Wire { tx: a, rx: d }, Wire { tx: c, rx: b }) + } + async fn send(server: &mut Wire, value: Value) { + server + .send(TransportMessage::Text(value.to_string())) + .await + .unwrap(); + } + async fn request(server: &mut Wire) -> JsonRpcRequest { + let JsonRpcMessage::Request(request) = + server.recv().await.unwrap().unwrap().into_parsed().unwrap() + else { + panic!("expected request") + }; + request + } + async fn notification(server: &mut Wire, method: &str) -> Value { + let JsonRpcMessage::Notification(n) = + server.recv().await.unwrap().unwrap().into_parsed().unwrap() + else { + panic!("expected notification") + }; + assert_eq!(n.method, method); + serde_json::to_value(n.params).unwrap() + } + async fn dispatch(server: &mut Wire) -> DispatchActionParams { + serde_json::from_value(notification(server, "dispatchAction").await).unwrap() + } + fn options() -> TcpConnectionSubscription { + TcpConnectionSubscription { + r#type: "tcpConnection".into(), + host: "localhost".into(), + port: 3000, + encoding: TcpDataEncoding::Base64, + receive_window_bytes: 4, + maximum_chunk_size: 3, + } + } + fn snapshot() -> Value { + json!({"snapshot":{"resource":"ahp-tcp:/owned","fromSeq":10,"state":{ + "session":"ahp-session:/s1","target":{"host":"localhost","port":3000},"encoding":"base64", + "input":{"windowBytes":4,"maximumChunkSize":3,"receivedBytes":0,"consumedBytes":0}, + "output":{"windowBytes":4,"maximumChunkSize":3,"receivedBytes":0,"consumedBytes":0}, + "clientClosed":false,"hostClosed":false + }}}) + } + async fn reply(server: &mut Wire, request: JsonRpcRequest, result: Value) { + send( + server, + json!({"jsonrpc":"2.0","id":request.id,"result":result}), + ) + .await; + } + async fn emit(server: &mut Wire, seq: u64, action: Value, origin: Option) { + send( + server, + json!({"jsonrpc":"2.0","method":"action","params":{ + "channel":"ahp-tcp:/owned","serverSeq":seq,"action":action, + "origin":origin.map(|seq|json!({"clientId":"owner","clientSeq":seq})) + }}), + ) + .await; + } + async fn acknowledge_close(server: &mut Wire, seq: u64) { + let close = dispatch(server).await; + assert!(matches!(&close.action, StateAction::TcpClientClose(_))); + emit( + server, + seq, + serde_json::to_value(close.action).unwrap(), + Some(close.client_seq), + ) + .await; + emit(server, seq + 1, json!({"type":"tcp/hostClose"}), None).await; + } + + async fn unrelated_burst(client: &Client, server: &mut Wire, first_seq: u64) { + for i in 0..16 { + for (offset, channel, action) in [ + ( + 0, + "ahp-session:/other", + json!({"type":"session/titleChanged","title":"busy"}), + ), + ( + 1, + "ahp-tcp:/other", + json!({"type":"tcp/data","offset":i,"data":"AA=="}), + ), + ] { + send( + server, + json!({"jsonrpc":"2.0","method":"action","params":{ + "channel":channel,"serverSeq":first_seq + i * 2 + offset,"action":action + }}), + ) + .await; + } + } + let (ping, ()) = tokio::join!(client.ping(), async { + let req = request(server).await; + assert_eq!(req.method, "ping"); + reply(server, req, Value::Null).await; + }); + ping.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_scoped_creation_and_active_traffic() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, mut server) = initialized_with_config(ClientConfig { + subscription_buffer: 2, + ..ClientConfig::default() + }) + .await; + let opener = client.clone(); + let opening = tokio::spawn(async move { + opener + .open_tcp_connection("ahp-session:/s1".into(), options()) + .await + }); + let req = request(&mut server).await; + unrelated_burst(&client, &mut server, 1).await; + let mut initial = snapshot(); + initial["snapshot"]["fromSeq"] = json!(32); + reply(&mut server, req, initial).await; + emit( + &mut server, + 33, + json!({"type":"tcp/data","offset":0,"data":"Bw=="}), + None, + ) + .await; + let connection = opening.await.unwrap().unwrap(); + { + let _inner = connection.shared.inner.lock().await; + unrelated_burst(&client, &mut server, 34).await; + } + assert_eq!(connection.read().await.unwrap(), Some(vec![7])); + assert!(matches!( + dispatch(&mut server).await.action, + StateAction::TcpDataConsumed(_) + )); + connection.dispose().await.unwrap(); + assert!(matches!( + dispatch(&mut server).await.action, + StateAction::TcpClientReset(_) + )); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + async fn initialized() -> (Client, Wire) { + initialized_with_config(ClientConfig::default()).await + } + + async fn initialized_with_config(config: ClientConfig) -> (Client, Wire) { + let (wire, mut server) = pair(); + let client = Client::connect(wire, config).await.unwrap(); + let (result, ()) = tokio::join!( + client.initialize( + "owner".into(), + vec![ahp_types::PROTOCOL_VERSION.into()], + vec![] + ), + async { + let req = request(&mut server).await; + assert_eq!(req.method, "initialize"); + reply(&mut server,req,json!({"protocolVersion":ahp_types::PROTOCOL_VERSION,"serverSeq":0,"snapshots":[],"tcpConnections":{"encodings":["base64"]}})).await; + } + ); + result.unwrap(); + (client, server) + } + async fn opened(first: Option<&str>) -> (Client, TcpConnection, Wire) { + let (client, mut server) = initialized().await; + let (result, ()) = tokio::join!( + client.open_tcp_connection("ahp-session:/s1".into(), options()), + async { + let req = request(&mut server).await; + assert_eq!(req.method, "subscribe"); + reply(&mut server, req, snapshot()).await; + if let Some(data) = first { + emit( + &mut server, + 11, + json!({"type":"tcp/data","offset":0,"data":data}), + None, + ) + .await; + } + } + ); + (client, result.unwrap(), server) + } + + #[tokio::test] + async fn owned_tcp_shutdown_disposes_live_and_suspended_streams() { + tokio::time::timeout(Duration::from_secs(5), async { + for suspended in [false, true] { + let (client, connection, mut server) = opened(None).await; + connection.write_all(b"abcd").await.unwrap(); + dispatch(&mut server).await; + dispatch(&mut server).await; + let reader = connection.clone(); + let read = tokio::spawn(async move { reader.read().await }); + let writer = connection.clone(); + let write = tokio::spawn(async move { writer.write(b"x").await }); + let drainer = connection.clone(); + let drain = tokio::spawn(async move { drainer.drain().await }); + tokio::task::yield_now().await; + assert!(!read.is_finished() && !write.is_finished() && !drain.is_finished()); + if suspended { + client.shutdown_preserving_tcp().await; + assert!(!connection.is_closed().await); + } + client.shutdown().await; + assert!(connection.is_closed().await); + assert!(matches!(read.await.unwrap(), Err(TcpError::Disposed))); + assert!(matches!(write.await.unwrap(), Err(TcpError::Disposed))); + assert!(matches!(drain.await.unwrap(), Err(TcpError::Disposed))); + assert!(!client.tcp_register(&connection).await); + client.shutdown().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_close_waits_for_accepted_input() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, connection, mut server) = opened(None).await; + connection.write_all(b"ab").await.unwrap(); + let input = dispatch(&mut server).await; + let mut changed = connection.shared.changed.subscribe(); + let closing = connection.clone(); + let close = tokio::spawn(async move { closing.close().await }); + while !connection.shared.inner.lock().await.ending { + changed.changed().await.unwrap(); + } + assert!(!close.is_finished()); + assert!(server.rx.try_recv().is_err()); + emit( + &mut server, + 11, + serde_json::to_value(&input.action).unwrap(), + Some(input.client_seq), + ) + .await; + emit( + &mut server, + 12, + json!({"type":"tcp/inputConsumed","consumedBytes":2}), + None, + ) + .await; + acknowledge_close(&mut server, 13).await; + close.await.unwrap().unwrap(); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_large_payload_encoding() { + tokio::time::timeout(Duration::from_secs(10), async { + let (client, mut server) = initialized().await; + const SIZE: usize = 4 * 1024 * 1024; + let mut params = options(); + params.receive_window_bytes = SIZE as i64; + params.maximum_chunk_size = SIZE as i64; + let mut initial = snapshot(); + for direction in ["input", "output"] { + initial["snapshot"]["state"][direction]["windowBytes"] = json!(SIZE); + initial["snapshot"]["state"][direction]["maximumChunkSize"] = json!(SIZE); + } + let (connection, ()) = tokio::join!( + client.open_tcp_connection("ahp-session:/s1".into(), params), + async { + let req = request(&mut server).await; + reply(&mut server, req, initial).await; + } + ); + let connection = connection.unwrap(); + let payload = vec![0xab; SIZE]; + assert_eq!(connection.write(&payload).await.unwrap(), SIZE); + let input = dispatch(&mut server).await; + let StateAction::TcpInput(action) = &input.action else { + panic!("expected input") + }; + assert_eq!(action.offset, 0); + assert_eq!(STANDARD.decode(&action.data).unwrap(), payload); + emit( + &mut server, + 11, + serde_json::to_value(&input.action).unwrap(), + Some(input.client_seq), + ) + .await; + emit( + &mut server, + 12, + json!({"type":"tcp/inputConsumed","consumedBytes":SIZE}), + None, + ) + .await; + let (closed, ()) = tokio::join!(connection.close(), acknowledge_close(&mut server, 13)); + closed.unwrap(); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_negotiated_output_limits() { + tokio::time::timeout(Duration::from_secs(5), async { + for (window, chunk, valid) in [ + (2, 1, true), + (4, 2, true), + (0, 1, false), + (2, 0, false), + (5, 1, false), + (4, 3, false), + (1, 2, false), + ] { + let (client, mut server) = initialized().await; + let mut create = options(); + create.maximum_chunk_size = 2; + let mut snap = snapshot(); + snap["snapshot"]["state"]["output"]["windowBytes"] = json!(window); + snap["snapshot"]["state"]["output"]["maximumChunkSize"] = json!(chunk); + let (result, ()) = tokio::join!( + client.open_tcp_connection("ahp-session:/s1".into(), create), + async { + let req = request(&mut server).await; + reply(&mut server, req, snap).await; + } + ); + assert_eq!( + result.is_ok(), + valid, + "limits {window}/{chunk}: {:?}", + result.as_ref().err() + ); + if let Ok(connection) = result { + let inner = connection.shared.inner.lock().await; + assert_eq!(inner.state.output.window_bytes, window); + assert_eq!(inner.state.output.maximum_chunk_size, chunk); + drop(inner); + connection.dispose().await.unwrap(); + } + client.shutdown().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_request_limits_uint32() { + tokio::time::timeout(Duration::from_secs(5), async { + for (window, chunk, valid) in [ + (4_294_967_295, 4_294_967_295, true), + (4_294_967_296, 1, false), + (4_294_967_295, 4_294_967_296, false), + ] { + let (client, mut server) = initialized().await; + let mut create = options(); + create.receive_window_bytes = window; + create.maximum_chunk_size = chunk; + let opening = client.open_tcp_connection("ahp-session:/s1".into(), create); + tokio::pin!(opening); + let result = tokio::select! { + result = &mut opening => result, + req = request(&mut server) => { + assert!(valid, "out-of-UInt32 request was sent: {window}/{chunk}"); + let mut snap = snapshot(); + snap["snapshot"]["state"]["output"]["windowBytes"] = json!(window); + snap["snapshot"]["state"]["output"]["maximumChunkSize"] = json!(chunk); + reply(&mut server, req, snap).await; + opening.await + } + }; + assert_eq!(result.is_ok(), valid); + if let Ok(connection) = result { + connection.dispose().await.unwrap(); + } + client.shutdown().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_snapshot_limits_uint32() { + tokio::time::timeout(Duration::from_secs(5), async { + for (direction, field) in [ + ("input", "boundary"), + ("input", "windowBytes"), + ("input", "maximumChunkSize"), + ("output", "boundary"), + ("output", "windowBytes"), + ("output", "maximumChunkSize"), + ] { + let (client, mut server) = initialized().await; + let mut create = options(); + create.receive_window_bytes = 4_294_967_295; + create.maximum_chunk_size = 4_294_967_295; + let mut snap = snapshot(); + snap["snapshot"]["state"][direction]["windowBytes"] = json!(4_294_967_295_i64); + snap["snapshot"]["state"][direction]["maximumChunkSize"] = json!(4_294_967_295_i64); + if field != "boundary" { + snap["snapshot"]["state"][direction][field] = json!(4_294_967_296_i64); + } + let (result, ()) = tokio::join!( + client.open_tcp_connection("ahp-session:/s1".into(), create), + async { + let req = request(&mut server).await; + reply(&mut server, req, snap).await; + } + ); + assert_eq!(result.is_ok(), field == "boundary", "{direction}/{field}"); + if let Ok(connection) = result { + connection.dispose().await.unwrap(); + } + client.shutdown().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_resume_filters_ordinary_replay() { + tokio::time::timeout(Duration::from_secs(5), async { + let (old, connection, _server) = opened(None).await; + old.shutdown_preserving_tcp().await; + let (wire, mut server) = pair(); + let client = Client::connect(wire, ClientConfig::default()).await.unwrap(); + let (result, ()) = tokio::join!( + client.reconnect_tcp_connections(ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), meta: None, client_id: "owner".into(), + last_seen_server_seq: 20, subscriptions: vec![], + }, std::slice::from_ref(&connection)), + async { + let req = request(&mut server).await; + assert_eq!(serde_json::to_value(&req.params).unwrap()["lastSeenServerSeq"], 10); + reply(&mut server, req, json!({"type":"replay", "missing":[], "actions":[ + {"channel":connection.resource(),"serverSeq":11,"action":{"type":"tcp/data","offset":0,"data":"eA=="}}, + {"channel":"ahp-terminal:/t","serverSeq":15,"action":{"type":"terminal/data","data":"hello"}}, + {"channel":"ahp-terminal:/t","serverSeq":21,"action":{"type":"terminal/data","data":"!"}} + ]})).await; + } + ); + let ReconnectResult::Replay(replay) = result.unwrap() else { panic!("expected replay") }; + let mut text = "hello".to_string(); + for action in replay.actions { + if let StateAction::TerminalData(data) = action.action { text.push_str(&data.data); } + } + assert_eq!(text, "hello!"); + assert_eq!(connection.read().await.unwrap(), Some(b"x".to_vec())); + connection.dispose().await.unwrap(); + client.shutdown().await; + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_close_retains_crossing_data_until_acknowledged() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, connection, mut server) = opened(None).await; + let closing = connection.clone(); + let done = tokio::spawn(async move { closing.close().await }); + let close = dispatch(&mut server).await; + assert!(matches!(close.action, StateAction::TcpClientClose(_))); + assert!( + !connection.is_closed().await, + "local close released ownership" + ); + emit( + &mut server, + 11, + serde_json::to_value(close.action).unwrap(), + Some(close.client_seq), + ) + .await; + emit( + &mut server, + 12, + json!({"type":"tcp/data","offset":0,"data":"YWI="}), + None, + ) + .await; + emit(&mut server, 13, json!({"type":"tcp/hostClose"}), None).await; + let mut changed = connection.shared.changed.subscribe(); + while connection.shared.inner.lock().await.checkpoint < 13 { + changed.changed().await.unwrap(); + } + assert!(server.rx.try_recv().is_err(), "cleanup before output drain"); + assert_eq!(connection.read().await.unwrap(), Some(b"ab".to_vec())); + let credit = dispatch(&mut server).await; + assert!(matches!( + credit.action, + StateAction::TcpDataConsumed(TcpDataConsumedAction { consumed_bytes: 2 }) + )); + assert!(connection.read().await.unwrap().is_none()); + assert!(!connection.is_closed().await, "released before credit ack"); + emit( + &mut server, + 14, + serde_json::to_value(credit.action).unwrap(), + Some(credit.client_seq), + ) + .await; + done.await.unwrap().unwrap(); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_closing_resumes_and_reset_wakes_close() { + tokio::time::timeout(Duration::from_secs(5), async { + let (old, connection, mut old_server) = opened(None).await; + let closing = connection.clone(); + let closed = tokio::spawn(async move { closing.close().await }); + let original = dispatch(&mut old_server).await; + old.shutdown_preserving_tcp().await; + let (wire, mut server) = pair(); + let client = Client::connect(wire, ClientConfig::default()) + .await + .unwrap(); + let (resumed, ()) = tokio::join!( + client.reconnect_tcp_connections( + ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), + meta: None, + client_id: "owner".into(), + last_seen_server_seq: 10, + subscriptions: vec![], + }, + std::slice::from_ref(&connection) + ), + async { + let req = request(&mut server).await; + reply( + &mut server, + req, + json!({"type":"replay","actions":[],"missing":[]}), + ) + .await; + } + ); + resumed.unwrap(); + let action = dispatch(&mut server).await; + assert_eq!(action.client_seq, original.client_seq); + assert_eq!(action.action, original.action); + assert!(matches!(&action.action, StateAction::TcpClientClose(_))); + assert!(!closed.is_finished()); + emit( + &mut server, + 11, + json!({"type":"tcp/hostReset","reason":"connectionReset"}), + None, + ) + .await; + assert!(matches!( + closed.await.unwrap(), + Err(TcpError::Reset(TcpResetReason::ConnectionReset)) + )); + notification(&mut server, "unsubscribe").await; + connection.dispose().await.unwrap(); + assert!(server.rx.try_recv().is_err()); + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn managed_tcp_reconnect_and_shutdown() { + use crate::hosts::{HostConfig, HostId, MultiHostClient, ReconnectPolicy}; + tokio::time::timeout(Duration::from_secs(10), async { + for mode in ["replay", "retry", "snapshot", "missing", "refused"] { + let (servers_tx, mut servers) = mpsc::unbounded_channel(); + let factory = move |_id: HostId| -> std::pin::Pin> + Send>> { + let (wire, server) = pair(); + servers_tx.send(server).unwrap(); + Box::pin(async move { Ok(crate::BoxedTransport::new(wire)) }) + }; + let multi = MultiHostClient::new(); + let mut events = multi.events(); + let id = HostId::new("tcp"); + multi.add_host(HostConfig::new(id.clone(), "TCP", factory) + .with_client_id("owner") + .with_initial_subscriptions(vec![ahp_types::ROOT_RESOURCE_URI.into(), "ahp-tcp:/must-not-initialize".into()]) + .with_reconnect_policy(ReconnectPolicy::immediate_forever())).await.unwrap(); + let initialize = json!({"protocolVersion":ahp_types::PROTOCOL_VERSION,"serverSeq":10,"snapshots":[],"tcpConnections":{"encodings":["base64"]}}); + let mut server = servers.recv().await.unwrap(); + let req = request(&mut server).await; + assert_eq!(req.method, "initialize"); + let params = serde_json::to_value(&req.params).unwrap(); + assert!(!params["initialSubscriptions"].as_array().unwrap().iter().any(|uri|uri.as_str().unwrap().starts_with("ahp-tcp:"))); + reply(&mut server, req, initialize.clone()).await; + let req = request(&mut server).await; + assert_eq!(req.method, "listSessions"); + reply(&mut server, req, json!({"items":[]})).await; + while !multi.host(&id).await.unwrap().state.is_connected() { tokio::task::yield_now().await; } + let old = multi.client(&id).await.unwrap(); + let opener = old.clone(); + let opening = tokio::spawn(async move { opener.open_tcp_connection("ahp-session:/s1".into(),options()).await }); + let req = request(&mut server).await; + assert_eq!(req.method, "subscribe"); + reply(&mut server, req, snapshot()).await; + emit(&mut server, 11, json!({"type":"tcp/data","offset":0,"data":"eA=="}), None).await; + let connection = opening.await.unwrap().unwrap(); + assert_eq!(connection.read().await.unwrap(), Some(b"x".to_vec())); + let credit = dispatch(&mut server).await; + connection.write_all(b"ab").await.unwrap(); + let input = dispatch(&mut server).await; + let ordinary = old.dispatch("ahp-session:/s1".into(), StateAction::SessionTitleChanged(SessionTitleChangedAction{title:"ordinary".into()})).await.unwrap(); + dispatch(&mut server).await; + send(&mut server, json!({"jsonrpc":"2.0","method":"action","params":{ + "channel":"ahp-terminal:/t","serverSeq":20,"action":{"type":"terminal/data","data":"hello"} + }})).await; + loop { + if events.recv().await.unwrap().channel == "ahp-terminal:/t" { break; } + } + old.raw_client().shutdown_preserving_tcp().await; + let mut server = servers.recv().await.unwrap(); + let mut req = request(&mut server).await; + assert_eq!(req.method, "reconnect", "{mode}"); + if mode == "retry" { + drop(server); + server = servers.recv().await.unwrap(); + req = request(&mut server).await; + assert_eq!(req.method, "reconnect"); + } + let params: ReconnectParams = serde_json::from_value(serde_json::to_value(&req.params).unwrap()).unwrap(); + assert_eq!(params.client_id, "owner"); + assert!(params.last_seen_server_seq<=11); + assert!(params.subscriptions.contains(&connection.resource().to_owned()), "{mode}: missing retained stream; closed={}", connection.is_closed().await); + if mode == "refused" { + send(&mut server, json!({"jsonrpc":"2.0","id":req.id,"error":{"code":-32000,"message":"replay expired"}})).await; + } else { + let result = match mode { + "snapshot" => json!({"type":"snapshot","snapshots":[]}), + "missing" => json!({"type":"replay","actions":[],"missing":[connection.resource()]}), + _ => json!({"type":"replay","actions":[ + {"channel":"ahp-terminal:/t","serverSeq":20,"action":{"type":"terminal/data","data":"hello"}}, + {"channel":"ahp-terminal:/t","serverSeq":21,"action":{"type":"terminal/data","data":"!"}} + ],"missing":[]}), + }; + reply(&mut server, req, result).await; + if mode == "replay" || mode == "retry" { + emit(&mut server,22,json!({"type":"tcp/dataEof","finalOffset":1}),None).await; + } + } + let mut resent = Vec::new(); + loop { + match server.recv().await.unwrap().unwrap().into_parsed().unwrap() { + JsonRpcMessage::Request(req) if req.method=="initialize" => { + assert_eq!(mode, "refused"); + let params = serde_json::to_value(&req.params).unwrap(); + assert!(!params["initialSubscriptions"].as_array().unwrap().iter().any(|uri|uri.as_str().unwrap().starts_with("ahp-tcp:"))); + reply(&mut server,req,initialize.clone()).await; + } + JsonRpcMessage::Request(req) => { + assert_eq!(req.method,"listSessions"); + reply(&mut server,req,json!({"items":[]})).await; + break; + } + JsonRpcMessage::Notification(n) if n.method=="dispatchAction" => { + let action: DispatchActionParams = serde_json::from_value(serde_json::to_value(n.params).unwrap()).unwrap(); + resent.push(action); + } + JsonRpcMessage::Notification(n) => assert_eq!(n.method,"unsubscribe"), + _ => panic!("unexpected host packet"), + } + } + let fresh = loop { + if let Some(handle) = multi.client(&id).await { + if handle.generation()>old.generation() && multi.host(&id).await.unwrap().state.is_connected() { break handle; } + } + tokio::task::yield_now().await; + }; + if mode == "replay" || mode == "retry" { + let mut text = "hello".to_string(); + loop { + if let SubscriptionEvent::Action(action) = events.recv().await.unwrap().event { + if let StateAction::TerminalData(data) = action.action { text.push_str(&data.data); } + if action.server_seq == 22 { break; } + } + } + assert_eq!(text, "hello!", "managed replay duplicated terminal output"); + assert_eq!(resent.len(),2); + assert_eq!(resent[0].client_seq,credit.client_seq); + assert_eq!(resent[1].client_seq,input.client_seq); + assert!(connection.read().await.unwrap().is_none()); + connection.write_all(b"c").await.unwrap(); + assert!(dispatch(&mut server).await.client_seq>ordinary.client_seq); + let opener = fresh.clone(); + let opening = tokio::spawn(async move { opener.open_tcp_connection("ahp-session:/s1".into(),options()).await }); + let req = request(&mut server).await; + assert_eq!(req.method,"subscribe"); + let mut snap = snapshot();snap["snapshot"]["resource"]=json!("ahp-tcp:/second"); + reply(&mut server,req,snap).await; + let second=opening.await.unwrap().unwrap(); + let reader=second.clone(); + let waiting=tokio::spawn(async move{reader.read().await}); + multi.remove_host(&id).await.unwrap(); + assert!(connection.is_closed().await && second.is_closed().await); + assert!(waiting.await.unwrap().is_err()); + } else { + assert!(connection.is_closed().await,"{mode}"); + assert!(connection.read().await.is_err(),"{mode}"); + multi.remove_host(&id).await.unwrap(); + } + } + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_timeout_releases_late_child_on_live_transport() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, mut server) = initialized_with_config(ClientConfig { + default_request_timeout: Some(Duration::from_millis(100)), + ..ClientConfig::default() + }).await; + let opening = client.clone(); + let task = tokio::spawn(async move { + opening.open_tcp_connection("ahp-session:/s1".into(), options()).await + }); + let req = request(&mut server).await; + assert!(matches!(task.await.unwrap(), Err(TcpError::Client(error)) if matches!(error.as_ref(), ClientError::Cancelled))); + reply(&mut server, req.clone(), snapshot()).await; + let reset = dispatch(&mut server).await; + assert_eq!(reset.channel, "ahp-tcp:/owned"); + assert!(matches!(reset.action, StateAction::TcpClientReset(TcpClientResetAction { reason: TcpResetReason::ConnectionAborted }))); + assert_eq!(notification(&mut server, "unsubscribe").await["channel"], "ahp-tcp:/owned"); + reply(&mut server, req, snapshot()).await; + let ping_client = client.clone(); + let ping = tokio::spawn(async move { + let _: Value = ping_client.request("ping", json!({})).await.unwrap(); + }); + let req = request(&mut server).await; + assert_eq!(req.method, "ping"); + reply(&mut server, req, json!(null)).await; + ping.await.unwrap(); + assert!(server.rx.try_recv().is_err()); + client.shutdown().await; + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_resume_does_not_reuse_fully_acked_sequence() { + tokio::time::timeout(Duration::from_secs(5), async { + let (old, connection, mut old_server) = opened(None).await; + connection.write_all(b"ab").await.unwrap(); + let original = dispatch(&mut old_server).await; + emit( + &mut old_server, + 11, + serde_json::to_value(&original.action).unwrap(), + Some(original.client_seq), + ) + .await; + emit( + &mut old_server, + 12, + json!({"type":"tcp/inputConsumed","consumedBytes":2}), + None, + ) + .await; + connection.drain().await.unwrap(); + assert!(connection.shared.inner.lock().await.pending.is_empty()); + let ordinary = old + .dispatch( + "ahp-session:/s1".into(), + StateAction::SessionTitleChanged(SessionTitleChangedAction { + title: "ordinary".into(), + }), + ) + .await + .unwrap(); + dispatch(&mut old_server).await; + old.shutdown_preserving_tcp().await; + let (wire, mut server) = pair(); + let client = Client::connect(wire, ClientConfig::default()) + .await + .unwrap(); + let fresh = client.clone(); + let retained = connection.clone(); + let task = tokio::spawn(async move { + fresh + .reconnect_tcp_connections( + ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), + meta: None, + client_id: "owner".into(), + last_seen_server_seq: 12, + subscriptions: vec![], + }, + &[retained], + ) + .await + }); + let req = request(&mut server).await; + reply( + &mut server, + req, + json!({"type":"replay","actions":[],"missing":[]}), + ) + .await; + task.await.unwrap().unwrap(); + old.shutdown().await; + assert!(!connection.is_closed().await); + connection.write_all(b"c").await.unwrap(); + let next = dispatch(&mut server).await; + assert!(next.client_seq > ordinary.client_seq); + assert!(matches!( + next.action, + StateAction::TcpInput(TcpInputAction { offset: 2, .. }) + )); + connection.dispose().await.unwrap(); + dispatch(&mut server).await; + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_invalid_echo_terminates_without_retaining_payload() { + tokio::time::timeout(Duration::from_secs(5), async { + for mode in [ + "missing", + "foreign", + "zero", + "negative", + "unsafe", + "unassigned", + "mismatched", + "reusedAck", + "creditMissing", + "eofMissing", + "closeMissing", + "resetMissing", + ] { + let (client, connection, mut server) = opened(Some("eA==")).await; + connection.write_all(b"ab").await.unwrap(); + let original = dispatch(&mut server).await; + let mut action = serde_json::to_value(&original.action).unwrap(); + let mut origin = json!({"clientId":"owner","clientSeq":original.client_seq}); + match mode { + "missing" => origin = json!(null), + "foreign" => origin["clientId"] = json!("another"), + "zero" => origin["clientSeq"] = json!(0), + "negative" => origin["clientSeq"] = json!(-1), + "unsafe" => origin["clientSeq"] = json!(MAX_SAFE + 1), + "unassigned" => origin["clientSeq"] = json!(original.client_seq + 100), + "mismatched" => action["data"] = json!("eHk="), + "reusedAck" => { + emit(&mut server, 12, action.clone(), Some(original.client_seq)).await; + emit( + &mut server, + 13, + json!({"type":"tcp/inputConsumed","consumedBytes":2}), + None, + ) + .await; + connection.drain().await.unwrap(); + connection.write_all(b"c").await.unwrap(); + action = serde_json::to_value(dispatch(&mut server).await.action).unwrap(); + } + "creditMissing" => { + connection.read().await.unwrap(); + action = serde_json::to_value(dispatch(&mut server).await.action).unwrap(); + origin = json!(null); + } + "eofMissing" => { + connection.end().await.unwrap(); + action = serde_json::to_value(dispatch(&mut server).await.action).unwrap(); + origin = json!(null); + } + "closeMissing" => { + action = json!({"type":"tcp/clientClose"}); + origin = json!(null); + } + "resetMissing" => { + action = json!({"type":"tcp/clientReset","reason":"connectionAborted"}); + origin = json!(null); + } + _ => unreachable!(), + } + let (received, consumed) = { + let inner = connection.shared.inner.lock().await; + ( + inner.state.input.received_bytes, + inner.state.output.consumed_bytes, + ) + }; + let drainer = connection.clone(); + let drain = tokio::spawn(async move { drainer.drain().await }); + send(&mut server, json!({"jsonrpc":"2.0","method":"action","params":{ + "channel":connection.resource(),"serverSeq":14,"action":action,"origin":origin + }})).await; + assert!(drain.await.unwrap().is_err(), "{mode}"); + assert!(connection.read().await.is_err(), "{mode}"); + assert!(connection.write(b"z").await.is_err(), "{mode}"); + { + let inner = connection.shared.inner.lock().await; + assert_eq!(inner.state.input.received_bytes, received, "{mode}"); + assert_eq!(inner.state.output.consumed_bytes, consumed, "{mode}"); + assert!(inner.pending.is_empty(), "{mode}"); + } + assert!(matches!( + dispatch(&mut server).await.action, + StateAction::TcpClientReset(TcpClientResetAction { + reason: TcpResetReason::ProtocolError + }) + )); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_final_close_drains_buffered_reads() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, connection, mut server) = opened(Some("YWJj")).await; + connection.write_all(b"abcd").await.unwrap(); + let first = dispatch(&mut server).await; + let second = dispatch(&mut server).await; + let writer = connection.clone(); + let write = tokio::spawn(async move { writer.write(b"x").await }); + let drainer = connection.clone(); + let drain = tokio::spawn(async move { drainer.drain().await }); + emit(&mut server, 12, json!({"type":"tcp/hostClose"}), None).await; + assert!(write.await.unwrap().is_err()); + assert!(!drain.is_finished()); + let close = dispatch(&mut server).await; + assert!(matches!(&close.action, StateAction::TcpClientClose(_))); + assert!( + !connection.is_closed().await, + "response disposed unconsumed input" + ); + assert_eq!(connection.read().await.unwrap(), Some(b"abc".to_vec())); + assert!(connection.read().await.unwrap().is_none()); + let credit = dispatch(&mut server).await; + assert!(matches!(&credit.action, StateAction::TcpDataConsumed(_))); + emit( + &mut server, + 13, + serde_json::to_value(first.action).unwrap(), + Some(first.client_seq), + ) + .await; + emit( + &mut server, + 14, + serde_json::to_value(second.action).unwrap(), + Some(second.client_seq), + ) + .await; + emit( + &mut server, + 15, + serde_json::to_value(close.action).unwrap(), + Some(close.client_seq), + ) + .await; + let mut changed = connection.shared.changed.subscribe(); + while connection.shared.inner.lock().await.checkpoint < 15 { + changed.changed().await.unwrap(); + } + assert!( + !connection.is_closed().await, + "two-sided close discarded unconsumed bytes" + ); + assert!(server.rx.try_recv().is_err(), "cleanup before drain"); + emit( + &mut server, + 16, + json!({"type":"tcp/inputConsumed","consumedBytes":4}), + None, + ) + .await; + drain.await.unwrap().unwrap(); + assert!(!connection.is_closed().await); + emit( + &mut server, + 17, + serde_json::to_value(credit.action).unwrap(), + Some(credit.client_seq), + ) + .await; + notification(&mut server, "unsubscribe").await; + connection.close().await.unwrap(); + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_strict_overflow_wakes_reader() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, connection, mut server) = opened(None).await; + let reader = connection.clone(); + let read = tokio::spawn(async move { reader.read().await }); + let inner = connection.shared.inner.lock().await; + for seq in 11..ClientConfig::default().subscription_buffer as u64 + 14 { + emit(&mut server, seq, json!({"type":"future/tcp"}), None).await; + } + let barrier = client.clone(); + let ping = tokio::spawn(async move { + let _: Value = barrier.request("ping", json!({})).await.unwrap(); + }); + let req = request(&mut server).await; + reply(&mut server, req, json!(null)).await; + ping.await.unwrap(); + drop(inner); + assert!(matches!( + read.await.unwrap(), + Err(TcpError::Client(error)) if matches!(error.as_ref(), ClientError::SubscriptionLag(_)) + )); + assert!(matches!(dispatch(&mut server).await.action, StateAction::TcpClientReset(_))); + notification(&mut server, "unsubscribe").await; + assert!(connection.read().await.is_err()); + client.shutdown().await; + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_validation_and_invalid_creation_cleanup() { + tokio::time::timeout(Duration::from_secs(5), async { + let (wire, _server) = pair(); + let uninitialized = Client::connect(wire, ClientConfig::default()) + .await + .unwrap(); + assert!(uninitialized + .open_tcp_connection("ahp-session:/s1".into(), options()) + .await + .is_err()); + uninitialized.shutdown().await; + let (client, mut server) = initialized().await; + for params in [ + TcpConnectionSubscription { + port: 0, + ..options() + }, + TcpConnectionSubscription { + host: "http://localhost".into(), + ..options() + }, + TcpConnectionSubscription { + maximum_chunk_size: 5, + ..options() + }, + TcpConnectionSubscription { + receive_window_bytes: MAX_SAFE + 1, + ..options() + }, + ] { + assert!(client + .open_tcp_connection("ahp-session:/s1".into(), params) + .await + .is_err()); + } + for malformed in [false, true] { + let opening = client.clone(); + let task = tokio::spawn(async move { + opening + .open_tcp_connection("ahp-session:/s1".into(), options()) + .await + }); + let req = request(&mut server).await; + let mut result = snapshot(); + if malformed { + result["snapshot"]["state"]["input"]["windowBytes"] = json!(0.5); + } else { + result["snapshot"]["state"]["input"]["receivedBytes"] = json!(1); + } + reply(&mut server, req, result).await; + assert!(task.await.unwrap().is_err()); + assert_eq!( + notification(&mut server, "unsubscribe").await["channel"], + "ahp-tcp:/owned" + ); + } + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_malformed_replay_terminates() { + tokio::time::timeout(Duration::from_secs(5), async { + for malformed_envelope in [false, true] { + let (old, connection, _old_server) = opened(None).await; + old.shutdown_preserving_tcp().await; + let (wire, mut server) = pair(); + let client = Client::connect(wire, ClientConfig::default()).await.unwrap(); + let params = ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), meta: None, + client_id: "owner".into(), last_seen_server_seq: 10, + subscriptions: vec![], + }; + let mut wrong_owner = params.clone(); + wrong_owner.client_id = "different".into(); + assert!(client.reconnect_tcp_connections(wrong_owner, std::slice::from_ref(&connection)).await.is_err()); + let fresh = client.clone(); + let retained = connection.clone(); + let task = tokio::spawn(async move { + fresh.reconnect_tcp_connections(params, &[retained]).await + }); + let req = request(&mut server).await; + let action = if malformed_envelope { + json!({"channel":connection.resource(),"serverSeq":11.5,"action":{"type":"tcp/dataEof","finalOffset":0}}) + } else { + json!({"channel":connection.resource(),"serverSeq":11,"action":{"type":"tcp/dataEof","finalOffset":0.5}}) + }; + reply(&mut server, req, json!({"type":"replay","actions":[action],"missing":[]})).await; + let result = task.await.unwrap(); + assert_eq!(result.is_err(), malformed_envelope); + assert!(connection.read().await.is_err()); + assert!(matches!(dispatch(&mut server).await.action, StateAction::TcpClientReset(_))); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + } + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_credit_chunking_and_half_close() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, connection, mut server) = opened(Some("YWJj")).await; + assert_eq!(connection.read().await.unwrap(), Some(b"abc".to_vec())); + let credit = dispatch(&mut server).await; + assert!(matches!( + credit.action, + StateAction::TcpDataConsumed(TcpDataConsumedAction { consumed_bytes: 3 }) + )); + let writer = connection.clone(); + let writing = tokio::spawn(async move { writer.write_all(b"abcdef").await }); + let first = dispatch(&mut server).await; + let second = dispatch(&mut server).await; + assert!( + matches!(&first.action,StateAction::TcpInput(a) if a.offset==0 && a.data=="YWJj") + ); + assert!( + matches!(&second.action,StateAction::TcpInput(a) if a.offset==3 && a.data=="ZA==") + ); + assert!(!writing.is_finished()); + assert!(matches!( + connection.write(b"x").await, + Err(TcpError::Invalid(_)) + )); + emit( + &mut server, + 12, + serde_json::to_value(&first.action).unwrap(), + Some(first.client_seq), + ) + .await; + emit( + &mut server, + 13, + serde_json::to_value(&second.action).unwrap(), + Some(second.client_seq), + ) + .await; + emit( + &mut server, + 14, + json!({"type":"tcp/inputConsumed","consumedBytes":4}), + None, + ) + .await; + let last = dispatch(&mut server).await; + assert!( + matches!(&last.action,StateAction::TcpInput(a) if a.offset==4 && a.data=="ZWY=") + ); + writing.await.unwrap().unwrap(); + let draining = connection.clone(); + let drain = tokio::spawn(async move { draining.drain().await }); + emit( + &mut server, + 15, + serde_json::to_value(&last.action).unwrap(), + Some(last.client_seq), + ) + .await; + emit( + &mut server, + 16, + json!({"type":"tcp/inputConsumed","consumedBytes":6}), + None, + ) + .await; + drain.await.unwrap().unwrap(); + connection.end().await.unwrap(); + let end = dispatch(&mut server).await; + assert!(matches!( + &end.action, + StateAction::TcpInputEof(TcpInputEofAction { final_offset: 6 }) + )); + emit( + &mut server, + 17, + json!({"type":"tcp/data","offset":0,"data":"YWJj"}), + None, + ) + .await; + emit( + &mut server, + 18, + json!({"type":"tcp/dataEof","finalOffset":3}), + None, + ) + .await; + assert!(connection.read().await.unwrap().is_none()); + emit( + &mut server, + 19, + serde_json::to_value(credit.action).unwrap(), + Some(credit.client_seq), + ) + .await; + emit( + &mut server, + 20, + serde_json::to_value(end.action).unwrap(), + Some(end.client_seq), + ) + .await; + let (closed, ()) = tokio::join!(connection.close(), acknowledge_close(&mut server, 21)); + closed.unwrap(); + assert_eq!( + notification(&mut server, "unsubscribe").await["channel"], + connection.resource() + ); + connection.dispose().await.unwrap(); + assert!(server.rx.try_recv().is_err()); + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_reset_and_rejection_wake_waiters() { + tokio::time::timeout(Duration::from_secs(5),async{ + for rejection in [false,true] { + let (client,connection,mut server)=opened(None).await; + connection.write_all(b"abcd").await.unwrap(); + let first=dispatch(&mut server).await;dispatch(&mut server).await; + let reader=connection.clone();let read=tokio::spawn(async move{reader.read().await}); + let writer=connection.clone();let write=tokio::spawn(async move{writer.write(b"z").await}); + let drainer=connection.clone();let drain=tokio::spawn(async move{drainer.drain().await}); + if rejection { + send(&mut server,json!({"jsonrpc":"2.0","method":"action","params":{ + "channel":connection.resource(),"serverSeq":11,"action":first.action,"origin":{"clientId":"owner","clientSeq":first.client_seq},"rejectionReason":"" + }})).await; + }else{ + emit(&mut server,11,json!({"type":"tcp/hostReset","reason":"connectionReset"}),None).await; + } + assert!(read.await.unwrap().is_err());assert!(write.await.unwrap().is_err());assert!(drain.await.unwrap().is_err()); + if rejection{assert!(matches!(dispatch(&mut server).await.action,StateAction::TcpClientReset(TcpClientResetAction{reason:TcpResetReason::ProtocolError})));} + notification(&mut server,"unsubscribe").await; + client.shutdown().await; + } + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_resume_reconciles_echoes_before_resending() { + tokio::time::timeout(Duration::from_secs(5),async{ + for acknowledged in [false,true] { + let (old,connection,mut old_server)=opened(Some("eHk=")).await; + assert_eq!(connection.write(b"ab").await.unwrap(),2); + let original=dispatch(&mut old_server).await; + old.shutdown_preserving_tcp().await; + let (wire,mut server)=pair(); + let client=Client::connect(wire,ClientConfig::default()).await.unwrap(); + let fresh=client.clone();let retained=connection.clone(); + let resume=tokio::spawn(async move{ + fresh.reconnect_tcp_connections(ReconnectParams{ + channel:ahp_types::ROOT_RESOURCE_URI.into(),meta:None,client_id:"owner".into(),last_seen_server_seq:999,subscriptions:vec!["ahp-session:/s1".into()] + },&[retained]).await + }); + let req=request(&mut server).await;assert_eq!(req.method,"reconnect"); + let params:ReconnectParams=serde_json::from_value(serde_json::to_value(req.params.clone()).unwrap()).unwrap(); + assert!(params.last_seen_server_seq<=11);assert!(params.subscriptions.contains(&connection.resource().to_string())); + let mut actions=vec![json!({"channel":connection.resource(),"serverSeq":11,"action":{"type":"tcp/data","offset":0,"data":"eHk="}})]; + if acknowledged{actions.push(json!({"channel":connection.resource(),"serverSeq":12,"action":original.action,"origin":{"clientId":"owner","clientSeq":original.client_seq}}));} + reply(&mut server,req,json!({"type":"replay","actions":actions,"missing":[]})).await; + emit(&mut server,13,json!({"type":"tcp/data","offset":0,"data":"eHk="}),None).await; + emit(&mut server,14,json!({"type":"tcp/dataEof","finalOffset":2}),None).await; + resume.await.unwrap().unwrap(); + let identity = client.tcp_identity().await.unwrap(); + assert_eq!(identity.0, "owner"); + assert!(identity.1.is_some()); + if !acknowledged{ + let replay=dispatch(&mut server).await; + assert_eq!(replay.client_seq,original.client_seq);assert_eq!(replay.action,original.action); + } + assert_eq!(connection.read().await.unwrap(),Some(b"xy".to_vec())); + let credit=dispatch(&mut server).await; + assert!(credit.client_seq>original.client_seq);assert!(matches!(credit.action,StateAction::TcpDataConsumed(_))); + assert!(connection.read().await.unwrap().is_none()); + connection.dispose().await.unwrap();dispatch(&mut server).await;notification(&mut server,"unsubscribe").await; + client.shutdown().await; + } + }).await.unwrap(); + } + + #[tokio::test] + async fn owned_tcp_resume_applies_replay_before_queued_live_data() { + tokio::time::timeout(Duration::from_secs(5), async { + let (old, connection, mut old_server) = opened(None).await; + connection.write_all(b"xy").await.unwrap(); + let original = dispatch(&mut old_server).await; + old.shutdown_preserving_tcp().await; + let (wire, mut server) = pair(); + let client = Client::connect( + wire, + ClientConfig { + subscription_buffer: 2, + ..ClientConfig::default() + }, + ) + .await + .unwrap(); + let fresh = client.clone(); + let retained = connection.clone(); + let resume = tokio::spawn(async move { + fresh + .reconnect_tcp_connections( + ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), + meta: None, + client_id: "owner".into(), + last_seen_server_seq: 999, + subscriptions: vec![], + }, + &[retained], + ) + .await + }); + let req = request(&mut server).await; + unrelated_burst(&client, &mut server, 12).await; + { + // Hold replay application until later live frames reach the strict receiver. + let inner = connection.shared.inner.lock().await; + assert!(inner.resuming && !inner.online); + reply( + &mut server, + req, + json!({ + "type":"replay", "missing":[], "actions":[{ + "channel":connection.resource(), "serverSeq":11, + "action":{"type":"tcp/data","offset":0,"data":"YWI="} + }] + }), + ) + .await; + unrelated_burst(&client, &mut server, 44).await; + emit( + &mut server, + 76, + json!({"type":"tcp/data","offset":2,"data":"Y2Q="}), + None, + ) + .await; + emit( + &mut server, + 77, + json!({"type":"tcp/dataEof","finalOffset":4}), + None, + ) + .await; + let (ping, ()) = tokio::join!(client.ping(), async { + let req = request(&mut server).await; + assert_eq!(req.method, "ping"); + reply(&mut server, req, Value::Null).await; + }); + ping.unwrap(); + assert!(server.rx.try_recv().is_err()); + } + resume.await.unwrap().unwrap(); + let resend = dispatch(&mut server).await; + assert_eq!(resend.client_seq, original.client_seq); + assert_eq!(resend.action, original.action); + let (ping, ()) = tokio::join!(client.ping(), async { + let req = request(&mut server).await; + assert_eq!(req.method, "ping"); + reply(&mut server, req, Value::Null).await; + }); + ping.unwrap(); + assert!(server.rx.try_recv().is_err()); + for (index, expected) in [b"ab", b"cd"].iter().enumerate() { + assert_eq!(connection.read().await.unwrap(), Some(expected.to_vec())); + let credit = dispatch(&mut server).await; + assert!(credit.client_seq > original.client_seq); + assert_eq!( + credit.action, + StateAction::TcpDataConsumed(TcpDataConsumedAction { + consumed_bytes: (index as i64 + 1) * 2, + }) + ); + } + assert!(connection.read().await.unwrap().is_none()); + connection.dispose().await.unwrap(); + dispatch(&mut server).await; + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_snapshot_missing_and_decode_loss_are_terminal() { + tokio::time::timeout(Duration::from_secs(5), async { + for snapshot_fallback in [false, true] { + let (old, connection, _old_server) = opened(None).await; + old.shutdown_preserving_tcp().await; + let (wire, mut server) = pair(); + let client = Client::connect(wire, ClientConfig::default()) + .await + .unwrap(); + let fresh = client.clone(); + let retained = connection.clone(); + let resume = tokio::spawn(async move { + fresh + .reconnect_tcp_connections( + ReconnectParams { + channel: ahp_types::ROOT_RESOURCE_URI.into(), + meta: None, + client_id: "owner".into(), + last_seen_server_seq: 0, + subscriptions: vec![], + }, + &[retained], + ) + .await + }); + let req = request(&mut server).await; + reply( + &mut server, + req, + if snapshot_fallback { + json!({"type":"snapshot","snapshots":[snapshot()["snapshot"]]}) + } else { + json!({"type":"replay","actions":[],"missing":[connection.resource()]}) + }, + ) + .await; + resume.await.unwrap().unwrap(); + assert!(matches!( + connection.read().await, + Err(TcpError::ReplayUnavailable) + )); + notification(&mut server, "unsubscribe").await; + let (next, ()) = tokio::join!( + client.open_tcp_connection("ahp-session:/s1".into(), options()), + async { + let req = request(&mut server).await; + assert_eq!(req.method, "subscribe"); + reply(&mut server, req, snapshot()).await; + } + ); + next.unwrap().dispose().await.unwrap(); + dispatch(&mut server).await; + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + } + let (client, connection, mut server) = opened(None).await; + server + .send(TransportMessage::Text("{".into())) + .await + .unwrap(); + assert!(matches!(connection.read().await, Err(TcpError::Client(_)))); + assert!(matches!( + dispatch(&mut server).await.action, + StateAction::TcpClientReset(_) + )); + notification(&mut server, "unsubscribe").await; + client.shutdown().await; + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn owned_tcp_cancelled_creation_cleans_late_child() { + tokio::time::timeout(Duration::from_secs(5), async { + let (client, mut server) = initialized().await; + let creator = client.clone(); + let opening = tokio::spawn(async move { + creator + .open_tcp_connection("ahp-session:/s1".into(), options()) + .await + }); + let req = request(&mut server).await; + assert_eq!(req.method, "subscribe"); + opening.abort(); + assert!(matches!(opening.await, Err(error) if error.is_cancelled())); + reply(&mut server, req, snapshot()).await; + assert!(matches!( + dispatch(&mut server).await.action, + StateAction::TcpClientReset(_) + )); + assert_eq!( + notification(&mut server, "unsubscribe").await["channel"], + "ahp-tcp:/owned" + ); + client.shutdown().await; + }) + .await + .unwrap(); + } +} diff --git a/clients/rust/crates/ahp/tests/client_roundtrip.rs b/clients/rust/crates/ahp/tests/client_roundtrip.rs index 1e49931ec..6081e6feb 100644 --- a/clients/rust/crates/ahp/tests/client_roundtrip.rs +++ b/clients/rust/crates/ahp/tests/client_roundtrip.rs @@ -14,6 +14,197 @@ use ahp_types::messages::{ }; use tokio::sync::mpsc; +async fn send_event_test_message(server: &mut MemTransport, value: serde_json::Value) { + let message: JsonRpcMessage = serde_json::from_value(value).unwrap(); + server + .send(TransportMessage::encode(&message).unwrap()) + .await + .unwrap(); +} + +#[tokio::test] +async fn strict_events_capture_atomic_tcp_create_first_action() { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + let (client_side, mut server_side) = pair(); + let client = Client::connect(client_side, ClientConfig::default()).await.unwrap(); + let mut events = client.events_strict(); + let server = tokio::spawn(async move { + let JsonRpcMessage::Request(request) = server_side.recv().await.unwrap().unwrap().into_parsed().unwrap() else { + panic!("expected subscribe request"); + }; + assert_eq!(request.method, "subscribe"); + let params: ahp_types::commands::SubscribeParams = + serde_json::from_value(serde_json::to_value(request.params).unwrap()).unwrap(); + assert_eq!(params.channel, "ahp-session:/s1"); + assert_eq!(params.create.unwrap().r#type, "tcpConnection"); + send_event_test_message(&mut server_side, serde_json::json!({ + "jsonrpc": "2.0", "id": request.id, "result": { + "snapshot": { + "resource": "ahp-tcp:/created", "fromSeq": 0, + "state": { + "session": "ahp-session:/s1", "target": {"host": "localhost", "port": 3000}, + "encoding": "base64", "clientClosed": false, "hostClosed": false, + "input": {"windowBytes": 8, "maximumChunkSize": 6, "receivedBytes": 0, "consumedBytes": 0}, + "output": {"windowBytes": 8, "maximumChunkSize": 6, "receivedBytes": 0, "consumedBytes": 0} + } + } + } + })).await; + send_event_test_message(&mut server_side, serde_json::json!({ + "jsonrpc": "2.0", "method": "action", "params": { + "channel": "ahp-tcp:/created", "serverSeq": 1, "origin": null, + "action": {"type": "tcp/data", "offset": 0, "data": "AA=="} + } + })).await; + let JsonRpcMessage::Request(barrier) = server_side.recv().await.unwrap().unwrap().into_parsed().unwrap() else { + panic!("expected ping"); + }; + assert_eq!(barrier.method, "ping"); + send_event_test_message(&mut server_side, serde_json::json!({ + "jsonrpc": "2.0", "id": barrier.id, "result": null + })).await; + }); + let mut params = ahp_types::commands::SubscribeParams::new("ahp-session:/s1"); + params.create = Some(ahp_types::commands::TcpConnectionSubscription { + r#type: "tcpConnection".into(), host: "localhost".into(), port: 3000, + encoding: ahp_types::state::TcpDataEncoding::Base64, receive_window_bytes: 8, + maximum_chunk_size: 6, + }); + let result: ahp_types::commands::SubscribeResult = client.request("subscribe", params).await.unwrap(); + // The response to this barrier follows the first action on the wire. + client.ping().await.unwrap(); + let snapshot = result.snapshot.unwrap(); + let event = events.recv().await.unwrap().unwrap(); + assert_eq!(event.channel, snapshot.resource); + let SubscriptionEvent::Action(envelope) = event.event else { panic!("expected action") }; + let ahp_types::state::SnapshotState::Tcp(mut state) = snapshot.state else { panic!("expected TCP snapshot") }; + assert_eq!(envelope.server_seq, 1); + assert_eq!(ahp::apply_action_to_tcp(&mut state, &envelope.action), ahp::ReduceOutcome::Applied); + assert_eq!(state.output.received_bytes, 1); + client.shutdown().await; + server.await.unwrap(); + }).await.expect("strict create recipe timed out"); +} + +#[tokio::test] +async fn strict_events_overflow_is_terminal_and_other_receivers_continue() { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + let (client_side, mut server_side) = pair(); + let client = Client::connect( + client_side, + ClientConfig { + subscription_buffer: 1, + ..ClientConfig::default() + }, + ) + .await + .unwrap(); + let mut strict = client.events_strict(); + let mut ordinary = client.events(); + let server = + tokio::spawn(async move { + for sequences in [&[1, 2][..], &[3][..]] { + let JsonRpcMessage::Request(request) = server_side + .recv() + .await + .unwrap() + .unwrap() + .into_parsed() + .unwrap() + else { + panic!("expected ping") + }; + for seq in sequences { + send_event_test_message(&mut server_side, serde_json::json!({ + "jsonrpc": "2.0", "method": "action", "params": { + "channel": "ahp-tcp:/created", "serverSeq": seq, "origin": null, + "action": {"type": "tcp/data", "offset": seq - 1, "data": "AA=="} + } + })).await; + } + send_event_test_message( + &mut server_side, + serde_json::json!({ + "jsonrpc": "2.0", "id": request.id, "result": null + }), + ) + .await; + } + }); + client.ping().await.unwrap(); + assert!(matches!( + strict.recv().await, + Err(ahp::ClientError::SubscriptionLag( + ahp::SubscriptionLagError { skipped: 1 } + )) + )); + let SubscriptionEvent::Action(event) = ordinary.recv().await.unwrap().event else { + panic!("expected action") + }; + assert_eq!(event.server_seq, 2); + client.ping().await.unwrap(); + assert!(strict.recv().await.unwrap().is_none()); + let SubscriptionEvent::Action(event) = ordinary.recv().await.unwrap().event else { + panic!("expected action") + }; + assert_eq!(event.server_seq, 3); + client.shutdown().await; + assert!(client.events_strict().recv().await.unwrap().is_none()); + server.await.unwrap(); + }) + .await + .expect("strict overflow test timed out"); +} + +#[tokio::test] +async fn strict_events_decode_loss_is_terminal_but_future_actions_are_allowed() { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + for (wire, fails, ordinary_unknown) in [ + ("{", true, false), + (r#"{"jsonrpc":"2.0","method":"action","params":{"channel":42,"serverSeq":1,"action":{"type":"tcp/dataEof","finalOffset":0}}}"#, true, false), + (r#"{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"action":{"type":"tcp/dataEof","finalOffset":"bad"}}}"#, true, true), + (r#"{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"action":{"type":"tcp/inputConsumed","consumedBytes":"bad"}}}"#, true, true), + (r#"{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/created","serverSeq":1,"action":{"type":"tcp/futureControl"}}}"#, false, true), + ] { + let (client_side, mut server_side) = pair(); + let client = Client::connect(client_side, ClientConfig::default()).await.unwrap(); + let mut strict = client.events_strict(); + let mut ordinary = client.events(); + let server = tokio::spawn(async move { + let JsonRpcMessage::Request(request) = server_side.recv().await.unwrap().unwrap().into_parsed().unwrap() else { panic!("expected ping") }; + server_side.send(TransportMessage::Text(wire.into())).await.unwrap(); + send_event_test_message(&mut server_side, serde_json::json!({ + "jsonrpc": "2.0", "method": "action", "params": { + "channel": "ahp-tcp:/created", "serverSeq": 2, + "action": {"type": "tcp/data", "offset": 0, "data": "AA=="} + } + })).await; + send_event_test_message(&mut server_side, serde_json::json!({ + "jsonrpc": "2.0", "id": request.id, "result": null + })).await; + }); + client.ping().await.unwrap(); + if fails { + assert!(matches!(strict.recv().await, Err(ahp::ClientError::Transport(TransportError::Protocol(_)))), "{wire}"); + assert!(strict.recv().await.unwrap().is_none(), "{wire}"); + } else { + let SubscriptionEvent::Action(event) = strict.recv().await.unwrap().unwrap().event else { panic!("expected action") }; + assert!(matches!(event.action, StateAction::Unknown(_))); + let SubscriptionEvent::Action(event) = strict.recv().await.unwrap().unwrap().event else { panic!("expected action") }; + assert_eq!(event.server_seq, 2); + } + if ordinary_unknown { + let SubscriptionEvent::Action(event) = ordinary.recv().await.unwrap().event else { panic!("expected action") }; + assert!(matches!(event.action, StateAction::Unknown(_))); + } + let SubscriptionEvent::Action(event) = ordinary.recv().await.unwrap().event else { panic!("expected action") }; + assert_eq!(event.server_seq, 2, "ordinary stream changed behavior: {wire}"); + client.shutdown().await; + server.await.unwrap(); + } + }).await.expect("strict decode test timed out"); +} + /// A bidirectional in-memory transport pair. Each half owns one sender /// and one receiver; sends on one side are received on the other. struct MemTransport { diff --git a/clients/swift/AgentHostProtocol/README.md b/clients/swift/AgentHostProtocol/README.md index bab640157..763816900 100644 --- a/clients/swift/AgentHostProtocol/README.md +++ b/clients/swift/AgentHostProtocol/README.md @@ -78,6 +78,60 @@ for snapshot in initialized.snapshots { `AHPStateMirror` is a convenience for simple consumers. Larger apps can keep their own state store and route snapshots/actions through the generated reducers directly. +## Owned TCP Streams + +After `initialize` negotiates `tcpConnections`, open an integrated stream: + +```swift +let tcp = try await client.openTcpConnection( + session: sessionURI, + create: TcpConnectionSubscription( + type: "tcpConnection", host: "localhost", port: 3000, encoding: .base64, + receiveWindowBytes: 65536, maximumChunkSize: 16384 + ) +) +try await tcp.write(requestBytes) +try await tcp.end() // input EOF; output remains readable +while let bytes = try await tcp.read() { + try await consume(bytes) +} +try await tcp.dispose() +``` + +The SDK owns buffering, flow control, and replay. One reader and one writer may +run concurrently. `read()` releases receive credit; `drain()` waits for destination +consumption. `close()` stops writes but retains crossing output, so keep reading +during graceful close. `dispose()` aborts without draining; call it on +error/cancellation too. + +Transport loss suspends the same handles. Connect a fresh `AHPClient` and call +`reconnectTcpConnections(params:connections:)` with the original `clientId`; +apply its returned replay to ordinary subscriptions. For deliberate transport +replacement, use `shutdown(preservingTcpConnections: true)` instead of normal +shutdown, which terminates streams. + +With `MultiHostClient`, use `HostClientHandle.openTcpConnection(session:create:)` +for automatic reconnect. Host removal/shutdown terminates retained streams. +Missing resources and snapshot fallback fail streams rather than creating new +sockets. TCP streams do not belong in ordinary state mirrors. + +For lower-level accounting, `try tcpReducer(state:action:)` returns a new state; +`try AHPTcpReducer().reduce(into:action:)` updates in place after validation. +Invalid actions throw `TcpReducerError` without changing state. +Transport policy, connection limits, and native socket bridges remain +application-owned. See the [TCP channel contract](../../../docs/specification/tcp-channel.md). + +## Lower-Level Strict Events + +For custom loss-sensitive consumers, attach `await client.strictEvents()` before +sending requests and retain it for `for try await` consumption. Overflow throws +`SubscriptionLagError`; decode loss throws `TransportError.protocol`. Both +terminate the receiver rather than skipping events. Capacity uses +`AHPClientConfig.subscriptionBufferSize`; ordinary receivers are unchanged. +These raw receivers are global. The owned TCP adapter instead registers a strict +child-scoped receiver during creation reply processing and reattaches it per +child on reconnect. Unrelated traffic cannot exhaust a TCP stream's event buffer. + ## Multi-Host Client Use `MultiHostClient` when one app talks to more than one AHP host, or when you want the same supervisor model for a single host. It owns per-host transport creation, reconnect backoff, stable `clientId` lookup, event fan-in, session-summary caches, generation-checked client handles, and deterministic aggregated views. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 991813b78..e0e661fd8 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -109,6 +109,16 @@ public enum ActionType: Codable, Sendable, Equatable { case automationRunSessionRemoved case automationRunPrimarySessionChanged case automationRunCancelRequested + case tcpInput + case tcpData + case tcpInputConsumed + case tcpDataConsumed + case tcpInputEof + case tcpDataEof + case tcpClientClose + case tcpHostClose + case tcpClientReset + case tcpHostReset /// Unknown raw value from a newer protocol version, preserved verbatim. case unknown(String) @@ -219,6 +229,16 @@ public enum ActionType: Codable, Sendable, Equatable { case "automationRun/sessionRemoved": self = .automationRunSessionRemoved case "automationRun/primarySessionChanged": self = .automationRunPrimarySessionChanged case "automationRun/cancelRequested": self = .automationRunCancelRequested + case "tcp/input": self = .tcpInput + case "tcp/data": self = .tcpData + case "tcp/inputConsumed": self = .tcpInputConsumed + case "tcp/dataConsumed": self = .tcpDataConsumed + case "tcp/inputEof": self = .tcpInputEof + case "tcp/dataEof": self = .tcpDataEof + case "tcp/clientClose": self = .tcpClientClose + case "tcp/hostClose": self = .tcpHostClose + case "tcp/clientReset": self = .tcpClientReset + case "tcp/hostReset": self = .tcpHostReset default: self = .unknown(raw) } } @@ -329,6 +349,16 @@ public enum ActionType: Codable, Sendable, Equatable { case .automationRunSessionRemoved: try container.encode("automationRun/sessionRemoved") case .automationRunPrimarySessionChanged: try container.encode("automationRun/primarySessionChanged") case .automationRunCancelRequested: try container.encode("automationRun/cancelRequested") + case .tcpInput: try container.encode("tcp/input") + case .tcpData: try container.encode("tcp/data") + case .tcpInputConsumed: try container.encode("tcp/inputConsumed") + case .tcpDataConsumed: try container.encode("tcp/dataConsumed") + case .tcpInputEof: try container.encode("tcp/inputEof") + case .tcpDataEof: try container.encode("tcp/dataEof") + case .tcpClientClose: try container.encode("tcp/clientClose") + case .tcpHostClose: try container.encode("tcp/hostClose") + case .tcpClientReset: try container.encode("tcp/clientReset") + case .tcpHostReset: try container.encode("tcp/hostReset") case .unknown(let raw): try container.encode(raw) } } @@ -2333,6 +2363,140 @@ public struct ResourceWatchChangedAction: Codable, Sendable { } } +public struct TcpInputAction: Codable, Sendable { + public var type: ActionType + /// Absolute decoded-byte offset. + public var offset: Int + /// Nonempty canonical padded RFC 4648 base64; no whitespace. + public var data: String + + public init( + type: ActionType, + offset: Int, + data: String + ) { + self.type = type + self.offset = offset + self.data = data + } +} + +public struct TcpDataAction: Codable, Sendable { + public var type: ActionType + /// Absolute decoded-byte offset. + public var offset: Int + /// Nonempty canonical padded RFC 4648 base64; no whitespace. + public var data: String + + public init( + type: ActionType, + offset: Int, + data: String + ) { + self.type = type + self.offset = offset + self.data = data + } +} + +public struct TcpInputConsumedAction: Codable, Sendable { + public var type: ActionType + public var consumedBytes: Int + + public init( + type: ActionType, + consumedBytes: Int + ) { + self.type = type + self.consumedBytes = consumedBytes + } +} + +public struct TcpDataConsumedAction: Codable, Sendable { + public var type: ActionType + public var consumedBytes: Int + + public init( + type: ActionType, + consumedBytes: Int + ) { + self.type = type + self.consumedBytes = consumedBytes + } +} + +public struct TcpInputEofAction: Codable, Sendable { + public var type: ActionType + public var finalOffset: Int + + public init( + type: ActionType, + finalOffset: Int + ) { + self.type = type + self.finalOffset = finalOffset + } +} + +public struct TcpDataEofAction: Codable, Sendable { + public var type: ActionType + public var finalOffset: Int + + public init( + type: ActionType, + finalOffset: Int + ) { + self.type = type + self.finalOffset = finalOffset + } +} + +public struct TcpClientCloseAction: Codable, Sendable { + public var type: ActionType + + public init( + type: ActionType + ) { + self.type = type + } +} + +public struct TcpHostCloseAction: Codable, Sendable { + public var type: ActionType + + public init( + type: ActionType + ) { + self.type = type + } +} + +public struct TcpClientResetAction: Codable, Sendable { + public var type: ActionType + public var reason: TcpResetReason + + public init( + type: ActionType, + reason: TcpResetReason + ) { + self.type = type + self.reason = reason + } +} + +public struct TcpHostResetAction: Codable, Sendable { + public var type: ActionType + public var reason: TcpResetReason + + public init( + type: ActionType, + reason: TcpResetReason + ) { + self.type = type + self.reason = reason + } +} + public struct AutomationCreateRequestedAction: Codable, Sendable { public var type: ActionType /// Client-chosen `ahp-automation:` URI that becomes {@link AutomationEntry.resource}. @@ -2620,6 +2784,16 @@ public enum StateAction: Codable, Sendable { case terminalCommandExecuted(TerminalCommandExecutedAction) case terminalCommandFinished(TerminalCommandFinishedAction) case resourceWatchChanged(ResourceWatchChangedAction) + case tcpInput(TcpInputAction) + case tcpData(TcpDataAction) + case tcpInputConsumed(TcpInputConsumedAction) + case tcpDataConsumed(TcpDataConsumedAction) + case tcpInputEof(TcpInputEofAction) + case tcpDataEof(TcpDataEofAction) + case tcpClientClose(TcpClientCloseAction) + case tcpHostClose(TcpHostCloseAction) + case tcpClientReset(TcpClientResetAction) + case tcpHostReset(TcpHostResetAction) case automationCreateRequested(AutomationCreateRequestedAction) case automationUpdateRequested(AutomationUpdateRequestedAction) case automationSet(AutomationSetAction) @@ -2829,6 +3003,26 @@ public enum StateAction: Codable, Sendable { self = .terminalCommandFinished(try TerminalCommandFinishedAction(from: decoder)) case "resourceWatch/changed": self = .resourceWatchChanged(try ResourceWatchChangedAction(from: decoder)) + case "tcp/input": + self = .tcpInput(try TcpInputAction(from: decoder)) + case "tcp/data": + self = .tcpData(try TcpDataAction(from: decoder)) + case "tcp/inputConsumed": + self = .tcpInputConsumed(try TcpInputConsumedAction(from: decoder)) + case "tcp/dataConsumed": + self = .tcpDataConsumed(try TcpDataConsumedAction(from: decoder)) + case "tcp/inputEof": + self = .tcpInputEof(try TcpInputEofAction(from: decoder)) + case "tcp/dataEof": + self = .tcpDataEof(try TcpDataEofAction(from: decoder)) + case "tcp/clientClose": + self = .tcpClientClose(try TcpClientCloseAction(from: decoder)) + case "tcp/hostClose": + self = .tcpHostClose(try TcpHostCloseAction(from: decoder)) + case "tcp/clientReset": + self = .tcpClientReset(try TcpClientResetAction(from: decoder)) + case "tcp/hostReset": + self = .tcpHostReset(try TcpHostResetAction(from: decoder)) case "automation/createRequested": self = .automationCreateRequested(try AutomationCreateRequestedAction(from: decoder)) case "automation/updateRequested": @@ -2948,6 +3142,16 @@ public enum StateAction: Codable, Sendable { case .terminalCommandExecuted(let v): try v.encode(to: encoder) case .terminalCommandFinished(let v): try v.encode(to: encoder) case .resourceWatchChanged(let v): try v.encode(to: encoder) + case .tcpInput(let v): try v.encode(to: encoder) + case .tcpData(let v): try v.encode(to: encoder) + case .tcpInputConsumed(let v): try v.encode(to: encoder) + case .tcpDataConsumed(let v): try v.encode(to: encoder) + case .tcpInputEof(let v): try v.encode(to: encoder) + case .tcpDataEof(let v): try v.encode(to: encoder) + case .tcpClientClose(let v): try v.encode(to: encoder) + case .tcpHostClose(let v): try v.encode(to: encoder) + case .tcpClientReset(let v): try v.encode(to: encoder) + case .tcpHostReset(let v): try v.encode(to: encoder) case .automationCreateRequested(let v): try v.encode(to: encoder) case .automationUpdateRequested(let v): try v.encode(to: encoder) case .automationSet(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index f50b96186..10c1b6046 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -370,6 +370,8 @@ public struct InitializeResult: Codable, Sendable { /// `ahp-automations://` for {@link AutomationState}; absence means the /// host does not expose an automation catalogue or automation commands. public var automations: AutomationCapabilities? + /// Enables atomic creation of session-scoped, replay-only TCP channels. + public var tcpConnections: TcpConnectionsCapability? enum CodingKeys: String, CodingKey { case protocolVersion @@ -382,6 +384,7 @@ public struct InitializeResult: Codable, Sendable { case terminalCommandPrefix case telemetry case automations + case tcpConnections } public init( @@ -394,7 +397,8 @@ public struct InitializeResult: Codable, Sendable { completionTriggerCharacters: [String]? = nil, terminalCommandPrefix: String? = nil, telemetry: TelemetryCapabilities? = nil, - automations: AutomationCapabilities? = nil + automations: AutomationCapabilities? = nil, + tcpConnections: TcpConnectionsCapability? = nil ) { self.protocolVersion = protocolVersion self.serverSeq = serverSeq @@ -406,6 +410,7 @@ public struct InitializeResult: Codable, Sendable { self.terminalCommandPrefix = terminalCommandPrefix self.telemetry = telemetry self.automations = automations + self.tcpConnections = tcpConnections } } @@ -581,13 +586,19 @@ public struct ReconnectSnapshotResult: Codable, Sendable { public var type: ReconnectResultType /// Fresh snapshots for each subscription public var snapshots: [Snapshot] + /// Subscriptions that cannot be restored. Hosts supporting TCP MUST list all + /// requested TCP channels here and dispose their sockets on snapshot fallback. + /// Omitted by older hosts; absence does not authorize snapshot-restoring TCP. + public var missing: [String]? public init( type: ReconnectResultType, - snapshots: [Snapshot] + snapshots: [Snapshot], + missing: [String]? = nil ) { self.type = type self.snapshots = snapshots + self.missing = missing } } @@ -608,24 +619,31 @@ public struct SubscribeParams: Codable, Sendable { /// Servers that do not understand a requested view ignore it and return their /// default snapshot. Clients MUST tolerate receiving more state than requested. public var view: SubscribeView? + /// Atomically create a private child channel and subscribe to it. + /// Requires the advertised tcpConnections capability. channel identifies + /// the parent session; snapshot.resource identifies the created TCP channel. + public var create: TcpConnectionSubscription? enum CodingKeys: String, CodingKey { case channel case meta = "_meta" case delivery case view + case create } public init( channel: String, meta: [String: AnyCodable]? = nil, delivery: SubscriptionDeliveryOptions? = nil, - view: SubscribeView? = nil + view: SubscribeView? = nil, + create: TcpConnectionSubscription? = nil ) { self.channel = channel self.meta = meta self.delivery = delivery self.view = view + self.create = create } } @@ -672,6 +690,36 @@ public struct SubscribeResult: Codable, Sendable { } } +public struct TcpConnectionSubscription: Codable, Sendable { + public var type: String + /// DNS name or IP literal, not a URL. + public var host: String + /// Destination port. + public var port: Int + /// Selected from InitializeResult.tcpConnections.encodings. + public var encoding: TcpDataEncoding + /// Client receive window in decoded bytes. + public var receiveWindowBytes: Int + /// Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes. + public var maximumChunkSize: Int + + public init( + type: String, + host: String, + port: Int, + encoding: TcpDataEncoding, + receiveWindowBytes: Int, + maximumChunkSize: Int + ) { + self.type = type + self.host = host + self.port = port + self.encoding = encoding + self.receiveWindowBytes = receiveWindowBytes + self.maximumChunkSize = maximumChunkSize + } +} + public struct CreateSessionParams: Codable, Sendable { /// Channel URI this command targets. public var channel: String diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift index bd3d1ddd2..51e5dd4d8 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Errors.generated.swift @@ -39,6 +39,7 @@ public enum AhpErrorCodes { public static let permissionDenied = -32009 /// The target resource already exists and the operation does not allow overwriting public static let alreadyExists = -32010 + public static let tcpConnectionOpenFailed = -32012 } // MARK: - Error Detail Payloads diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index fd43ba1af..a4e63aae3 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -40,6 +40,111 @@ public enum StringOrMarkdown: Codable, Sendable, Equatable { // MARK: - Enums +/// Payload encodings advertised by the host. +public enum TcpDataEncoding: Codable, Sendable, Equatable { + case base64 + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "base64": self = .base64 + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .base64: try container.encode("base64") + case .unknown(let raw): try container.encode(raw) + } + } +} + +/// Endpoint that closes or resets a connection. +public enum TcpEndpoint: String, Codable, Sendable { + case client = "client" + case host = "host" +} + +/// Why a connection was aborted. +public enum TcpResetReason: Codable, Sendable, Equatable { + case connectionReset + case connectionAborted + case protocolError + case replayUnavailable + case policyRevoked + case sessionDisposed + case internalError + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "connectionReset": self = .connectionReset + case "connectionAborted": self = .connectionAborted + case "protocolError": self = .protocolError + case "replayUnavailable": self = .replayUnavailable + case "policyRevoked": self = .policyRevoked + case "sessionDisposed": self = .sessionDisposed + case "internalError": self = .internalError + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .connectionReset: try container.encode("connectionReset") + case .connectionAborted: try container.encode("connectionAborted") + case .protocolError: try container.encode("protocolError") + case .replayUnavailable: try container.encode("replayUnavailable") + case .policyRevoked: try container.encode("policyRevoked") + case .sessionDisposed: try container.encode("sessionDisposed") + case .internalError: try container.encode("internalError") + case .unknown(let raw): try container.encode(raw) + } + } +} + +/// Expected connection establishment failures. +public enum TcpConnectionOpenFailureReason: Codable, Sendable, Equatable { + case connectionFailed + case nameResolutionFailed + case resourceShortage + case sessionNotReady + /// Unknown raw value from a newer protocol version, preserved verbatim. + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + switch raw { + case "connectionFailed": self = .connectionFailed + case "nameResolutionFailed": self = .nameResolutionFailed + case "resourceShortage": self = .resourceShortage + case "sessionNotReady": self = .sessionNotReady + default: self = .unknown(raw) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .connectionFailed: try container.encode("connectionFailed") + case .nameResolutionFailed: try container.encode("nameResolutionFailed") + case .resourceShortage: try container.encode("resourceShortage") + case .sessionNotReady: try container.encode("sessionNotReady") + case .unknown(let raw): try container.encode(raw) + } + } +} + /// Policy configuration state for a model. public enum PolicyState: String, Codable, Sendable { case enabled = "enabled" @@ -6362,6 +6467,122 @@ public struct ResourceChange: Codable, Sendable { } } +public struct TcpConnectionState: Codable, Sendable { + public var session: String + public var target: TcpTarget + public var encoding: TcpDataEncoding + /// Client to destination socket. + public var input: FlowControlledByteDirectionState + /// Destination socket to client. + public var output: FlowControlledByteDirectionState + public var clientClosed: Bool + public var hostClosed: Bool + public var reset: TcpResetState? + + public init( + session: String, + target: TcpTarget, + encoding: TcpDataEncoding, + input: FlowControlledByteDirectionState, + output: FlowControlledByteDirectionState, + clientClosed: Bool, + hostClosed: Bool, + reset: TcpResetState? = nil + ) { + self.session = session + self.target = target + self.encoding = encoding + self.input = input + self.output = output + self.clientClosed = clientClosed + self.hostClosed = hostClosed + self.reset = reset + } +} + +public struct TcpTarget: Codable, Sendable { + /// DNS name or IP literal, resolved and connected in the host endpoint's network. + public var host: String + /// Destination port. + public var port: Int + + public init( + host: String, + port: Int + ) { + self.host = host + self.port = port + } +} + +public struct TcpResetState: Codable, Sendable { + public var source: TcpEndpoint + public var reason: TcpResetReason + + public init( + source: TcpEndpoint, + reason: TcpResetReason + ) { + self.source = source + self.reason = reason + } +} + +public struct FlowControlledByteDirectionState: Codable, Sendable { + /// Maximum accepted-but-not-consumed decoded bytes. + public var windowBytes: Int + /// Maximum decoded bytes per chunk; MUST NOT exceed windowBytes. + public var maximumChunkSize: Int + /// Cumulative accepted bytes. + public var receivedBytes: Int + /// Cumulative bytes released by the bounded consumer. + public var consumedBytes: Int + /// Present after EOF; equals receivedBytes permanently. + public var eofAtBytes: Int? + + public init( + windowBytes: Int, + maximumChunkSize: Int, + receivedBytes: Int, + consumedBytes: Int, + eofAtBytes: Int? = nil + ) { + self.windowBytes = windowBytes + self.maximumChunkSize = maximumChunkSize + self.receivedBytes = receivedBytes + self.consumedBytes = consumedBytes + self.eofAtBytes = eofAtBytes + } +} + +public struct TcpConnectionsCapability: Codable, Sendable { + /// Supported encodings. The base64 profile MUST be supported. + public var encodings: [TcpDataEncoding] + /// Informational limit; runtime policy may impose a lower limit. + public var maximumConnectionsPerClient: Int? + + public init( + encodings: [TcpDataEncoding], + maximumConnectionsPerClient: Int? = nil + ) { + self.encodings = encodings + self.maximumConnectionsPerClient = maximumConnectionsPerClient + } +} + +public struct TcpConnectionOpenErrorData: Codable, Sendable { + public var reason: TcpConnectionOpenFailureReason + public var retryable: Bool? + + public init( + reason: TcpConnectionOpenFailureReason, + retryable: Bool? = nil + ) { + self.reason = reason + self.retryable = retryable + } +} + public struct AutomationSessionOrigin: Codable, Sendable { public var kind: SessionOriginKind /// Owning {@link AutomationEntry.resource}. @@ -8213,6 +8434,7 @@ public enum ToolResultContent: Codable, Sendable { /// The state payload of a snapshot. public enum SnapshotState: Codable, Sendable { + case tcp(TcpConnectionState) case root(RootState) case session(SessionState) case chat(ChatState) @@ -8224,11 +8446,14 @@ public enum SnapshotState: Codable, Sendable { case automationRun(AutomationRunState) public init(from decoder: Decoder) throws { - // Try the most distinctive shapes first. SessionState has required + // Try the most distinctive shapes first. TcpConnectionState has required + // `input`, `output`, and `target`; SessionState has required // `lifecycle` / `activeClients` / `chats`; ChatState has required // `turns`; the remaining variants follow, with RootState as the // catch-all. - if let session = try? SessionState(from: decoder) { + if let tcp = try? TcpConnectionState(from: decoder) { + self = .tcp(tcp) + } else if let session = try? SessionState(from: decoder) { self = .session(session) } else if let chat = try? ChatState(from: decoder) { self = .chat(chat) @@ -8251,6 +8476,7 @@ public enum SnapshotState: Codable, Sendable { public func encode(to encoder: Encoder) throws { switch self { + case .tcp(let state): try state.encode(to: encoder) case .root(let state): try state.encode(to: encoder) case .session(let state): try state.encode(to: encoder) case .chat(let state): try state.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift index eac2ea15f..cf3a4e587 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift @@ -35,6 +35,16 @@ public protocol Reducer { // MARK: - AnyReducer (Type Erasure) +/// Throwing TCP reducer kept separate from the nonthrowing `Reducer` protocol. +public struct AHPTcpReducer { + public init() {} + + /// Validates before replacing state. An error leaves the caller's state unchanged. + public func reduce(into state: inout TcpConnectionState, action: StateAction) throws { + state = try tcpReducer(state: state, action: action) + } +} + /// A type-erased reducer that wraps any `Reducer` conforming type. /// /// Useful for storing reducers in collections or passing them as parameters diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 6f4ef620c..d167ccb02 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -3,6 +3,121 @@ import Foundation +/// Validation failure from the pure TCP reducer. The original state is unchanged. +public enum TcpReducerError: Error, LocalizedError, CustomStringConvertible { + case invalidAction(String) + + public var description: String { + switch self { + case .invalidAction(let message): return "Invalid TCP action: \(message)" + } + } + + public var errorDescription: String? { description } +} + +private func requireTcp(_ condition: Bool, _ message: String) throws { + if !condition { throw TcpReducerError.invalidAction(message) } +} + +private func requireTcpOffset(_ value: Int) throws { + try requireTcp(value >= 0 && value <= 9007199254740991, "offset must be a nonnegative safe integer") +} + +private func tcpBase64Value(_ byte: UInt8) -> Int { + switch byte { + case 65...90: return Int(byte) - 65 + case 97...122: return Int(byte) - 97 + 26 + case 48...57: return Int(byte) - 48 + 52 + case 43: return 62 + case 47: return 63 + default: return -1 + } +} + +private func tcpPayloadLength(_ data: String, maximumChunkSize: Int) throws -> Int { + let count = data.utf16.count + try requireTcp(count > 0 && count <= 4 * (maximumChunkSize / 3 + (maximumChunkSize % 3 > 0 ? 1 : 0)), "chunk size") + let padding = data.hasSuffix("==") ? 2 : data.hasSuffix("=") ? 1 : 0 + try requireTcp(count % 4 == 0, "base64 encoding") + var last = 0 + for byte in data.utf8.dropLast(padding) { + last = tcpBase64Value(byte) + try requireTcp(last >= 0, "base64 encoding") + } + if padding > 0 { + try requireTcp(last % (padding == 2 ? 16 : 4) == 0, "noncanonical base64 padding bits") + } + let length = count / 4 * 3 - padding + try requireTcp(length <= maximumChunkSize, "chunk size") + return length +} + +private func tcpReceive(_ direction: FlowControlledByteDirectionState, offset: Int, data: String, senderClosed: Bool) throws -> FlowControlledByteDirectionState { + try requireTcpOffset(offset) + let end = offset + (try tcpPayloadLength(data, maximumChunkSize: direction.maximumChunkSize)) + try requireTcpOffset(end) + if end <= direction.receivedBytes { return direction } + try requireTcp(offset == direction.receivedBytes, "gap or overlapping byte range") + try requireTcp(!senderClosed && direction.eofAtBytes == nil, "data after EOF or sender close") + try requireTcp(end - direction.consumedBytes <= direction.windowBytes, "receive window exceeded") + var next = direction + next.receivedBytes = end + return next +} + +private func tcpConsume(_ direction: FlowControlledByteDirectionState, consumedBytes: Int) throws -> FlowControlledByteDirectionState { + try requireTcpOffset(consumedBytes) + try requireTcp(consumedBytes <= direction.receivedBytes, "consuming bytes not received") + if consumedBytes <= direction.consumedBytes { return direction } + var next = direction + next.consumedBytes = consumedBytes + return next +} + +private func tcpEof(_ direction: FlowControlledByteDirectionState, finalOffset: Int, senderClosed: Bool) throws -> FlowControlledByteDirectionState { + try requireTcpOffset(finalOffset) + try requireTcp(finalOffset == direction.receivedBytes, "EOF offset") + if direction.eofAtBytes == finalOffset { return direction } + try requireTcp(!senderClosed, "EOF after sender close") + var next = direction + next.eofAtBytes = finalOffset + return next +} + +/// Reduces TCP accounting without retaining payloads or restoring streams. +/// On error, adapters must reset/close the channel and not perform the rejected write. +/// Only write data when the corresponding receivedBytes counter advances. +public func tcpReducer(state: TcpConnectionState, action: StateAction) throws -> TcpConnectionState { + if state.reset != nil { return state } + var next = state + switch action { + case .tcpInput(let a): + next.input = try tcpReceive(state.input, offset: a.offset, data: a.data, senderClosed: state.clientClosed) + case .tcpData(let a): + next.output = try tcpReceive(state.output, offset: a.offset, data: a.data, senderClosed: state.hostClosed) + case .tcpInputConsumed(let a): + next.input = try tcpConsume(state.input, consumedBytes: a.consumedBytes) + case .tcpDataConsumed(let a): + next.output = try tcpConsume(state.output, consumedBytes: a.consumedBytes) + case .tcpInputEof(let a): + next.input = try tcpEof(state.input, finalOffset: a.finalOffset, senderClosed: state.clientClosed) + case .tcpDataEof(let a): + next.output = try tcpEof(state.output, finalOffset: a.finalOffset, senderClosed: state.hostClosed) + case .tcpClientClose: + next.clientClosed = true + case .tcpHostClose: + next.hostClosed = true + case .tcpClientReset(let a): + next.reset = TcpResetState(source: .client, reason: a.reason) + case .tcpHostReset(let a): + next.reset = TcpResetState(source: .host, reason: a.reason) + default: + break + } + return next +} + private let iso8601TimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift index 7e7b5e385..bbd09d032 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClient.swift @@ -48,13 +48,18 @@ public actor AHPClient { // ── Sequence numbers ───────────────────────────────────────────────── private var nextRequestId: Int = 1 - private var nextClientSeq: Int = 1 + internal nonisolated let tcpSequences = TcpClientSequences() + internal var tcpIdentity: String? + internal var tcpCapability: TcpConnectionsCapability? + internal var ownedTcpConnections: [ObjectIdentifier: TcpConnection] = [:] + internal var tcpDisposed = false // ── Pending request continuations ──────────────────────────────────── /// In-flight JSON-RPC requests keyed by id. Resolves with the raw `result` /// AnyCodable on success or fails with `AHPClientError` on error/timeout/ /// shutdown. private var pending: [Int: PendingEntry] = [:] + private var abandonedTcpCreations: Set = [] // ── Subscription registry ──────────────────────────────────────────── /// Per-URI listeners. Each entry holds one or more `AsyncStream` continuations @@ -65,6 +70,8 @@ public actor AHPClient { // ── Top-level multicast taps ───────────────────────────────────────── /// Multicast listeners for `events` (top-level fan-out tagged with resource). private var eventListeners: [EventListener] = [] + private var strictEventListeners: [UInt64: AsyncThrowingStream.Continuation] = [:] + private var resourceEventListeners: [String: [UInt64: AsyncThrowingStream.Continuation]] = [:] /// Multicast listeners for `stateChanges`. private var stateListeners: [StateListener] = [] @@ -156,6 +163,19 @@ public actor AHPClient { /// requests with `AHPClientError.shutdown`, and finish all subscription /// streams. public func shutdown() async { + await shutdown(preservingTcpConnections: false) + } + + /// Explicitly retains TCP handles only when preparing a replacement transport. + public func shutdown(preservingTcpConnections: Bool) async { + if !preservingTcpConnections { + tcpDisposed = true + let connections = Array(ownedTcpConnections.values) + for connection in connections { + do { try await connection.dispose() } + catch { print("[AHPClient] failed to release TCP connection during shutdown: \(error)") } + } + } if didShutdown { return } didShutdown = true @@ -232,6 +252,50 @@ public actor AHPClient { } } + /// A bounded global receiver for loss-sensitive actions. Attach before the + /// request that starts delivery, then retain and consume this same stream. + /// Overflow preserves the buffered prefix, throws `SubscriptionLagError`, + /// and permanently terminates this receiver. Other receivers are unaffected. + /// Discarded malformed inbound frames or notification payloads terminate + /// strict receivers with `TransportError.protocol` instead. + /// The caller owns channel reset/unsubscribe and reconnect handling. + public func strictEvents() -> AsyncThrowingStream { + tcpEventReceiver().stream + } + + internal func tcpEventReceiver(resource: String? = nil) -> TcpEventReceiver { + let listenerId = nextListenerId() + let stream = AsyncThrowingStream(bufferingPolicy: .bufferingOldest(config.subscriptionBufferSize)) { cont in + if let resource { + self.resourceEventListeners[resource, default: [:]][listenerId] = cont + } else { + self.strictEventListeners[listenerId] = cont + } + cont.onTermination = { [weak self] _ in + Task { [weak self] in + await self?.removeStrictEventListener(id: listenerId) + } + } + } + return TcpEventReceiver(id: listenerId, stream: stream) + } + + internal func cancelTcpReceiver(_ id: UInt64) { + strictEventListeners.removeValue(forKey: id)?.finish() + for resource in Array(resourceEventListeners.keys) { + resourceEventListeners[resource]?.removeValue(forKey: id)?.finish() + if resourceEventListeners[resource]?.isEmpty == true { + resourceEventListeners.removeValue(forKey: resource) + } + } + } + + private func bindTcpReceiver(_ id: UInt64, resource: String) { + guard let continuation = resourceEventListeners[""]?.removeValue(forKey: id) else { return } + if resourceEventListeners[""]?.isEmpty == true { resourceEventListeners.removeValue(forKey: "") } + resourceEventListeners[resource, default: [:]][id] = continuation + } + /// A multicast stream of `ConnectionState` transitions. /// /// Each call returns a *fresh* stream. The current value is available @@ -267,6 +331,8 @@ public actor AHPClient { initialSubscriptions: initialSubscriptions.isEmpty ? nil : initialSubscriptions ) let result: InitializeResult = try await request(method: "initialize", params: params) + tcpIdentity = clientId + tcpCapability = result.tcpConnections if result.serverSeq > lastSeenServerSeq { lastSeenServerSeq = result.serverSeq } @@ -404,8 +470,7 @@ public actor AHPClient { /// a handle carrying the assigned `clientSeq`. @discardableResult public func dispatch(_ action: StateAction, channel: String) async throws -> DispatchHandle { - let seq = nextClientSeq - nextClientSeq += 1 + let seq = try tcpSequences.reserve() return try await dispatch(action, channel: channel, clientSeq: seq) } @@ -418,9 +483,7 @@ public actor AHPClient { /// remains suitable for simple fire-and-forget clients. @discardableResult public func dispatch(_ action: StateAction, channel: String, clientSeq: Int) async throws -> DispatchHandle { - if clientSeq >= nextClientSeq { - nextClientSeq = clientSeq + 1 - } + tcpSequences.advance(past: clientSeq) try await notify( method: "dispatchAction", params: DispatchActionParams(channel: channel, clientSeq: clientSeq, action: action) @@ -579,6 +642,28 @@ public actor AHPClient { public func request( method: String, params: P + ) async throws -> R { + try await requestImpl(method: method, params: params) + } + + internal func requestTcpCreation(_ params: SubscribeParams, receiverId: UInt64) async throws -> SubscribeResult { + try await requestImpl(method: "subscribe", params: params, ownsTcpCreation: true, receiverId: receiverId) + } + + private func releaseAbandonedTcpCreation(_ result: Data) async { + do { + guard let object = try JSONSerialization.jsonObject(with: result) as? [String: Any], + let snapshot = object["snapshot"] as? [String: Any], + let resource = snapshot["resource"] as? String, + resource.hasPrefix("ahp-tcp:"), connectionState != .disconnected else { return } + try await Task { try await self.unsubscribe(resource) }.value + } catch { + await handleTransportFailure(error) + } + } + + private func requestImpl( + method: String, params: P, ownsTcpCreation: Bool = false, receiverId: UInt64? = nil ) async throws -> R { if didShutdown { throw AHPClientError.shutdown } guard let cont = outboundContinuation else { @@ -598,7 +683,7 @@ public actor AHPClient { let resultData: Data = try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let entry = PendingEntry(continuation: continuation) + let entry = PendingEntry(continuation: continuation, ownsTcpCreation: ownsTcpCreation, receiverId: receiverId) pending[id] = entry let wireData: Data @@ -637,6 +722,7 @@ public actor AHPClient { do { return try decoder.decode(R.self, from: resultData) } catch { + if ownsTcpCreation { await releaseAbandonedTcpCreation(resultData) } throw AHPClientError.decoding( "failed to decode result for \(method): \(error)" ) @@ -708,6 +794,7 @@ public actor AHPClient { /// wins, so the continuation is never double-resumed. private func cancelPending(id: Int) { if let entry = pending.removeValue(forKey: id) { + if entry.ownsTcpCreation { abandonedTcpCreations.insert(id) } entry.timeoutTask?.cancel() entry.continuation.resume(throwing: CancellationError()) } @@ -805,6 +892,7 @@ public actor AHPClient { switch msg { case .text(let s): guard let d = s.data(using: .utf8) else { + failStrictEvents("inbound text frame is not valid UTF-8") #if DEBUG print("[AHPClient] dropped malformed text frame") #endif @@ -821,6 +909,7 @@ public actor AHPClient { // see `TransportMessage` docs and microsoft/agent-host-protocol#123. // Transports SHOULD prefer `.text`/`.binary` for inbound frames. guard let d = try? encoder.encode(parsed) else { + failStrictEvents("inbound parsed frame could not be encoded") #if DEBUG print("[AHPClient] dropped unencodable parsed frame") #endif @@ -829,6 +918,7 @@ public actor AHPClient { data = d } guard let frame = parseRawFrame(from: data) else { + failStrictEvents("malformed inbound JSON-RPC frame") #if DEBUG print("[AHPClient] dropped malformed frame") #endif @@ -905,9 +995,24 @@ public actor AHPClient { case .successResponse(let id, let resultData): if let entry = pending.removeValue(forKey: id) { entry.timeoutTask?.cancel() + if let receiverId = entry.receiverId { + do { + let result = try decoder.decode(SubscribeResult.self, from: resultData) + if let snapshot = result.snapshot { + bindTcpReceiver(receiverId, resource: snapshot.resource) + } + } catch { + entry.continuation.resume(throwing: AHPClientError.decoding("failed to decode TCP creation result: \(error)")) + await releaseAbandonedTcpCreation(resultData) + return + } + } entry.continuation.resume(returning: resultData) + } else if abandonedTcpCreations.remove(id) != nil { + await releaseAbandonedTcpCreation(resultData) } case .errorResponse(let id, let error): + abandonedTcpCreations.remove(id) if let entry = pending.removeValue(forKey: id) { entry.timeoutTask?.cancel() entry.continuation.resume(throwing: AHPClientError.rpc( @@ -966,11 +1071,15 @@ public actor AHPClient { } private func handleActionNotification(paramsData: Data?) async { - guard let paramsData else { return } + guard let paramsData else { + failStrictEvents("inbound action notification is missing params") + return + } let envelope: ActionEnvelope do { envelope = try decoder.decode(ActionEnvelope.self, from: paramsData) } catch { + failStrictEvents("failed to decode inbound action envelope") #if DEBUG print("[AHPClient] failed to decode action envelope: \(error)") #endif @@ -996,11 +1105,15 @@ public actor AHPClient { wrap: (P) -> SubscriptionEvent, channel: (P) -> String ) async { - guard let paramsData else { return } + guard let paramsData else { + failStrictEvents("inbound subscription notification is missing params") + return + } let params: P do { params = try decoder.decode(P.self, from: paramsData) } catch { + failStrictEvents("failed to decode inbound subscription notification") #if DEBUG print("[AHPClient] failed to decode notification params: \(error)") #endif @@ -1041,6 +1154,7 @@ public actor AHPClient { failAllPending(with: clientError) finishAllSubscriptions() + finishStrictEventListeners() // Top-level taps stay alive after a transport drop so consumers can // observe later state transitions (in this single-shot client, only // `.disconnected` will follow). @@ -1048,6 +1162,7 @@ public actor AHPClient { } private func failAllPending(with error: AHPClientError) { + abandonedTcpCreations.removeAll() let entries = pending pending.removeAll() for (_, entry) in entries { @@ -1058,6 +1173,7 @@ public actor AHPClient { private func timeoutPending(id: Int) { if let entry = pending.removeValue(forKey: id) { + if entry.ownsTcpCreation { abandonedTcpCreations.insert(id) } entry.timeoutTask = nil entry.continuation.resume(throwing: AHPClientError.requestTimeout) } @@ -1203,6 +1319,31 @@ public actor AHPClient { private func finishAllEventListeners() { for l in eventListeners { l.continuation.finish() } eventListeners.removeAll() + finishStrictEventListeners() + } + + private func finishStrictEventListeners() { + for continuation in strictEventListeners.values { continuation.finish() } + strictEventListeners.removeAll() + for listeners in resourceEventListeners.values { + for continuation in listeners.values { continuation.finish() } + } + resourceEventListeners.removeAll() + } + + private func failStrictEvents(_ message: String) { + let listeners = strictEventListeners + strictEventListeners.removeAll() + for continuation in listeners.values { + continuation.finish(throwing: TransportError.protocol(message)) + } + let resources = resourceEventListeners + resourceEventListeners.removeAll() + for listeners in resources.values { + for continuation in listeners.values { + continuation.finish(throwing: TransportError.protocol(message)) + } + } } private func finishAllStateListeners() { @@ -1212,6 +1353,27 @@ public actor AHPClient { private func broadcast(_ event: ClientEvent) { for l in eventListeners { l.continuation.yield(event) } + deliverStrict(event, to: strictEventListeners) + if let resource = event.resource, let listeners = resourceEventListeners[resource] { + deliverStrict(event, to: listeners) + } + } + + private func deliverStrict(_ event: ClientEvent, to listeners: [UInt64: AsyncThrowingStream.Continuation]) { + for (id, continuation) in listeners { + switch continuation.yield(event) { + case .dropped: + continuation.finish(throwing: SubscriptionLagError(capacity: config.subscriptionBufferSize)) + cancelTcpReceiver(id) + case .terminated: + cancelTcpReceiver(id) + case .enqueued: + break + @unknown default: + continuation.finish(throwing: SubscriptionLagError(capacity: config.subscriptionBufferSize)) + cancelTcpReceiver(id) + } + } } private func transition(to newState: ConnectionState) async { @@ -1232,6 +1394,10 @@ public actor AHPClient { eventListeners.removeAll { $0.id == id } } + private func removeStrictEventListener(id: UInt64) { + cancelTcpReceiver(id) + } + private func removeStateListener(id: UInt64) { stateListeners.removeAll { $0.id == id } } @@ -1256,6 +1422,10 @@ public actor AHPClient { return perUriListeners[uri]?.count ?? 0 } + internal func _strictEventListenerCount() -> Int { + strictEventListeners.count + resourceEventListeners.values.reduce(0) { $0 + $1.count } + } + /// Internal accessor used by tests. Counts the in-flight pending /// requests. internal func _pendingCount() -> Int { @@ -1321,10 +1491,14 @@ public struct ResourceRequestHandlers: Sendable { private final class PendingEntry: @unchecked Sendable { let continuation: CheckedContinuation + let ownsTcpCreation: Bool + let receiverId: UInt64? var timeoutTask: Task? - init(continuation: CheckedContinuation) { + init(continuation: CheckedContinuation, ownsTcpCreation: Bool = false, receiverId: UInt64? = nil) { self.continuation = continuation + self.ownsTcpCreation = ownsTcpCreation + self.receiverId = receiverId } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClientError.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClientError.swift index d8219fcd6..eb5383410 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClientError.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPClientError.swift @@ -3,6 +3,17 @@ import Foundation import AgentHostProtocol +/// A strict event receiver exhausted its buffer and cannot continue across the gap. +public struct SubscriptionLagError: Error, Sendable, LocalizedError { + public let capacity: Int + + public init(capacity: Int) { self.capacity = capacity } + + public var errorDescription: String? { + "Event receiver exceeded its capacity of \(capacity); the receiver is permanently terminated." + } +} + /// Errors raised by `AHPClient`. public enum AHPClientError: Error, Sendable { /// Underlying transport failure. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift index 271684937..602285659 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/AHPStateMirror.swift @@ -83,6 +83,9 @@ public actor AHPStateMirror { /// Seed the mirror from a `Snapshot`, routing by its `state` discriminator. public func applySnapshot(_ snapshot: Snapshot) { switch snapshot.state { + case .tcp: + // TCP belongs to a lossless stream adapter, not this snapshot-restored mirror. + break case .root(let state): rootState = state case .session(let state): diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostClientHandle.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostClientHandle.swift index d0e599fec..d66b30ac7 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostClientHandle.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostClientHandle.swift @@ -12,6 +12,9 @@ import AgentHostProtocol /// `HostError.hostReconnected` instead of silently writing to the new /// connection. /// +/// TCP streams opened through this handle survive replay reconnects even when +/// this handle becomes stale. Removing the host or shutting it down ends them. +/// /// **Race note:** generation is checked once at the start of each call, so /// it is possible (but rare) for a reconnect to land between /// `checkAlive()` and the actual `dispatch`/`request`. In that race the @@ -47,6 +50,13 @@ public struct HostClientHandle: Sendable { currentGeneration: current ) } + + } + + /// Opens a TCP stream retained and replayed by this host's supervisor. + public func openTcpConnection(session: String, create: TcpConnectionSubscription) async throws -> TcpConnection { + try await checkAlive() + return try await shared.openTcpConnection(generation: generation, session: session, create: create) } /// Dispatch an action through this connection on `channel`, refusing if diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift index 1982a1e1d..5dcfe8fe5 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift @@ -167,7 +167,9 @@ internal final class HostRuntime: Sendable { func shutdown() async { cmdContinuation.yield(.shutdown) cmdContinuation.finish() + supervisorTask?.cancel() await supervisorTask?.value + await closeTcpConnections() } // MARK: - Supervisor loop @@ -186,7 +188,7 @@ internal final class HostRuntime: Sendable { // report `.connected` through the entire backoff sleep. await transition(to: .connecting, error: nil) - outer: while true { + outer: while !Task.isCancelled { attempt += 1 // Try to connect. On success we run the connection until it ends. @@ -196,7 +198,8 @@ internal final class HostRuntime: Sendable { attempt = 0 } let outcome = await runConnection(streams: streams, iter: &iter) - await tearDownClient() + if case .shutdown = outcome { await tearDownClient(permanent: true) } + else { await tearDownClient() } switch outcome { case .shutdown: return @@ -314,8 +317,11 @@ internal final class HostRuntime: Sendable { ) async throws -> ConnectionStreams { // Decide between initialize and reconnect based on prior state. let priorSnapshot = await shared.internalState - let canReconnect = priorSnapshot.serverSeq > 0 && !priorSnapshot.subscriptions.isEmpty + let tcpConnections = Array(await shared.tcpConnections.values) + if let previous = await shared.previousClient { await client.inheritTcpClient(previous) } + let canReconnect = !tcpConnections.isEmpty || (priorSnapshot.serverSeq > 0 && !priorSnapshot.subscriptions.isEmpty) let priorSubscriptions = priorSnapshot.subscriptions + let initialSubscriptions = priorSubscriptions.filter { !$0.hasPrefix("ahp-tcp:") } let priorSeq = priorSnapshot.serverSeq var initResult: InitializeResult? = nil @@ -324,17 +330,23 @@ internal final class HostRuntime: Sendable { if canReconnect { do { - reconnectResult = try await client.reconnect( - clientId: clientId, - lastSeenServerSeq: priorSeq, - subscriptions: priorSubscriptions - ) + if tcpConnections.isEmpty { + reconnectResult = try await client.reconnect(clientId: clientId, + lastSeenServerSeq: priorSeq, subscriptions: priorSubscriptions) + } else { + reconnectResult = try await client.reconnectTcpConnections( + params: ReconnectParams(channel: RootResourceURI, clientId: clientId, + lastSeenServerSeq: priorSeq, subscriptions: priorSubscriptions), + connections: tcpConnections) + } } catch let error as AHPClientError { if case .rpc = error { + let errors = await shared.closeTcpConnections() + if !errors.isEmpty { throw TransportError.protocol(errors.joined(separator: "; ")) } let init1 = try await client.initialize( clientId: clientId, protocolVersions: SUPPORTED_PROTOCOL_VERSIONS, - initialSubscriptions: priorSubscriptions + initialSubscriptions: initialSubscriptions ) initResult = init1 newSeq = init1.serverSeq @@ -346,7 +358,7 @@ internal final class HostRuntime: Sendable { let init1 = try await client.initialize( clientId: clientId, protocolVersions: SUPPORTED_PROTOCOL_VERSIONS, - initialSubscriptions: priorSubscriptions + initialSubscriptions: initialSubscriptions ) initResult = init1 newSeq = init1.serverSeq @@ -391,10 +403,16 @@ internal final class HostRuntime: Sendable { return generation }() - if let reconnectResult { + if var reconnectResult { + // TCP may need an older checkpoint than the ordinary host mirror. + if !tcpConnections.isEmpty, case .replay(var replay) = reconnectResult { + replay.actions.removeAll { $0.serverSeq <= priorSeq } + reconnectResult = .replay(replay) + } await applyReconnectResult(reconnectResult, priorSubscriptions: priorSubscriptions) await hostEventSink(.reconnectResult(config.id, reconnectResult)) } + await shared.tcpDidReconnect() await transition(to: .connected, error: nil) await hostEventSink(.connected(config.id, generation: newGeneration)) @@ -696,17 +714,19 @@ internal final class HostRuntime: Sendable { await hostEventSink(.stateChanged(config.id, state, lastError: error)) } - private func tearDownClient() async { - let prev: AHPClient? = await { - var captured: AHPClient? = nil - await shared.update { state in - captured = state.currentClient - state.currentClient = nil - } - return captured - }() + private func tearDownClient(permanent: Bool = false) async { + let prev = await shared.detachClient() + if permanent { await closeTcpConnections() } if let prev { - await prev.shutdown() + await prev.shutdown(preservingTcpConnections: !permanent) + } + } + + private func closeTcpConnections() async { + let errors = await shared.closeTcpConnections() + if !errors.isEmpty { + let reason = errors.joined(separator: "; ") + await transition(to: .failed(reason: reason), error: reason) } } } @@ -772,7 +792,7 @@ private func withClientShutdownOnThrow( do { return try await body() } catch { - await client.shutdown() + await client.shutdown(preservingTcpConnections: true) throw error } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift index cc5e3676e..9bf50ca16 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostShared.swift @@ -62,6 +62,62 @@ internal struct HostInternal { /// without contending against the supervisor's I/O. internal actor HostShared { private(set) var internalState: HostInternal + private(set) var previousClient: AHPClient? + private(set) var tcpConnections: [String: TcpConnection] = [:] + private var tcpCreations: [UUID: Task] = [:] + + func openTcpConnection(generation: UInt64, session: String, create: TcpConnectionSubscription) async throws -> TcpConnection { + guard let client = internalState.currentClient else { throw HostError.hostShutDown(internalState.id) } + guard generation == internalState.generation else { + throw HostError.hostReconnected(host: internalState.id, handleGeneration: generation, currentGeneration: internalState.generation) + } + let id = UUID() + let task = Task { try await self.createTcpConnection(client: client, session: session, create: create) } + tcpCreations[id] = task + defer { tcpCreations.removeValue(forKey: id) } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + private func createTcpConnection(client: AHPClient, session: String, create: TcpConnectionSubscription) async throws -> TcpConnection { + let connection = try await client.openTcpConnection(session: session, create: create) + guard internalState.currentClient === client, !Task.isCancelled else { + try await connection.dispose() + throw CancellationError() + } + tcpConnections[connection.resource] = connection + let resource = connection.resource + await connection.whenReleased { [weak self] in await self?.removeTcpConnection(resource) } + return connection + } + + private func removeTcpConnection(_ resource: String) { tcpConnections.removeValue(forKey: resource) } + + func detachClient() async -> AHPClient? { + let client = internalState.currentClient + internalState.currentClient = nil + if let client { previousClient = client } + let creations = Array(tcpCreations.values) + for creation in creations { creation.cancel() } + // The initiating caller receives setup errors; wait for its cleanup before closing transport. + for creation in creations { _ = await creation.result } + return client + } + + func tcpDidReconnect() { previousClient = nil } + + func closeTcpConnections() async -> [String] { + let connections = Array(tcpConnections.values) + var errors: [String] = [] + for connection in connections { + do { try await connection.dispose() } + catch { errors.append(String(describing: error)) } + } + return errors + } init(_ initial: HostInternal) { self.internalState = initial diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift index e08fd4015..1938519ad 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/MultiHostStateMirror.swift @@ -127,6 +127,9 @@ public actor MultiHostStateMirror { public func applySnapshot(host: HostId, snapshot: Snapshot) { let key = HostedResourceKey(hostId: host, uri: snapshot.resource) switch snapshot.state { + case .tcp: + // TCP belongs to a lossless stream adapter, not this snapshot-restored mirror. + break case .root(let state): rootStates[host] = state case .session(let state): diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/TcpConnection.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/TcpConnection.swift new file mode 100644 index 000000000..f0f12c9d3 --- /dev/null +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/TcpConnection.swift @@ -0,0 +1,569 @@ +import Foundation +import AgentHostProtocol + +internal struct TcpEventReceiver: Sendable { + let id: UInt64 + let stream: AsyncThrowingStream +} + +internal final class TcpClientSequences: @unchecked Sendable { + private let lock = NSLock() + private var next = 1 + + func reserve() throws -> Int { + lock.lock() + defer { lock.unlock() } + try TcpProtocol.safe(next) + let value = next + next += 1 + return value + } + + func advance(past value: Int) { + lock.lock() + defer { lock.unlock() } + next = max(next, value + 1) + } + + var lastAssigned: Int { + lock.lock() + defer { lock.unlock() } + return next - 1 + } +} + +private final class TcpReadChunk { + let data: Data + var next: TcpReadChunk? + init(_ data: Data) { self.data = data } +} + +/// An owned TCP stream with pull-based receive credit and one writer at a time. +public actor TcpConnection { + public nonisolated let resource: String + public nonisolated let clientId: String + public private(set) var state: TcpConnectionState + public private(set) var appliedCheckpoint: Int + public private(set) var isSuspended = false + internal private(set) var lastClientSequence = 0 + internal private(set) var isClosed = false + private var client: AHPClient + private var pending: [Int: StateAction] = [:] + private var head: TcpReadChunk? + private var tail: TcpReadChunk? + private var sentBytes: Int + private var consumedBytes: Int + private var writing = false + private var reading = false + private var ending = false + private var closing = false + private var released = false + private var failure: Error? + private var waiters: [UUID: CheckedContinuation] = [:] + private var pump: Task? + private var receiver: TcpEventReceiver? + private var generation = 0 + private var onRelease: (@Sendable () async -> Void)? + + internal func whenReleased(_ callback: @escaping @Sendable () async -> Void) async { + if released { await callback() } + else { onRelease = callback } + } + + internal init(client: AHPClient, clientId: String, snapshot: Snapshot, state: TcpConnectionState) { + self.client = client + self.clientId = clientId + resource = snapshot.resource + self.state = state + appliedCheckpoint = snapshot.fromSeq + sentBytes = state.input.receivedBytes + consumedBytes = state.output.consumedBytes + } + + deinit { pump?.cancel() } + + private func wake() { + let current = waiters + waiters.removeAll() + for waiter in current.values { waiter.resume() } + } + + private func changed() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { continuation.resume(throwing: CancellationError()) } + else { waiters[id] = continuation } + } + } onCancel: { + Task { await self.cancelWaiter(id) } + } + } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + + private func check() throws { + try Task.checkCancellation() + if let failure { throw failure } + } + + private func enqueue(_ action: StateAction) throws -> (AHPClient, Int, StateAction)? { + let seq = try client.tcpSequences.reserve() + lastClientSequence = seq + pending[seq] = action + return isSuspended ? nil : (client, seq, action) + } + + private func send(_ item: (AHPClient, Int, StateAction)?) async throws { + guard let (sender, sequence, action) = item else { return } + do { + try await sender.dispatch(action, channel: resource, clientSeq: sequence) + } catch { + if await sender.connectionState == .disconnected { + if sender === client { suspend() } + } + else { throw error } + } + } + + /// Returns one chunk and releases its credit. Nil means all output before EOF was drained. + public func read() async throws -> Data? { + try check() + guard !reading else { throw TransportError.protocol("TCP permits one reader at a time") } + reading = true + defer { reading = false } + while true { + try check() + if !isSuspended || isClosed { + if let chunk = head { + let credit = isClosed ? nil : try enqueue(.tcpDataConsumed(TcpDataConsumedAction( + type: .tcpDataConsumed, consumedBytes: consumedBytes + chunk.data.count + ))) + head = chunk.next + if head == nil { tail = nil } + consumedBytes += chunk.data.count + try await send(credit) + return chunk.data + } + if isClosed || state.hostClosed || state.output.eofAtBytes != nil { return nil } + } + try await changed() + } + } + + /// Writes in maximumChunkSize pieces and waits for credit; concurrent writers are rejected. + public func write(_ data: Data) async throws { + try check() + guard !writing && !ending && !closing && !isClosed else { throw TransportError.protocol("TCP write requires an open, idle writer") } + writing = true + defer { writing = false; wake() } + var offset = 0 + while offset < data.count { + try check() + guard !closing && !isClosed else { throw TransportError.closed } + let credit = state.input.windowBytes - (sentBytes - state.input.consumedBytes) + if isSuspended || credit == 0 { try await changed(); continue } + let count = min(data.count - offset, min(credit, state.input.maximumChunkSize)) + let start = data.startIndex + offset + try TcpProtocol.safe(sentBytes + count) + let action = StateAction.tcpInput(TcpInputAction( + type: .tcpInput, offset: sentBytes, + data: data.subdata(in: start..<(start + count)).base64EncodedString() + )) + let message = try enqueue(action) + sentBytes += count + offset += count + try await send(message) + } + } + + /// Waits until the destination consumes every reserved input byte. + public func drain() async throws { + while true { + try check() + if state.input.consumedBytes >= sentBytes { return } + guard !isClosed else { throw TransportError.closed } + try await changed() + } + } + + /// Half-closes input; output remains readable. Finish the current write first. + public func end() async throws { + try check() + guard !writing && !closing && !isClosed else { throw TransportError.protocol("TCP end requires an open, idle writer") } + if ending { return } + ending = true + var queued = false + do { + while isSuspended { + try check() + guard !closing && !isClosed else { throw TransportError.closed } + try await changed() + } + let action = try enqueue(.tcpInputEof(TcpInputEofAction(type: .tcpInputEof, finalOffset: sentBytes))) + queued = true + try await send(action) + } catch { + if !queued { ending = false; wake() } + throw error + } + } + + /// Stops input; retains crossing output and ownership until both sides close and drain. + public func close() async throws { + if closing || isClosed { return } + closing = true + wake() + do { + try await send(enqueue(.tcpClientClose(TcpClientCloseAction(type: .tcpClientClose)))) + } catch { + try await fail(error) + throw error + } + } + + private func finishClose() async throws { + guard !isClosed, closing, state.clientClosed, state.hostClosed, + state.input.consumedBytes >= sentBytes, + state.output.consumedBytes >= state.output.receivedBytes, + head == nil, pending.isEmpty else { return } + isClosed = true + wake() + try await release() + } + + /// Terminates all pending operations and releases the owned subscription once. + public func dispose() async throws { + try await fail(TransportError.closed) + } + + internal func suspend() { + if isClosed { return } + isSuspended = true + generation += 1 + pump?.cancel() + pump = nil + wake() + } + + internal func bind(_ replacement: AHPClient) async throws { + guard !isClosed else { throw TransportError.closed } + let disconnected = await client.connectionState == .disconnected + guard !isClosed else { throw TransportError.closed } + guard isSuspended || disconnected else { + throw TransportError.protocol("Suspend the previous transport before reconnecting TCP") + } + suspend() + if let receiver { await client.cancelTcpReceiver(receiver.id) } + receiver = nil + lastClientSequence = max(lastClientSequence, client.tcpSequences.lastAssigned) + try TcpProtocol.safe(lastClientSequence) + if client !== replacement { + try await replacement.trackTcpConnection(self) + await client.forgetTcpConnection(self) + if isClosed { + await replacement.forgetTcpConnection(self) + throw TransportError.closed + } + } + client = replacement + } + + internal func canRebind() async -> Bool { + let disconnected = await client.connectionState == .disconnected + return !isClosed && (isSuspended || disconnected) + } + + internal func owner() -> AHPClient { client } + + internal func accept(_ envelope: ActionEnvelope, generation expected: Int? = nil) async throws { + guard !isClosed, envelope.channel == resource, expected == nil || expected == generation else { return } + do { + try TcpProtocol.safe(envelope.serverSeq) + let clientEcho: Bool + switch envelope.action { + case .tcpInput, .tcpDataConsumed, .tcpInputEof, .tcpClientClose, .tcpClientReset: clientEcho = true + default: clientEcho = false + } + var acknowledgedSequence: Int? + if clientEcho { + guard let origin = envelope.origin, origin.clientId == clientId else { + throw TransportError.protocol("TCP client echo requires the owning client origin") + } + try TcpProtocol.safe(origin.clientSeq) + guard origin.clientSeq <= lastClientSequence else { + throw TransportError.protocol("TCP client echo has an unassigned sequence") + } + acknowledgedSequence = origin.clientSeq + } + if envelope.serverSeq <= appliedCheckpoint { return } + if let rejection = envelope.rejectionReason { throw TransportError.protocol(rejection) } + let next = try tcpReducer(state: state, action: envelope.action) + if let sequence = acknowledgedSequence { + if let expected = pending[sequence] { + guard TcpProtocol.matchesEcho(expected, envelope.action) else { + throw TransportError.protocol("TCP client echo does not match its pending action") + } + } else if next.input.receivedBytes != state.input.receivedBytes + || next.input.eofAtBytes != state.input.eofAtBytes + || next.output.consumedBytes != state.output.consumedBytes + || next.clientClosed != state.clientClosed + || (state.reset == nil && next.reset != nil) { + throw TransportError.protocol("TCP advancing client echo has no pending action") + } + } + guard next.output.receivedBytes - consumedBytes <= next.output.windowBytes else { + throw TransportError.protocol("TCP output exceeds locally released credit") + } + if case .tcpData(let action) = envelope.action, next.output.receivedBytes > state.output.receivedBytes { + guard let bytes = Data(base64Encoded: action.data) else { throw TransportError.protocol("Invalid TCP base64") } + let chunk = TcpReadChunk(bytes) + if let tail { tail.next = chunk } else { head = chunk } + tail = chunk + } + state = next + appliedCheckpoint = envelope.serverSeq + if let sequence = acknowledgedSequence { pending.removeValue(forKey: sequence) } + wake() + } catch { + try await fail(error, reset: true) + return + } + if let reset = state.reset { try await fail(TransportError.protocol("TCP reset: \(reset.reason)")) } + else { + if state.hostClosed { try await close() } + try await finishClose() + } + } + + internal func resume(_ events: TcpEventReceiver) async throws { + for (seq, action) in pending.sorted(by: { $0.key < $1.key }) { + if isClosed { break } + try await client.dispatch(action, channel: resource, clientSeq: seq) + } + await start(events) + } + + internal func start(_ events: TcpEventReceiver) async { + guard !isClosed else { await client.cancelTcpReceiver(events.id); return } + isSuspended = false + receiver = events + generation += 1 + let current = generation + pump = Task { [weak self] in + do { + for try await item in events.stream { + if Task.isCancelled { return } + if case .action(let envelope) = item.event { try await self?.accept(envelope, generation: current) } + } + await self?.suspendIfCurrent(current) + } catch { + if !Task.isCancelled { await self?.receiveFailure(error, generation: current) } + } + } + if await client.connectionState == .disconnected, current == generation { suspend() } + wake() + } + + private func suspendIfCurrent(_ expected: Int) { + if expected == generation { suspend() } + } + + private func receiveFailure(_ error: Error, generation expected: Int) async { + guard expected == generation else { return } + do { try await fail(error, reset: true) } + catch { failure = error; wake() } + } + + internal func fail(_ error: Error, reset: Bool = false) async throws { + guard !isClosed else { return } + failure = error + isClosed = true + var message: (AHPClient, Int, StateAction)? + do { + if reset && !isSuspended { message = try enqueue(.tcpClientReset(TcpClientResetAction(type: .tcpClientReset, reason: .protocolError))) } + } catch { failure = error } + head = nil + tail = nil + pending.removeAll() + wake() + do { try await send(message) } + catch { failure = error } + try await release() + } + + private func release() async throws { + if released { return } + released = true + let callback = onRelease + onRelease = nil + await client.forgetTcpConnection(self) + await callback?() + generation += 1 + pending.removeAll() + pump?.cancel() + pump = nil + if let receiver { await client.cancelTcpReceiver(receiver.id) } + receiver = nil + if await client.connectionState != .disconnected { + let owner = client + let resource = resource + do { try await Task { try await owner.unsubscribe(resource) }.value } + catch { failure = error; wake(); throw error } + } + } +} + +internal enum TcpProtocol { + static func matchesEcho(_ expected: StateAction, _ actual: StateAction) -> Bool { + switch (expected, actual) { + case (.tcpInput(let expected), .tcpInput(let actual)): + return expected.offset == actual.offset && expected.data == actual.data + case (.tcpDataConsumed(let expected), .tcpDataConsumed(let actual)): + return expected.consumedBytes == actual.consumedBytes + case (.tcpInputEof(let expected), .tcpInputEof(let actual)): + return expected.finalOffset == actual.finalOffset + case (.tcpClientClose, .tcpClientClose): return true + case (.tcpClientReset(let expected), .tcpClientReset(let actual)): return expected.reason == actual.reason + default: return false + } + } + + static func safe(_ value: Int) throws { + guard value >= 0 && value <= 9007199254740991 else { throw TransportError.protocol("TCP counter must be a nonnegative safe integer") } + } + + private static func validateLimits(_ windowBytes: Int, _ maximumChunkSize: Int) throws { + guard (1...Int(UInt32.max)).contains(windowBytes), maximumChunkSize > 0, maximumChunkSize <= windowBytes else { + throw TransportError.protocol("TCP window and chunk limits must be positive UInt32 values, with chunk no larger than window") + } + } + + static func validate(_ session: String, _ create: TcpConnectionSubscription, _ capability: TcpConnectionsCapability?) throws { + guard session.hasPrefix("ahp-session:"), create.type == "tcpConnection", !create.host.trimmingCharacters(in: .whitespaces).isEmpty, + (1...65535).contains(create.port), create.encoding == .base64, + capability?.encodings.contains(create.encoding) == true else { throw TransportError.protocol("Invalid or unsupported TCP creation request") } + try validateLimits(create.receiveWindowBytes, create.maximumChunkSize) + } + + static func validate(_ session: String, _ create: TcpConnectionSubscription, _ snapshot: Snapshot?) throws -> TcpConnectionState { + guard let snapshot, snapshot.resource.hasPrefix("ahp-tcp:"), case .tcp(let state) = snapshot.state, + state.session == session, state.target.host == create.host, state.target.port == create.port, + state.encoding == create.encoding, !state.clientClosed, !state.hostClosed, state.reset == nil else { + throw TransportError.protocol("Invalid TCP creation snapshot") + } + try safe(snapshot.fromSeq) + for direction in [state.input, state.output] { + try validateLimits(direction.windowBytes, direction.maximumChunkSize) + guard direction.receivedBytes == 0, direction.consumedBytes == 0, direction.eofAtBytes == nil else { + throw TransportError.protocol("TCP creation requires fresh byte directions") + } + } + guard state.output.windowBytes <= create.receiveWindowBytes, state.output.maximumChunkSize <= create.maximumChunkSize else { + throw TransportError.protocol("TCP creation exceeded requested receive limits") + } + return state + } +} + +extension AHPClient { + internal func trackTcpConnection(_ connection: TcpConnection) throws { + guard !tcpDisposed else { throw AHPClientError.shutdown } + ownedTcpConnections[ObjectIdentifier(connection)] = connection + } + + internal func forgetTcpConnection(_ connection: TcpConnection) { + ownedTcpConnections.removeValue(forKey: ObjectIdentifier(connection)) + } + + internal func inheritTcpClient(_ previous: AHPClient) async { + tcpIdentity = await previous.tcpIdentity + tcpCapability = await previous.tcpCapability + tcpSequences.advance(past: previous.tcpSequences.lastAssigned) + } + + /// Creates a stream after initialization, registering its child route during reply processing. + public func openTcpConnection(session: String, create: TcpConnectionSubscription) async throws -> TcpConnection { + guard let clientId = tcpIdentity else { throw TransportError.protocol("Initialize before opening TCP") } + try TcpProtocol.validate(session, create, tcpCapability) + let events = tcpEventReceiver(resource: "") + var snapshot: Snapshot? + var connection: TcpConnection? + do { + let result = try await requestTcpCreation(SubscribeParams(channel: session, create: create), receiverId: events.id) + snapshot = result.snapshot + let state = try TcpProtocol.validate(session, create, snapshot) + try Task.checkCancellation() + guard let snapshot else { throw TransportError.protocol("Missing TCP creation snapshot") } + let created = TcpConnection(client: self, clientId: clientId, snapshot: snapshot, state: state) + connection = created + try trackTcpConnection(created) + await created.start(events) + guard !tcpDisposed else { throw AHPClientError.shutdown } + return created + } catch { + cancelTcpReceiver(events.id) + if let connection { try await connection.dispose() } + else if let snapshot, snapshot.resource.hasPrefix("ahp-tcp:"), connectionState != .disconnected { + try await Task { try await self.unsubscribe(snapshot.resource) }.value + } + throw error + } + } + + /// Reconciles retained handles on this fresh transport; never recreates sockets or restores TCP snapshots. + /// Returned replay excludes actions at or below the caller's original checkpoint. + public func reconnectTcpConnections(params: ReconnectParams, connections: [TcpConnection]) async throws -> ReconnectResult { + try TcpProtocol.safe(params.lastSeenServerSeq) + guard params.channel == RootResourceURI, tcpIdentity == nil || tcpIdentity == params.clientId, + Set(connections.map(\.resource)).count == connections.count else { throw TransportError.protocol("Invalid TCP reconnect ownership") } + var checkpoint = params.lastSeenServerSeq + var resources = Set(params.subscriptions) + for connection in connections { + guard connection.clientId == params.clientId, await connection.canRebind() else { throw TransportError.protocol("TCP reconnect requires suspended handles owned by the same logical client") } + checkpoint = min(checkpoint, await connection.appliedCheckpoint) + resources.insert(connection.resource) + } + let receivers = connections.map { tcpEventReceiver(resource: $0.resource) } + do { + if let first = connections.first { await inheritTcpClient(await first.owner()) } + for connection in connections { try await connection.bind(self) } + for connection in connections { tcpSequences.advance(past: await connection.lastClientSequence) } + let result = try await reconnect(clientId: params.clientId, lastSeenServerSeq: checkpoint, subscriptions: resources.sorted()) + tcpIdentity = params.clientId + if case .replay(var replay) = result { + var previous = checkpoint + for action in replay.actions { + try TcpProtocol.safe(action.serverSeq) + guard action.serverSeq > previous else { throw TransportError.protocol("TCP replay is not ordered") } + previous = action.serverSeq + } + for connection in connections { + if replay.missing.contains(connection.resource) { + try await connection.fail(TransportError.protocol("TCP resource is missing on reconnect")) + } else { + for envelope in replay.actions { try await connection.accept(envelope) } + } + } + for (connection, receiver) in zip(connections, receivers) { + try Task.checkCancellation() + if await connection.isClosed { + cancelTcpReceiver(receiver.id) + } else { try await connection.resume(receiver) } + } + replay.actions.removeAll { $0.serverSeq <= params.lastSeenServerSeq } + return .replay(replay) + } else { + for connection in connections { try await connection.fail(TransportError.protocol("TCP cannot be restored from a reconnect snapshot")) } + for receiver in receivers { cancelTcpReceiver(receiver.id) } + } + return result + } catch { + for connection in connections { await connection.suspend() } + for receiver in receivers { cancelTcpReceiver(receiver.id) } + throw error + } + } +} diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTestHelpers.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTestHelpers.swift index ac972a57a..f90b9f7c0 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTestHelpers.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTestHelpers.swift @@ -84,3 +84,20 @@ struct TimeoutError: Error, LocalizedError { var errorDescription: String? { "operation timed out in test" } } +func nextWithTimeout( + _ iterator: inout AsyncThrowingStream.AsyncIterator, + _ timeout: Duration = .seconds(2) +) async throws -> E? where E: Sendable { + try await withThrowingTaskGroup(of: E?.self) { group in + group.addTask { [iterator = iterator] in + var iter = iterator + return try await iter.next() + } + group.addTask { + try await Task.sleep(for: timeout) + throw TimeoutError() + } + defer { group.cancelAll() } + return try await group.next()! + } +} diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift index be89657a2..07ab1612e 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/AHPClientTests.swift @@ -7,6 +7,1194 @@ import AgentHostProtocol final class AHPClientTests: XCTestCase { + private func tcpCreation() -> TcpConnectionSubscription { + TcpConnectionSubscription(type: "tcpConnection", host: "localhost", port: 3000, encoding: .base64, receiveWindowBytes: 4, maximumChunkSize: 2) + } + + private func tcpSnapshot(_ resource: String = "ahp-tcp:/created") -> Snapshot { + let direction = FlowControlledByteDirectionState(windowBytes: 4, maximumChunkSize: 2, receivedBytes: 0, consumedBytes: 0) + return Snapshot(resource: resource, state: .tcp(TcpConnectionState( + session: "ahp-session:/s1", target: TcpTarget(host: "localhost", port: 3000), encoding: .base64, + input: direction, output: direction, clientClosed: false, hostClosed: false + )), fromSeq: 0) + } + + private func openTcpHost() async throws -> (MultiHostClient, AsyncStream, InMemoryTransport, TcpConnection) { + let servers = AsyncStream.makeStream() + let multi = MultiHostClient() + let config = HostConfig(id: "tcp", label: "TCP", transportFactory: { _ in + let (side, server) = InMemoryTransport.pair() + servers.continuation.yield(server) + return side + }).withClientId("owner").withReconnectPolicy(.immediateForever).withSessionSummaryRefreshOnConnect(false) + _ = try await multi.add(config) + var iterator = servers.stream.makeAsyncIterator() + let initialServer = try await nextWithTimeout(&iterator) + let initial = try XCTUnwrap(initialServer) + let request = try await readRequest(from: initial, expectedMethod: "initialize") + try await respond(to: request.id, with: InitializeResult( + protocolVersion: SUPPORTED_PROTOCOL_VERSIONS[0], serverSeq: 0, snapshots: [], + tcpConnections: TcpConnectionsCapability(encodings: [.base64]) + ), on: initial) + await waitUntil { await multi.host("tcp")?.state.isConnected == true } + let current = await multi.client(for: "tcp") + let handle = try XCTUnwrap(current) + let open = Task { try await handle.openTcpConnection(session: "ahp-session:/s1", create: tcpCreation()) } + let create = try await readRequest(from: initial, expectedMethod: "subscribe") + try await respond(to: create.id, with: SubscribeResult(snapshot: tcpSnapshot()), on: initial) + return (multi, servers.stream, initial, try await open.value) + } + + func testTcpHostReconnectRetainsStreamCreditPayloadAndGlobalSequence() async throws { + for spontaneous in [false, true] { + let (multi, servers, oldServer, connection) = try await openTcpHost() + defer { Task { await multi.shutdown() } } + let current = await multi.client(for: "tcp") + let handle = try XCTUnwrap(current) + let write = Task { try await connection.write(Data([1, 2, 3, 4, 5, 6])) } + let first = try await readDispatchNotification(from: oldServer) + let second = try await readDispatchNotification(from: oldServer) + try await tcpPush(oldServer, 1, .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg="))) + try await tcpPush(oldServer, 2, first.action, origin: ActionOrigin(clientId: "owner", clientSeq: first.clientSeq)) + _ = try await handle.dispatch(.sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "ordinary")), + channel: "ahp-session:/s1", clientSeq: 1000) + let ordinary = try await readDispatchNotification(from: oldServer) + XCTAssertEqual(ordinary.clientSeq, 1000) + await waitUntil { await connection.appliedCheckpoint == 2 } + try await pushNotification(method: "action", params: ActionEnvelope(channel: RootResourceURI, + action: .rootActiveSessionsChanged(RootActiveSessionsChangedAction(type: .rootActiveSessionsChanged, activeSessions: 1)), serverSeq: 50), on: oldServer) + await waitUntil { await multi.host("tcp")?.serverSeq == 50 } + if spontaneous { try await oldServer.close() } + else { try await multi.reconnect("tcp") } + var iterator = servers.makeAsyncIterator() + let nextServer = try await nextWithTimeout(&iterator) + let server = try XCTUnwrap(nextServer) + let request = try await readRequest(from: server, expectedMethod: "reconnect") + let params = try JSONDecoder().decode(ReconnectParams.self, from: JSONEncoder().encode(request.params)) + XCTAssertEqual(params.clientId, "owner") + XCTAssertEqual(params.lastSeenServerSeq, 2) + XCTAssertTrue(params.subscriptions.contains(connection.resource)) + try await respond(to: request.id, with: ReconnectResult.replay(ReconnectReplayResult(type: .replay, actions: [ + ActionEnvelope(channel: connection.resource, action: .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 2)), serverSeq: 3), + ActionEnvelope(channel: connection.resource, action: .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg=")), serverSeq: 4), + ActionEnvelope(channel: RootResourceURI, action: .rootActiveSessionsChanged(RootActiveSessionsChangedAction(type: .rootActiveSessionsChanged, activeSessions: 999)), serverSeq: 5), + ], missing: [])), on: server) + let resent = try await readDispatchNotification(from: server) + XCTAssertEqual(resent.clientSeq, second.clientSeq) + XCTAssertTrue(TcpProtocol.matchesEcho(resent.action, second.action)) + let tail = try await readDispatchNotification(from: server) + guard case .tcpInput(let input) = tail.action else { return XCTFail("missing resumed write") } + XCTAssertEqual(input.offset, 4) + XCTAssertGreaterThan(tail.clientSeq, 1000) + try await write.value + await waitUntil { await multi.host("tcp")?.generation != handle.generation } + let host = await multi.host("tcp") + XCTAssertEqual(host?.activeSessions, 1) + do { try await handle.checkAlive(); XCTFail("old handle remained valid") } catch is HostError { } + let bytes = try await connection.read() + XCTAssertEqual(bytes, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed = credit.action else { return XCTFail("missing receive credit") } + let freshHandle = await multi.client(for: "tcp") + let fresh = try XCTUnwrap(freshHandle) + let open = Task { try await fresh.openTcpConnection(session: "ahp-session:/s1", create: tcpCreation()) } + let create = try await readRequest(from: server, expectedMethod: "subscribe") + try await respond(to: create.id, with: SubscribeResult(snapshot: tcpSnapshot("ahp-tcp:/second")), on: server) + let additional = try await open.value + try await additional.dispose() + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + let read = Task { try await connection.read() } + let remove = Task { try await multi.remove("tcp") } + let unsubscribe = try await readNotification(from: server, expectedMethod: "unsubscribe") + let channel = try JSONDecoder().decode(UnsubscribeParams.self, from: JSONEncoder().encode(unsubscribe)).channel + XCTAssertEqual(channel, connection.resource) + try await remove.value + do { _ = try await read.value; XCTFail("removed stream remained readable or duplicate data was enqueued") } catch { } + await multi.shutdown() + } + } + + func testTcpHostReconnectFallbackFailsStreamsClosed() async throws { + for mode in ["snapshot", "missing", "initialize"] { + let (multi, servers, _, connection) = try await openTcpHost() + let generation = await multi.host("tcp")!.generation + let read = Task { try await connection.read() } + try await multi.reconnect("tcp") + var iterator = servers.makeAsyncIterator() + let nextServer = try await nextWithTimeout(&iterator) + let server = try XCTUnwrap(nextServer) + let request = try await readRequest(from: server, expectedMethod: "reconnect") + if mode == "initialize" { + try await server.send(.text(""" + {"jsonrpc":"2.0","id":\(request.id),"error":{"code":-32601,"message":"reconnect unavailable"}} + """)) + } else { + let result: ReconnectResult = mode == "snapshot" + ? .snapshot(ReconnectSnapshotResult(type: .snapshot, snapshots: [tcpSnapshot()])) + : .replay(ReconnectReplayResult(type: .replay, actions: [], missing: [connection.resource])) + try await respond(to: request.id, with: result, on: server) + } + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + if mode == "initialize" { + let initialize = try await readRequest(from: server, expectedMethod: "initialize") + let params = try JSONDecoder().decode(InitializeParams.self, from: JSONEncoder().encode(initialize.params)) + XCTAssertFalse(try XCTUnwrap(params.initialSubscriptions).contains(connection.resource)) + try await respond(to: initialize.id, with: InitializeResult(protocolVersion: SUPPORTED_PROTOCOL_VERSIONS[0], serverSeq: 0, snapshots: []), on: server) + } + await waitUntil { await multi.host("tcp")?.generation != generation } + do { _ = try await read.value; XCTFail("fallback restored a TCP stream") } catch { } + try await connection.dispose() + await multi.shutdown() + } + } + + func testTcpHostShutdownTerminatesBlockedOperationsAndPendingCreation() async throws { + let (multi, _, server, connection) = try await openTcpHost() + let write = Task { try await connection.write(Data(repeating: 0, count: 6)) } + _ = try await readDispatchNotification(from: server) + _ = try await readDispatchNotification(from: server) + let read = Task { try await connection.read() } + let drain = Task { try await connection.drain() } + let handle = await multi.client(for: "tcp") + let creation = Task { try await XCTUnwrap(handle).openTcpConnection(session: "ahp-session:/s1", create: tcpCreation()) } + _ = try await readRequest(from: server, expectedMethod: "subscribe") + let shutdown = Task { await multi.shutdown() } + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + await shutdown.value + do { _ = try await creation.value; XCTFail("shutdown left creation active") } catch is CancellationError { } + do { _ = try await read.value; XCTFail("shutdown left read active") } catch { } + do { try await write.value; XCTFail("shutdown left write active") } catch { } + do { try await drain.value; XCTFail("shutdown left drain active") } catch { } + } + + func testTcpHostShutdownDuringReconnectTerminatesRetainedStream() async throws { + let (multi, servers, oldServer, connection) = try await openTcpHost() + let read = Task { try await connection.read() } + try await oldServer.close() + var iterator = servers.makeAsyncIterator() + let nextServer = try await nextWithTimeout(&iterator) + let server = try XCTUnwrap(nextServer) + _ = try await readRequest(from: server, expectedMethod: "reconnect") + let finished = expectation(description: "shutdown cancels outstanding reconnect") + let shutdown = Task { + await multi.shutdown() + finished.fulfill() + } + await fulfillment(of: [finished], timeout: 2) + await shutdown.value + do { _ = try await read.value; XCTFail("shutdown left retained read active") } catch { } + } + + private func openTcp(_ client: AHPClient, _ server: InMemoryTransport, firstAction: Bool = false, invalidSnapshot: Bool = false, maximumChunkSize: Int = 2) async throws -> TcpConnection { + try await client.connect() + let initialize = Task { try await client.initialize(clientId: "owner", protocolVersions: ["test"]) } + let initialization = try await readRequest(from: server, expectedMethod: "initialize") + try await respond(to: initialization.id, with: InitializeResult( + protocolVersion: "test", serverSeq: 0, snapshots: [], + tcpConnections: TcpConnectionsCapability(encodings: [.base64]) + ), on: server) + _ = try await initialize.value + let open = Task { try await client.openTcpConnection(session: "ahp-session:/s1", create: TcpConnectionSubscription( + type: "tcpConnection", host: "localhost", port: 3000, encoding: .base64, receiveWindowBytes: max(4, maximumChunkSize), maximumChunkSize: maximumChunkSize + )) } + let request = try await readRequest(from: server, expectedMethod: "subscribe") + let direction = FlowControlledByteDirectionState(windowBytes: max(4, maximumChunkSize), maximumChunkSize: maximumChunkSize, receivedBytes: invalidSnapshot ? 1 : 0, consumedBytes: 0) + try await respond(to: request.id, with: SubscribeResult(snapshot: Snapshot( + resource: "ahp-tcp:/created", state: .tcp(TcpConnectionState( + session: "ahp-session:/s1", target: TcpTarget(host: "localhost", port: 3000), encoding: .base64, + input: direction, output: direction, clientClosed: false, hostClosed: false + )), fromSeq: 0 + )), on: server) + if firstAction { try await tcpPush(server, 1, .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg="))) } + return try await open.value + } + + private func tcpPush(_ server: InMemoryTransport, _ sequence: Int, _ action: StateAction, origin: ActionOrigin? = nil, rejectionReason: String? = nil, channel: String = "ahp-tcp:/created") async throws { + try await pushNotification(method: "action", params: ActionEnvelope(channel: channel, action: action, serverSeq: sequence, origin: origin, rejectionReason: rejectionReason), on: server) + } + + private func tcpUnrelatedBurst(_ client: AHPClient, _ server: InMemoryTransport, _ firstSequence: Int) async throws { + let barrier = await client.attachSubscription("ahp-session:/barrier") + var iterator = barrier.makeAsyncIterator() + for i in 0..<16 { + try await tcpPush(server, firstSequence + i * 2, .sessionTitleChanged(SessionTitleChangedAction( + type: .sessionTitleChanged, title: "busy" + )), channel: "ahp-session:/other") + try await tcpPush(server, firstSequence + i * 2 + 1, .tcpData(TcpDataAction( + type: .tcpData, offset: i, data: "AA==" + )), channel: "ahp-tcp:/other") + } + try await tcpPush(server, firstSequence + 32, .sessionTitleChanged(SessionTitleChangedAction( + type: .sessionTitleChanged, title: "barrier" + )), channel: "ahp-session:/barrier") + _ = try await nextWithTimeout(&iterator) + } + + func testTcpScopedCreationAndActiveTraffic() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side, config: AHPClientConfig(subscriptionBufferSize: 2)) + try await client.connect() + let initialize = Task { try await client.initialize(clientId: "owner", protocolVersions: ["test"]) } + let initialization = try await readRequest(from: server, expectedMethod: "initialize") + try await respond(to: initialization.id, with: InitializeResult( + protocolVersion: "test", serverSeq: 0, snapshots: [], + tcpConnections: TcpConnectionsCapability(encodings: [.base64]) + ), on: server) + _ = try await initialize.value + let opening = Task { try await client.openTcpConnection(session: "ahp-session:/s1", create: tcpCreation()) } + let request = try await readRequest(from: server, expectedMethod: "subscribe") + try await tcpUnrelatedBurst(client, server, 1) + var initial = tcpSnapshot() + initial.fromSeq = 33 + try await respond(to: request.id, with: SubscribeResult(snapshot: initial), on: server) + try await tcpPush(server, 34, .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bw=="))) + let connection = try await opening.value + try await tcpUnrelatedBurst(client, server, 35) + let first = try await connection.read() + XCTAssertEqual(first, Data([7])) + _ = try await readDispatchNotification(from: server) + try await tcpPush(server, 68, .tcpData(TcpDataAction(type: .tcpData, offset: 1, data: "CA=="))) + let second = try await connection.read() + XCTAssertEqual(second, Data([8])) + _ = try await readDispatchNotification(from: server) + try await closeTcp(connection, server) + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + await client.shutdown() + } + + func testTcpScopedReconnectIsolatesTrafficAndReportsOwnedOverflow() async throws { + for overflow in [false, true] { + let (oldSide, oldServer) = InMemoryTransport.pair() + let old = AHPClient(transport: oldSide) + let connection = try await openTcp(old, oldServer) + await old.shutdown(preservingTcpConnections: true) + let (side, server) = InMemoryTransport.pair() + let fresh = AHPClient(transport: side, config: AHPClientConfig(subscriptionBufferSize: 2)) + try await fresh.connect() + let reconnect = Task { try await fresh.reconnectTcpConnections(params: ReconnectParams( + channel: RootResourceURI, clientId: "owner", lastSeenServerSeq: 0, subscriptions: [] + ), connections: [connection]) } + let request = try await readRequest(from: server, expectedMethod: "reconnect") + try await tcpUnrelatedBurst(fresh, server, 2) + if overflow { + for i in 0..<3 { + try await tcpPush(server, 35 + i, .tcpData(TcpDataAction(type: .tcpData, offset: i, data: "AA=="))) + } + try await tcpUnrelatedBurst(fresh, server, 38) + } + let actions = overflow ? [] : [ + ActionEnvelope(channel: connection.resource, action: .tcpData(TcpDataAction( + type: .tcpData, offset: 0, data: "Bw==" + )), serverSeq: 1), + ] + try await respond(to: request.id, with: ReconnectResult.replay(ReconnectReplayResult( + type: .replay, actions: actions, missing: [] + )), on: server) + if !overflow { + try await tcpPush(server, 35, .tcpData(TcpDataAction(type: .tcpData, offset: 1, data: "CA=="))) + } + _ = try await reconnect.value + if overflow { + let reset = try await readDispatchNotification(from: server) + guard case .tcpClientReset = reset.action else { return XCTFail("missing overflow reset") } + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + do { _ = try await connection.read(); XCTFail("owned overflow was ignored") } + catch { XCTAssertTrue(error is SubscriptionLagError) } + } else { + try await tcpUnrelatedBurst(fresh, server, 36) + let first = try await connection.read() + let second = try await connection.read() + XCTAssertEqual(first, Data([7])) + XCTAssertEqual(second, Data([8])) + _ = try await readDispatchNotification(from: server) + _ = try await readDispatchNotification(from: server) + try await closeTcp(connection, server) + } + let count = await fresh._strictEventListenerCount() + XCTAssertEqual(count, 0) + await fresh.shutdown() + } + } + + private func closeTcp(_ connection: TcpConnection, _ server: InMemoryTransport) async throws { + let close = Task { try await connection.close() } + let dispatch = try await readDispatchNotification(from: server) + guard case .tcpClientClose = dispatch.action else { return XCTFail("missing client close") } + try await close.value + try await connection.dispose() + let unsubscribe = try await readNotification(from: server, expectedMethod: "unsubscribe") + let params = try JSONDecoder().decode(UnsubscribeParams.self, from: JSONEncoder().encode(unsubscribe)) + XCTAssertEqual(params.channel, connection.resource) + } + + func testTcpAdapterRejectsStaleCreationAndDetachesCancelledSetup() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + do { _ = try await openTcp(client, server, invalidSnapshot: true); XCTFail("stale TCP snapshot accepted") } catch { } + let unsubscribed = try await readNotification(from: server, expectedMethod: "unsubscribe") + let params = try JSONDecoder().decode(UnsubscribeParams.self, from: JSONEncoder().encode(unsubscribed)) + XCTAssertEqual(params.channel, "ahp-tcp:/created") + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + let open = Task { try await client.openTcpConnection(session: "ahp-session:/s1", create: TcpConnectionSubscription( + type: "tcpConnection", host: "localhost", port: 3000, encoding: .base64, receiveWindowBytes: 4, maximumChunkSize: 2 + )) } + _ = try await readRequest(from: server, expectedMethod: "subscribe") + open.cancel() + do { _ = try await open.value; XCTFail("cancelled TCP setup succeeded") } catch { } + let afterCancellation = await client._strictEventListenerCount() + XCTAssertEqual(afterCancellation, 0) + await client.shutdown() + } + + func testTcpCreationRequiresCanonicalDiscriminator() throws { + let create = TcpConnectionSubscription(type: "tcpConnection", host: "localhost", port: 3000, + encoding: .base64, receiveWindowBytes: 4, maximumChunkSize: 2) + let capability = TcpConnectionsCapability(encodings: [.base64]) + try TcpProtocol.validate("ahp-session:/s1", create, capability) + let wire = try JSONSerialization.jsonObject(with: JSONEncoder().encode( + SubscribeParams(channel: "ahp-session:/s1", create: create))) as? [String: Any] + XCTAssertEqual((wire?["create"] as? [String: Any])?["type"] as? String, "tcpConnection") + let invalid = TcpConnectionSubscription(type: "tcp", host: "localhost", port: 3000, + encoding: .base64, receiveWindowBytes: 4, maximumChunkSize: 2) + XCTAssertThrowsError(try TcpProtocol.validate("ahp-session:/s1", invalid, capability)) + } + + func testTcpLocalCloseRetainsCrossingTrafficUntilBothDirectionsDrain() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + try await connection.write(Data([1, 2])) + let input = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + try await connection.close() + let close = try await readDispatchNotification(from: server) + guard case .tcpClientClose = close.action else { return XCTFail("missing close") } + let closed = await connection.isClosed + let listeners = await client._strictEventListenerCount() + XCTAssertFalse(closed) + XCTAssertEqual(listeners, 1) + if closed { await client.shutdown(); return } + let read = Task { try await connection.read() } + try await tcpPush(server, 1, input.action, origin: ActionOrigin(clientId: "owner", clientSeq: input.clientSeq)) + try await tcpPush(server, 2, close.action, origin: ActionOrigin(clientId: "owner", clientSeq: close.clientSeq)) + try await tcpPush(server, 3, .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg="))) + let bytes = try await read.value + XCTAssertEqual(bytes, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed(let consumed) = credit.action else { return XCTFail("missing output credit") } + XCTAssertEqual(consumed.consumedBytes, 2) + try await connection.accept(ActionEnvelope(channel: connection.resource, + action: .tcpHostClose(TcpHostCloseAction(type: .tcpHostClose)), serverSeq: 4)) + let beforeCredit = await connection.isClosed + XCTAssertFalse(beforeCredit) + try await connection.accept(ActionEnvelope(channel: connection.resource, action: credit.action, serverSeq: 5, + origin: ActionOrigin(clientId: "owner", clientSeq: credit.clientSeq))) + let beforeDrain = await connection.isClosed + XCTAssertFalse(beforeDrain) + try await tcpPush(server, 6, .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 2))) + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + try await drain.value + let eof = try await connection.read() + XCTAssertNil(eof) + let remaining = await client._strictEventListenerCount() + XCTAssertEqual(remaining, 0) + try await connection.close() + try await connection.dispose() + await client.shutdown() + } + + func testTcpCreationAndSnapshotLimitsUseUInt32Range() throws { + let capability = TcpConnectionsCapability(encodings: [.base64]) + for limit in [1, 4294967295, 0, -1, 4294967296, 9007199254740991] { + let valid = (1...4294967295).contains(limit) + for chunk in [false, true] { + var create = tcpCreation() + create.receiveWindowBytes = limit + create.maximumChunkSize = chunk ? limit : 1 + if valid { try TcpProtocol.validate("ahp-session:/s1", create, capability) } + else { XCTAssertThrowsError(try TcpProtocol.validate("ahp-session:/s1", create, capability), "creation \(limit)") } + var request = tcpCreation() + request.receiveWindowBytes = 4294967295 + request.maximumChunkSize = 4294967295 + for input in [false, true] { + var snapshot = tcpSnapshot() + guard case .tcp(var state) = snapshot.state else { return XCTFail("missing TCP state") } + let direction = FlowControlledByteDirectionState(windowBytes: limit, maximumChunkSize: chunk ? limit : 1, receivedBytes: 0, consumedBytes: 0) + if input { state.input = direction } else { state.output = direction } + snapshot.state = .tcp(state) + if valid { _ = try TcpProtocol.validate("ahp-session:/s1", request, snapshot) } + else { XCTAssertThrowsError(try TcpProtocol.validate("ahp-session:/s1", request, snapshot), "snapshot \(limit), input \(input)") } + } + } + } + } + + func testTcpSingleClientReconnectFiltersReturnedReplayAtCallerCheckpoint() async throws { + let (oldSide, oldServer) = InMemoryTransport.pair() + let old = AHPClient(transport: oldSide) + let connection = try await openTcp(old, oldServer) + try await connection.accept(ActionEnvelope(channel: connection.resource, + action: .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 0)), serverSeq: 10)) + await old.shutdown(preservingTcpConnections: true) + let (side, server) = InMemoryTransport.pair() + let fresh = AHPClient(transport: side) + try await fresh.connect() + let parameters = ReconnectParams(channel: RootResourceURI, clientId: "owner", + lastSeenServerSeq: 100, subscriptions: ["ahp-session:/s1"]) + let reconnect = Task { try await fresh.reconnectTcpConnections(params: parameters, connections: [connection]) } + let request = try await readRequest(from: server, expectedMethod: "reconnect") + let wire = try JSONDecoder().decode(ReconnectParams.self, from: JSONEncoder().encode(request.params)) + XCTAssertEqual(wire.lastSeenServerSeq, 10) + var actions = (11...101).map { sequence in + ActionEnvelope(channel: sequence == 50 ? connection.resource : "ahp-session:/s1", + action: sequence == 50 + ? .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg=")) + : .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "title-\(sequence)")), + serverSeq: sequence) + } + actions.append(ActionEnvelope(channel: connection.resource, + action: .tcpDataEof(TcpDataEofAction(type: .tcpDataEof, finalOffset: 2)), serverSeq: 102)) + try await respond(to: request.id, with: ReconnectResult.replay(ReconnectReplayResult( + type: .replay, actions: actions, missing: ["ahp-session:/missing"])), on: server) + guard case .replay(let returned) = try await reconnect.value else { return XCTFail("missing replay") } + let checkpoint = await connection.appliedCheckpoint + let state = await connection.state + XCTAssertEqual(checkpoint, 102) + XCTAssertEqual(state.output.receivedBytes, 2) + XCTAssertEqual(state.output.eofAtBytes, 2) + let bytes = try await connection.read() + XCTAssertEqual(bytes, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed = credit.action else { return XCTFail("missing credit") } + XCTAssertEqual(returned.actions.map(\.serverSeq), [101, 102]) + XCTAssertEqual(returned.missing, ["ahp-session:/missing"]) + XCTAssertEqual(parameters.lastSeenServerSeq, 100) + try await connection.dispose() + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + await fresh.shutdown() + await old.shutdown() + } + + func testTcpPeerCloseRespondsWithoutWaitingForCreditOrUnreadOutput() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + let write = Task { try await connection.write(Data(repeating: 0, count: 5)) } + let first = try await readDispatchNotification(from: server) + let second = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + try await tcpPush(server, 1, first.action, origin: ActionOrigin(clientId: "owner", clientSeq: first.clientSeq)) + try await tcpPush(server, 2, second.action, origin: ActionOrigin(clientId: "owner", clientSeq: second.clientSeq)) + try await tcpPush(server, 3, .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg="))) + try await tcpPush(server, 4, .tcpHostClose(TcpHostCloseAction(type: .tcpHostClose))) + let close = try await readDispatchNotification(from: server) + guard case .tcpClientClose = close.action else { return XCTFail("missing close response") } + let beforeDrain = await connection.state + let listeners = await client._strictEventListenerCount() + XCTAssertEqual(beforeDrain.input.consumedBytes, 0) + XCTAssertEqual(beforeDrain.output.consumedBytes, 0) + XCTAssertEqual(listeners, 1) + do { try await write.value; XCTFail("writer survived close") } catch { } + try await tcpPush(server, 5, close.action, origin: ActionOrigin(clientId: "owner", clientSeq: close.clientSeq)) + try await tcpPush(server, 6, .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 4))) + try await drain.value + let bytes = try await connection.read() + XCTAssertEqual(bytes, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed = credit.action else { return XCTFail("missing credit") } + try await tcpPush(server, 7, credit.action, origin: ActionOrigin(clientId: "owner", clientSeq: credit.clientSeq)) + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + let eof = try await connection.read() + XCTAssertNil(eof) + await client.shutdown() + } + + func testTcpAdapterReleasesLateCreationWithoutUnsubscribingParent() async throws { + for (cancel, resource) in [(false, "ahp-tcp:/late"), (true, "ahp-tcp:/late"), (true, "ahp-session:/s1")] { + let (side, server) = InMemoryTransport.pair() + var config = AHPClientConfig() + config.requestTimeout = .seconds(1) + let client = AHPClient(transport: side, config: config) + let initial = try await openTcp(client, server) + try await closeTcp(initial, server) + let open = Task { try await client.openTcpConnection(session: "ahp-session:/s1", create: TcpConnectionSubscription( + type: "tcpConnection", host: "localhost", port: 3000, encoding: .base64, receiveWindowBytes: 4, maximumChunkSize: 2 + )) } + let request = try await readRequest(from: server, expectedMethod: "subscribe") + if cancel { open.cancel() } + do { _ = try await open.value; XCTFail("abandoned creation succeeded") } + catch { + if cancel { XCTAssertTrue(error is CancellationError) } + else { + guard case AHPClientError.requestTimeout = error else { return XCTFail("expected request timeout, got \(error)") } + } + } + let count = await client._strictEventListenerCount() + let pending = await client._pendingCount() + XCTAssertEqual(count, 0) + XCTAssertEqual(pending, 0) + try await respond(to: request.id, with: ["snapshot": ["resource": resource]], on: server) + if resource.hasPrefix("ahp-tcp:") { + let params = try await readNotification(from: server, expectedMethod: "unsubscribe") + let unsubscribe = try JSONDecoder().decode(UnsubscribeParams.self, from: JSONEncoder().encode(params)) + XCTAssertEqual(unsubscribe.channel, resource) + } + let barrier = await client.attachSubscription("ahp-session:/barrier") + var iterator = barrier.makeAsyncIterator() + try await respond(to: request.id, with: ["snapshot": ["resource": resource]], on: server) + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/barrier", action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "barrier")), serverSeq: 99 + ), on: server) + _ = try await nextWithTimeout(&iterator) + let probe = Task { + let result: [String: Bool] = try await client.request(method: "probe", params: ["probe": "ok"]) + return result + } + let probeRequest = try await readRequest(from: server, expectedMethod: "probe") + try await respond(to: probeRequest.id, with: ["ok": true], on: server) + _ = try await probe.value + let state = await client.connectionState + XCTAssertEqual(state, .connected) + await client.shutdown() + } + } + + func testTcpResetOrDisposeTerminatesClosingStream() async throws { + for reset in [false, true] { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + let write = Task { try await connection.write(Data(repeating: 0, count: 5)) } + _ = try await readDispatchNotification(from: server) + _ = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + try await connection.close() + _ = try await readDispatchNotification(from: server) + do { try await write.value; XCTFail("writer continued after close") } catch { } + if reset { + try await connection.accept(ActionEnvelope(channel: connection.resource, + action: .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg=")), serverSeq: 1)) + try await tcpPush(server, 2, .tcpHostReset(TcpHostResetAction(type: .tcpHostReset, reason: .protocolError))) + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + do { _ = try await connection.read(); XCTFail("reset retained buffered bytes") } catch { } + } else { + let read = Task { try await connection.read() } + try await connection.dispose() + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + do { _ = try await read.value; XCTFail("dispose returned EOF") } catch { } + } + do { try await drain.value; XCTFail("drain survived terminal failure") } catch { } + let listeners = await client._strictEventListenerCount() + XCTAssertEqual(listeners, 0) + try await connection.dispose() + try await connection.close() + await client.shutdown() + } + } + + func testTcpCloseWhileSuspendedReplaysAndDrainsBeforeRelease() async throws { + let (oldSide, oldServer) = InMemoryTransport.pair() + let old = AHPClient(transport: oldSide) + let connection = try await openTcp(old, oldServer) + await old.shutdown(preservingTcpConnections: true) + try await connection.close() + let closed = await connection.isClosed + XCTAssertFalse(closed) + let read = Task { try await connection.read() } + let (side, server) = InMemoryTransport.pair() + let fresh = AHPClient(transport: side) + try await fresh.connect() + let reconnect = Task { try await fresh.reconnectTcpConnections(params: ReconnectParams( + channel: RootResourceURI, clientId: "owner", lastSeenServerSeq: 0, subscriptions: []), + connections: [connection]) } + let request = try await readRequest(from: server, expectedMethod: "reconnect") + let parameters = try JSONDecoder().decode(ReconnectParams.self, from: JSONEncoder().encode(request.params)) + XCTAssertTrue(parameters.subscriptions.contains(connection.resource)) + try await respond(to: request.id, with: ReconnectResult.replay(ReconnectReplayResult(type: .replay, actions: [ + ActionEnvelope(channel: connection.resource, action: .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg=")), serverSeq: 1), + ActionEnvelope(channel: connection.resource, action: .tcpHostClose(TcpHostCloseAction(type: .tcpHostClose)), serverSeq: 2), + ], missing: [])), on: server) + _ = try await reconnect.value + let close = try await readDispatchNotification(from: server) + guard case .tcpClientClose = close.action else { return XCTFail("missing retained close") } + let bytes = try await read.value + XCTAssertEqual(bytes, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed = credit.action else { return XCTFail("missing output credit") } + let listeners = await fresh._strictEventListenerCount() + XCTAssertEqual(listeners, 1) + try await tcpPush(server, 3, close.action, origin: ActionOrigin(clientId: "owner", clientSeq: close.clientSeq)) + try await tcpPush(server, 4, credit.action, origin: ActionOrigin(clientId: "owner", clientSeq: credit.clientSeq)) + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + let eof = try await connection.read() + XCTAssertNil(eof) + let remaining = await fresh._strictEventListenerCount() + XCTAssertEqual(remaining, 0) + await fresh.shutdown() + await old.shutdown() + } + + func testTcpAdapterResetCloseAndDisposeWakeAllBlockedOperations() async throws { + for terminal in [0, 1, 2] { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + let read = Task { try await connection.read() } + let write = Task { try await connection.write(Data(repeating: 0, count: 5)) } + _ = try await readDispatchNotification(from: server) + _ = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + if terminal == 1 { try await tcpPush(server, 1, .tcpHostReset(TcpHostResetAction(type: .tcpHostReset, reason: .protocolError))) } + else if terminal == 2 { try await closeTcp(connection, server) } + else { try await connection.dispose() } + if terminal != 2 { _ = try await readNotification(from: server, expectedMethod: "unsubscribe") } + do { _ = try await read.value; XCTFail("read survived terminal failure") } catch { } + do { try await write.value; XCTFail("write survived terminal close") } catch { } + do { try await drain.value; XCTFail("drain survived terminal close") } catch { } + try await connection.dispose() + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + await client.shutdown() + } + } + + func testTcpAdapterReconnectContinuesBlockedWriterAfterReplayCredit() async throws { + let (oldSide, oldServer) = InMemoryTransport.pair() + let old = AHPClient(transport: oldSide) + let connection = try await openTcp(old, oldServer) + let write = Task { try await connection.write(Data(repeating: 0, count: 6)) } + let first = try await readDispatchNotification(from: oldServer) + let second = try await readDispatchNotification(from: oldServer) + try await tcpPush(oldServer, 1, first.action, origin: ActionOrigin(clientId: "owner", clientSeq: first.clientSeq)) + try await tcpPush(oldServer, 2, second.action, origin: ActionOrigin(clientId: "owner", clientSeq: second.clientSeq)) + try await tcpPush(oldServer, 3, .tcpDataEof(TcpDataEofAction(type: .tcpDataEof, finalOffset: 0))) + let eof = try await connection.read() + XCTAssertNil(eof) + _ = try await old.dispatch(.sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "ordinary action")), + channel: "ahp-session:/s1", clientSeq: 100) + let unrelated = try await readDispatchNotification(from: oldServer) + XCTAssertEqual(unrelated.clientSeq, 100) + await old.shutdown(preservingTcpConnections: true) + let (side, server) = InMemoryTransport.pair() + let fresh = AHPClient(transport: side) + try await fresh.connect() + let reconnect = Task { try await fresh.reconnectTcpConnections(params: ReconnectParams( + channel: RootResourceURI, clientId: "owner", lastSeenServerSeq: 20, subscriptions: [] + ), connections: [connection]) } + let request = try await readRequest(from: server, expectedMethod: "reconnect") + let params = try JSONDecoder().decode(ReconnectParams.self, from: JSONEncoder().encode(request.params)) + XCTAssertEqual(params.lastSeenServerSeq, 3) + let actions = [ + ActionEnvelope(channel: connection.resource, action: .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 2)), serverSeq: 4), + ] + try await respond(to: request.id, with: ReconnectResult.replay(ReconnectReplayResult(type: .replay, actions: actions, missing: [])), on: server) + _ = try await reconnect.value + let tail = try await readDispatchNotification(from: server) + guard case .tcpInput(let input) = tail.action else { return XCTFail("missing resumed write") } + XCTAssertEqual(input.offset, 4) + XCTAssertGreaterThan(tail.clientSeq, 100) + try await write.value + try await closeTcp(connection, server) + await fresh.shutdown() + } + + func testTcpAdapterReservesCreditChunksReadsDuplicatesAndHalfCloses() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + let completed = expectation(description: "write resumed after credit") + let write = Task { + try await connection.write(Data([0, 1, 2, 3, 4, 5])[1...]) + completed.fulfill() + } + let first = try await readDispatchNotification(from: server) + let second = try await readDispatchNotification(from: server) + guard case .tcpInput(let input) = second.action else { return XCTFail("missing input") } + XCTAssertEqual(input.offset, 2) + XCTAssertEqual(Data(base64Encoded: input.data)?.count, 2) + do { try await connection.write(Data([9])); XCTFail("concurrent writer accepted") } + catch { } + try await tcpPush(server, 1, first.action, origin: ActionOrigin(clientId: "owner", clientSeq: first.clientSeq)) + try await tcpPush(server, 2, second.action, origin: ActionOrigin(clientId: "owner", clientSeq: second.clientSeq)) + try await tcpPush(server, 3, .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 2))) + let third = try await readDispatchNotification(from: server) + try await write.value + await fulfillment(of: [completed], timeout: 2) + guard case .tcpInput(let tail) = third.action else { return XCTFail("missing tail") } + XCTAssertEqual(tail.offset, 4) + let drain = Task { try await connection.drain() } + try await tcpPush(server, 4, third.action, origin: ActionOrigin(clientId: "owner", clientSeq: third.clientSeq)) + try await tcpPush(server, 5, .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: 5))) + try await drain.value + let data = StateAction.tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg=")) + try await tcpPush(server, 6, data) + try await tcpPush(server, 7, data) + try await tcpPush(server, 8, .tcpDataEof(TcpDataEofAction(type: .tcpDataEof, finalOffset: 2))) + let bytes = try await connection.read() + XCTAssertEqual(bytes, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed(let consumed) = credit.action else { return XCTFail("missing output credit") } + XCTAssertEqual(consumed.consumedBytes, 2) + let eof = try await connection.read() + XCTAssertNil(eof) + try await connection.end() + let end = try await readDispatchNotification(from: server) + guard case .tcpInputEof(let final) = end.action else { return XCTFail("missing input EOF") } + XCTAssertEqual(final.finalOffset, 5) + try await closeTcp(connection, server) + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + await client.shutdown() + } + + func testTcpAdapterFirstActionAndStrictLossWakeWaiters() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server, firstAction: true) + let bytes = try await connection.read() + XCTAssertEqual(bytes, Data([7, 8])) + _ = try await readDispatchNotification(from: server) + let read = Task { try await connection.read() } + let write = Task { try await connection.write(Data(repeating: 0, count: 5)) } + _ = try await readDispatchNotification(from: server) + _ = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + try await server.send(.text("{")) + do { _ = try await read.value; XCTFail("read survived decode loss") } catch { } + do { try await write.value; XCTFail("write survived decode loss") } catch { } + do { try await drain.value; XCTFail("drain survived decode loss") } catch { } + let reset = try await readDispatchNotification(from: server) + guard case .tcpClientReset = reset.action else { return XCTFail("missing reset") } + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + try await connection.dispose() + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + await client.shutdown() + } + + func testTcpAdapterRejectsMalformedClientEchoWithoutAdvancingState() async throws { + for malformed in ["missing", "owner", "negative", "unsafe", "unassigned", "wrong-pending", "reused", "payload", "eof", "credit", "close", "reset", "rejected-empty"] { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + let read = Task { try await connection.read() } + let write = Task { try await connection.write(Data([1, 2, 3, 4, 5])) } + let first = try await readDispatchNotification(from: server) + let second = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + var action = first.action + var origin: ActionOrigin? = ActionOrigin(clientId: "owner", clientSeq: first.clientSeq) + switch malformed { + case "missing": origin = nil + case "owner": origin = ActionOrigin(clientId: "other", clientSeq: first.clientSeq) + case "negative": origin = ActionOrigin(clientId: "owner", clientSeq: -1) + case "unsafe": origin = ActionOrigin(clientId: "owner", clientSeq: 9007199254740992) + case "unassigned": origin = ActionOrigin(clientId: "owner", clientSeq: second.clientSeq + 1) + case "wrong-pending": origin = ActionOrigin(clientId: "owner", clientSeq: second.clientSeq) + case "reused": + try await tcpPush(server, 1, first.action, origin: origin) + action = second.action + case "payload": action = .tcpInput(TcpInputAction(type: .tcpInput, offset: 0, data: "AgE=")) + case "eof": origin = nil; action = .tcpInputEof(TcpInputEofAction(type: .tcpInputEof, finalOffset: 0)) + case "credit": origin = nil; action = .tcpDataConsumed(TcpDataConsumedAction(type: .tcpDataConsumed, consumedBytes: 0)) + case "close": origin = nil; action = .tcpClientClose(TcpClientCloseAction(type: .tcpClientClose)) + case "reset": origin = nil; action = .tcpClientReset(TcpClientResetAction(type: .tcpClientReset, reason: .protocolError)) + case "rejected-empty": break + default: XCTFail("unknown test case") + } + try await tcpPush(server, 2, action, origin: origin, rejectionReason: malformed == "rejected-empty" ? "" : nil) + do { _ = try await read.value; XCTFail("read survived \(malformed) echo") } catch { XCTAssertTrue(error is TransportError) } + do { try await write.value; XCTFail("write survived \(malformed) echo") } catch { XCTAssertTrue(error is TransportError) } + do { try await drain.value; XCTFail("drain survived \(malformed) echo") } catch { XCTAssertTrue(error is TransportError) } + let state = await connection.state + XCTAssertEqual(state.input.receivedBytes, malformed == "reused" ? 2 : 0) + XCTAssertEqual(state.input.consumedBytes, 0) + let reset = try await readDispatchNotification(from: server) + guard case .tcpClientReset = reset.action else { return XCTFail("missing reset") } + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + try await connection.dispose() + await client.shutdown() + } + } + + func testTcpAdapterReconnectRetainsReadersAndOnlyResendsUnacknowledgedActions() async throws { + for acknowledged in [false, true] { + let (oldSide, oldServer) = InMemoryTransport.pair() + let old = AHPClient(transport: oldSide) + let connection = try await openTcp(old, oldServer) + try await connection.write(Data([1, 2])) + let original = try await readDispatchNotification(from: oldServer) + let read = Task { try await connection.read() } + await old.shutdown(preservingTcpConnections: true) + let (side, server) = InMemoryTransport.pair() + let fresh = AHPClient(transport: side) + try await fresh.connect() + do { + _ = try await fresh.reconnectTcpConnections(params: ReconnectParams( + channel: RootResourceURI, clientId: "other", lastSeenServerSeq: 20, subscriptions: [] + ), connections: [connection]) + XCTFail("wrong identity accepted") + } catch { } + let reconnect = Task { try await fresh.reconnectTcpConnections(params: ReconnectParams( + channel: RootResourceURI, clientId: "owner", lastSeenServerSeq: 20, subscriptions: ["ahp-session:/s1"] + ), connections: [connection]) } + let request = try await readRequest(from: server, expectedMethod: "reconnect") + let params = try JSONDecoder().decode(ReconnectParams.self, from: JSONEncoder().encode(request.params)) + XCTAssertEqual(params.lastSeenServerSeq, 0) + XCTAssertTrue(params.subscriptions.contains(connection.resource)) + var actions = [ + ActionEnvelope(channel: connection.resource, action: .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg=")), serverSeq: 1, + origin: ActionOrigin(clientId: "owner", clientSeq: original.clientSeq)), + ] + if acknowledged { + actions.append(ActionEnvelope(channel: connection.resource, action: original.action, serverSeq: 2, + origin: ActionOrigin(clientId: "owner", clientSeq: original.clientSeq))) + } + try await respond(to: request.id, with: ReconnectResult.replay(ReconnectReplayResult(type: .replay, actions: actions, missing: [])), on: server) + try await tcpPush(server, 3, original.action, origin: ActionOrigin(clientId: "owner", clientSeq: original.clientSeq)) + try await tcpPush(server, 4, .tcpDataEof(TcpDataEofAction(type: .tcpDataEof, finalOffset: 2))) + _ = try await reconnect.value + let bytes = try await read.value + XCTAssertEqual(bytes, Data([7, 8])) + if !acknowledged { + let resent = try await readDispatchNotification(from: server) + XCTAssertEqual(resent.clientSeq, original.clientSeq) + guard case .tcpInput(let input) = resent.action, case .tcpInput(let previous) = original.action else { + return XCTFail("input resend changed action type") + } + XCTAssertEqual(input.offset, previous.offset) + XCTAssertEqual(input.data, previous.data) + } + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed = credit.action else { return XCTFail("acknowledged input was resent") } + XCTAssertGreaterThan(credit.clientSeq, original.clientSeq) + let eof = try await connection.read() + XCTAssertNil(eof) + try await closeTcp(connection, server) + await fresh.shutdown() + } + } + + func testTcpAdapterReconnectSnapshotAndMissingFailClosed() async throws { + for snapshot in [false, true] { + let (oldSide, oldServer) = InMemoryTransport.pair() + let old = AHPClient(transport: oldSide) + let connection = try await openTcp(old, oldServer) + let read = Task { try await connection.read() } + await old.shutdown(preservingTcpConnections: true) + let (side, server) = InMemoryTransport.pair() + let fresh = AHPClient(transport: side) + try await fresh.connect() + let reconnect = Task { try await fresh.reconnectTcpConnections(params: ReconnectParams( + channel: RootResourceURI, clientId: "owner", lastSeenServerSeq: 0, subscriptions: [] + ), connections: [connection]) } + let request = try await readRequest(from: server, expectedMethod: "reconnect") + let result: ReconnectResult = snapshot + ? .snapshot(ReconnectSnapshotResult(type: .snapshot, snapshots: [])) + : .replay(ReconnectReplayResult(type: .replay, actions: [], missing: [connection.resource])) + try await respond(to: request.id, with: result, on: server) + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + _ = try await reconnect.value + do { _ = try await read.value; XCTFail("read survived snapshot/missing fallback") } catch { } + let count = await fresh._strictEventListenerCount() + XCTAssertEqual(count, 0) + try await connection.dispose() + let open = Task { try await fresh.openTcpConnection(session: "ahp-session:/s1", create: tcpCreation()) } + let create = try await readRequest(from: server, expectedMethod: "subscribe") + try await respond(to: create.id, with: SubscribeResult(snapshot: tcpSnapshot("ahp-tcp:/replacement")), on: server) + let replacement = try await open.value + try await replacement.dispose() + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + await fresh.shutdown() + } + } + + func testTcpClientDefaultShutdownDisposesEvenAfterPreservedTransportShutdown() async throws { + for preserved in [false, true] { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + let connection = try await openTcp(client, server) + let read = Task { try await connection.read() } + let write = Task { try await connection.write(Data(repeating: 0, count: 6)) } + _ = try await readDispatchNotification(from: server) + _ = try await readDispatchNotification(from: server) + let drain = Task { try await connection.drain() } + if preserved { + await client.shutdown(preservingTcpConnections: true) + let closed = await connection.isClosed + XCTAssertFalse(closed) + } + let shutdown = Task { await client.shutdown() } + if !preserved { _ = try await readNotification(from: server, expectedMethod: "unsubscribe") } + await shutdown.value + do { _ = try await read.value; XCTFail("shutdown left read active") } catch { } + do { try await write.value; XCTFail("shutdown left write active") } catch { } + do { try await drain.value; XCTFail("shutdown left drain active") } catch { } + let count = await client._strictEventListenerCount() + XCTAssertEqual(count, 0) + } + } + + func testTcpAdapterLargeEncodingAndFinalClosePreserveCompletedDrainAndBufferedReads() async throws { + let (side, server) = InMemoryTransport.pair() + let client = AHPClient(transport: side) + var bytes = Data(repeating: 0, count: 4 * 1024 * 1024) + bytes[0] = 1 + bytes[bytes.count - 1] = 255 + let connection = try await openTcp(client, server, maximumChunkSize: bytes.count) + try await connection.write(bytes) + let sent = try await readDispatchNotification(from: server) + guard case .tcpInput(let input) = sent.action else { return XCTFail("missing input") } + XCTAssertEqual(input.offset, 0) + XCTAssertEqual(Data(base64Encoded: input.data), bytes) + let drain = Task { try await connection.drain() } + try await tcpPush(server, 1, sent.action, origin: ActionOrigin(clientId: "owner", clientSeq: sent.clientSeq)) + try await tcpPush(server, 2, .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: bytes.count))) + try await tcpPush(server, 3, .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "Bwg="))) + try await tcpPush(server, 4, .tcpHostClose(TcpHostCloseAction(type: .tcpHostClose))) + let close = try await readDispatchNotification(from: server) + guard case .tcpClientClose = close.action else { return XCTFail("missing close acknowledgement") } + let listeners = await client._strictEventListenerCount() + XCTAssertEqual(listeners, 1) + try await tcpPush(server, 5, close.action, origin: ActionOrigin(clientId: "owner", clientSeq: close.clientSeq)) + try await drain.value + try await connection.drain() + let data = try await connection.read() + XCTAssertEqual(data, Data([7, 8])) + let credit = try await readDispatchNotification(from: server) + guard case .tcpDataConsumed = credit.action else { return XCTFail("missing output credit") } + try await tcpPush(server, 6, credit.action, origin: ActionOrigin(clientId: "owner", clientSeq: credit.clientSeq)) + _ = try await readNotification(from: server, expectedMethod: "unsubscribe") + let eof = try await connection.read() + XCTAssertNil(eof) + try await connection.dispose() + await client.shutdown() + } + + func testStrictEventsFailOnMalformedFramesAndNotificationPayloads() async throws { + let malformed: [(String, Bool)] = [ + ("{", false), + ("{", true), + (#"{"jsonrpc":"2.0","id":1}"#, false), + (#"{"jsonrpc":"2.0","method":"action"}"#, false), + (#"{"jsonrpc":"2.0","method":"action","params":null}"#, false), + (#"{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/child","serverSeq":2,"action":{"type":"tcp/dataEof","finalOffset":0.5}}}"#, false), + (#"{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/child","serverSeq":2,"action":{"type":"tcp/inputConsumed","consumedBytes":0.5}}}"#, false), + (#"{"jsonrpc":"2.0","method":"root/sessionAdded","params":[]}"#, false), + (#"{"jsonrpc":"2.0","method":"root/sessionAdded"}"#, false), + ] + for (wire, binary) in malformed { + let (clientSide, serverSide) = InMemoryTransport.pair() + let client = AHPClient(transport: clientSide) + let strict = await client.strictEvents() + let ordinary = await client.events + let barrier = await client.attachSubscription("ahp-session:/barrier") + var barrierIter = barrier.makeAsyncIterator() + try await client.connect() + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/s1", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "prefix")), serverSeq: 1 + ), on: serverSide) + try await serverSide.send(binary ? .binary(Data(wire.utf8)) : .text(wire)) + for seq in [2, 99] { + try await pushNotification(method: "action", params: ActionEnvelope( + channel: seq == 99 ? "ahp-session:/barrier" : "ahp-session:/s1", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "later")), serverSeq: seq + ), on: serverSide) + } + + _ = try await nextWithTimeout(&barrierIter) + let registered = await client._strictEventListenerCount() + XCTAssertEqual(registered, 0, "failed receivers must detach before the consumer drains the error") + var strictIter = strict.makeAsyncIterator() + let prefix = try await nextWithTimeout(&strictIter) + guard case .action(let envelope) = prefix?.event else { return XCTFail("missing prefix for \(wire)") } + XCTAssertEqual(envelope.serverSeq, 1) + do { + _ = try await nextWithTimeout(&strictIter) + XCTFail("expected terminal protocol error for \(wire)") + } catch let error as TransportError { + guard case .protocol = error else { return XCTFail("expected protocol error, got \(error)") } + } + let terminated = try await nextWithTimeout(&strictIter) + XCTAssertNil(terminated, "a decode-failed receiver must never resume") + var ordinaryIter = ordinary.makeAsyncIterator() + for expected in [1, 2, 99] { + let event = try await nextWithTimeout(&ordinaryIter) + guard case .action(let envelope) = event?.event else { return XCTFail("ordinary receiver stopped") } + XCTAssertEqual(envelope.serverSeq, expected) + } + await client.shutdown() + } + } + + func testStrictEventsAllowUnknownNotificationsAndActions() async throws { + let (clientSide, serverSide) = InMemoryTransport.pair() + let client = AHPClient(transport: clientSide) + let events = await client.strictEvents() + try await client.connect() + try await serverSide.send(.text(#"{"jsonrpc":"2.0","method":"future/notification"}"#)) + try await serverSide.send(.text(#"{"jsonrpc":"2.0","method":"action","params":{"channel":"ahp-tcp:/child","serverSeq":1,"action":{"type":"tcp/future"}}}"#)) + var iterator = events.makeAsyncIterator() + let event = try await nextWithTimeout(&iterator) + guard case .action(let envelope) = event?.event else { return XCTFail("unknown actions must remain forward-compatible") } + XCTAssertEqual(envelope.serverSeq, 1) + XCTAssertEqual(envelope.channel, "ahp-tcp:/child") + await client.shutdown() + } + + func testStrictDecodeFailureWakesBlockedNextAndUnregisters() async throws { + let (clientSide, serverSide) = InMemoryTransport.pair() + let client = AHPClient(transport: clientSide) + let strict = await client.strictEvents() + let ordinary = await client.events + try await client.connect() + let started = expectation(description: "strict next started") + let finished = expectation(description: "strict next woke with protocol error") + let reader = Task { + var iterator = strict.makeAsyncIterator() + started.fulfill() + do { + _ = try await iterator.next() + XCTFail("expected protocol failure") + } catch let error as TransportError { + switch error { + case .protocol: break + default: XCTFail("expected protocol error, got \(error)") + } + } catch { + XCTFail("unexpected error: \(error)") + } + finished.fulfill() + } + defer { reader.cancel() } + await fulfillment(of: [started], timeout: 2) + try await serverSide.send(.text("{")) + await fulfillment(of: [finished], timeout: 2) + let registered = await client._strictEventListenerCount() + XCTAssertEqual(registered, 0) + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/s1", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "still connected")), serverSeq: 1 + ), on: serverSide) + var ordinaryIter = ordinary.makeAsyncIterator() + let event = try await nextWithTimeout(&ordinaryIter) + guard case .action(let envelope) = event?.event else { return XCTFail("ordinary receiver stopped") } + XCTAssertEqual(envelope.serverSeq, 1) + await client.shutdown() + } + + func testStrictEventsPreserveFirstTcpActionBeforeCreateReturns() async throws { + let (clientSide, serverSide) = InMemoryTransport.pair() + let client = AHPClient(transport: clientSide) + let events = await client.strictEvents() + let barrier = await client.attachSubscription("ahp-session:/barrier") + var barrierIter = barrier.makeAsyncIterator() + try await client.connect() + + let server = Task { + let request = try await readRequest(from: serverSide, expectedMethod: "subscribe") + let params = try JSONDecoder().decode(SubscribeParams.self, from: JSONEncoder().encode(request.params)) + XCTAssertEqual(params.channel, "ahp-session:/s1") + XCTAssertEqual(params.create?.host, "localhost") + let direction = FlowControlledByteDirectionState(windowBytes: 8, maximumChunkSize: 8, receivedBytes: 0, consumedBytes: 0) + let result = SubscribeResult(snapshot: Snapshot( + resource: "ahp-tcp:/created", + state: .tcp(TcpConnectionState( + session: params.channel, target: TcpTarget(host: "localhost", port: 3000), encoding: .base64, + input: direction, output: direction, clientClosed: false, hostClosed: false + )), + fromSeq: 0 + )) + try await respond(to: request.id, with: result, on: serverSide) + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-tcp:/created", + action: .tcpData(TcpDataAction(type: .tcpData, offset: 0, data: "AA==")), serverSeq: 1 + ), on: serverSide) + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/barrier", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "barrier")), serverSeq: 2 + ), on: serverSide) + } + let result: SubscribeResult = try await client.request(method: "subscribe", params: SubscribeParams( + channel: "ahp-session:/s1", + create: TcpConnectionSubscription(type: "tcpConnection", host: "localhost", port: 3000, + encoding: .base64, receiveWindowBytes: 8, maximumChunkSize: 8) + )) + try await server.value + _ = try await nextWithTimeout(&barrierIter) + let snapshot = try XCTUnwrap(result.snapshot) + var iterator = events.makeAsyncIterator() + let event = try await nextWithTimeout(&iterator) + XCTAssertEqual(event?.resource, snapshot.resource) + guard case .action(let envelope) = event?.event, case .tcp(let initial) = snapshot.state else { + return XCTFail("expected TCP snapshot and first raw action") + } + XCTAssertEqual(try tcpReducer(state: initial, action: envelope.action).output.receivedBytes, 1) + await client.shutdown() + } + + func testStrictEventsOverflowIsTerminalAndOrdinaryEventsStillDropOldest() async throws { + let (clientSide, serverSide) = InMemoryTransport.pair() + let client = AHPClient(transport: clientSide, config: AHPClientConfig(subscriptionBufferSize: 2)) + let strict = await client.strictEvents() + let ordinary = await client.events + let barrier = await client.attachSubscription("ahp-session:/barrier") + var barrierIter = barrier.makeAsyncIterator() + try await client.connect() + for seq in 1...3 { + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/s1", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "\(seq)")), + serverSeq: seq + ), on: serverSide) + } + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/barrier", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "barrier")), serverSeq: 99 + ), on: serverSide) + _ = try await nextWithTimeout(&barrierIter) + let registered = await client._strictEventListenerCount() + XCTAssertEqual(registered, 0, "overflowed receivers must detach before their error is drained") + var strictIter = strict.makeAsyncIterator() + for expected in 1...2 { + let event = try await nextWithTimeout(&strictIter) + guard case .action(let envelope) = event?.event else { return XCTFail("missing contiguous prefix") } + XCTAssertEqual(envelope.serverSeq, expected) + } + do { + _ = try await nextWithTimeout(&strictIter) + XCTFail("expected terminal lag error") + } catch let error as SubscriptionLagError { + XCTAssertEqual(error.capacity, 2) + } + var ordinaryIter = ordinary.makeAsyncIterator() + for expected in [3, 99] { + let event = try await nextWithTimeout(&ordinaryIter) + guard case .action(let envelope) = event?.event else { return XCTFail("missing ordinary event") } + XCTAssertEqual(envelope.serverSeq, expected) + } + let healthy = await client.strictEvents() + try await pushNotification(method: "action", params: ActionEnvelope( + channel: "ahp-session:/barrier", + action: .sessionTitleChanged(SessionTitleChangedAction(type: .sessionTitleChanged, title: "later")), serverSeq: 100 + ), on: serverSide) + _ = try await nextWithTimeout(&barrierIter) + let terminated = try await nextWithTimeout(&strictIter) + XCTAssertNil(terminated, "an overflowed receiver must never resume") + var healthyIter = healthy.makeAsyncIterator() + let later = try await nextWithTimeout(&healthyIter) + guard case .action(let envelope) = later?.event else { return XCTFail("other receivers must remain usable") } + XCTAssertEqual(envelope.serverSeq, 100) + await client.shutdown() + let finished = try await nextWithTimeout(&healthyIter) + XCTAssertNil(finished) + } + // MARK: - request_response_round_trip func testInitializeHandshakeRoundTrip() async throws { @@ -836,6 +2024,11 @@ final class AHPClientTests: XCTestCase { throw TestError.unexpectedMessage("expected request, got \(parsed)") } XCTAssertEqual(method, expectedMethod) + if method == "subscribe", + let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(params)) as? [String: Any], + let create = object["create"] as? [String: Any] { + XCTAssertEqual(create["type"] as? String, "tcpConnection") + } return ParsedRequest(id: id, method: method, params: params) } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift index fc3031a3f..c56eb0077 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -188,6 +188,12 @@ final class TypesRoundTripFixtureTests: XCTestCase { return try reencode(dec.decode(Implementation.self, from: inputData)) case "InitializeResult": return try reencode(dec.decode(InitializeResult.self, from: inputData)) + case "SubscribeParams": + return try reencode(dec.decode(SubscribeParams.self, from: inputData)) + case "ReconnectResult": + return try reencode(dec.decode(ReconnectResult.self, from: inputData)) + case "TcpConnectionOpenErrorData": + return try reencode(dec.decode(TcpConnectionOpenErrorData.self, from: inputData)) case "ChatSource": return try reencode(dec.decode(ChatSource.self, from: inputData)) case "Snapshot": diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift index 100ef243c..4669b7967 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/FixtureDrivenReducerTests.swift @@ -18,6 +18,7 @@ final class FixtureDrivenReducerTests: XCTestCase { let initial: AnyCodable let actions: [AnyCodable] let expected: AnyCodable + let expectedError: String? } // MARK: - Fixture Loading @@ -77,9 +78,8 @@ final class FixtureDrivenReducerTests: XCTestCase { // // All six reducer arms are now implemented (root / chat / session / terminal / // changeset / annotations / resourceWatch) and every fixture family runs real - // assertions. The gap set is empty; any future unimplemented reducer family - // would need to add stems here explicitly so the skip is documented and - // tripwired rather than silent. + // assertions, including TCP validation failures. + // Individual gaps in implemented families use the exact-match set below. // // History of closed gaps: // - Representational gap (fixture 103 — delta with unknown `kind`): CLOSED @@ -114,6 +114,7 @@ final class FixtureDrivenReducerTests: XCTestCase { ranRealAssertions, expectedReal, "Expected \(expectedReal) fixtures to decode+assert for real; only \(ranRealAssertions) did." ) + print("Fixture results: \(ranRealAssertions) asserted") // The gap set must be exactly the fixtures that failed to run. If a gap // closes, gapHits shrinks → mismatch → update the list. If a new fixture @@ -141,7 +142,7 @@ final class FixtureDrivenReducerTests: XCTestCase { // At least this many fixtures must run for each — pins the coverage jump // so the skip cannot silently return. Asserted as a lower bound so the // corpus can grow without churning this test. - let minFamilyCounts = ["terminal": 19, "changeset": 11, "resourceWatch": 2] + let minFamilyCounts = ["terminal": 19, "changeset": 11, "resourceWatch": 2, "tcp": 48] var totalRan = 0 for (family, minCount) in minFamilyCounts { let familyFixtures = Self.fixtures.filter { $0.fixture.reducer == family } @@ -157,53 +158,32 @@ final class FixtureDrivenReducerTests: XCTestCase { } totalRan += familyFixtures.count } - print("Previously-skipped reducer fixtures now running: \(totalRan) (terminal/changeset/resourceWatch).") + print("Previously-skipped reducer fixtures now running: \(totalRan) (terminal/changeset/resourceWatch/tcp).") XCTAssertGreaterThanOrEqual(totalRan, 32) } private func runFixture(file: String, fixture: Fixture) throws { - let actions = try { - let actionsData = try JSONEncoder().encode(fixture.actions) - return try JSONDecoder().decode([StateAction].self, from: actionsData) - }() - switch fixture.reducer { case "root": - try compareFixture(file: file, fixture: fixture, stateType: RootState.self) { state in - actions.reduce(state) { rootReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: RootState.self, reduce: rootReducer) case "session": - try compareFixture(file: file, fixture: fixture, stateType: SessionState.self) { state in - actions.reduce(state) { sessionReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: SessionState.self, reduce: sessionReducer) case "terminal": - try compareFixture(file: file, fixture: fixture, stateType: TerminalState.self) { state in - actions.reduce(state) { terminalReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: TerminalState.self, reduce: terminalReducer) case "changeset": - try compareFixture(file: file, fixture: fixture, stateType: ChangesetState.self) { state in - actions.reduce(state) { changesetReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: ChangesetState.self, reduce: changesetReducer) case "resourceWatch": - try compareFixture(file: file, fixture: fixture, stateType: ResourceWatchState.self) { state in - actions.reduce(state) { resourceWatchReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: ResourceWatchState.self, reduce: resourceWatchReducer) case "chat": - try compareFixture(file: file, fixture: fixture, stateType: ChatState.self) { state in - actions.reduce(state) { chatReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: ChatState.self, reduce: chatReducer) case "annotations": - try compareFixture(file: file, fixture: fixture, stateType: AnnotationsState.self) { state in - actions.reduce(state) { annotationsReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: AnnotationsState.self, reduce: annotationsReducer) case "automation": - try compareFixture(file: file, fixture: fixture, stateType: AutomationState.self) { state in - actions.reduce(state) { automationReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: AutomationState.self, reduce: automationReducer) case "automationRun": - try compareFixture(file: file, fixture: fixture, stateType: AutomationRunState.self) { state in - actions.reduce(state) { automationRunReducer(state: $0, action: $1) } - } + try compareFixture(file: file, fixture: fixture, stateType: AutomationRunState.self, reduce: automationRunReducer) + case "tcp": + try compareFixture(file: file, fixture: fixture, stateType: TcpConnectionState.self, reduce: tcpReducer) default: throw FixtureError.unsupportedReducer(fixture.reducer) } @@ -221,7 +201,7 @@ final class FixtureDrivenReducerTests: XCTestCase { file: String, fixture: Fixture, stateType: S.Type, - reduce: (S) -> S + reduce: (S, StateAction) throws -> S ) throws { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -229,7 +209,32 @@ final class FixtureDrivenReducerTests: XCTestCase { let initialData = try JSONEncoder().encode(fixture.initial) let initialState = try decoder.decode(S.self, from: initialData) - let finalState = reduce(initialState) + var finalState = initialState + if fixture.expectedError != nil { + XCTAssertFalse(fixture.actions.isEmpty, "expectedError requires a final action") + } + for (index, raw) in fixture.actions.enumerated() { + let data = try encoder.encode(raw) + let mustFail = fixture.expectedError != nil && index == fixture.actions.count - 1 + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let offset = (object?["offset"] as? NSNumber)?.doubleValue + if mustFail && fixture.reducer == "tcp", let offset, offset.rounded(.towardZero) != offset { + XCTAssertEqual(fixture.expectedError, "Invalid TCP action: offset must be a nonnegative safe integer") + XCTAssertThrowsError(try decoder.decode(StateAction.self, from: data)) { error in + XCTAssertTrue(error is DecodingError, "\(file): \(error)") + } + } else { + let action = try decoder.decode(StateAction.self, from: data) + if mustFail { + XCTAssertThrowsError(try reduce(finalState, action)) { error in + XCTAssertTrue(error is TcpReducerError, "\(file): \(error)") + XCTAssertEqual(String(describing: error), fixture.expectedError, file) + } + } else { + finalState = try reduce(finalState, action) + } + } + } // Normalize expected through the same Swift type to drop unknown properties let expectedData = try JSONEncoder().encode(fixture.expected) diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift index 09bae9ead..a95f3d9bd 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/NativeReducerTests.swift @@ -13,6 +13,76 @@ import XCTest final class NativeReducerTests: XCTestCase { + private func tcpState(size: Int = 8) -> TcpConnectionState { + TcpConnectionState( + session: "ahp-session:/test", target: TcpTarget(host: "localhost", port: 3000), + encoding: .base64, + input: FlowControlledByteDirectionState(windowBytes: size, maximumChunkSize: size, receivedBytes: 0, consumedBytes: 0), + output: FlowControlledByteDirectionState(windowBytes: size, maximumChunkSize: size, receivedBytes: 0, consumedBytes: 0), + clientClosed: false, hostClosed: false + ) + } + + func testTcpFourMiBChunkAndAtomicNativeWrapper() throws { + let size = 4 * 1024 * 1024 + let data = String(repeating: "AAAA", count: size / 3) + "AA==" + let before = tcpState(size: size) + let action = StateAction.tcpInput(TcpInputAction(type: .tcpInput, offset: 0, data: data)) + var state = before + try AHPTcpReducer().reduce(into: &state, action: action) + XCTAssertEqual(state.input.receivedBytes, size) + XCTAssertEqual(before.input.receivedBytes, 0) + try AHPTcpReducer().reduce(into: &state, action: action) + XCTAssertEqual(state.input.receivedBytes, size) + let saved = try JSONEncoder().encode(state) + XCTAssertThrowsError(try AHPTcpReducer().reduce( + into: &state, action: .tcpInput(TcpInputAction(type: .tcpInput, offset: size, data: "AA==")) + )) { error in + XCTAssertEqual(String(describing: error), "Invalid TCP action: receive window exceeded") + } + XCTAssertEqual(try JSONSerialization.jsonObject(with: saved) as? NSDictionary, + try JSONSerialization.jsonObject(with: JSONEncoder().encode(state)) as? NSDictionary) + } + + func testTcpSafeIntegerCounters() throws { + let max = 9007199254740991 + var state = tcpState() + state.input.receivedBytes = max - 1 + state.input.consumedBytes = max - 1 + state = try tcpReducer(state: state, action: .tcpInput(TcpInputAction(type: .tcpInput, offset: max - 1, data: "AA=="))) + state = try tcpReducer(state: state, action: .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: max))) + state = try tcpReducer(state: state, action: .tcpInputEof(TcpInputEofAction(type: .tcpInputEof, finalOffset: max))) + XCTAssertEqual(state.input.receivedBytes, max) + XCTAssertEqual(state.input.consumedBytes, max) + XCTAssertEqual(state.input.eofAtBytes, max) + for value in [-1, max + 1, Int.max] { + let actions: [StateAction] = [ + .tcpInput(TcpInputAction(type: .tcpInput, offset: value, data: "AA==")), + .tcpData(TcpDataAction(type: .tcpData, offset: value, data: "AA==")), + .tcpInputConsumed(TcpInputConsumedAction(type: .tcpInputConsumed, consumedBytes: value)), + .tcpDataConsumed(TcpDataConsumedAction(type: .tcpDataConsumed, consumedBytes: value)), + .tcpInputEof(TcpInputEofAction(type: .tcpInputEof, finalOffset: value)), + .tcpDataEof(TcpDataEofAction(type: .tcpDataEof, finalOffset: value)), + ] + for action in actions { + XCTAssertThrowsError(try AHPTcpReducer().reduce(into: &state, action: action)) { error in + XCTAssertEqual(String(describing: error), "Invalid TCP action: offset must be a nonnegative safe integer") + } + XCTAssertEqual(state.input.receivedBytes, max) + } + } + } + + func testTcpRejectsWhitespaceAndUnicodeBase64() { + for data in ["AAA\n", "AAA\r", "AAA\t", "AAA ", "AAA\u{e9}", "AA\u{1f600}"] { + XCTAssertThrowsError(try tcpReducer( + state: tcpState(), action: .tcpInput(TcpInputAction(type: .tcpInput, offset: 0, data: data)) + )) { error in + XCTAssertEqual(String(describing: error), "Invalid TCP action: base64 encoding") + } + } + } + // MARK: - Constants private let S = "ahp-session:/test-session" diff --git a/clients/typescript/README.md b/clients/typescript/README.md index e7ea7371f..f75079fb6 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -14,7 +14,7 @@ The package exposes four subpath exports: | Import path | What it gives you | |---|---| | `@microsoft/agent-host-protocol` | Wire types, actions, commands, reducers, version constants. No I/O. | -| `@microsoft/agent-host-protocol/client` | `AhpClient`, `Subscription`, `ManagedSubscriptionManager`, `AhpStateMirror`, the `AhpTransport` interface, `InMemoryTransport`, and the error taxonomy. | +| `@microsoft/agent-host-protocol/client` | `AhpClient`, `TcpConnection`, `Subscription`, `ManagedSubscriptionManager`, `AhpStateMirror`, the `AhpTransport` interface, `InMemoryTransport`, and the error taxonomy. | | `@microsoft/agent-host-protocol/hosts` | `MultiHostClient`, `HostClientHandle`, `ReconnectPolicy`, `ClientIdStore` (with `InMemoryClientIdStore`), `MultiHostStateMirror`, and the `Host*Error` family. Builds on `/client` to manage one or more host connections with reconnect, generation-checked handles, and fan-in events. | | `@microsoft/agent-host-protocol/ws` | `WebSocketTransport` — an `AhpTransport` implementation backed by the global `WebSocket`. | @@ -140,9 +140,88 @@ state and call the reducers directly. | `RpcTimeoutError` | Client-side timeout fired before the server responded. Carries `method`, `timeoutMs`. Distinct from `RpcError`. | | `TransportError` | Failure of the underlying transport. `kind: 'closed' \| 'io' \| 'protocol'`. | | `ClientClosedError` | Request was in flight when the client was shut down. | +| `SubscriptionLagError` | An event receiver using `overflow: 'error'` exceeded its bounded buffer. That receiver terminates instead of skipping events. | +| `TcpConnectionError` | Invalid TCP operation, protocol violation, reset, disposal, or unavailable replay. Carries a protocol reset `reason` where applicable. | | `AhpClientError` | Base class for every error this SDK throws — use `instanceof` to catch them all. | -Malformed inbound frames don't throw — they're logged via `console.warn` and the channel stays alive (matching the Rust client's `tracing::warn!` behavior). Pending requests still time out via `RpcTimeoutError` if the dropped frame would have been their reply. +Malformed inbound frames are logged via `console.warn` and the connection stays alive. Strict event receivers terminate with `TransportError` rather than silently continuing across the undecodable frame. Pending requests still time out via `RpcTimeoutError` if the dropped frame would have been their reply. + +## TCP channels + +`AhpClient.openTcpConnection` returns an owned `TcpConnection` byte stream. +The SDK handles creation, buffering, flow control, and replay: + +```ts +import { TcpDataEncoding } from '@microsoft/agent-host-protocol'; + +const connection = await client.openTcpConnection(sessionUri, { + type: 'tcpConnection', + host: 'localhost', + port: 3000, + encoding: TcpDataEncoding.Base64, + receiveWindowBytes: 65536, + maximumChunkSize: 32768, +}); +try { + await connection.write(new TextEncoder().encode('hello')); + connection.end(); + await connection.drain(); + for (let bytes; (bytes = await connection.read()) !== undefined;) { + await consumeBytes(bytes); // Your application's native stream or socket. + } +} finally { + connection.dispose(); +} +``` + +One reader and one writer may run concurrently; overlapping reads or writes are +rejected. Do not modify a write buffer until `write` resolves. `read` releases +receive credit, so read only when your destination can accept bytes. `drain` +waits for destination consumption; `end` half-closes input after the writer +finishes. `close` begins a graceful handshake; keep reading crossing output. +Use `abort` or `dispose` to terminate without draining. + +Transport loss suspends the original connection rather than interpreting it as +EOF. For a single-host client, reconnect retained handles on a fresh transport: + +```ts +const retained = oldClient.tcpConnections; +// For a deliberate transport replacement, preserve the original streams: +await oldClient.shutdown({ preserveTcpConnections: true }); + +const replacement = new AhpClient(await openTransport()); +replacement.connect(); +await replacement.reconnectTcpConnections({ + clientId, + lastSeenServerSeq, + subscriptions: ordinarySubscriptions, +}, retained); +``` + +Use the original `clientId` and continue using the same stream handles. The helper +reconciles TCP replay; apply its returned result to ordinary subscriptions. +Missing channels and snapshot fallback fail streams rather than opening new +sockets. Dispose a stream to cancel operations waiting through a disconnect. + +`HostClientHandle.openTcpConnection` provides the same API through `/hosts`; +the host runtime automatically retains and resumes streams during reconnect and +disposes them on host removal or shutdown. Do not put TCP streams in ordinary +state mirrors or managed snapshot subscriptions. + +Embedded protocol clients can reuse the portable adapter with their own transport +binding. Suspend streams on transport loss and call `beginResume` before the +reconnect request. Apply its result synchronously with `reconcileTcpConnections`, +passing the ordinary consumer's original checkpoint and using the returned result +for ordinary replay, then call `finishResume` when the client's outgoing send +gate opens. + +Consumers choose transport/retry policy, connection-count limits, destination +approval, and adapters to Node `Duplex`, browser streams, or native sockets. +For lower-level integrations, `tcpReducer` and `client.events({ overflow: 'error' })` +are available. Attach the event receiver before requesting creation; overflow +or decode loss terminates it instead of skipping actions. +See the [TCP channel contract](../../docs/specification/tcp-channel.md) for wire +semantics and implementation requirements. ## Server-initiated requests diff --git a/clients/typescript/src/client/async-queue.ts b/clients/typescript/src/client/async-queue.ts index 7caebd802..ef446d3f9 100644 --- a/clients/typescript/src/client/async-queue.ts +++ b/clients/typescript/src/client/async-queue.ts @@ -9,14 +9,24 @@ * * The buffer is bounded by `bufferLimit` (default 4096). When the buffer * fills, the oldest entries are dropped and laggard cursors are - * fast-forwarded past the gap. Callers who must not drop events should - * drain promptly or use a larger limit. + * fast-forwarded past the gap. Strict readers instead fail with + * SubscriptionLagError and terminate, so consumers cannot continue across + * an unnoticed gap. * * @internal */ +import { SubscriptionLagError } from './error.js'; + +/** Overflow behavior for an independently buffered event receiver. */ +export interface EventStreamOptions { + /** Default `drop-oldest`. Use `error` when skipping an event would corrupt the consumer. */ + overflow?: 'drop-oldest' | 'error'; +} + interface Waiter { resolve(result: IteratorResult): void; + reject(error: Error): void; } interface Cursor { @@ -24,6 +34,8 @@ interface Cursor { position: number; waiter: Waiter | null; detached: boolean; + strict: boolean; + error?: Error; } export class AsyncBroadcastQueue implements AsyncIterable { @@ -62,7 +74,13 @@ export class AsyncBroadcastQueue implements AsyncIterable { this.buffer.splice(0, drop); this.base += drop; for (const cursor of this.cursors) { - if (cursor.position < this.base) cursor.position = this.base; + if (cursor.position < this.base) { + if (cursor.strict) { + this.failCursor(cursor, new SubscriptionLagError(this.base - cursor.position)); + } else { + cursor.position = this.base; + } + } } } @@ -82,6 +100,25 @@ export class AsyncBroadcastQueue implements AsyncIterable { this.trim(); } + /** Invalidate strict receivers when the producer could not decode an event. */ + failStrictReaders(error: Error): void { + for (const cursor of this.cursors) { + if (cursor.strict) this.failCursor(cursor, error); + } + this.trim(); + } + + private failCursor(cursor: Cursor, error: Error): void { + cursor.detached = true; + this.cursors.delete(cursor); + if (cursor.waiter) { + cursor.waiter.reject(error); + cursor.waiter = null; + } else { + cursor.error = error; + } + } + /** * Close the queue. New readers see immediate end-of-stream. Existing * readers can still drain values that were published before close; @@ -103,11 +140,12 @@ export class AsyncBroadcastQueue implements AsyncIterable { } /** Create a new independent reader. */ - reader(): AsyncIterableIterator { + reader(options: EventStreamOptions = {}): AsyncIterableIterator { const cursor: Cursor = { position: this.base + this.buffer.length, waiter: null, detached: this.closed, + strict: options.overflow === 'error', }; if (!this.closed) this.cursors.add(cursor); @@ -125,6 +163,11 @@ export class AsyncBroadcastQueue implements AsyncIterable { return this; }, next(): Promise> { + if (cursor.error) { + const error = cursor.error; + cursor.error = undefined; + return Promise.reject(error); + } // Once the iterator has been detached via `return()`, all // subsequent `next()` calls resolve `done: true` immediately — // even if there are unread buffered values. AsyncIterator @@ -142,11 +185,12 @@ export class AsyncBroadcastQueue implements AsyncIterable { if (queue.closed) { return Promise.resolve({ value: undefined as unknown as T, done: true }); } - return new Promise>(resolve => { - cursor.waiter = { resolve }; + return new Promise>((resolve, reject) => { + cursor.waiter = { resolve, reject }; }); }, return(): Promise> { + cursor.error = undefined; detach(); return Promise.resolve({ value: undefined as unknown as T, done: true }); }, diff --git a/clients/typescript/src/client/client.ts b/clients/typescript/src/client/client.ts index 36066b583..71387b5da 100644 --- a/clients/typescript/src/client/client.ts +++ b/clients/typescript/src/client/client.ts @@ -66,6 +66,9 @@ import type { ServerCommandMap, } from '../types/common/messages.js'; import type { ActionEnvelope } from '../types/common/actions.js'; +import type { TcpConnectionSubscription } from '../types/channels-tcp/commands.js'; +import { TcpDataEncoding, TcpResetReason, type TcpConnectionsCapability } from '../types/channels-tcp/state.js'; +import { TcpConnection, TcpConnectionError, reconcileTcpConnections, validateTcpRequest, validateTcpSnapshot, type TcpBinding } from './tcp-connection.js'; import type { SessionAddedParams, SessionRemovedParams, @@ -74,7 +77,7 @@ import type { import type { AuthRequiredParams } from '../types/common/notifications.js'; import type { URI } from '../types/common/state.js'; import { JsonRpcErrorCodes } from '../types/common/errors.js'; -import { AsyncBroadcastQueue } from './async-queue.js'; +import { AsyncBroadcastQueue, type EventStreamOptions } from './async-queue.js'; import type { ClientEvent, ConnectionState, SubscriptionEvent } from './events.js'; import { ClientClosedError, @@ -249,6 +252,19 @@ export class AhpClient { private state: ConnectionState = { status: 'idle' }; private receiveLoop: Promise | null = null; private serverRequestHandler: ServerRequestHandler | null = null; + private clientId?: string; + private tcpCapability?: TcpConnectionsCapability; + private readonly ownedTcp = new Map(); + + /** Owned live or suspended TCP streams, suitable for reconnectTcpConnections. */ + get tcpConnections(): readonly TcpConnection[] { return [...this.ownedTcp.values()]; } + + /** @internal Preserve negotiated metadata and sequence allocation across a host-runtime reconnect. */ + inheritHandshake(previous: AhpClient): void { + this.clientId = previous.clientId; + this.tcpCapability = previous.tcpCapability; + this.nextClientSeq = Math.max(this.nextClientSeq, previous.nextClientSeq); + } constructor(transport: AhpTransport, config: AhpClientConfig = {}) { this.transport = transport; @@ -275,9 +291,12 @@ export class AhpClient { * * Each call returns a fresh independent iterator. Events are also * delivered to the matching per-URI {@link Subscription}. + * For TCP, attach with `overflow: 'error'` before sending subscribe.create. + * A strict receiver terminates on overflow; never resume a byte stream + * after a SubscriptionLagError. */ - events(): AsyncIterableIterator { - return this.allEvents.reader(); + events(options: EventStreamOptions = {}): AsyncIterableIterator { + return this.allEvents.reader(options); } /** @@ -319,7 +338,11 @@ export class AhpClient { * pending request with {@link ClientClosedError}, and terminates all * subscription and event streams. */ - async shutdown(): Promise { + async shutdown(options: { preserveTcpConnections?: boolean } = {}): Promise { + for (const connection of this.ownedTcp.values()) { + if (options.preserveTcpConnections) connection.suspend(); + else connection.dispose(); + } if (this.state.status === 'closing' || this.state.status === 'closed') return; this.setState({ status: 'closing' }); // Tear down first so pending requests reject with ClientClosedError @@ -361,7 +384,10 @@ export class AhpClient { : {}), ...(args.locale !== undefined ? { locale: args.locale } : {}), }; - return this.request('initialize', params); + return this.requestWithHandler('initialize', params, result => { + this.clientId = args.clientId; + this.tcpCapability = result.tcpConnections; + }); } /** Re-establish a dropped connection. */ @@ -379,6 +405,119 @@ export class AhpClient { return this.request('reconnect', params); } + /** + * Create an owned, flow-controlled TCP stream. The child route is installed + * during response processing, before any subsequent action is dispatched. + */ + async openTcpConnection(session: URI, create: TcpConnectionSubscription): Promise { + this.assertOpen(); + validateTcpRequest(session, create); + if (!this.clientId || !Array.isArray(this.tcpCapability?.encodings) || !this.tcpCapability.encodings.includes(TcpDataEncoding.Base64)) { + throw new TcpConnectionError('Initialize with a TCP-capable host before opening a connection'); + } + const clientId = this.clientId; + let connection: TcpConnection | undefined; + let resource: URI | undefined; + try { + await this.requestWithHandler('subscribe', { channel: session, create }, result => { + const child = result.snapshot?.resource; + if (typeof child === 'string' && child.startsWith('ahp-tcp:') && !this.ownedTcp.has(child)) resource = child; + if (!result.snapshot) throw new TcpConnectionError('TCP creation requires a snapshot'); + const state = validateTcpSnapshot(result.snapshot, session, create); + if (!resource) throw new TcpConnectionError('Host reused an existing TCP resource'); + connection = new TcpConnection(resource, state, clientId, result.snapshot.fromSeq, this.tcpBinding(resource)); + this.ownedTcp.set(resource, connection); + }, result => { + const child = result.snapshot?.resource; + if (typeof child === 'string' && child.startsWith('ahp-tcp:') && !this.ownedTcp.has(child)) { + this.notify('unsubscribe', { channel: child }); + } + }); + if (!connection) throw new TcpConnectionError('TCP creation did not return a connection'); + return connection; + } catch (error) { + if (resource !== undefined && !this.isClosed()) this.notify('unsubscribe', { channel: resource }); + throw error; + } + } + + /** + * Resume original suspended streams on a replacement transport. Replay, + * pending writes, sequence allocation and snapshot failure are handled here. + * The caller still owns transport selection and reconnection timing. + */ + async reconnectTcpConnections(args: { + clientId: string; + lastSeenServerSeq: number; + subscriptions: readonly URI[]; + }, connections: readonly TcpConnection[]): Promise { + this.assertOpen(); + if (!Number.isSafeInteger(args.lastSeenServerSeq) || args.lastSeenServerSeq < 0) { + throw new TcpConnectionError('Invalid TCP reconnect checkpoint'); + } + const retained = [...connections]; + const resources = new Set(); + const consumerCheckpoint = args.lastSeenServerSeq; + let lastSeenServerSeq = consumerCheckpoint; + let sequenceFloor = this.nextClientSeq; + for (const connection of retained) { + if (connection.clientId !== args.clientId || !connection.canResume || + resources.has(connection.resource) || this.ownedTcp.has(connection.resource)) { + throw new TcpConnectionError('TCP reconnect requires distinct suspended streams owned by this client'); + } + resources.add(connection.resource); + lastSeenServerSeq = Math.min(lastSeenServerSeq, connection.lastServerSeq); + sequenceFloor = Math.max(sequenceFloor, connection.sequenceFloor + 1); + } + this.nextClientSeq = sequenceFloor; + this.clientId = args.clientId; + this.tcpCapability ??= retained.find(connection => connection.capability)?.capability; + for (const connection of retained) { + connection.beginResume(args.clientId, this.tcpBinding(connection.resource)); + this.ownedTcp.set(connection.resource, connection); + } + try { + return await this.requestWithHandler('reconnect', { + channel: 'ahp-root://', clientId: args.clientId, lastSeenServerSeq, + subscriptions: [...new Set([...args.subscriptions, ...resources])], + }, result => { + for (const connection of retained) { + if (connection.isClosed) { + this.notify('unsubscribe', { channel: connection.resource }); + } + } + const reconciled = reconcileTcpConnections(retained, result, consumerCheckpoint); + if (result.type === 'replay') { + for (const connection of retained) connection.finishResume(); + } + return reconciled; + }, () => { + for (const resource of resources) { + if (!this.ownedTcp.has(resource)) this.notify('unsubscribe', { channel: resource }); + } + }); + } catch (error) { + for (const connection of retained) { + if (this.isClosed()) connection.suspend(); + else connection.fail(error instanceof Error ? error : new TcpConnectionError(String(error))); + } + throw error; + } + } + + private tcpBinding(resource: URI): TcpBinding { + return { + capability: this.tcpCapability, + nextSeq: () => this.nextClientSeq++, + sequenceFloor: () => this.nextClientSeq - 1, + send: (seq, action) => { this.dispatch(resource, action, seq); }, + detach: unsubscribe => { + this.ownedTcp.delete(resource); + if (unsubscribe && !this.isClosed()) this.notify('unsubscribe', { channel: resource }); + }, + }; + } + /** * Subscribe to a URI and obtain a {@link Subscription} that streams * subsequent events. The returned subscription is registered locally @@ -429,6 +568,11 @@ export class AhpClient { * No-op after the client has been shut down. */ async unsubscribe(uri: URI): Promise { + const connection = this.ownedTcp.get(uri); + if (connection) { + connection.dispose(); + return; + } if (this.isClosed()) return; const queue = this.subscriptions.get(uri); if (queue) { @@ -593,6 +737,15 @@ export class AhpClient { async request( method: M, params: CommandMap[M]['params'], + ): Promise { + return this.requestWithHandler(method, params); + } + + private requestWithHandler( + method: M, + params: CommandMap[M]['params'], + onResult?: (result: CommandMap[M]['result']) => CommandMap[M]['result'] | void, + onLateResult?: (result: CommandMap[M]['result']) => void, ): Promise { this.assertOpen(); const id = this.nextRequestId++; @@ -604,8 +757,24 @@ export class AhpClient { }; return new Promise((resolve, reject) => { + let timedOut = false; const pending: PendingRequest = { - resolve: value => resolve(value as CommandMap[M]['result']), + resolve: value => { + try { + const result = value as CommandMap[M]['result']; + if (timedOut) onLateResult?.(result); + else { + const mapped = onResult?.(result); + resolve(mapped === undefined ? result : mapped); + } + } catch (error) { + if (timedOut) { + this.tearDown({ type: 'transport', error: new TransportError('protocol', 'Invalid late response', { cause: error }) }); + } else { + reject(error); + } + } + }, reject, method: method as string, timer: null, @@ -614,7 +783,10 @@ export class AhpClient { if (this.requestTimeoutMs > 0) { pending.timer = setTimeout(() => { - if (this.pending.delete(id)) { + if (this.pending.has(id)) { + timedOut = true; + if (!onLateResult) this.pending.delete(id); + pending.timer = null; reject(new RpcTimeoutError(method as string, this.requestTimeoutMs)); } }, this.requestTimeoutMs); @@ -683,6 +855,7 @@ export class AhpClient { private tearDown(reason: { type: 'shutdown' } | { type: 'transport'; error: TransportError }): void { if (this.state.status === 'closed') return; this.setState({ status: 'closed', reason }); + for (const connection of this.ownedTcp.values()) connection.suspend(); // Fail every pending request. const failure = reason.type === 'shutdown' ? new ClientClosedError() : reason.error; @@ -737,6 +910,12 @@ export class AhpClient { } } } catch (err) { + this.allEvents.failStrictReaders( + new TransportError('protocol', 'malformed inbound frame', { cause: err }), + ); + for (const connection of this.ownedTcp.values()) { + connection.abort(TcpResetReason.ProtocolError, new TransportError('protocol', 'Malformed TCP transport frame', { cause: err })); + } // A single malformed frame doesn't tear down the channel — a // well-behaved server should not send them, and a transient bad // frame from a peer that recovers shouldn't kill in-flight @@ -829,6 +1008,16 @@ export class AhpClient { switch (n.method) { case 'action': { const env = n.params as ActionEnvelope; + if (!env || typeof env.channel !== 'string' || !Number.isSafeInteger(env.serverSeq) || env.serverSeq < 0 || + !env.action || typeof env.action.type !== 'string') { + const error = new TransportError('protocol', 'Invalid action envelope'); + this.allEvents.failStrictReaders(error); + for (const connection of this.ownedTcp.values()) connection.abort(TcpResetReason.ProtocolError, error); + // eslint-disable-next-line no-console + console.warn('AhpClient: invalid action envelope'); + return; + } + this.ownedTcp.get(env.channel)?.accept(env); this.fanOut(env.channel, { type: 'action', params: env }); break; } diff --git a/clients/typescript/src/client/error.ts b/clients/typescript/src/client/error.ts index d0cc2b509..c8172f5fe 100644 --- a/clients/typescript/src/client/error.ts +++ b/clients/typescript/src/client/error.ts @@ -1,7 +1,7 @@ /** * Error taxonomy for {@link AhpClient}. * - * Five error families surface to consumers: + * Error families surface to consumers: * * - {@link TransportError} — failures of the underlying {@link AhpTransport} * (closed connection, I/O, undecodable frames). @@ -11,13 +11,15 @@ * error, the wait just elapsed. * - {@link ClientClosedError} — the client was shut down (or the transport * was torn down) while a request was in flight. + * - {@link SubscriptionLagError} — a strict event receiver exceeded its + * bounded buffer and cannot safely continue. * - {@link AhpClientError} — base class; consumers can use `instanceof` to * catch every error this SDK throws. * - * Malformed inbound frames do not throw — they are logged via - * `console.warn` and the channel stays alive (matching the Rust client's - * `tracing::warn!` behavior). Pending requests still time out via - * {@link RpcTimeoutError} if the bad frame would have been their reply. + * Malformed inbound frames are logged via `console.warn` without closing + * the connection. Strict event receivers fail with {@link TransportError}; + * ordinary receivers retain their existing behavior. Pending requests still + * time out via {@link RpcTimeoutError} if the bad frame was their reply. * * @module client/error */ @@ -87,3 +89,11 @@ export class ClientClosedError extends AhpClientError { this.name = 'ClientClosedError'; } } + +/** A strict event receiver overflowed. Its stream terminates rather than skipping events. */ +export class SubscriptionLagError extends AhpClientError { + constructor(readonly missedEvents: number) { + super(`Event receiver lagged by ${missedEvents} event(s); stream terminated`); + this.name = 'SubscriptionLagError'; + } +} diff --git a/clients/typescript/src/client/hosts/host-client-handle.ts b/clients/typescript/src/client/hosts/host-client-handle.ts index ad6087062..e1d7d3ba0 100644 --- a/clients/typescript/src/client/hosts/host-client-handle.ts +++ b/clients/typescript/src/client/hosts/host-client-handle.ts @@ -15,6 +15,8 @@ import type { CommandMap } from '../../types/common/messages.js'; import type { StateAction } from '../../types/common/actions.js'; import type { URI } from '../../types/common/state.js'; import type { AhpClient, DispatchHandle } from '../client.js'; +import type { TcpConnection } from '../tcp-connection.js'; +import type { TcpConnectionSubscription } from '../../types/channels-tcp/commands.js'; import { HostReconnectedError, HostShutDownError, @@ -83,6 +85,12 @@ export class HostClientHandle { return this.client.dispatch(channel, action, clientSeq); } + /** Open an owned byte stream; the host runtime preserves it across replay-capable reconnects. */ + openTcpConnection(session: URI, create: TcpConnectionSubscription): Promise { + this.checkAlive(); + return this.client.openTcpConnection(session, create); + } + /** * Issue an arbitrary typed JSON-RPC request through this connection, * refusing if the connection has been replaced by a reconnect or the diff --git a/clients/typescript/src/client/hosts/runtime.ts b/clients/typescript/src/client/hosts/runtime.ts index 26bfaa238..4f2117e6f 100644 --- a/clients/typescript/src/client/hosts/runtime.ts +++ b/clients/typescript/src/client/hosts/runtime.ts @@ -293,6 +293,7 @@ export class HostRuntime { private readonly shutdownController = new AbortController(); private manualReconnectController = new AbortController(); private supervisorPromise: Promise | null = null; + private previousClient: AhpClient | null = null; /** * Resolved by {@link reconnect} when the manual-reconnect cycle has * actually been observed by the supervisor (state transitions to @@ -514,6 +515,8 @@ export class HostRuntime { // 'disconnected' — fall through to the retry/backoff path. } + for (const connection of this.previousClient?.tcpConnections ?? []) connection.dispose(); + this.previousClient = null; // Final cleanup. The handle source mirrors shared state already. this.handleSource.currentClient = null; this.handleSource.generation = this.shared.generation; @@ -553,6 +556,7 @@ export class HostRuntime { const transport = transportResult; const client = new AhpClient(transport, this.config.clientConfig); + if (this.previousClient) client.inheritHandshake(this.previousClient); client.connect(); // Attach the events stream BEFORE the handshake so any // notifications the server pushes between the handshake response @@ -566,7 +570,8 @@ export class HostRuntime { serverSeq: this.shared.serverSeq, subscriptions: [...this.shared.subscriptions], }; - const canReconnect = prior.serverSeq > 0 && prior.subscriptions.length > 0; + const tcpConnections = this.previousClient?.tcpConnections ?? []; + const canReconnect = tcpConnections.length > 0 || (prior.serverSeq > 0 && prior.subscriptions.length > 0); let reconnectResult: ReconnectResult | null = null; let initSnapshots: Snapshot[] | null = null; @@ -579,11 +584,11 @@ export class HostRuntime { if (canReconnect) { try { const reconnectRes = await raceWithAbort( - client.reconnect({ + client.reconnectTcpConnections({ clientId: this.shared.clientId, lastSeenServerSeq: prior.serverSeq, subscriptions: prior.subscriptions, - }), + }, tcpConnections), cancelSignal, ); if (reconnectRes === ABORTED) throw new Error('reconnect aborted'); @@ -681,6 +686,7 @@ export class HostRuntime { // Commit shared state. this.shared.generation += 1; this.shared.currentClient = client; + this.previousClient = null; this.shared.lastConnectedAt = Date.now(); this.shared.lastError = null; if (this.shared.serverSeq < initServerSeq) { @@ -733,7 +739,11 @@ export class HostRuntime { // Connect failed mid-flight — shut the half-built client down so // we don't leak the transport. try { - await client.shutdown(); + const preserveTcpConnections = this.shared.shutdownReason === null; + await client.shutdown({ preserveTcpConnections }); + if (preserveTcpConnections && (client.tcpConnections.length > 0 || !this.previousClient?.tcpConnections.length)) { + this.previousClient = client; + } } catch { // best-effort } @@ -790,7 +800,9 @@ export class HostRuntime { this.handleSource.currentClient = null; if (prev) { try { - await prev.shutdown(); + const preserveTcpConnections = this.shared.shutdownReason === null; + await prev.shutdown({ preserveTcpConnections }); + this.previousClient = preserveTcpConnections ? prev : null; } catch { // best-effort } diff --git a/clients/typescript/src/client/index.ts b/clients/typescript/src/client/index.ts index e264977bc..90ab46e30 100644 --- a/clients/typescript/src/client/index.ts +++ b/clients/typescript/src/client/index.ts @@ -5,6 +5,7 @@ */ export { AhpClient, Subscription } from './client.js'; +export { TcpConnection, TcpConnectionError, reconcileTcpConnections } from './tcp-connection.js'; export { createResourceRequestHandler } from './client.js'; export type { AhpClientConfig, @@ -14,11 +15,13 @@ export type { SubscribeOptions, } from './client.js'; export type { ClientEvent, ClosedReason, ConnectionState, SubscriptionEvent } from './events.js'; +export type { EventStreamOptions } from './async-queue.js'; export { AhpClientError, ClientClosedError, RpcError, RpcTimeoutError, + SubscriptionLagError, TransportError, } from './error.js'; export type { TransportErrorKind } from './error.js'; diff --git a/clients/typescript/src/client/tcp-connection.ts b/clients/typescript/src/client/tcp-connection.ts new file mode 100644 index 000000000..ead8ab17a --- /dev/null +++ b/clients/typescript/src/client/tcp-connection.ts @@ -0,0 +1,395 @@ +/** + * An owned, flow-controlled TCP byte stream. No socket or connection policy. + * @module client/tcp-connection + */ + +import { ActionType, type ActionEnvelope } from '../types/common/actions.js'; +import { IS_CLIENT_DISPATCHABLE, type ClientTcpAction, type TcpAction } from '../types/action-origin.generated.js'; +import { tcpReducer } from '../types/channels-tcp/reducer.js'; +import type { TcpConnectionSubscription } from '../types/channels-tcp/commands.js'; +import { TcpDataEncoding, TcpResetReason, type TcpConnectionsCapability, type TcpConnectionState } from '../types/channels-tcp/state.js'; +import type { Snapshot } from '../types/common/state.js'; +import type { ReconnectResult } from '../types/common/commands.js'; +import { AhpClientError } from './error.js'; + +/** A TCP channel failed or an operation cannot be performed in its current state. */ +export class TcpConnectionError extends AhpClientError { + constructor(message: string, readonly reason?: TcpResetReason) { + super(message); + this.name = 'TcpConnectionError'; + } +} + +/** + * Reconcile owned streams before live delivery resumes. Custom AHP clients can + * use this with the same stream adapter; no TCP snapshot may restore payload. + * The returned result omits ordinary actions the consumer already applied, + * independently of the lower wire checkpoint needed by retained TCP streams. + * Call finishResume only after the client's reconnect send gate is released. + */ +export function reconcileTcpConnections(connections: Iterable, result: ReconnectResult, lastSeenServerSeq: number): ReconnectResult { + requireTcp(Number.isSafeInteger(lastSeenServerSeq) && lastSeenServerSeq >= 0, 'Invalid reconnect consumer checkpoint'); + const owned = new Map([...connections].map(connection => [connection.resource, connection])); + const missing = new Set(result.missing ?? []); + for (const connection of owned.values()) { + if (result.type !== 'replay' || missing.has(connection.resource)) { + connection.fail(new TcpConnectionError('TCP replay unavailable', TcpResetReason.ReplayUnavailable)); + owned.delete(connection.resource); + } + } + if (result.type === 'replay') { + for (const envelope of result.actions) {owned.get(envelope.channel)?.accept(envelope, true);} + for (const connection of owned.values()) {connection.completeReplay();} + return { + ...result, + actions: result.actions.filter(envelope => envelope.channel.startsWith('ahp-tcp:') || envelope.serverSeq > lastSeenServerSeq), + }; + } + return result; +} + +/** @internal Transport ownership supplied by AhpClient, not by stream consumers. */ +export interface TcpBinding { + capability?: TcpConnectionsCapability; + nextSeq(): number; + sequenceFloor(): number; + send(seq: number, action: ClientTcpAction): void; + detach(unsubscribe: boolean): void; +} + +function requireTcp(condition: boolean, message: string): asserts condition { + if (!condition) {throw new TcpConnectionError(message, TcpResetReason.ProtocolError);} +} + +/** @internal Shared wire validation for consumers and hosts; host policy is separate. */ +export function validateTcpRequest(session: string, create: TcpConnectionSubscription): void { + requireTcp(typeof session === 'string' && session.startsWith('ahp-session:'), 'TCP creation requires a parent session'); + requireTcp(!!create && create.type === 'tcpConnection', 'Invalid TCP creation kind'); + requireTcp(typeof create.host === 'string' && create.host.length > 0 && !/[\s/\0]/.test(create.host), 'Invalid TCP host'); + requireTcp(Number.isInteger(create.port) && create.port >= 1 && create.port <= 65535, 'Invalid TCP port'); + requireTcp(create.encoding === TcpDataEncoding.Base64, 'Unsupported TCP encoding'); + requireTcp(Number.isInteger(create.receiveWindowBytes) && create.receiveWindowBytes >= 1 && create.receiveWindowBytes <= 0xffffffff, 'Invalid TCP receive window'); + requireTcp(Number.isInteger(create.maximumChunkSize) && create.maximumChunkSize >= 1 && create.maximumChunkSize <= create.receiveWindowBytes, 'Invalid TCP chunk size'); +} + +/** @internal A snapshot is accepted only for fresh creation, never stream recovery. */ +export function validateTcpSnapshot(snapshot: Snapshot, session: string, create: TcpConnectionSubscription): TcpConnectionState { + requireTcp(!!snapshot && typeof snapshot.resource === 'string' && snapshot.resource.startsWith('ahp-tcp:'), 'Invalid TCP creation resource'); + requireTcp(Number.isSafeInteger(snapshot.fromSeq) && snapshot.fromSeq >= 0, 'Invalid TCP creation sequence'); + const state = snapshot.state; + requireTcp(!!state && typeof state === 'object' && 'input' in state && 'output' in state && 'target' in state, 'Invalid TCP creation state'); + requireTcp(state.session === session && state.target?.host === create.host && state.target?.port === create.port, 'TCP creation target mismatch'); + requireTcp(state.encoding === TcpDataEncoding.Base64, 'TCP creation encoding mismatch'); + requireTcp(state.clientClosed === false && state.hostClosed === false && state.reset === undefined, 'TCP creation is already closed'); + for (const direction of [state.input, state.output]) { + requireTcp(!!direction && Number.isInteger(direction.windowBytes) && direction.windowBytes >= 1 && direction.windowBytes <= 0xffffffff, 'Invalid TCP creation window'); + requireTcp(Number.isInteger(direction.maximumChunkSize) && direction.maximumChunkSize >= 1 && direction.maximumChunkSize <= direction.windowBytes, 'Invalid TCP creation chunk limit'); + requireTcp(direction.receivedBytes === 0 && direction.consumedBytes === 0 && direction.eofAtBytes === undefined, 'TCP creation must start with empty directions'); + } + requireTcp(state.output.windowBytes <= create.receiveWindowBytes && state.output.maximumChunkSize <= create.maximumChunkSize, 'Host exceeded requested TCP receive limits'); + return structuredClone(state); +} + +function encode(data: Uint8Array): string { + const parts: string[] = []; + for (let i = 0; i < data.length; i += 32768) { + parts.push(String.fromCharCode(...data.subarray(i, i + 32768))); + } + return btoa(parts.join('')); +} + +function decode(data: string): Uint8Array { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) {bytes[i] = binary.charCodeAt(i);} + return bytes; +} + +function matchesPendingAction(expected: ClientTcpAction, actual: ActionEnvelope['action']): boolean { + switch (expected.type) { + case ActionType.TcpInput: + return actual.type === expected.type && actual.offset === expected.offset && actual.data === expected.data; + case ActionType.TcpDataConsumed: + return actual.type === expected.type && actual.consumedBytes === expected.consumedBytes; + case ActionType.TcpInputEof: + return actual.type === expected.type && actual.finalOffset === expected.finalOffset; + case ActionType.TcpClientClose: + return actual.type === expected.type; + case ActionType.TcpClientReset: + return actual.type === expected.type && actual.reason === expected.reason; + } +} + +/** + * A single-reader, single-writer byte stream created by AhpClient.openTcpConnection. + * Reads release receive credit. Writes wait for credit without an unbounded + * send queue; do not modify the input buffer until write completes. + * Transport loss suspends the stream until reconnectTcpConnections resumes it. + */ +export class TcpConnection implements Disposable { + private readonly pending = new Map(); + private readonly received: Uint8Array[] = []; + private readonly waiters = new Set<() => void>(); + private sentBytes = 0; + private consumedBytes = 0; + private reading = false; + private writing = false; + private ending = false; + private closing = false; + private terminal = false; + private failure?: Error; + private suspended = false; + private resuming = false; + private replayComplete = false; + private lastSequence = 0; + + /** @internal */ + constructor( + readonly resource: string, + private currentState: TcpConnectionState, + readonly clientId: string, + private checkpoint: number, + private binding: TcpBinding, + ) {} + + /** A defensive copy for diagnostics; mutating it cannot change stream accounting. */ + get state(): TcpConnectionState { return structuredClone(this.currentState); } + get isClosed(): boolean { return this.terminal; } + get isSuspended(): boolean { return this.suspended && !this.terminal; } + /** @internal */ + get canResume(): boolean { return this.isSuspended && !this.resuming; } + /** @internal */ + get lastServerSeq(): number { return this.checkpoint; } + /** @internal */ + get sequenceFloor(): number { return Math.max(this.lastSequence, this.binding.sequenceFloor()); } + /** @internal Negotiated metadata follows the original logical host across reconnect. */ + get capability(): TcpConnectionsCapability | undefined { return this.binding.capability; } + + private changed(): void { + for (const resolve of this.waiters) {resolve();} + this.waiters.clear(); + } + + private wait(): Promise { + return new Promise(resolve => this.waiters.add(resolve)); + } + + private checkOpen(): void { + if (this.failure) {throw this.failure;} + if (this.terminal) {throw new TcpConnectionError('TCP connection is closed');} + } + + private dispatch(action: ClientTcpAction): void { + const seq = this.binding.nextSeq(); + requireTcp(Number.isSafeInteger(seq) && seq > 0, 'TCP client sequence exhausted'); + this.lastSequence = seq; + this.pending.set(seq, action); + if (!this.suspended) {this.binding.send(seq, action);} + } + + /** @internal Live delivery is synchronous with the client's receive loop. */ + accept(envelope: ActionEnvelope, replay = false): void { + if (this.terminal) {return;} + try { + requireTcp(!this.suspended || replay || this.replayComplete, 'TCP action arrived before reconnect replay'); + requireTcp(envelope.channel === this.resource && Number.isSafeInteger(envelope.serverSeq) && envelope.serverSeq >= 0, 'Invalid TCP action envelope'); + if (envelope.serverSeq <= this.checkpoint) {return;} + requireTcp(envelope.rejectionReason === undefined, envelope.rejectionReason ?? 'TCP action rejected'); + const action = envelope.action; + requireTcp(typeof action?.type === 'string' && action.type.startsWith('tcp/'), 'Non-TCP action on TCP channel'); + let echoedSequence: number | undefined; + let pendingAction: ClientTcpAction | undefined; + if (IS_CLIENT_DISPATCHABLE[action.type]) { + const sequence = envelope.origin?.clientSeq; + requireTcp(envelope.origin?.clientId === this.clientId && typeof sequence === 'number' + && Number.isSafeInteger(sequence) && sequence > 0 && sequence <= this.lastSequence, 'Invalid TCP echo origin or sequence'); + echoedSequence = sequence; + pendingAction = this.pending.get(sequence); + if (pendingAction) { + requireTcp(matchesPendingAction(pendingAction, action), 'TCP echo does not match pending action'); + } + } + const previous = this.currentState; + const next = tcpReducer(previous, action as TcpAction); + if (echoedSequence !== undefined && !pendingAction) { + // Old unmatched echoes may only be state-neutral duplicates; no payload history is retained. + requireTcp(next === previous, 'Unmatched TCP echo'); + } + requireTcp(next.input.receivedBytes <= this.sentBytes, 'Host echoed input that this stream never sent'); + requireTcp(next.output.consumedBytes <= this.consumedBytes, 'Host echoed output credit not released by this stream'); + requireTcp(next.output.receivedBytes - this.consumedBytes <= next.output.windowBytes, 'TCP output exceeds locally released credit'); + if (action.type === ActionType.TcpData && next.output.receivedBytes > previous.output.receivedBytes) { + this.received.push(decode(action.data)); + } + this.currentState = next; + this.checkpoint = envelope.serverSeq; + if (echoedSequence !== undefined && pendingAction) { + this.pending.delete(echoedSequence); + } + if (next.reset) { + this.fail(new TcpConnectionError(`TCP reset: ${next.reset.reason}`, next.reset.reason)); + } else if (next.hostClosed && !this.closing) { + this.close(); + } + this.finishIfClosed(); + } catch (error) { + this.abort(TcpResetReason.ProtocolError, error instanceof TcpConnectionError + ? error + : new TcpConnectionError(error instanceof Error ? error.message : String(error), TcpResetReason.ProtocolError)); + } + this.changed(); + } + + /** Returns undefined only after the peer ended its output and buffered bytes drained. */ + async read(): Promise { + if (this.reading) {throw new TcpConnectionError('TCP permits only one reader');} + this.reading = true; + try { + while (true) { + if (this.failure) {throw this.failure;} + const data = this.received.shift(); + if (data) { + this.consumedBytes += data.byteLength; + this.dispatch({ type: ActionType.TcpDataConsumed, consumedBytes: this.consumedBytes }); + return data; + } + if (this.currentState.output.eofAtBytes !== undefined || this.currentState.hostClosed || this.terminal) {return undefined;} + await this.wait(); + } + } finally { + this.reading = false; + } + } + + /** Retain all bytes for dispatch under available credit; drain() waits for destination consumption. */ + async write(data: Uint8Array): Promise { + this.checkOpen(); + if (this.writing || this.ending || this.closing) {throw new TcpConnectionError('TCP write requires an open, idle writer');} + this.writing = true; + try { + let offset = 0; + while (offset < data.byteLength) { + this.checkOpen(); + if (this.closing) {throw new TcpConnectionError('TCP closed during write');} + const direction = this.currentState.input; + const credit = direction.windowBytes - (this.sentBytes - direction.consumedBytes); + if (credit === 0) { + await this.wait(); + continue; + } + const length = Math.min(credit, direction.maximumChunkSize, data.byteLength - offset); + requireTcp(Number.isSafeInteger(this.sentBytes + length), 'TCP byte offset exhausted'); + const action: ClientTcpAction = { type: ActionType.TcpInput, offset: this.sentBytes, data: encode(data.subarray(offset, offset + length)) }; + this.sentBytes += length; + offset += length; + this.dispatch(action); + } + } finally { + this.writing = false; + } + } + + /** Wait until the destination has consumed all dispatched input. */ + async drain(): Promise { + while (this.currentState.input.consumedBytes < this.sentBytes) { + this.checkOpen(); + await this.wait(); + } + if (this.failure) {throw this.failure;} + } + + /** Half-close input after write completes; output remains readable. */ + end(): void { + this.checkOpen(); + if (this.writing || this.closing) {throw new TcpConnectionError('TCP end requires an open, idle writer');} + if (!this.ending) { + this.ending = true; + this.dispatch({ type: ActionType.TcpInputEof, finalOffset: this.sentBytes }); + } + } + + /** Begin the final close handshake. Use end() for a one-direction half-close. */ + close(): void { + if (!this.closing && !this.terminal) { + this.closing = true; + this.dispatch({ type: ActionType.TcpClientClose }); + this.changed(); + } + } + + private finishIfClosed(): void { + if (!this.terminal && this.currentState.clientClosed && this.currentState.hostClosed && + this.received.length === 0 && this.currentState.input.consumedBytes >= this.sentBytes && + this.currentState.output.consumedBytes >= this.consumedBytes) { + this.terminal = true; + this.pending.clear(); + this.binding.detach(true); + this.changed(); + } + } + + /** @internal Transport loss retains the original buffers and pending actions. */ + suspend(): void { + this.suspended = true; + this.resuming = false; + this.replayComplete = false; + } + + /** @internal Claims a suspended stream for one replacement client. */ + beginResume(clientId: string, binding: TcpBinding): void { + this.checkOpen(); + requireTcp(this.clientId === clientId, 'Cannot resume TCP under another client identity'); + requireTcp(this.suspended && !this.resuming, 'TCP is not available for reconnect'); + this.resuming = true; + this.binding.detach(false); + this.binding = binding; + } + + /** @internal Live events may follow the response while the client's send gate remains closed. */ + completeReplay(): void { + if (this.terminal) {return;} + requireTcp(this.resuming, 'TCP reconnect was not started'); + this.replayComplete = true; + } + + /** @internal Replay must already have been applied before resending pending input. */ + finishResume(): void { + if (this.terminal) {return;} + requireTcp(this.resuming && this.replayComplete, 'TCP reconnect replay is incomplete'); + this.suspended = false; + this.resuming = false; + for (const [seq, action] of this.pending) { + if (this.suspended) {break;} + this.binding.send(seq, action); + } + this.changed(); + } + + /** Immediately abort, discard buffered bytes, and reject blocked operations. */ + abort(reason = TcpResetReason.ConnectionAborted, error: Error = new TcpConnectionError(`TCP aborted: ${reason}`, reason)): void { + if (this.terminal) {return;} + try { + if (!this.suspended) {this.dispatch({ type: ActionType.TcpClientReset, reason });} + } finally { + this.fail(error); + } + } + + /** @internal */ + fail(error: Error): void { + if (this.terminal) {return;} + this.failure = error; + this.terminal = true; + this.pending.clear(); + this.received.length = 0; + this.binding.detach(true); + this.changed(); + } + + /** Release the remote subscription and cancel local operations. Idempotent. */ + dispose(): void { + this.fail(new TcpConnectionError('TCP connection disposed', TcpResetReason.ConnectionAborted)); + } + + [Symbol.dispose](): void { this.dispose(); } +} diff --git a/clients/typescript/test/async-queue.test.ts b/clients/typescript/test/async-queue.test.ts index 2aa972a8c..3f08fcfc8 100644 --- a/clients/typescript/test/async-queue.test.ts +++ b/clients/typescript/test/async-queue.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { AsyncBroadcastQueue } from '../src/client/async-queue.js'; +import { SubscriptionLagError } from '../src/client/error.js'; test('reader created after publish does not replay history', async () => { const q = new AsyncBroadcastQueue(); @@ -78,3 +79,51 @@ test('bounded buffer drops oldest and fast-forwards laggards', async () => { assert.equal((await r.next()).value, 3); assert.equal((await r.next()).value, 4); }); + +test('strict readers deliver the full buffer, then fail terminally on overflow', async () => { + const q = new AsyncBroadcastQueue(2); + const strict = q.reader({ overflow: 'error' }); + const ordinary = q.reader(); + q.publish(1); + q.publish(2); + assert.equal((await strict.next()).value, 1); + assert.equal((await strict.next()).value, 2); + q.publish(3); + q.publish(4); + q.publish(5); + await assert.rejects(strict.next(), error => + error instanceof SubscriptionLagError && error.missedEvents === 1); + assert.equal((await strict.next()).done, true); + assert.equal((await ordinary.next()).value, 4); + assert.equal((await ordinary.next()).value, 5); +}); + +test('one lagging strict reader does not terminate a prompt strict reader', async () => { + const q = new AsyncBroadcastQueue(1); + const slow = q.reader({ overflow: 'error' }); + const fast = q.reader({ overflow: 'error' }); + const pending = fast.next(); + q.publish(1); + assert.equal((await pending).value, 1); + q.publish(2); + assert.equal((await fast.next()).value, 2); + await assert.rejects(slow.next(), SubscriptionLagError); + q.close(); + assert.equal((await fast.next()).done, true); +}); + +test('closing or returning strict readers preserves normal iterator semantics', async () => { + const q = new AsyncBroadcastQueue(1); + const reader = q.reader({ overflow: 'error' }); + q.publish(1); + q.close(); + assert.equal((await reader.next()).value, 1); + assert.equal((await reader.next()).done, true); + const overflowed = new AsyncBroadcastQueue(1); + const detached = overflowed.reader({ overflow: 'error' }); + overflowed.publish(1); + overflowed.publish(2); + await detached.return!(); + assert.equal((await detached.next()).done, true); + assert.equal(overflowed.hasReaders, false); +}); diff --git a/clients/typescript/test/client.test.ts b/clients/typescript/test/client.test.ts index 6177c092f..973456343 100644 --- a/clients/typescript/test/client.test.ts +++ b/clients/typescript/test/client.test.ts @@ -17,6 +17,8 @@ import { InMemoryTransport, RpcError, RpcTimeoutError, + SubscriptionLagError, + TransportError, type AhpTransport, type SubscriptionEvent, } from '../src/client/index.js'; @@ -42,6 +44,8 @@ import type { import { JsonRpcErrorCodes } from '../src/types/common/errors.js'; import { AutomationOperation, type AutomationEntry } from '../src/types/channels-automation/state.js'; import { MessageKind } from '../src/types/channels-chat/state.js'; +import { TcpDataEncoding, type TcpConnectionState } from '../src/types/channels-tcp/state.js'; +import { tcpReducer } from '../src/types/channels-tcp/reducer.js'; const ROOT = 'ahp-root://' as const; const AUTOMATIONS = 'ahp-automations://' as const; @@ -108,6 +112,141 @@ test('initialize round-trip', async () => { await client.shutdown(); }); +test('pre-attached strict events retain immediate TCP child actions without replacing the parent', async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + client.connect(); + t.after(() => client.shutdown()); + const parent = client.attachSubscription('ahp-session:/s1'); + const events = client.events({ overflow: 'error' }); + const opening = client.request('subscribe', { + channel: parent.uri, + create: { + type: 'tcpConnection', + host: 'localhost', + port: 3000, + encoding: TcpDataEncoding.Base64, + receiveWindowBytes: 8, + maximumChunkSize: 6, + }, + }); + const req = await readRequest(s); + assert.equal((req.params as SubscribeParams).create?.type, 'tcpConnection'); + const state: TcpConnectionState = { + session: parent.uri, + target: { host: 'localhost', port: 3000 }, + encoding: TcpDataEncoding.Base64, + input: { windowBytes: 8, maximumChunkSize: 6, receivedBytes: 0, consumedBytes: 0 }, + output: { windowBytes: 8, maximumChunkSize: 6, receivedBytes: 0, consumedBytes: 0 }, + clientClosed: false, + hostClosed: false, + }; + const result: SubscribeResult = { + channel: parent.uri, + snapshot: { resource: 'ahp-tcp:/t1', fromSeq: 0, state }, + }; + reply(s, req.id, result); + pushNotification(s, 'action', { + channel: result.snapshot.resource, serverSeq: 1, + action: { type: ActionType.TcpData, offset: 0, data: 'AQ==' }, + }); + pushNotification(s, 'action', { + channel: parent.uri, serverSeq: 2, + action: { type: ActionType.SessionTitleChanged, title: 'Still subscribed' }, + }); + assert.deepEqual(await opening, result); + const first = await events.next(); + assert.equal(first.value.channel, result.snapshot.resource); + assert.equal(first.value.event.type, 'action'); + if (first.value.event.type !== 'action' || first.value.event.params.action.type !== ActionType.TcpData) { + assert.fail('expected the first TCP payload'); + } + assert.equal(tcpReducer(state, first.value.event.params.action).output.receivedBytes, 1); + assert.equal((await parent.next()).value.type, 'action'); + await client.unsubscribe(result.snapshot.resource); + const closing = await readNotification(s); + assert.equal(closing.method, 'unsubscribe'); + assert.deepEqual(closing.params, { channel: result.snapshot.resource }); + await events.return!(); +}); + +test('strict global events report overflow instead of continuing after missing TCP payload', async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + client.connect(); + t.after(() => client.shutdown()); + const events = client.events({ overflow: 'error' }); + for (let offset = 0; offset <= 4096; offset++) { + pushNotification(s, 'action', { + channel: 'ahp-tcp:/t1', serverSeq: offset + 1, + action: { type: ActionType.TcpData, offset, data: 'AQ==' }, + }); + } + const fence = client.request('ping', {}); + const req = await readRequest(s); + reply(s, req.id, {}); + await fence; + await assert.rejects(events.next(), SubscriptionLagError); + assert.equal((await events.next()).done, true); +}); + +test('malformed frames terminate strict events without changing ordinary receivers', async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + client.connect(); + t.after(() => client.shutdown()); + const strict = client.events({ overflow: 'error' }); + const ordinary = client.events(); + const rejection = assert.rejects(strict.next(), error => + error instanceof TransportError && error.kind === 'protocol'); + s.send('{'); + pushNotification(s, 'action', { + channel: 'ahp-session:/s1', serverSeq: 1, + action: { type: ActionType.SessionTitleChanged, title: 'Still connected' }, + }); + await rejection; + assert.equal((await strict.next()).done, true); + assert.equal((await ordinary.next()).value.channel, 'ahp-session:/s1'); + assert.equal(client.connectionState.status, 'connected'); + await ordinary.return!(); +}); + +test('pre-attached TCP events preserve replay-before-live ordering and expose snapshot failure', async t => { + const [c, s] = InMemoryTransport.pair(); + const client = new AhpClient(c); + client.connect(); + t.after(() => client.shutdown()); + const events = client.events({ overflow: 'error' }); + const reconnecting = client.reconnect({ + clientId: 'owner', lastSeenServerSeq: 0, subscriptions: ['ahp-tcp:/t1'], + }); + const req = await readRequest(s); + const replayed = { + channel: 'ahp-tcp:/t1', serverSeq: 1, + action: { type: ActionType.TcpData, offset: 0, data: 'AQ==' }, + }; + reply(s, req.id, { type: 'replay', actions: [replayed], missing: [] }); + pushNotification(s, 'action', { + channel: 'ahp-tcp:/t1', serverSeq: 2, + action: { type: ActionType.TcpData, offset: 1, data: 'Ag==' }, + }); + const replay = await reconnecting; + assert.equal(replay.type, 'replay'); + if (replay.type !== 'replay') assert.fail('expected replay'); + assert.deepEqual(replay.actions, [replayed]); + const live = await events.next(); + assert.equal(live.value.event.type, 'action'); + if (live.value.event.type !== 'action') assert.fail('expected action'); + assert.equal(live.value.event.params.serverSeq, 2); + const fallback = client.reconnect({ + clientId: 'owner', lastSeenServerSeq: 2, subscriptions: ['ahp-tcp:/t1'], + }); + const next = await readRequest(s); + reply(s, next.id, { type: 'snapshot', snapshots: [], missing: ['ahp-tcp:/t1'] }); + assert.deepEqual(await fallback, { type: 'snapshot', snapshots: [], missing: ['ahp-tcp:/t1'] }); + await events.return!(); +}); + test('subscribe attaches before sending the request and fans out an action', async () => { const [c, s] = InMemoryTransport.pair(); const client = new AhpClient(c); diff --git a/clients/typescript/test/tcp-connection.test.ts b/clients/typescript/test/tcp-connection.test.ts new file mode 100644 index 000000000..82cf9ee77 --- /dev/null +++ b/clients/typescript/test/tcp-connection.test.ts @@ -0,0 +1,634 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { + AhpClient, AhpStateMirror, InMemoryTransport, TcpConnection, TcpConnectionError, reconcileTcpConnections, + type AhpTransport, +} from '../src/client/index.js'; +import { ActionType, type ActionEnvelope, type StateAction } from '../src/types/common/actions.js'; +import { TcpDataEncoding, TcpResetReason, type TcpConnectionState } from '../src/types/channels-tcp/state.js'; +import type { TcpConnectionSubscription } from '../src/types/channels-tcp/commands.js'; +import type { DispatchActionParams } from '../src/types/common/commands.js'; +import type { JsonRpcRequest, JsonRpcNotification } from '../src/types/common/messages.js'; +import { MultiHostClient, immediateForeverPolicy } from '../src/client/hosts/index.js'; +import { validateTcpRequest } from '../src/client/tcp-connection.js'; +import { TerminalClaimKind, TerminalLifecycleStatus } from '../src/types/channels-terminal/state.js'; + +function terminalMirror() { + const mirror = new AhpStateMirror(); + mirror.applySnapshot({ + resource: 'ahp-terminal:/test', fromSeq: 0, + state: { + title: 'Test', content: [], lifecycle: { status: TerminalLifecycleStatus.Running }, + claim: { kind: TerminalClaimKind.Client, clientId: 'owner' }, + }, + }); + return mirror; +} + +const session = 'ahp-session:/s1'; +const resource = 'ahp-tcp:/t1'; +const create: TcpConnectionSubscription = { + type: 'tcpConnection', host: 'localhost', port: 3000, + encoding: TcpDataEncoding.Base64, receiveWindowBytes: 4, maximumChunkSize: 2, +}; + +function initial(): TcpConnectionState { + return { + session, target: { host: create.host, port: create.port }, encoding: TcpDataEncoding.Base64, + input: { windowBytes: 4, maximumChunkSize: 2, receivedBytes: 0, consumedBytes: 0 }, + output: { windowBytes: 4, maximumChunkSize: 2, receivedBytes: 0, consumedBytes: 0 }, + clientClosed: false, hostClosed: false, + }; +} + +async function message(server: AhpTransport): Promise { + const frame = await server.recv(); + assert.ok(frame && frame.kind === 'text'); + return JSON.parse(frame.text); +} + +async function request(server: AhpTransport): Promise { + const msg = await message(server); + assert.ok('id' in msg); + return msg; +} + +async function dispatch(server: AhpTransport): Promise { + const msg = await message(server); + assert.equal(msg.method, 'dispatchAction'); + return msg.params as DispatchActionParams; +} + +function reply(server: AhpTransport, req: JsonRpcRequest, result: unknown): void { + server.send(JSON.stringify({ jsonrpc: '2.0', id: req.id, result })); +} + +function push(server: AhpTransport, envelope: ActionEnvelope): void { + server.send(JSON.stringify({ jsonrpc: '2.0', method: 'action', params: envelope })); +} + +async function fence(client: AhpClient, server: AhpTransport): Promise { + const pending = client.ping(); + const req = await request(server); + assert.equal(req.method, 'ping'); + reply(server, req, {}); + await pending; +} + +async function setup(t: TestContext, first?: StateAction, state = initial()) { + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport); + client.connect(); + t.after(() => client.shutdown()); + const initialization = client.initialize({ clientId: 'owner', protocolVersions: ['0.9.0'] }); + reply(server, await request(server), { + channel: 'ahp-root://', protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], + tcpConnections: { encodings: ['base64'] }, + }); + await initialization; + const opening = client.openTcpConnection(session, create); + const req = await request(server); + assert.deepEqual(req.params, { channel: session, create }); + reply(server, req, { channel: session, snapshot: { resource, fromSeq: 0, state } }); + let serverSeq = 0; + const send = (action: StateAction, origin?: ActionEnvelope['origin']) => { + const envelope: ActionEnvelope = { channel: resource, serverSeq: ++serverSeq, action, ...(origin ? { origin } : {}) }; + push(server, envelope); + return envelope; + }; + if (first) send(first); + const connection = await opening; + const echo = (params: DispatchActionParams) => send(params.action, { clientId: 'owner', clientSeq: params.clientSeq }); + return { client, transport, server, connection, send, echo }; +} + +test('custom clients apply replay before live delivery and hold writes behind their send gate', async () => { + const sent: DispatchActionParams[] = []; + let nextSeq = 0; + const binding = { + nextSeq: () => ++nextSeq, + sequenceFloor: () => nextSeq, + send: (clientSeq: number, action: DispatchActionParams['action']) => { sent.push({ channel: resource, clientSeq, action }); }, + detach: () => {}, + }; + const connection = new TcpConnection(resource, initial(), 'owner', 0, binding); + try { + connection.suspend(); + connection.beginResume('owner', binding); + assert.throws(() => connection.finishResume(), /replay is incomplete/); + await connection.write(Uint8Array.of(1)); + reconcileTcpConnections([connection], { + type: 'replay', missing: [], + actions: [{ channel: resource, serverSeq: 1, action: { type: ActionType.TcpData, offset: 0, data: 'Ag==' } }], + }, 0); + connection.accept({ channel: resource, serverSeq: 2, action: { type: ActionType.TcpData, offset: 1, data: 'Aw==' } }); + assert.deepEqual(await connection.read(), Uint8Array.of(2)); + assert.deepEqual(await connection.read(), Uint8Array.of(3)); + assert.equal(sent.length, 0, 'neither input nor consumption escapes the send gate'); + connection.finishResume(); + assert.deepEqual(sent.map(item => item.clientSeq), [1, 2, 3]); + assert.deepEqual(sent.map(item => item.action.type), [ActionType.TcpInput, ActionType.TcpDataConsumed, ActionType.TcpDataConsumed]); + } finally { + connection.dispose(); + } +}); + +test('TCP creation installs the stream before immediate data and reads release credit', async t => { + const h = await setup(t, { type: ActionType.TcpData, offset: 0, data: 'AP8=' }); + await fence(h.client, h.server); // Receipt alone must not send consumed credit. + assert.deepEqual(await h.connection.read(), Uint8Array.of(0, 255)); + const credit = await dispatch(h.server); + assert.deepEqual(credit.action, { type: ActionType.TcpDataConsumed, consumedBytes: 2 }); + h.echo(credit); + await fence(h.client, h.server); + const copy = h.connection.state; + copy.input.windowBytes = 999; + assert.equal(h.connection.state.input.windowBytes, 4); +}); + +test('TCP writes reserve unacknowledged credit, chunk bytes, and drain destination consumption', async t => { + const h = await setup(t); + let finished = false; + const writing = h.connection.write(Uint8Array.of(0, 255, 128, 1, 2, 3)).then(() => { finished = true; }); + const first = await dispatch(h.server); + const second = await dispatch(h.server); + assert.deepEqual(first.action, { type: ActionType.TcpInput, offset: 0, data: 'AP8=' }); + assert.deepEqual(second.action, { type: ActionType.TcpInput, offset: 2, data: 'gAE=' }); + assert.equal(finished, false); + await assert.rejects(h.connection.write(Uint8Array.of(4)), /idle writer/); + assert.throws(() => h.connection.end(), /idle writer/); + h.echo(first); + h.echo(second); + await fence(h.client, h.server); + assert.equal(finished, false); // Echo is not consumed credit. + h.send({ type: ActionType.TcpInputConsumed, consumedBytes: 2 }); + const third = await dispatch(h.server); + assert.deepEqual(third.action, { type: ActionType.TcpInput, offset: 4, data: 'AgM=' }); + await writing; + h.echo(third); + let drained = false; + const draining = h.connection.drain().then(() => { drained = true; }); + await fence(h.client, h.server); + assert.equal(drained, false); + h.send({ type: ActionType.TcpInputConsumed, consumedBytes: 6 }); + await draining; +}); + +test('TCP EOF is idempotent, independent, and duplicate data is delivered once', async t => { + const h = await setup(t); + await h.connection.write(Uint8Array.of(7)); + h.echo(await dispatch(h.server)); + h.connection.end(); + h.connection.end(); + const eof = await dispatch(h.server); + assert.deepEqual(eof.action, { type: ActionType.TcpInputEof, finalOffset: 1 }); + h.echo(eof); + h.send({ type: ActionType.TcpData, offset: 0, data: 'CAk=' }); + h.send({ type: ActionType.TcpData, offset: 0, data: 'CAk=' }); + h.send({ type: ActionType.TcpDataEof, finalOffset: 2 }); + assert.deepEqual(await h.connection.read(), Uint8Array.of(8, 9)); + assert.equal((await dispatch(h.server)).action.type, ActionType.TcpDataConsumed); + assert.equal(await h.connection.read(), undefined); + await assert.rejects(h.connection.write(Uint8Array.of(1)), /idle writer/); +}); + +test('TCP reset wakes blocked read, write, and drain, and releases ownership once', async t => { + const h = await setup(t); + const writing = assert.rejects(h.connection.write(new Uint8Array(6)), /connectionReset/); + await dispatch(h.server); + await dispatch(h.server); + const reading = assert.rejects(h.connection.read(), /connectionReset/); + await assert.rejects(h.connection.read(), /one reader/); + const draining = assert.rejects(h.connection.drain(), /connectionReset/); + h.send({ type: ActionType.TcpHostReset, reason: TcpResetReason.ConnectionReset }); + await Promise.all([writing, reading, draining]); + assert.equal((await message(h.server)).method, 'unsubscribe'); + assert.equal(h.client.tcpConnections.length, 0); + h.connection.dispose(); + await fence(h.client, h.server); +}); + +test('TCP rejects unowned echoes and rejected envelopes without exposing their payload', async t => { + for (const rejection of [false, true]) { + const h = await setup(t); + const reading = assert.rejects(h.connection.read(), TcpConnectionError); + push(h.server, { + channel: resource, serverSeq: 1, + action: rejection + ? { type: ActionType.TcpData, offset: 0, data: 'AQ==' } + : { type: ActionType.TcpInput, offset: 0, data: 'AQ==' }, + ...(rejection ? { rejectionReason: '' } : { origin: { clientId: 'other', clientSeq: 1 } }), + }); + await reading; + assert.equal((await dispatch(h.server)).action.type, ActionType.TcpClientReset); + assert.equal((await message(h.server)).method, 'unsubscribe'); + } +}); + +test('TCP rejects unsolicited credit and mismatched input echoes', async t => { + for (const kind of ['unsolicited-credit', 'changed-payload', 'changed-action-kind'] as const) { + await t.test(kind, async t => { + const h = await setup(t); + if (kind === 'unsolicited-credit') { + h.send({ type: ActionType.TcpData, offset: 0, data: 'AQI=' }); + h.send({ type: ActionType.TcpData, offset: 2, data: 'AwQ=' }); + h.send({ type: ActionType.TcpDataConsumed, consumedBytes: 4 }, { clientId: 'owner', clientSeq: 100 }); + h.send({ type: ActionType.TcpData, offset: 4, data: 'BQY=' }); + h.send({ type: ActionType.TcpData, offset: 6, data: 'Bwg=' }); + } else { + await h.connection.write(Uint8Array.of(1)); + const pending = await dispatch(h.server); + h.echo({ ...pending, action: kind === 'changed-payload' + ? { type: ActionType.TcpInput, offset: 0, data: 'Ag==' } + : { type: ActionType.TcpClientClose } }); + } + await fence(h.client, h.server); + assert.equal(h.connection.isClosed, true, kind); + await assert.rejects(h.connection.read(), error => + error instanceof TcpConnectionError && error.reason === TcpResetReason.ProtocolError); + }); + } +}); + +test('retired client echoes permit only state-neutral duplicates', async t => { + const h = await setup(t); + await h.connection.write(Uint8Array.of(1)); + const pending = await dispatch(h.server); + h.echo(pending); + h.echo({ ...pending, action: { data: 'AQ==', offset: 0, type: ActionType.TcpInput } }); + await fence(h.client, h.server); + assert.equal(h.connection.isClosed, false); + assert.equal(h.connection.state.input.receivedBytes, 1); + h.send({ type: ActionType.TcpData, offset: 0, data: 'AgM=' }); + h.echo({ ...pending, action: { type: ActionType.TcpDataConsumed, consumedBytes: 2 } }); + await fence(h.client, h.server); + assert.equal(h.connection.isClosed, true, 'a retired sequence must not grant fresh credit'); +}); + +test('host actions carrying an origin cannot acknowledge pending client input', async t => { + const h = await setup(t); + await h.connection.write(Uint8Array.of(1)); + const pending = await dispatch(h.server); + h.send({ type: ActionType.TcpData, offset: 0, data: 'Ag==' }, { clientId: 'owner', clientSeq: pending.clientSeq }); + await fence(h.client, h.server); + await h.client.shutdown({ preserveTcpConnections: true }); + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport); + client.connect(); + t.after(() => client.shutdown()); + const resuming = client.reconnectTcpConnections({ + clientId: 'owner', lastSeenServerSeq: 1, subscriptions: [], + }, [h.connection]); + reply(server, await request(server), { type: 'replay', actions: [], missing: [] }); + await resuming; + const ping = client.ping(); + const first = await message(server); + reply(server, 'id' in first ? first : await request(server), {}); + await ping; + assert.equal(first.method, 'dispatchAction'); + assert.deepEqual(first.params, pending); +}); + +test('host close gets a response without new credit while final disposal waits for input drain', async t => { + const h = await setup(t); + await h.connection.write(Uint8Array.of(1, 2, 3, 4)); + h.echo(await dispatch(h.server)); + h.echo(await dispatch(h.server)); + h.send({ type: ActionType.TcpHostClose }); + const close = await dispatch(h.server); + assert.deepEqual(close.action, { type: ActionType.TcpClientClose }); + h.echo(close); + await fence(h.client, h.server); + assert.equal(h.connection.isClosed, false, 'responding is not final disposal'); + assert.equal(h.client.tcpConnections.length, 1); + assert.equal(h.connection.state.input.consumedBytes, 0); + const drained = h.connection.drain(); + h.send({ type: ActionType.TcpInputConsumed, consumedBytes: 4 }); + await drained; + assert.equal(h.connection.isClosed, true); + assert.equal((await message(h.server)).method, 'unsubscribe'); +}); + +test('TCP resumes the same consumer and resends only unacknowledged bytes with original sequences', async t => { + const h = await setup(t); + await h.connection.write(Uint8Array.of(1, 2, 3, 4)); + const acknowledged = await dispatch(h.server); + const pending = await dispatch(h.server); + h.echo(acknowledged); + h.send({ type: ActionType.TcpData, offset: 0, data: 'BQY=' }); + await fence(h.client, h.server); + h.client.dispatch(session, { type: ActionType.SessionTitleChanged, title: 'Other traffic' }, 100); + await dispatch(h.server); + const changes = h.client.stateChanges(); + await h.server.close(); + assert.equal((await changes.next()).value.status, 'closed'); + assert.equal(h.connection.isSuspended, true); + const offlineWrite = h.connection.write(Uint8Array.of(9)); + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport); + client.connect(); + t.after(() => client.shutdown()); + await assert.rejects(client.reconnectTcpConnections({ + clientId: 'another-owner', lastSeenServerSeq: 999, subscriptions: [], + }, [h.connection]), /owned by this client/); + const resumed = client.reconnectTcpConnections({ + clientId: 'owner', lastSeenServerSeq: 999, subscriptions: [session], + }, [h.connection]); + const req = await request(server); + assert.deepEqual(req.params, { + channel: 'ahp-root://', clientId: 'owner', lastSeenServerSeq: 2, + subscriptions: [session, resource], + }); + reply(server, req, { type: 'replay', actions: [], missing: [] }); + push(server, { channel: resource, serverSeq: 3, action: { type: ActionType.TcpData, offset: 2, data: 'Bwg=' } }); + await resumed; + assert.deepEqual(await dispatch(server), pending); + assert.equal(h.client.tcpConnections.length, 0); + assert.equal(client.tcpConnections[0], h.connection); + assert.deepEqual(await h.connection.read(), Uint8Array.of(5, 6)); + assert.equal((await dispatch(server)).clientSeq, 101); + assert.deepEqual(await h.connection.read(), Uint8Array.of(7, 8)); + assert.equal((await dispatch(server)).clientSeq, 102); + push(server, { + channel: resource, serverSeq: 4, action: pending.action, + origin: { clientId: 'owner', clientSeq: pending.clientSeq }, + }); + push(server, { channel: resource, serverSeq: 5, action: { type: ActionType.TcpInputConsumed, consumedBytes: 4 } }); + const afterResume = await dispatch(server); + assert.equal(afterResume.clientSeq, 103); + assert.deepEqual(afterResume.action, { type: ActionType.TcpInput, offset: 4, data: 'CQ==' }); + await offlineWrite; +}); + +test('TCP reconnect does not replay ordinary actions already applied by its consumer', async t => { + const h = await setup(t); + const mirror = terminalMirror(); + const events = h.client.events(); + const ordinary: ActionEnvelope = { + channel: 'ahp-terminal:/test', serverSeq: 1, + action: { type: ActionType.TerminalData, data: 'hello' }, + }; + push(h.server, ordinary); + const first = await events.next(); + assert.equal(first.value.event.type, 'action'); + if (first.value.event.type === 'action') mirror.apply(first.value.event.params); + await h.client.shutdown({ preserveTcpConnections: true }); + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport); + client.connect(); + t.after(() => client.shutdown()); + const resuming = client.reconnectTcpConnections({ + clientId: 'owner', lastSeenServerSeq: 1, subscriptions: [ordinary.channel], + }, [h.connection]); + const req = await request(server); + assert.equal((req.params as { lastSeenServerSeq: number }).lastSeenServerSeq, 0); + reply(server, req, { type: 'replay', missing: [], actions: [ + ordinary, + { channel: resource, serverSeq: 2, action: { type: ActionType.TcpData, offset: 0, data: 'AQ==' } }, + { ...ordinary, serverSeq: 3, action: { type: ActionType.TerminalData, data: '!' } }, + ] }); + const result = await resuming; + assert.equal(result.type, 'replay'); + if (result.type !== 'replay') assert.fail('expected replay'); + for (const envelope of result.actions) mirror.apply(envelope); + assert.deepEqual(mirror.getTerminal(ordinary.channel)?.content, [{ type: 'unclassified', value: 'hello!' }]); + assert.deepEqual(await h.connection.read(), Uint8Array.of(1)); +}); + +test('TCP replay reconciles echoes before resend, and missing or snapshot recovery fails closed', async t => { + for (const recovery of ['echo', 'missing', 'snapshot'] as const) { + const h = await setup(t); + await h.connection.write(Uint8Array.of(1)); + const pending = await dispatch(h.server); + const changes = h.client.stateChanges(); + await h.server.close(); + await changes.next(); + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport); + client.connect(); + t.after(() => client.shutdown()); + const reconnecting = client.reconnectTcpConnections({ + clientId: 'owner', lastSeenServerSeq: 100, subscriptions: [], + }, [h.connection]); + const req = await request(server); + reply(server, req, recovery === 'snapshot' ? { type: 'snapshot', snapshots: [] } : { + type: 'replay', missing: recovery === 'missing' ? [resource] : [], + actions: recovery === 'echo' ? [{ + channel: resource, serverSeq: 1, action: pending.action, + origin: { clientId: 'owner', clientSeq: pending.clientSeq }, + }] : [], + }); + await reconnecting; + if (recovery === 'echo') { + await fence(client, server); // No resend may precede this ping. + assert.equal(h.connection.state.input.receivedBytes, 1); + } else { + assert.equal(h.connection.isClosed, true); + await assert.rejects(h.connection.read(), /replay unavailable/); + assert.equal((await message(server)).method, 'unsubscribe'); + } + } +}); + +test('TCP final close drains crossing output and accepted input before releasing ownership', async t => { + const h = await setup(t); + await h.connection.write(Uint8Array.of(1)); + h.echo(await dispatch(h.server)); + h.connection.close(); + const closing = await dispatch(h.server); + assert.equal(closing.action.type, ActionType.TcpClientClose); + h.echo(closing); + h.send({ type: ActionType.TcpData, offset: 0, data: 'Ag==' }); + h.send({ type: ActionType.TcpHostClose }); + assert.deepEqual(await h.connection.read(), Uint8Array.of(2)); + h.echo(await dispatch(h.server)); + const draining = h.connection.drain(); + h.send({ type: ActionType.TcpInputConsumed, consumedBytes: 1 }); + await draining; + assert.equal(h.connection.isClosed, true); + assert.equal((await message(h.server)).method, 'unsubscribe'); + assert.equal(await h.connection.read(), undefined); + await fence(h.client, h.server); +}); + +test('explicit client shutdown disposes suspended TCP and cancels blocked operations', async t => { + const h = await setup(t); + const changes = h.client.stateChanges(); + await h.server.close(); + await changes.next(); + const reading = assert.rejects(h.connection.read(), /disposed/); + await h.client.shutdown(); + await reading; + assert.equal(h.connection.isClosed, true); + assert.equal(h.client.tcpConnections.length, 0); +}); + +test('unsubscribing an owned TCP resource also terminates its local stream', async t => { + const h = await setup(t); + const reading = assert.rejects(h.connection.read(), /disposed/); + await h.client.unsubscribe(resource); + await reading; + assert.equal((await message(h.server)).method, 'unsubscribe'); + assert.equal(h.client.tcpConnections.length, 0); + h.connection.dispose(); + await fence(h.client, h.server); +}); + +test('invalid TCP data aborts the stream instead of leaving a blocked reader', async t => { + const h = await setup(t); + const reading = assert.rejects(h.connection.read(), error => + error instanceof TcpConnectionError && error.reason === TcpResetReason.ProtocolError); + h.send({ type: ActionType.TcpData, offset: 1, data: 'AQ==' }); + await reading; + assert.deepEqual((await dispatch(h.server)).action, { type: ActionType.TcpClientReset, reason: TcpResetReason.ProtocolError }); + assert.equal((await message(h.server)).method, 'unsubscribe'); +}); + +test('TCP adapter encodes a negotiated 4 MiB input chunk without stack overflow', async t => { + const state = initial(); + state.input.windowBytes = state.input.maximumChunkSize = 4 * 1024 * 1024; + const h = await setup(t, undefined, state); + await h.connection.write(new Uint8Array(state.input.windowBytes).fill(255)); + const sent = await dispatch(h.server); + assert.equal(sent.action.type, ActionType.TcpInput); + if (sent.action.type !== ActionType.TcpInput) assert.fail('expected input'); + assert.equal(sent.action.offset, 0); + assert.equal(Buffer.from(sent.action.data, 'base64').length, state.input.windowBytes); + h.echo(sent); + h.send({ type: ActionType.TcpInputConsumed, consumedBytes: state.input.windowBytes }); + await h.connection.drain(); +}); + +test('host runtime automatically resumes TCP handles without recreating or snapshotting streams', async t => { + const pairs = [InMemoryTransport.pair(), InMemoryTransport.pair()]; + const multi = new MultiHostClient(); + t.after(() => multi.shutdown()); + const events = multi.hostEvents(); + let attempt = 0; + await multi.addHost({ + id: 'host', label: 'Host', clientId: 'owner', reconnectPolicy: immediateForeverPolicy(), + transportFactory: async () => { + assert.ok(attempt < pairs.length, 'unexpected replacement transport'); + return pairs[attempt++][0]; + }, + }); + const first = pairs[0][1]; + const init = await request(first); + assert.equal(init.method, 'initialize'); + reply(first, init, { + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], + tcpConnections: { encodings: ['base64'] }, + }); + const listing = await request(first); + assert.equal(listing.method, 'listSessions'); + reply(first, listing, { items: [] }); + while ((await events.next()).value.type !== 'connected') { /* wait for committed handshake */ } + const handle = multi.client('host'); + assert.ok(handle); + const opening = handle.openTcpConnection(session, create); + reply(first, await request(first), { snapshot: { resource, fromSeq: 0, state: initial() } }); + const connection = await opening; + const mirror = terminalMirror(); + const fanOut = multi.events(); + const ordinary: ActionEnvelope = { + channel: 'ahp-terminal:/test', serverSeq: 1, + action: { type: ActionType.TerminalData, data: 'hello' }, + }; + push(first, ordinary); + const received = await fanOut.next(); + assert.equal(received.value.event.type, 'action'); + if (received.value.event.type === 'action') mirror.apply(received.value.event.params); + await connection.write(Uint8Array.of(1)); + const sent = await dispatch(first); + await first.close(); + const second = pairs[1][1]; + const reconnect = await request(second); + assert.equal(reconnect.method, 'reconnect'); + assert.ok((reconnect.params as { subscriptions: string[] }).subscriptions.includes(resource)); + assert.equal((reconnect.params as { lastSeenServerSeq: number }).lastSeenServerSeq, 0); + reply(second, reconnect, { type: 'replay', missing: [], actions: [ordinary, { + channel: resource, serverSeq: 2, action: sent.action, + origin: { clientId: 'owner', clientSeq: sent.clientSeq }, + }, { + channel: resource, serverSeq: 3, action: { type: ActionType.TcpData, offset: 0, data: 'BQ==' }, + }, { ...ordinary, serverSeq: 4, action: { type: ActionType.TerminalData, data: '!' } }] }); + push(second, { channel: resource, serverSeq: 5, action: { type: ActionType.TcpData, offset: 1, data: 'Bg==' } }); + const refresh = await request(second); + assert.equal(refresh.method, 'listSessions'); // The echoed input must not be resent. + reply(second, refresh, { items: [] }); + while ((await events.next()).value.type !== 'connected') { /* wait for reconnect */ } + while (true) { + const next = await fanOut.next(); + if (next.value.event.type === 'action') { + mirror.apply(next.value.event.params); + if (next.value.event.params.serverSeq === 5) break; + } + } + assert.deepEqual(mirror.getTerminal(ordinary.channel)?.content, [{ type: 'unclassified', value: 'hello!' }]); + assert.equal(multi.client('host')?.rawClient().tcpConnections[0], connection); + assert.equal(connection.state.input.receivedBytes, 1); + assert.deepEqual(await connection.read(), Uint8Array.of(5)); + await dispatch(second); + assert.deepEqual(await connection.read(), Uint8Array.of(6)); + await dispatch(second); + const blocked = assert.rejects(connection.read(), /disposed/); + await multi.shutdown(); + await blocked; + assert.equal(connection.isClosed, true); +}); + +test('a late reconnect response cannot resurrect a stream abandoned on timeout', async t => { + const h = await setup(t); + await h.client.shutdown({ preserveTcpConnections: true }); + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport, { requestTimeoutMs: 25 }); + client.connect(); + t.after(() => client.shutdown()); + const resuming = assert.rejects(client.reconnectTcpConnections({ + clientId: 'owner', lastSeenServerSeq: 0, subscriptions: [], + }, [h.connection]), /timed out/); + const req = await request(server); + await resuming; + assert.equal((await message(server)).method, 'unsubscribe'); + reply(server, req, { type: 'replay', actions: [], missing: [] }); + assert.equal((await message(server)).method, 'unsubscribe'); + assert.equal(h.connection.isClosed, true); + assert.equal(client.tcpConnections.length, 0); +}); + +test('TCP creation validates wire parameters before dispatch', async t => { + const h = await setup(t); + for (const host of ['', ' ', 'bad host', 'localhost\n', 'local\0host', 'host/path', 'https://localhost']) { + await assert.rejects(h.client.openTcpConnection(session, { ...create, host }), { + name: 'TcpConnectionError', reason: TcpResetReason.ProtocolError, message: 'Invalid TCP host', + }); + } + for (const host of ['localhost', '127.0.0.1', '::1', '2001:db8::1']) { + assert.doesNotThrow(() => validateTcpRequest(session, { ...create, host })); + } + await fence(h.client, h.server); +}); + +test('TCP creation validates capabilities and releases a malformed child or a late timeout response', async t => { + const [transport, server] = InMemoryTransport.pair(); + const client = new AhpClient(transport, { requestTimeoutMs: 25 }); + client.connect(); + t.after(() => client.shutdown()); + await assert.rejects(client.openTcpConnection(session, create), /TCP-capable/); + const init = client.initialize({ clientId: 'owner', protocolVersions: ['0.9.0'] }); + reply(server, await request(server), { tcpConnections: { encodings: ['base64'] } }); + await init; + const malformed = assert.rejects(client.openTcpConnection(session, create), /empty directions/); + const req = await request(server); + const state = initial(); + state.input.receivedBytes = 1; + reply(server, req, { snapshot: { resource, fromSeq: 0, state } }); + await malformed; + assert.equal((await message(server)).method, 'unsubscribe'); + const timedOut = assert.rejects(client.openTcpConnection(session, create), /timed out/); + const late = await request(server); + await timedOut; + reply(server, late, { snapshot: { resource, fromSeq: 0, state: initial() } }); + assert.equal((await message(server)).method, 'unsubscribe'); + assert.equal(client.tcpConnections.length, 0); +}); diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index f5736a2be..93b3fc1bc 100644 --- a/clients/typescript/test/types-round-trip.test.ts +++ b/clients/typescript/test/types-round-trip.test.ts @@ -59,7 +59,8 @@ import type { SessionSummary, } from '../src/types/channels-session/state.js'; import type { SessionAddedParams } from '../src/types/channels-root/notifications.js'; -import type { Implementation, InitializeResult } from '../src/types/common/commands.js'; +import type { Implementation, InitializeResult, ReconnectResult, SubscribeParams } from '../src/types/common/commands.js'; +import type { TcpConnectionOpenErrorData } from '../src/types/channels-tcp/state.js'; import type { ChatSource } from '../src/types/channels-chat/commands.js'; // ─── Fixture directory ─────────────────────────────────────────────────────── @@ -243,6 +244,9 @@ function bindToType(file: string, type: string, parsed: unknown): void { case 'PartialSessionSummary': void (parsed as Partial); break; case 'Implementation': void (parsed as Implementation); break; case 'InitializeResult': void (parsed as InitializeResult); break; + case 'SubscribeParams': void (parsed as SubscribeParams); break; + case 'ReconnectResult': void (parsed as ReconnectResult); break; + case 'TcpConnectionOpenErrorData': void (parsed as TcpConnectionOpenErrorData); break; case 'ChatSource': void (parsed as ChatSource); break; case 'Snapshot': void (parsed as Snapshot); break; default: diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index eb69724be..984014209 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -70,6 +70,7 @@ export default withMermaid(defineConfig({ { text: 'Automation Catalogue Channel', link: '/specification/automation-channel' }, { text: 'Automation Run Channel', link: '/specification/automation-run-channel' }, { text: 'Terminal Channel', link: '/specification/terminal-channel' }, + { text: 'TCP Channel', link: '/specification/tcp-channel' }, { text: 'Resource Watch Channel', link: '/specification/resource-watch-channel' }, { text: 'Telemetry Channel', link: '/specification/telemetry-channel' }, ], @@ -91,6 +92,7 @@ export default withMermaid(defineConfig({ { text: 'Session Channel', link: '/reference/session' }, { text: 'Chat Channel', link: '/reference/chat' }, { text: 'Terminal Channel', link: '/reference/terminal' }, + { text: 'TCP Channel', link: '/reference/tcp' }, { text: 'Changeset Channel', link: '/reference/changeset' }, { text: 'Annotations Channel', link: '/reference/annotations' }, { text: 'Automation Catalogue Channel', link: '/reference/automation' }, diff --git a/docs/proposals/tcp-channels.md b/docs/proposals/tcp-channels.md new file mode 100644 index 000000000..65a547674 --- /dev/null +++ b/docs/proposals/tcp-channels.md @@ -0,0 +1,91 @@ +# Proposal: session-scoped direct TCP channels + +**Status:** experimental protocol implementation; not a shipped browser feature. + +## Motivation + +An agent starts a dev server in its execution environment and asks a browser +running on the user's machine to open `http://localhost:3000`. Local browser +networking reaches the wrong loopback. Published ports and rewritten URLs do +not generally preserve redirects, private DNS, multiple ports, WebSockets, +HTTPS, or origin-sensitive authentication. + +The invariant is: a browser opened for a session reaches destinations through +that session's Agent Host connection, rather than the browser machine's network. +The session scopes ownership and lifetime; it does not make the transport an +implementation of the agent's tool permissions or sandbox. Tools delegated to a +different runtime do not implicitly retarget the host TCP endpoint. + +```text +Browser -> client-local authenticated proxy -> existing AHP connection + -> private ahp-tcp channel -> host network -> destination +``` + +The browser proxy should consume a provider-neutral duplex stream. It should +not know whether AHP arrived over SSH, WSL, Remote Agent IPC, a tunnel, or a +cloud relay. + +## Selected design + +The [TCP channel specification](../specification/tcp-channel.md) and canonical +types implement this baseline: + +- Host-only `tcpConnections` capability, with base64 as the mandatory encoding. +- Atomic `subscribe.create` under a parent session; host-assigned private URI. +- Ordered actions for input/output, cumulative consumed offsets, EOF, close, + and reset. +- Bounded per-direction byte credit, with no payload in reducer state. +- Same-socket recovery using AHP's logical-client replay; never snapshot-only + recovery or replacement-socket replay. + +This follows SSH [RFC 4254 sections 5 and 7.2](https://www.rfc-editor.org/rfc/rfc4254) +for independent channels, destination connections, byte credit, half-close, +and two-sided close. It does **not** claim SSH transport compatibility or SSH +reconnection: AHP deliberately uses cumulative offsets and bounded replay over +a replaceable transport instead of SSH's additive window updates and +transport-scoped channel lifetime. + +## Alternatives + +**Separate SOCKS/CONNECT proxy:** efficient, standard byte protocol, but requires +a second reachable data-plane binding for every carrier/runtime. It does not +meet the baseline goal of working over any existing AHP connection. + +**Stateless notifications:** simpler, but discards AHP replay and acknowledgments. +The selected design preserves original sockets only when byte history is intact. + +**Binary transport frames:** avoids base64's roughly 33% bandwidth overhead but +changes every transport and SDK. Deferred, not necessary for interoperability. + +**Generic stream framework:** possible later; no speculative listener, reverse +connection, datagram, durable payload, or universal execution-context API here. + +## Rollout and review questions + +This patch does not bump the protocol version. New actions use the current +registry version; support is gated by the explicit experimental capability, +not by version comparison alone. Release assignment remains a maintainer task. + +The wire additions are optional and preserve old request/response shapes. +Native source compatibility is distinct: Rust callers constructing +`SubscribeParams`, `InitializeResult`, or `ReconnectSnapshotResult` with struct +literals must supply the new optional fields (`None` preserves old behavior). +Exhaustive matches over generated `SnapshotState` must handle the TCP variant. +Use the existing subscribe constructors when no creation is needed. No promise +of native binary compatibility is made for regenerated SDK assemblies. + +Before enabling it in a host: + +1. Implement and test its host-network adapter, session ownership, and any + transport destination restrictions. +2. Use the SDK's owned TCP connection adapter rather than reusing a lossy UI + queue; bridge its byte operations to the application's native stream. +3. Test disconnect at every open/write/EOF/close boundary, concurrent clients, + pending-action reconciliation, replay exhaustion, and explicit unsubscribe. +4. Connect VS Code's existing local browser proxy to that adapter. +5. Measure page-load latency and control-message latency under large bundles, + source maps, WebSockets, slow receivers, and concurrent transfers. + +Maintainer review should focus on create-on-subscribe, mandatory replay-only +recovery, and private-channel replay budgeting. No separate binary framing, +mixed per-channel replay result, or public endpoint is introduced. diff --git a/docs/specification/lifecycle.md b/docs/specification/lifecycle.md index a2dd20cc3..8f836e69d 100644 --- a/docs/specification/lifecycle.md +++ b/docs/specification/lifecycle.md @@ -143,6 +143,13 @@ If the gap exceeds the replay buffer, the server sends fresh snapshots instead: Protocol notifications are **not** replayed — the client SHOULD re-fetch the session list via [`listSessions`](/reference/root#listsessions). Stateless channels are simply re-subscribed; missed messages are dropped. +Private [TCP channels](/specification/tcp-channel) can resume only the original +live sockets and consumers with complete action replay. On snapshot fallback, +TCP-capable hosts MUST omit TCP snapshots, dispose those sockets, and include +their requested URIs in the snapshot result's optional `missing` array. +Clients MUST NOT restore TCP payload from a snapshot. The field is optional for +compatibility with older hosts; the TCP capability makes this behavior mandatory. + ## Unexpected Disconnection If the server process terminates unexpectedly: diff --git a/docs/specification/subscriptions.md b/docs/specification/subscriptions.md index e5aaa3404..91c7a3e5f 100644 --- a/docs/specification/subscriptions.md +++ b/docs/specification/subscriptions.md @@ -32,6 +32,7 @@ The rest of this page details the URI scheme and the lifecycle of a subscription | `ahp-changeset:/` | `ChangesetState` | Per-changeset state. URI is obtained by expanding a `Changeset.uriTemplate` advertised on a session or chat; the id is server-defined. | | `ahp-otlp:` _(authority/path host-defined)_ | _stateless_ | OpenTelemetry signal channels (logs, traces, metrics). Concrete URIs are advertised on `InitializeResult.telemetry`; clients MUST treat them as opaque. See [Telemetry Channel](/specification/telemetry-channel). | | `ahp-resource-watch:/` | `ResourceWatchState` | Per-watch channel returned by `createResourceWatch`. Delivers `resourceWatch/changed` actions for file/directory changes under the watched URI. The id is receiver-assigned. | +| `ahp-tcp:/` | `TcpConnectionState` | Private session-scoped TCP connection, atomically created via `subscribe.create`. Replay-only restoration; never snapshot-restored. See [TCP Channel](/specification/tcp-channel). | Future channel types (LSP relay, MCP relay, …) introduce their own URI schemes. Clients MUST NOT subscribe to a scheme they do not understand. @@ -81,6 +82,12 @@ Future channel types (LSP relay, MCP relay, …) introduce their own URI schemes After subscribing, the client receives all messages scoped to that channel — both action envelopes (for state channels) and any channel-specific notifications. +When the host advertises `tcpConnections`, `subscribe.create` can atomically +create and subscribe to a private TCP child of the session named by `channel`. +The result's `snapshot.resource` names the new child, not the parent. +This does not change ordinary subscribe semantics. See [TCP creation](/specification/tcp-channel#atomic-creation-and-subscription) +for ordering, ownership, and failure rules. + ### Delivery preferences Clients MAY include `delivery.maxLatencyMs` on `subscribe` to request an upper @@ -90,6 +97,9 @@ while preserving the same reduced state a client would observe from immediate delivery. A value of `0` requests immediate delivery with no intentional coalescing. Omitting `delivery` uses the server's default delivery behavior. +TCP channels are an exception: payload actions MUST NOT be coalesced or +dropped, even if the reduced counters would be equivalent. + ### Snapshot views Clients MAY include `view` on `subscribe` to ask the server to shape the diff --git a/docs/specification/tcp-channel.md b/docs/specification/tcp-channel.md new file mode 100644 index 000000000..44c069821 --- /dev/null +++ b/docs/specification/tcp-channel.md @@ -0,0 +1,366 @@ +# TCP Channel + + + +An optional `ahp-tcp:` channel carries one outbound TCP connection from the +agent host's network, scoped to an agent session. It uses the existing JSON-RPC transport, +subscriptions, and ordered actions. No auxiliary endpoint or transport-specific +forwarder is required. See the [design proposal](../proposals/tcp-channels.md) +for motivation and alternatives, and the [type reference](../reference/tcp.md) +for the wire definitions. + +## Capability and scope + +The host advertises `InitializeResult.tcpConnections`: + +```json +{ "encodings": ["base64"], "maximumConnectionsPerClient": 64 } +``` + +Absence means unsupported. Clients MUST NOT use TCP creation without this +capability; an older host might otherwise ignore `subscribe.create` and +subscribe to the parent session. Hosts implementing the capability MUST reject +unknown creation kinds and unsupported encodings with `InvalidParams`. + +Only the host advertises support: it never opens a connection into the client. +The client opts in by creating a channel. Capability presence does not grant +permission to reach any particular destination. + +The parent session scopes ownership and lifetime. DNS, loopback, and IP routes +belong to the host endpoint handling the channel, not the client browser. +This transport capability does not reproduce an agent's tool-permission system +or command sandbox. A session whose tools execute in another runtime does not +implicitly move the TCP endpoint into that runtime. Clients MUST NOT infer +network equivalence from workspace or session identifiers. + +## Atomic creation and subscription + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "method": "subscribe", + "params": { + "channel": "ahp-session:/s1", + "create": { + "type": "tcpConnection", + "host": "localhost", + "port": 3000, + "encoding": "base64", + "receiveWindowBytes": 1048576, + "maximumChunkSize": 32768 + } + } +} +``` + +The host authenticates the caller, checks access to the session, validates +the target and any transport access restrictions, resolves the destination, +and connects before returning success. +`host` is a nonempty DNS name or IP literal, not a URL; `port` is an integer in +`[1, 65535]`. Receive windows are integers in `[1, 2^32 - 1]`; chunk limits are +integers in `[1, receiveWindowBytes]`. Implementations MUST enforce smaller +resource limits where necessary. + +The host assigns an unpredictable URI and installs a private subscription: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "result": { + "snapshot": { + "resource": "ahp-tcp:/opaque-id", + "fromSeq": 42, + "state": { + "session": "ahp-session:/s1", + "target": { "host": "localhost", "port": 3000 }, + "encoding": "base64", + "input": { + "windowBytes": 1048576, "maximumChunkSize": 32768, + "receivedBytes": 0, "consumedBytes": 0 + }, + "output": { + "windowBytes": 1048576, "maximumChunkSize": 32768, + "receivedBytes": 0, "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } + } + } +} +``` + +Unlike normal subscribe, `snapshot.resource` is the new child, not +`params.channel`. Creation MUST NOT subscribe to, replace, or unsubscribe from +the parent. The output limits are the client's requested limits, possibly +reduced by the host; input limits are selected by the host. Both counters begin +at zero, with neither EOF nor reset present. + +The subscription and snapshot are established atomically. The response MUST +precede all actions for the new channel on the ordered transport. Clients MUST +install the child route and seed its reducer as part of processing that response, +before applying subsequent child actions. A lossless event receiver installed +**before** the request can instead retain those actions until the caller seeds +the reducer from the response. Merely attaching an asynchronous subscription +after awaiting a generic request is not sufficient if its receive loop can +advance and discard early actions. + +Hosts MUST bound connection attempts and their duration. If the transport is +lost before creation succeeds, they MUST cancel the attempt. A channel whose +response was lost is retained only for the bounded reconnect grace period and +disposed when omitted from the owner's reconnect subscriptions or when that +period expires. Clients MUST NOT retry creation as if it were idempotent: each +successful creation opens a different socket. + +For TCP, `view` is invalid and `delivery` MUST be absent or request zero +intentional latency. Hosts MUST NOT coalesce, truncate, or drop TCP actions, +even when doing so would leave the same reducer counters. Byte side effects, +not only the reduced state, are part of the contract. + +## Ownership + +The connection belongs to the authenticated logical client that created it, +not merely to whoever supplies its `clientId`. Reconnect MUST establish the +same authenticated identity. Knowing a channel URI is not authorization. + +TCP channels are absent from root/session catalogues and invisible to other +clients. Unauthorized subscriptions or dispatches MUST be rejected without +exposing payload. Host-originated actions MUST NOT be accepted from a client. +The action classifier establishes only the allowed direction; the host must +also enforce ownership and the channel's parent-session permission. + +An explicit `unsubscribe` disposes the socket immediately. A new `subscribe` +to an existing TCP URI MUST be rejected: state snapshots cannot restore a byte +consumer. The creator is already subscribed; only `reconnect` can restore that +subscription after transport loss. + +## Actions and credit + +| Action | Origin | Meaning | +| --- | --- | --- | +| `tcp/input` | Owning client | Client-to-host bytes: `offset`, `data` | +| `tcp/data` | Host | Host-to-client bytes: `offset`, `data` | +| `tcp/inputConsumed` | Host | Cumulative input credit: `consumedBytes` | +| `tcp/dataConsumed` | Owning client | Cumulative output credit: `consumedBytes` | +| `tcp/inputEof` | Owning client | Input half-close at `finalOffset` | +| `tcp/dataEof` | Host | Output half-close at `finalOffset` | +| `tcp/clientClose` | Owning client | Client's final close | +| `tcp/hostClose` | Host | Host's final close | +| `tcp/clientReset` | Owning client | Abort with `reason` | +| `tcp/hostReset` | Host | Abort with `reason` | + +Clients use `dispatchAction`; the host accepts and echoes actions in ordinary +`ActionEnvelope`s. TCP input MUST NOT use optimistic state reduction. A sender +keeps a separate bounded pending queue and reserves credit for unacknowledged +bytes; otherwise it could send the entire window repeatedly before any echo. +An echo can acknowledge a pending client action only when its owning client, +assigned sequence, action type, and action fields match that retained action. +An origin attached to a host-produced action is not such an acknowledgment. +Previously acknowledged echoes must not advance state or release new credit. +Output credit must also be bounded by bytes the local consumer actually released, +not merely by consumed offsets claimed by the host. +Echoes carrying `rejectionReason` MUST NOT be reduced or perform byte writes. +A rejected TCP write requires resetting that connection. + +All data is nonempty canonical padded RFC 4648 base64 without whitespace. +Encoding length does not consume credit: decoded bytes do. + +For each direction: + +```text +0 <= consumedBytes <= receivedBytes <= 2^53 - 1 +outstanding = receivedBytes - consumedBytes +0 <= outstanding <= windowBytes +availableCredit = windowBytes - outstanding +``` + +For a new data action `[offset, offset + decodedLength)`: + +1. Validate base64, safe nonnegative integer offsets, and the chunk limit. +2. If its end is at or before `receivedBytes`, it is a duplicate: no write. +3. Otherwise `offset` MUST equal `receivedBytes`; a gap or partial overlap + resets the connection with `protocolError`. +4. New data MUST fit the remaining credit and MUST precede that sender's EOF + and final close. +5. Advance `receivedBytes`, then write the accepted bytes exactly once. + +The reference `tcpReducer` performs these checks and retains no payload. +Invalid actions throw before any mutation. The adapter MUST turn validation +failure into a channel reset and socket disposal, not continue the stream. +If a write fails after reduction, reset; the channel cannot safely resume. + +Cumulative consumed offsets cannot exceed `receivedBytes`. Equal or older +credit updates are idempotent no-ops. Receivers return credit only as their +bounded stream buffer releases bytes, not merely when JSON is decoded. This is +not acknowledgment that the remote application processed the data. + +Adapters MUST stop reading source sockets when credit is exhausted and bound +encoded queues, pending dispatches, write buffers, and retained replay. Generic +SDK event queues that drop old messages for slow consumers are unsuitable for +TCP: use lossless bounded delivery with backpressure, or explicitly reset on +overflow. Pure state mirrors alone are not TCP stream implementations. + +### SDK implementation status + +All six SDKs include TCP wire types, pure reducers, and byte-stream adapters, +with the TypeScript `tcpReducer` as the reference state machine. Reducers +validate actions without retaining payload; validation failures use each SDK's +native error mechanism and leave state unchanged. The adapters own bounded +payload buffers, credit reservation, acknowledgement, EOF/reset, and same-stream +replay. Kotlin's adapter is transport-independent because that SDK does not ship +a client runtime; the other SDKs integrate adapters with their clients. +Ordinary state mirrors must not be used to restore TCP streams. + +Reducer conformance cases live in `types/test-cases/reducers/` alongside the +other channels, using `reducer: "tcp"`. A fixture's optional `expectedError` +means only its final action must fail; `expected` is the unchanged state after +the preceding actions. All six implementations execute these cases. A native +integer decoder may reject an unrepresentable fractional offset before it +reaches the reducer; this is checked as rejection, not skipped or rounded. +JavaScript-only checks and generated large-payload regressions live in the +existing `types/reducers.test.ts`, with native regression tests where needed. + +## EOF, close, and reset + +EOF's final offset MUST equal the direction's `receivedBytes`. It consumes no +credit. Apply the socket half-close only after all preceding bytes in that +direction have been delivered. Duplicate identical EOF is a no-op; the opposite +direction can continue. + +Either side can close without EOF and the other MUST respond with its own +close if it has not sent one. `clientClosed` and `hostClosed` record this +handshake. There is no redundant phase field: + +- Neither flag: open (one or both directions may have reached EOF). +- One flag: closing. +- Both flags: closed. +- A present `reset`: aborted, regardless of prior close flags. + +New bytes are forbidden after **their sender's** close, not after either close. +This permits data already in flight in the opposite direction to cross a close. +Close does not consume credit. Accepted bytes remain ordered before disposal; +credit updates remain valid while draining. Implementations MUST retain enough +state/replay to deliver accepted bytes and the final close, or explicitly reset +if a bounded drain/reconnect deadline expires. A close response MUST NOT wait +for additional send credit, which could deadlock the handshake. +This governs dispatch of the response, not completion of the close operation or +final disposal: already-dispatched input may still be awaiting consumption when +the response is sent. Keep the accepted-byte drain conditions after responding. + +Reset immediately aborts both directions and discards buffered bytes. Once a +reset is accepted, later actions have no effect. Session disposal, policy +revocation, unrecoverable replay loss, socket failure, and process shutdown +dispose the socket. Reset reasons contain no unsanitized system error text. + +## Reconnect: same sockets, complete replay + +AHP transport replacement MAY preserve the **same** TCP connection. The host +retains it for an implementation-defined bounded grace period; it does not +create a replacement socket. The client must retain its original stream, +reducer, bounded pending writes, and applied-action checkpoint in memory. + +The client includes the private URI in `reconnect.subscriptions` only while it +still owns that consumer. The host checks identity, authorization, socket and +channel lifetime, and complete replay availability. Omitted TCP subscriptions +are disposed. A fresh process cannot resume solely from persisted offsets. + +`lastSeenServerSeq` MUST NOT advance beyond an action until it is applied or +safely queued for that same live consumer. Treating JSON receipt as stream +delivery can skip bytes after reconnect. Replay MUST run through the same +ordered reducer/side-effect path as live actions. No new actions may overtake +the replay response. + +If retained TCP consumers require a lower wire checkpoint than ordinary state +consumers, keep those checkpoints separate. Process the complete TCP replay, +but do not redeliver ordinary-channel actions at or below the ordinary +consumer's previously applied checkpoint. Otherwise incremental actions such +as terminal output would be applied twice merely because an idle TCP stream +retained an older checkpoint. + +Unacknowledged client actions are retained with their original `clientSeq` and +byte offsets. Replay first reconciles echoed input; only then may the client +resend remaining pending actions in order. Offset duplicate suppression prevents +a second socket write when acknowledgment delivery was ambiguous. EOF and +cumulative credit are likewise idempotent. + +**Snapshots cannot recover payload.** If normal AHP replay cannot supply the +complete missed range, the host MUST: + +1. Include the requested TCP URI in `ReconnectResult.missing`. +2. Omit it from snapshots and discard any incomplete TCP replay for it. +3. Dispose its destination socket. + +The snapshot result gains optional `missing` for compatibility with older +hosts. TCP-capable hosts MUST populate it for failed TCP subscriptions. Clients +MUST close their local stream for every missing TCP channel and MUST NOT accept +a TCP snapshot in a reconnect response, even if `missing` is absent. + +Host/client restart, lost local buffers, changed identity, expired grace, and +lost replay all fail closed. Browser/application retry may establish a new +connection; the TCP adapter MUST NOT replay an old request into a new socket. + +Replay storage MUST be bounded. Implementations SHOULD isolate private TCP +replay pressure from ordinary state history and fairly interleave small TCP +chunks with control traffic. A bounded buffer that cannot retain a required +range must reset, never silently skip bytes. Replay and logging MUST preserve +channel ownership; payload may contain credentials and SHOULD NOT be persisted +or logged. + +## Errors and policy + +Malformed targets, encodings, and numeric limits use `InvalidParams`. +Unknown sessions use `SessionNotFound`; denied access uses `PermissionDenied`. +Expected connection failures use `TcpConnectionOpenFailed` (`-32012`) with: + +```json +{ "reason": "nameResolutionFailed", "retryable": false } +``` + +Other reasons are `connectionFailed`, `resourceShortage`, and `sessionNotReady`. +An unclassified implementation failure uses `InternalError`. + +The host remains authoritative for DNS and transport access. It MUST authenticate +callers, enforce session/channel ownership, and bound connection attempts, +connections, bandwidth, replay, and memory. If it applies destination access +restrictions, those checks MUST cover resolved addresses without an unchecked +second DNS lookup. Agent tool permissions and sandbox policy remain the concern +of the session's execution backend; the TCP transport MUST NOT infer those policies +from provider-specific settings. + +Browser clients still own URL approval, page sharing, local proxy protection, +and storage isolation. They should use one host-network route per browser +storage session, including redirects, subresources, and popups. HTTPS TLS remains +end-to-end between the browser and destination. + +## SDK adoption + +Use the SDK's owned TCP connection adapter rather than implementing credit and +replay in application code. The client-backed helpers validate creation and +install child routing before accepting live actions. Reconnect operates on +the original handles, processes replay before live delivery, and disposes +streams that are missing or cannot be recovered without snapshots. + +| SDK responsibility | Consumer responsibility | +| --- | --- | +| Ordered actions, duplicate suppression, and rejected-envelope handling | Transport selection, authentication, and reconnect timing/policy | +| Receive buffering, consumed credit, input reservations, and chunk encoding | Connection-count limits and application admission policy | +| Half-close, final close, reset, and cancellation cleanup | Native socket/stream bridges and application-specific destination approval | +| Original-consumer replay, pending-action reconciliation, and sequence allocation | Reading only when the application's destination can accept bytes | + +The negotiated receive window bounds unread SDK payload. Returning a buffer +from `read` transfers responsibility for it to the consumer and releases credit; +a native-stream bridge must not eagerly drain into an unbounded application +queue. Socket I/O and host-side destination connection management remain outside +the client adapter. + +The [TypeScript SDK](https://github.com/microsoft/agent-host-protocol/tree/main/clients/typescript#tcp-channels) +shows the owned stream and reconnect APIs. Native SDKs expose equivalent +operations in their language's conventions. Kotlin accepts a caller-supplied +transport binding rather than adding a new client runtime. + +The lower-level types, reducers, and strict event receivers remain available +for custom integrations. Such integrations must meet the same lossless, +same-consumer rules; ordinary snapshot mirrors and lossy UI event queues cannot +replace the adapter. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 56571ab87..30c202de5 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -2476,6 +2476,182 @@ "type" ] }, + "TcpInputAction": { + "type": "object", + "description": "Client bytes. Never apply optimistically to the authoritative reducer.\nWrite to the destination only when accepted input.receivedBytes advances.", + "properties": { + "type": { + "const": "tcp/input" + }, + "offset": { + "type": "integer", + "description": "Absolute decoded-byte offset.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "data": { + "type": "string", + "description": "Nonempty canonical padded RFC 4648 base64; no whitespace." + } + }, + "required": [ + "type", + "offset", + "data" + ] + }, + "TcpDataAction": { + "type": "object", + "description": "Host bytes. Deliver once, only when output.receivedBytes advances.", + "properties": { + "type": { + "const": "tcp/data" + }, + "offset": { + "type": "integer", + "description": "Absolute decoded-byte offset.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "data": { + "type": "string", + "description": "Nonempty canonical padded RFC 4648 base64; no whitespace." + } + }, + "required": [ + "type", + "offset", + "data" + ] + }, + "TcpInputConsumedAction": { + "type": "object", + "description": "Cumulative input bytes released from the host's bounded write buffer.\nNot an acknowledgment that the destination application processed the bytes.", + "properties": { + "type": { + "const": "tcp/inputConsumed" + }, + "consumedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "consumedBytes" + ] + }, + "TcpDataConsumedAction": { + "type": "object", + "description": "Cumulative output bytes released by the client's bounded stream consumer.", + "properties": { + "type": { + "const": "tcp/dataConsumed" + }, + "consumedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "consumedBytes" + ] + }, + "TcpInputEofAction": { + "type": "object", + "description": "Half-close client input after all preceding input bytes have been written.", + "properties": { + "type": { + "const": "tcp/inputEof" + }, + "finalOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "finalOffset" + ] + }, + "TcpDataEofAction": { + "type": "object", + "description": "Half-close host output after all preceding output bytes have been delivered.", + "properties": { + "type": { + "const": "tcp/dataEof" + }, + "finalOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "finalOffset" + ] + }, + "TcpClientCloseAction": { + "type": "object", + "description": "Client's final close. Respond with hostClose if not already sent.", + "properties": { + "type": { + "const": "tcp/clientClose" + } + }, + "required": [ + "type" + ] + }, + "TcpHostCloseAction": { + "type": "object", + "description": "Host's final close. Respond with clientClose if not already sent.", + "properties": { + "type": { + "const": "tcp/hostClose" + } + }, + "required": [ + "type" + ] + }, + "TcpClientResetAction": { + "type": "object", + "description": "Abort both directions and discard buffered payload.", + "properties": { + "type": { + "const": "tcp/clientReset" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "type", + "reason" + ] + }, + "TcpHostResetAction": { + "type": "object", + "description": "Abort both directions and discard buffered payload.", + "properties": { + "type": { + "const": "tcp/hostReset" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "type", + "reason" + ] + }, "ChatToolCallConfirmedAction": { "oneOf": [ { @@ -2596,6 +2772,48 @@ } ] }, + "FlowControlledByteDirectionState": { + "type": "object", + "description": "Bounded byte credit in one direction of a stream.\nAll counters are nonnegative safe integers (at most 2^53 - 1).\n0 <= consumedBytes <= receivedBytes and\nreceivedBytes - consumedBytes <= windowBytes.", + "properties": { + "windowBytes": { + "type": "integer", + "description": "Maximum accepted-but-not-consumed decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per chunk; MUST NOT exceed windowBytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "receivedBytes": { + "type": "integer", + "description": "Cumulative accepted bytes.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "consumedBytes": { + "type": "integer", + "description": "Cumulative bytes released by the bounded consumer.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "eofAtBytes": { + "type": "integer", + "description": "Present after EOF; equals receivedBytes permanently.", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "windowBytes", + "maximumChunkSize", + "receivedBytes", + "consumedBytes" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -3037,6 +3255,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/TcpConnectionState" } ], "description": "The current state of the resource" @@ -8425,6 +8646,118 @@ "sessions" ] }, + "TcpConnectionsCapability": { + "type": "object", + "description": "Host support for private, session-scoped TCP channels.\nPresence on initialize is required before using subscribe.create.", + "properties": { + "encodings": { + "type": "array", + "items": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "description": "Supported encodings. The base64 profile MUST be supported." + }, + "maximumConnectionsPerClient": { + "type": "integer", + "description": "Informational limit; runtime policy may impose a lower limit.", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "encodings" + ] + }, + "TcpTarget": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "DNS name or IP literal, resolved and connected in the host endpoint's network." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "host", + "port" + ] + }, + "TcpResetState": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/TcpEndpoint" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "source", + "reason" + ] + }, + "TcpConnectionOpenErrorData": { + "type": "object", + "description": "Required detail for TcpConnectionOpenFailed (-32012).\nPolicy denial and malformed requests use PermissionDenied and InvalidParams.", + "properties": { + "reason": { + "$ref": "#/$defs/TcpConnectionOpenFailureReason" + }, + "retryable": { + "type": "boolean" + } + }, + "required": [ + "reason" + ] + }, + "TcpConnectionState": { + "type": "object", + "description": "State of one host-assigned `ahp-tcp:` channel.\n\nPayload is never stored in this state. Only the creating authenticated\nlogical client may observe or dispatch to the channel. Reconnect requires\nthe original sockets, local stream state, and complete action replay;\na snapshot cannot restore this channel.\n\nClose flags record the two-sided handshake. Either flag means closing;\nboth mean closed. A present reset terminates the connection immediately,\nindependently of the close history.", + "properties": { + "session": { + "$ref": "#/$defs/URI" + }, + "target": { + "$ref": "#/$defs/TcpTarget" + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "input": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Client to destination socket." + }, + "output": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Destination socket to client." + }, + "clientClosed": { + "type": "boolean" + }, + "hostClosed": { + "type": "boolean" + }, + "reset": { + "$ref": "#/$defs/TcpResetState" + } + }, + "required": [ + "session", + "target", + "encoding", + "input", + "output", + "clientClosed", + "hostClosed" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -9306,6 +9639,36 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/TcpInputAction" + }, + { + "$ref": "#/$defs/TcpDataAction" + }, + { + "$ref": "#/$defs/TcpInputConsumedAction" + }, + { + "$ref": "#/$defs/TcpDataConsumedAction" + }, + { + "$ref": "#/$defs/TcpInputEofAction" + }, + { + "$ref": "#/$defs/TcpDataEofAction" + }, + { + "$ref": "#/$defs/TcpClientCloseAction" + }, + { + "$ref": "#/$defs/TcpHostCloseAction" + }, + { + "$ref": "#/$defs/TcpClientResetAction" + }, + { + "$ref": "#/$defs/TcpHostResetAction" } ] }, @@ -9380,6 +9743,19 @@ "type": "string", "description": "Execution lifecycle of a {@link ChangesetOperation}.\n\nAn operation is invoked imperatively via `invokeChangesetOperation`, but\nits progress and outcome are reflected back into changeset state so that\nevery subscriber observes a consistent view (e.g. a spinner on a \"Create\nPull Request\" button, or an inline error after a failed \"revert\")." }, + "TcpResetReason": { + "enum": [ + "connectionReset", + "connectionAborted", + "protocolError", + "replayUnavailable", + "policyRevoked", + "sessionDisposed", + "internalError" + ], + "type": "string", + "description": "Why a connection was aborted." + }, "PolicyState": { "enum": [ "enabled", @@ -9485,6 +9861,31 @@ ], "type": "string", "description": "Operations the host currently permits for an automation.\n\nThe list on {@link AutomationEntry.operations} is authoritative and may\nchange over time. Clients MUST NOT infer permission from capabilities alone:\ncapabilities describe what the host implementation can support, while\noperations describe what is allowed for this particular automation now." + }, + "TcpDataEncoding": { + "enum": [ + "base64" + ], + "type": "string", + "description": "Payload encodings advertised by the host." + }, + "TcpEndpoint": { + "enum": [ + "client", + "host" + ], + "type": "string", + "description": "Endpoint that closes or resets a connection." + }, + "TcpConnectionOpenFailureReason": { + "enum": [ + "connectionFailed", + "nameResolutionFailed", + "resourceShortage", + "sessionNotReady" + ], + "type": "string", + "description": "Expected connection establishment failures." } } } diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 3ecfad133..e31aa04ef 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -181,6 +181,10 @@ "automations": { "$ref": "#/$defs/AutomationCapabilities", "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + }, + "tcpConnections": { + "$ref": "#/$defs/TcpConnectionsCapability", + "description": "Enables atomic creation of session-scoped, replay-only TCP channels." } }, "required": [ @@ -341,6 +345,13 @@ "$ref": "#/$defs/Snapshot" }, "description": "Fresh snapshots for each subscription" + }, + "missing": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "Subscriptions that cannot be restored. Hosts supporting TCP MUST list all\nrequested TCP channels here and dispose their sockets on snapshot fallback.\nOmitted by older hosts; absence does not authorize snapshot-restoring TCP." } }, "required": [ @@ -368,6 +379,10 @@ "view": { "$ref": "#/$defs/SubscribeView", "description": "Optional client-requested shape for the returned snapshot.\n\nServers that do not understand a requested view ignore it and return their\ndefault snapshot. Clients MUST tolerate receiving more state than requested." + }, + "create": { + "$ref": "#/$defs/TcpConnectionSubscription", + "description": "Atomically create a private child channel and subscribe to it.\nRequires the advertised tcpConnections capability. channel identifies\nthe parent session; snapshot.resource identifies the created TCP channel." } }, "required": [ @@ -1745,6 +1760,52 @@ "description": "Empty acknowledgement; the updated automation state is delivered by action.", "properties": {} }, + "TcpConnectionSubscription": { + "type": "object", + "description": "Creates and exclusively subscribes to one TCP connection.\n\nSubscribeParams.channel MUST identify the parent `ahp-session:` channel.\nThe host returns the new `ahp-tcp:` URI in snapshot.resource, not the parent.\nIt installs the subscription and sends the response before any TCP actions.\nUnknown creation kinds MUST be rejected, never treated as normal subscribe.", + "properties": { + "type": { + "type": "string", + "enum": [ + "tcpConnection" + ] + }, + "host": { + "type": "string", + "description": "DNS name or IP literal, not a URL." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding", + "description": "Selected from InitializeResult.tcpConnections.encodings." + }, + "receiveWindowBytes": { + "type": "integer", + "description": "Client receive window in decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes.", + "minimum": 1, + "maximum": 4294967295 + } + }, + "required": [ + "type", + "host", + "port", + "encoding", + "receiveWindowBytes", + "maximumChunkSize" + ] + }, "ReconnectResult": { "oneOf": [ { @@ -1831,6 +1892,48 @@ ], "description": "Identifies the file or range a {@link ChangesetOperation} should act on.\n\nThe `kind` MUST match one of the operation's declared\n{@link ChangesetOperation.scopes}." }, + "FlowControlledByteDirectionState": { + "type": "object", + "description": "Bounded byte credit in one direction of a stream.\nAll counters are nonnegative safe integers (at most 2^53 - 1).\n0 <= consumedBytes <= receivedBytes and\nreceivedBytes - consumedBytes <= windowBytes.", + "properties": { + "windowBytes": { + "type": "integer", + "description": "Maximum accepted-but-not-consumed decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per chunk; MUST NOT exceed windowBytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "receivedBytes": { + "type": "integer", + "description": "Cumulative accepted bytes.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "consumedBytes": { + "type": "integer", + "description": "Cumulative bytes released by the bounded consumer.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "eofAtBytes": { + "type": "integer", + "description": "Present after EOF; equals receivedBytes permanently.", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "windowBytes", + "maximumChunkSize", + "receivedBytes", + "consumedBytes" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -2272,6 +2375,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/TcpConnectionState" } ], "description": "The current state of the resource" @@ -7660,6 +7766,118 @@ "sessions" ] }, + "TcpConnectionsCapability": { + "type": "object", + "description": "Host support for private, session-scoped TCP channels.\nPresence on initialize is required before using subscribe.create.", + "properties": { + "encodings": { + "type": "array", + "items": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "description": "Supported encodings. The base64 profile MUST be supported." + }, + "maximumConnectionsPerClient": { + "type": "integer", + "description": "Informational limit; runtime policy may impose a lower limit.", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "encodings" + ] + }, + "TcpTarget": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "DNS name or IP literal, resolved and connected in the host endpoint's network." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "host", + "port" + ] + }, + "TcpResetState": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/TcpEndpoint" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "source", + "reason" + ] + }, + "TcpConnectionOpenErrorData": { + "type": "object", + "description": "Required detail for TcpConnectionOpenFailed (-32012).\nPolicy denial and malformed requests use PermissionDenied and InvalidParams.", + "properties": { + "reason": { + "$ref": "#/$defs/TcpConnectionOpenFailureReason" + }, + "retryable": { + "type": "boolean" + } + }, + "required": [ + "reason" + ] + }, + "TcpConnectionState": { + "type": "object", + "description": "State of one host-assigned `ahp-tcp:` channel.\n\nPayload is never stored in this state. Only the creating authenticated\nlogical client may observe or dispatch to the channel. Reconnect requires\nthe original sockets, local stream state, and complete action replay;\na snapshot cannot restore this channel.\n\nClose flags record the two-sided handshake. Either flag means closing;\nboth mean closed. A present reset terminates the connection immediately,\nindependently of the close history.", + "properties": { + "session": { + "$ref": "#/$defs/URI" + }, + "target": { + "$ref": "#/$defs/TcpTarget" + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "input": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Client to destination socket." + }, + "output": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Destination socket to client." + }, + "clientClosed": { + "type": "boolean" + }, + "hostClosed": { + "type": "boolean" + }, + "reset": { + "$ref": "#/$defs/TcpResetState" + } + }, + "required": [ + "session", + "target", + "encoding", + "input", + "output", + "clientClosed", + "hostClosed" + ] + }, "ActionOrigin": { "type": "object", "description": "Identifies the client that originally dispatched an action.", @@ -10131,6 +10349,182 @@ "type" ] }, + "TcpInputAction": { + "type": "object", + "description": "Client bytes. Never apply optimistically to the authoritative reducer.\nWrite to the destination only when accepted input.receivedBytes advances.", + "properties": { + "type": { + "const": "tcp/input" + }, + "offset": { + "type": "integer", + "description": "Absolute decoded-byte offset.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "data": { + "type": "string", + "description": "Nonempty canonical padded RFC 4648 base64; no whitespace." + } + }, + "required": [ + "type", + "offset", + "data" + ] + }, + "TcpDataAction": { + "type": "object", + "description": "Host bytes. Deliver once, only when output.receivedBytes advances.", + "properties": { + "type": { + "const": "tcp/data" + }, + "offset": { + "type": "integer", + "description": "Absolute decoded-byte offset.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "data": { + "type": "string", + "description": "Nonempty canonical padded RFC 4648 base64; no whitespace." + } + }, + "required": [ + "type", + "offset", + "data" + ] + }, + "TcpInputConsumedAction": { + "type": "object", + "description": "Cumulative input bytes released from the host's bounded write buffer.\nNot an acknowledgment that the destination application processed the bytes.", + "properties": { + "type": { + "const": "tcp/inputConsumed" + }, + "consumedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "consumedBytes" + ] + }, + "TcpDataConsumedAction": { + "type": "object", + "description": "Cumulative output bytes released by the client's bounded stream consumer.", + "properties": { + "type": { + "const": "tcp/dataConsumed" + }, + "consumedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "consumedBytes" + ] + }, + "TcpInputEofAction": { + "type": "object", + "description": "Half-close client input after all preceding input bytes have been written.", + "properties": { + "type": { + "const": "tcp/inputEof" + }, + "finalOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "finalOffset" + ] + }, + "TcpDataEofAction": { + "type": "object", + "description": "Half-close host output after all preceding output bytes have been delivered.", + "properties": { + "type": { + "const": "tcp/dataEof" + }, + "finalOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "finalOffset" + ] + }, + "TcpClientCloseAction": { + "type": "object", + "description": "Client's final close. Respond with hostClose if not already sent.", + "properties": { + "type": { + "const": "tcp/clientClose" + } + }, + "required": [ + "type" + ] + }, + "TcpHostCloseAction": { + "type": "object", + "description": "Host's final close. Respond with clientClose if not already sent.", + "properties": { + "type": { + "const": "tcp/hostClose" + } + }, + "required": [ + "type" + ] + }, + "TcpClientResetAction": { + "type": "object", + "description": "Abort both directions and discard buffered payload.", + "properties": { + "type": { + "const": "tcp/clientReset" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "type", + "reason" + ] + }, + "TcpHostResetAction": { + "type": "object", + "description": "Abort both directions and discard buffered payload.", + "properties": { + "type": { + "const": "tcp/hostReset" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "type", + "reason" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -10445,6 +10839,36 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/TcpInputAction" + }, + { + "$ref": "#/$defs/TcpDataAction" + }, + { + "$ref": "#/$defs/TcpInputConsumedAction" + }, + { + "$ref": "#/$defs/TcpDataConsumedAction" + }, + { + "$ref": "#/$defs/TcpInputEofAction" + }, + { + "$ref": "#/$defs/TcpDataEofAction" + }, + { + "$ref": "#/$defs/TcpClientCloseAction" + }, + { + "$ref": "#/$defs/TcpHostCloseAction" + }, + { + "$ref": "#/$defs/TcpClientResetAction" + }, + { + "$ref": "#/$defs/TcpHostResetAction" } ], "description": "Discriminated union of all state actions." @@ -10532,6 +10956,13 @@ ], "description": "A string that may optionally be rendered as Markdown.\n\n- A plain `string` is rendered as-is (no Markdown processing).\n- An object with `{ markdown: string }` is rendered with Markdown formatting." }, + "TcpDataEncoding": { + "enum": [ + "base64" + ], + "type": "string", + "description": "Payload encodings advertised by the host." + }, "JsonPrimitive": { "oneOf": [ { @@ -11214,6 +11645,37 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "TcpEndpoint": { + "enum": [ + "client", + "host" + ], + "type": "string", + "description": "Endpoint that closes or resets a connection." + }, + "TcpResetReason": { + "enum": [ + "connectionReset", + "connectionAborted", + "protocolError", + "replayUnavailable", + "policyRevoked", + "sessionDisposed", + "internalError" + ], + "type": "string", + "description": "Why a connection was aborted." + }, + "TcpConnectionOpenFailureReason": { + "enum": [ + "connectionFailed", + "nameResolutionFailed", + "resourceShortage", + "sessionNotReady" + ], + "type": "string", + "description": "Expected connection establishment failures." + }, "PendingMessageKind": { "enum": [ "steering", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 536be4dc4..7f3cf8e7c 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -86,12 +86,58 @@ }, "[AhpErrorCodes.UnsupportedProtocolVersion]": { "$ref": "#/$defs/UnsupportedProtocolVersionErrorData" + }, + "[AhpErrorCodes.TcpConnectionOpenFailed]": { + "$ref": "#/$defs/TcpConnectionOpenErrorData" } }, "required": [ "[AhpErrorCodes.AuthRequired]", "[AhpErrorCodes.PermissionDenied]", - "[AhpErrorCodes.UnsupportedProtocolVersion]" + "[AhpErrorCodes.UnsupportedProtocolVersion]", + "[AhpErrorCodes.TcpConnectionOpenFailed]" + ] + }, + "FlowControlledByteDirectionState": { + "type": "object", + "description": "Bounded byte credit in one direction of a stream.\nAll counters are nonnegative safe integers (at most 2^53 - 1).\n0 <= consumedBytes <= receivedBytes and\nreceivedBytes - consumedBytes <= windowBytes.", + "properties": { + "windowBytes": { + "type": "integer", + "description": "Maximum accepted-but-not-consumed decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per chunk; MUST NOT exceed windowBytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "receivedBytes": { + "type": "integer", + "description": "Cumulative accepted bytes.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "consumedBytes": { + "type": "integer", + "description": "Cumulative bytes released by the bounded consumer.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "eofAtBytes": { + "type": "integer", + "description": "Present after EOF; equals receivedBytes permanently.", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "windowBytes", + "maximumChunkSize", + "receivedBytes", + "consumedBytes" ] }, "Icon": { @@ -535,6 +581,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/TcpConnectionState" } ], "description": "The current state of the resource" @@ -5923,6 +5972,118 @@ "sessions" ] }, + "TcpConnectionsCapability": { + "type": "object", + "description": "Host support for private, session-scoped TCP channels.\nPresence on initialize is required before using subscribe.create.", + "properties": { + "encodings": { + "type": "array", + "items": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "description": "Supported encodings. The base64 profile MUST be supported." + }, + "maximumConnectionsPerClient": { + "type": "integer", + "description": "Informational limit; runtime policy may impose a lower limit.", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "encodings" + ] + }, + "TcpTarget": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "DNS name or IP literal, resolved and connected in the host endpoint's network." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "host", + "port" + ] + }, + "TcpResetState": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/TcpEndpoint" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "source", + "reason" + ] + }, + "TcpConnectionOpenErrorData": { + "type": "object", + "description": "Required detail for TcpConnectionOpenFailed (-32012).\nPolicy denial and malformed requests use PermissionDenied and InvalidParams.", + "properties": { + "reason": { + "$ref": "#/$defs/TcpConnectionOpenFailureReason" + }, + "retryable": { + "type": "boolean" + } + }, + "required": [ + "reason" + ] + }, + "TcpConnectionState": { + "type": "object", + "description": "State of one host-assigned `ahp-tcp:` channel.\n\nPayload is never stored in this state. Only the creating authenticated\nlogical client may observe or dispatch to the channel. Reconnect requires\nthe original sockets, local stream state, and complete action replay;\na snapshot cannot restore this channel.\n\nClose flags record the two-sided handshake. Either flag means closing;\nboth mean closed. A present reset terminates the connection immediately,\nindependently of the close history.", + "properties": { + "session": { + "$ref": "#/$defs/URI" + }, + "target": { + "$ref": "#/$defs/TcpTarget" + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "input": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Client to destination socket." + }, + "output": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Destination socket to client." + }, + "clientClosed": { + "type": "boolean" + }, + "hostClosed": { + "type": "boolean" + }, + "reset": { + "$ref": "#/$defs/TcpResetState" + } + }, + "required": [ + "session", + "target", + "encoding", + "input", + "output", + "clientClosed", + "hostClosed" + ] + }, "BaseParams": { "type": "object", "description": "Base shape every command's params extends.\n\n`channel` identifies the channel the command targets, mirroring the\n`channel` field on every protocol notification. For commands that operate\non a specific channel (a session, terminal, or changeset), `channel` is\nthat channel's URI. For commands that are connection-level rather than\nchannel-scoped (e.g. {@link InitializeParams | `initialize`},\n{@link PingParams | `ping`}, {@link ListSessionsParams | `listSessions`},\nthe `resource*` filesystem commands, and {@link AuthenticateParams |\n`authenticate`}), the params type narrows `channel` to the literal\nroot URI `'ahp-root://'`.\n\nThis invariant lets implementations route every incoming message —\nrequest, response, or notification — by inspecting `params.channel`\nwithout needing to know the per-method param shape.", @@ -6099,6 +6260,10 @@ "automations": { "$ref": "#/$defs/AutomationCapabilities", "description": "Host-owned automation support. Presence means clients may subscribe to\n`ahp-automations://` for {@link AutomationState}; absence means the\nhost does not expose an automation catalogue or automation commands." + }, + "tcpConnections": { + "$ref": "#/$defs/TcpConnectionsCapability", + "description": "Enables atomic creation of session-scoped, replay-only TCP channels." } }, "required": [ @@ -6259,6 +6424,13 @@ "$ref": "#/$defs/Snapshot" }, "description": "Fresh snapshots for each subscription" + }, + "missing": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "Subscriptions that cannot be restored. Hosts supporting TCP MUST list all\nrequested TCP channels here and dispose their sockets on snapshot fallback.\nOmitted by older hosts; absence does not authorize snapshot-restoring TCP." } }, "required": [ @@ -6286,6 +6458,10 @@ "view": { "$ref": "#/$defs/SubscribeView", "description": "Optional client-requested shape for the returned snapshot.\n\nServers that do not understand a requested view ignore it and return their\ndefault snapshot. Clients MUST tolerate receiving more state than requested." + }, + "create": { + "$ref": "#/$defs/TcpConnectionSubscription", + "description": "Atomically create a private child channel and subscribe to it.\nRequires the advertised tcpConnections capability. channel identifies\nthe parent session; snapshot.resource identifies the created TCP channel." } }, "required": [ @@ -7663,6 +7839,52 @@ "description": "Empty acknowledgement; the updated automation state is delivered by action.", "properties": {} }, + "TcpConnectionSubscription": { + "type": "object", + "description": "Creates and exclusively subscribes to one TCP connection.\n\nSubscribeParams.channel MUST identify the parent `ahp-session:` channel.\nThe host returns the new `ahp-tcp:` URI in snapshot.resource, not the parent.\nIt installs the subscription and sends the response before any TCP actions.\nUnknown creation kinds MUST be rejected, never treated as normal subscribe.", + "properties": { + "type": { + "type": "string", + "enum": [ + "tcpConnection" + ] + }, + "host": { + "type": "string", + "description": "DNS name or IP literal, not a URL." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding", + "description": "Selected from InitializeResult.tcpConnections.encodings." + }, + "receiveWindowBytes": { + "type": "integer", + "description": "Client receive window in decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes.", + "minimum": 1, + "maximum": 4294967295 + } + }, + "required": [ + "type", + "host", + "port", + "encoding", + "receiveWindowBytes", + "maximumChunkSize" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -8399,6 +8621,44 @@ ], "description": "Discriminated lifecycle of an automation run." }, + "TcpDataEncoding": { + "enum": [ + "base64" + ], + "type": "string", + "description": "Payload encodings advertised by the host." + }, + "TcpEndpoint": { + "enum": [ + "client", + "host" + ], + "type": "string", + "description": "Endpoint that closes or resets a connection." + }, + "TcpResetReason": { + "enum": [ + "connectionReset", + "connectionAborted", + "protocolError", + "replayUnavailable", + "policyRevoked", + "sessionDisposed", + "internalError" + ], + "type": "string", + "description": "Why a connection was aborted." + }, + "TcpConnectionOpenFailureReason": { + "enum": [ + "connectionFailed", + "nameResolutionFailed", + "resourceShortage", + "sessionNotReady" + ], + "type": "string", + "description": "Expected connection establishment failures." + }, "ActionEnvelope": { "type": "object", "description": "Every action is wrapped in an `ActionEnvelope`.\n\nThe envelope identifies the channel the action belongs to (e.g.\n`ahp-root://` for root actions, the session URI for session actions, the\nterminal URI for terminal actions). Individual action payloads carry only\nfields that are intrinsic to the action; the channel comes from the\nenvelope so that any subscribable resource can route its actions uniformly.", @@ -8736,6 +8996,36 @@ }, { "$ref": "#/$defs/AutomationRunCancelRequestedAction" + }, + { + "$ref": "#/$defs/TcpInputAction" + }, + { + "$ref": "#/$defs/TcpDataAction" + }, + { + "$ref": "#/$defs/TcpInputConsumedAction" + }, + { + "$ref": "#/$defs/TcpDataConsumedAction" + }, + { + "$ref": "#/$defs/TcpInputEofAction" + }, + { + "$ref": "#/$defs/TcpDataEofAction" + }, + { + "$ref": "#/$defs/TcpClientCloseAction" + }, + { + "$ref": "#/$defs/TcpHostCloseAction" + }, + { + "$ref": "#/$defs/TcpClientResetAction" + }, + { + "$ref": "#/$defs/TcpHostResetAction" } ], "description": "Discriminated union of all state actions." @@ -11108,6 +11398,182 @@ "type" ] }, + "TcpInputAction": { + "type": "object", + "description": "Client bytes. Never apply optimistically to the authoritative reducer.\nWrite to the destination only when accepted input.receivedBytes advances.", + "properties": { + "type": { + "const": "tcp/input" + }, + "offset": { + "type": "integer", + "description": "Absolute decoded-byte offset.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "data": { + "type": "string", + "description": "Nonempty canonical padded RFC 4648 base64; no whitespace." + } + }, + "required": [ + "type", + "offset", + "data" + ] + }, + "TcpDataAction": { + "type": "object", + "description": "Host bytes. Deliver once, only when output.receivedBytes advances.", + "properties": { + "type": { + "const": "tcp/data" + }, + "offset": { + "type": "integer", + "description": "Absolute decoded-byte offset.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "data": { + "type": "string", + "description": "Nonempty canonical padded RFC 4648 base64; no whitespace." + } + }, + "required": [ + "type", + "offset", + "data" + ] + }, + "TcpInputConsumedAction": { + "type": "object", + "description": "Cumulative input bytes released from the host's bounded write buffer.\nNot an acknowledgment that the destination application processed the bytes.", + "properties": { + "type": { + "const": "tcp/inputConsumed" + }, + "consumedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "consumedBytes" + ] + }, + "TcpDataConsumedAction": { + "type": "object", + "description": "Cumulative output bytes released by the client's bounded stream consumer.", + "properties": { + "type": { + "const": "tcp/dataConsumed" + }, + "consumedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "consumedBytes" + ] + }, + "TcpInputEofAction": { + "type": "object", + "description": "Half-close client input after all preceding input bytes have been written.", + "properties": { + "type": { + "const": "tcp/inputEof" + }, + "finalOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "finalOffset" + ] + }, + "TcpDataEofAction": { + "type": "object", + "description": "Half-close host output after all preceding output bytes have been delivered.", + "properties": { + "type": { + "const": "tcp/dataEof" + }, + "finalOffset": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "finalOffset" + ] + }, + "TcpClientCloseAction": { + "type": "object", + "description": "Client's final close. Respond with hostClose if not already sent.", + "properties": { + "type": { + "const": "tcp/clientClose" + } + }, + "required": [ + "type" + ] + }, + "TcpHostCloseAction": { + "type": "object", + "description": "Host's final close. Respond with clientClose if not already sent.", + "properties": { + "type": { + "const": "tcp/hostClose" + } + }, + "required": [ + "type" + ] + }, + "TcpClientResetAction": { + "type": "object", + "description": "Abort both directions and discard buffered payload.", + "properties": { + "type": { + "const": "tcp/clientReset" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "type", + "reason" + ] + }, + "TcpHostResetAction": { + "type": "object", + "description": "Abort both directions and discard buffered payload.", + "properties": { + "type": { + "const": "tcp/hostReset" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "type", + "reason" + ] + }, "ChatToolCallApprovedAction": { "type": "object", "description": "Client approves a pending tool call. The tool transitions to `running`.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index c43b4d93d..779125131 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -272,6 +272,48 @@ } ] }, + "FlowControlledByteDirectionState": { + "type": "object", + "description": "Bounded byte credit in one direction of a stream.\nAll counters are nonnegative safe integers (at most 2^53 - 1).\n0 <= consumedBytes <= receivedBytes and\nreceivedBytes - consumedBytes <= windowBytes.", + "properties": { + "windowBytes": { + "type": "integer", + "description": "Maximum accepted-but-not-consumed decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per chunk; MUST NOT exceed windowBytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "receivedBytes": { + "type": "integer", + "description": "Cumulative accepted bytes.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "consumedBytes": { + "type": "integer", + "description": "Cumulative bytes released by the bounded consumer.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "eofAtBytes": { + "type": "integer", + "description": "Present after EOF; equals receivedBytes permanently.", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "windowBytes", + "maximumChunkSize", + "receivedBytes", + "consumedBytes" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -713,6 +755,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/TcpConnectionState" } ], "description": "The current state of the resource" @@ -6101,6 +6146,118 @@ "sessions" ] }, + "TcpConnectionsCapability": { + "type": "object", + "description": "Host support for private, session-scoped TCP channels.\nPresence on initialize is required before using subscribe.create.", + "properties": { + "encodings": { + "type": "array", + "items": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "description": "Supported encodings. The base64 profile MUST be supported." + }, + "maximumConnectionsPerClient": { + "type": "integer", + "description": "Informational limit; runtime policy may impose a lower limit.", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "encodings" + ] + }, + "TcpTarget": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "DNS name or IP literal, resolved and connected in the host endpoint's network." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "host", + "port" + ] + }, + "TcpResetState": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/TcpEndpoint" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "source", + "reason" + ] + }, + "TcpConnectionOpenErrorData": { + "type": "object", + "description": "Required detail for TcpConnectionOpenFailed (-32012).\nPolicy denial and malformed requests use PermissionDenied and InvalidParams.", + "properties": { + "reason": { + "$ref": "#/$defs/TcpConnectionOpenFailureReason" + }, + "retryable": { + "type": "boolean" + } + }, + "required": [ + "reason" + ] + }, + "TcpConnectionState": { + "type": "object", + "description": "State of one host-assigned `ahp-tcp:` channel.\n\nPayload is never stored in this state. Only the creating authenticated\nlogical client may observe or dispatch to the channel. Reconnect requires\nthe original sockets, local stream state, and complete action replay;\na snapshot cannot restore this channel.\n\nClose flags record the two-sided handshake. Either flag means closing;\nboth mean closed. A present reset terminates the connection immediately,\nindependently of the close history.", + "properties": { + "session": { + "$ref": "#/$defs/URI" + }, + "target": { + "$ref": "#/$defs/TcpTarget" + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "input": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Client to destination socket." + }, + "output": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Destination socket to client." + }, + "clientClosed": { + "type": "boolean" + }, + "hostClosed": { + "type": "boolean" + }, + "reset": { + "$ref": "#/$defs/TcpResetState" + } + }, + "required": [ + "session", + "target", + "encoding", + "input", + "output", + "clientClosed", + "hostClosed" + ] + }, "URI": { "type": "string", "description": "A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`)." @@ -6844,6 +7001,44 @@ } ], "description": "Discriminated lifecycle of an automation run." + }, + "TcpDataEncoding": { + "enum": [ + "base64" + ], + "type": "string", + "description": "Payload encodings advertised by the host." + }, + "TcpEndpoint": { + "enum": [ + "client", + "host" + ], + "type": "string", + "description": "Endpoint that closes or resets a connection." + }, + "TcpResetReason": { + "enum": [ + "connectionReset", + "connectionAborted", + "protocolError", + "replayUnavailable", + "policyRevoked", + "sessionDisposed", + "internalError" + ], + "type": "string", + "description": "Why a connection was aborted." + }, + "TcpConnectionOpenFailureReason": { + "enum": [ + "connectionFailed", + "nameResolutionFailed", + "resourceShortage", + "sessionNotReady" + ], + "type": "string", + "description": "Expected connection establishment failures." } } } diff --git a/schema/state.schema.json b/schema/state.schema.json index 335969bc1..1b95bd615 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -5,6 +5,48 @@ "title": "AHP State Types", "description": "All state types in the Agent Host Protocol.", "$defs": { + "FlowControlledByteDirectionState": { + "type": "object", + "description": "Bounded byte credit in one direction of a stream.\nAll counters are nonnegative safe integers (at most 2^53 - 1).\n0 <= consumedBytes <= receivedBytes and\nreceivedBytes - consumedBytes <= windowBytes.", + "properties": { + "windowBytes": { + "type": "integer", + "description": "Maximum accepted-but-not-consumed decoded bytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "maximumChunkSize": { + "type": "integer", + "description": "Maximum decoded bytes per chunk; MUST NOT exceed windowBytes.", + "minimum": 1, + "maximum": 4294967295 + }, + "receivedBytes": { + "type": "integer", + "description": "Cumulative accepted bytes.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "consumedBytes": { + "type": "integer", + "description": "Cumulative bytes released by the bounded consumer.", + "minimum": 0, + "maximum": 9007199254740991 + }, + "eofAtBytes": { + "type": "integer", + "description": "Present after EOF; equals receivedBytes permanently.", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "windowBytes", + "maximumChunkSize", + "receivedBytes", + "consumedBytes" + ] + }, "Icon": { "type": "object", "description": "An optionally-sized icon that can be displayed in a user interface.", @@ -446,6 +488,9 @@ }, { "$ref": "#/$defs/AutomationRunState" + }, + { + "$ref": "#/$defs/TcpConnectionState" } ], "description": "The current state of the resource" @@ -5834,6 +5879,118 @@ "sessions" ] }, + "TcpConnectionsCapability": { + "type": "object", + "description": "Host support for private, session-scoped TCP channels.\nPresence on initialize is required before using subscribe.create.", + "properties": { + "encodings": { + "type": "array", + "items": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "description": "Supported encodings. The base64 profile MUST be supported." + }, + "maximumConnectionsPerClient": { + "type": "integer", + "description": "Informational limit; runtime policy may impose a lower limit.", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "encodings" + ] + }, + "TcpTarget": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "DNS name or IP literal, resolved and connected in the host endpoint's network." + }, + "port": { + "type": "integer", + "description": "Destination port.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "host", + "port" + ] + }, + "TcpResetState": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/TcpEndpoint" + }, + "reason": { + "$ref": "#/$defs/TcpResetReason" + } + }, + "required": [ + "source", + "reason" + ] + }, + "TcpConnectionOpenErrorData": { + "type": "object", + "description": "Required detail for TcpConnectionOpenFailed (-32012).\nPolicy denial and malformed requests use PermissionDenied and InvalidParams.", + "properties": { + "reason": { + "$ref": "#/$defs/TcpConnectionOpenFailureReason" + }, + "retryable": { + "type": "boolean" + } + }, + "required": [ + "reason" + ] + }, + "TcpConnectionState": { + "type": "object", + "description": "State of one host-assigned `ahp-tcp:` channel.\n\nPayload is never stored in this state. Only the creating authenticated\nlogical client may observe or dispatch to the channel. Reconnect requires\nthe original sockets, local stream state, and complete action replay;\na snapshot cannot restore this channel.\n\nClose flags record the two-sided handshake. Either flag means closing;\nboth mean closed. A present reset terminates the connection immediately,\nindependently of the close history.", + "properties": { + "session": { + "$ref": "#/$defs/URI" + }, + "target": { + "$ref": "#/$defs/TcpTarget" + }, + "encoding": { + "$ref": "#/$defs/TcpDataEncoding" + }, + "input": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Client to destination socket." + }, + "output": { + "$ref": "#/$defs/FlowControlledByteDirectionState", + "description": "Destination socket to client." + }, + "clientClosed": { + "type": "boolean" + }, + "hostClosed": { + "type": "boolean" + }, + "reset": { + "$ref": "#/$defs/TcpResetState" + } + }, + "required": [ + "session", + "target", + "encoding", + "input", + "output", + "clientClosed", + "hostClosed" + ] + }, "StringOrMarkdown": { "oneOf": [ { @@ -6569,6 +6726,44 @@ ], "type": "string", "description": "Operations the host currently permits for an automation.\n\nThe list on {@link AutomationEntry.operations} is authoritative and may\nchange over time. Clients MUST NOT infer permission from capabilities alone:\ncapabilities describe what the host implementation can support, while\noperations describe what is allowed for this particular automation now." + }, + "TcpDataEncoding": { + "enum": [ + "base64" + ], + "type": "string", + "description": "Payload encodings advertised by the host." + }, + "TcpEndpoint": { + "enum": [ + "client", + "host" + ], + "type": "string", + "description": "Endpoint that closes or resets a connection." + }, + "TcpResetReason": { + "enum": [ + "connectionReset", + "connectionAborted", + "protocolError", + "replayUnavailable", + "policyRevoked", + "sessionDisposed", + "internalError" + ], + "type": "string", + "description": "Why a connection was aborted." + }, + "TcpConnectionOpenFailureReason": { + "enum": [ + "connectionFailed", + "nameResolutionFailed", + "resourceShortage", + "sessionNotReady" + ], + "type": "string", + "description": "Expected connection establishment failures." } } } diff --git a/scripts/find-protocol-sources.ts b/scripts/find-protocol-sources.ts index 79fd2bdc1..f7c5d5565 100644 --- a/scripts/find-protocol-sources.ts +++ b/scripts/find-protocol-sources.ts @@ -26,6 +26,7 @@ export const PROTOCOL_SOURCE_DIRS: readonly string[] = [ 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-tcp', ]; /** diff --git a/scripts/generate-action-origin.ts b/scripts/generate-action-origin.ts index b38eb2bb7..9e341a3c4 100644 --- a/scripts/generate-action-origin.ts +++ b/scripts/generate-action-origin.ts @@ -17,7 +17,7 @@ const GENERATED_HEADER = `// Generated from types/actions.ts — do not edit // Run \`npm run generate\` to regenerate. `; -type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; +type ActionScope = 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun' | 'tcp'; interface ActionInfo { /** The interface name (e.g. 'RootAgentsChangedAction') */ @@ -157,6 +157,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { : category === 'Resource Watch Actions' ? 'resourceWatch' : category === 'Automation Actions' ? 'automation' : category === 'Automation Run Actions' ? 'automationRun' + : category === 'TCP Actions' ? 'tcp' : 'session'; const isClientDispatchable = hasJsDocTag(node as any, 'clientDispatchable'); @@ -211,6 +212,7 @@ export function generateActionOrigin(project: Project, outDir: string): void { const resourceWatchActions = actions.filter(a => a.scope === 'resourceWatch'); const automationActions = actions.filter(a => a.scope === 'automation'); const automationRunActions = actions.filter(a => a.scope === 'automationRun'); + const tcpActions = actions.filter(a => a.scope === 'tcp'); const clientRootActions = rootActions.filter(a => a.isClientDispatchable); const serverRootActions = rootActions.filter(a => !a.isClientDispatchable); const clientSessionActions = sessionActions.filter(a => a.isClientDispatchable); @@ -229,6 +231,8 @@ export function generateActionOrigin(project: Project, outDir: string): void { const serverAutomationActions = automationActions.filter(a => !a.isClientDispatchable); const clientAutomationRunActions = automationRunActions.filter(a => a.isClientDispatchable); const serverAutomationRunActions = automationRunActions.filter(a => !a.isClientDispatchable); + const clientTcpActions = tcpActions.filter(a => a.isClientDispatchable); + const serverTcpActions = tcpActions.filter(a => !a.isClientDispatchable); const lines: string[] = [GENERATED_HEADER]; @@ -520,6 +524,30 @@ export function generateActionOrigin(project: Project, outDir: string): void { lines.push(`;`); lines.push(``); + // TcpAction + lines.push(`/** Union of all TCP-scoped actions. */`); + lines.push(`export type TcpAction =`); + for (const a of tcpActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of TCP actions that clients may dispatch. */`); + lines.push(`export type ClientTcpAction =`); + for (const a of clientTcpActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); + + lines.push(`/** Union of TCP actions that only the server may produce. */`); + lines.push(`export type ServerTcpAction =`); + for (const a of serverTcpActions) { + lines.push(` | ${a.name}`); + } + lines.push(`;`); + lines.push(``); // IS_CLIENT_DISPATCHABLE map lines.push(`// ─── Client-Dispatchable Map ─────────────────────────────────────────────────`); diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index e28c83086..e8b1a9ca8 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -749,6 +749,7 @@ function generateDiscriminatedUnion(project: Project, cfg: UnionConfig): string // ─── State File Generator ──────────────────────────────────────────────────── const STATE_ENUMS = [ + 'TcpDataEncoding', 'TcpEndpoint', 'TcpResetReason', 'TcpConnectionOpenFailureReason', 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', 'SessionOriginKind', 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', @@ -901,6 +902,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: strin { name: 'ChangesetOperation', mutable: true }, { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, + { name: 'TcpConnectionState' }, { name: 'TcpTarget' }, { name: 'TcpResetState' }, + { name: 'FlowControlledByteDirectionState' }, { name: 'TcpConnectionsCapability' }, + { name: 'TcpConnectionOpenErrorData' }, { name: 'ResourceChange' }, { name: 'AnnotationsSummary' }, { name: 'AnnotationsState' }, @@ -1419,14 +1423,18 @@ internal sealed class CustomizationEnablementConverter : UnionConverter /// SnapshotState is the state payload of a snapshot — root, session, - /// chat, terminal, changeset, resource-watch, annotations, automation catalogue, - /// or automation-run state. Read +/// chat, terminal, changeset, resource-watch, annotations, automation catalogue, +/// automation-run, or TCP state. Read /// probes for distinctive fields in an order where no probe shadows another -/// (chat → session → terminal → changeset → resource-watch → annotations → root). +/// (tcp → automationRun → automations → session → chat → terminal → changeset → +/// resource-watch → annotations → root). /// [JsonConverter(typeof(SnapshotStateConverter))] public sealed class SnapshotState { + /// Private TCP channel state variant, when populated. + public TcpConnectionState? Tcp { get; set; } + /// Root state variant, when populated. public RootState? Root { get; set; } @@ -1463,7 +1471,13 @@ internal sealed class SnapshotStateConverter : JsonConverter using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; var result = new SnapshotState(); - if (root.TryGetProperty("automation", out _) && + if (root.TryGetProperty("input", out _) && + root.TryGetProperty("output", out _) && + root.TryGetProperty("target", out _)) + { + result.Tcp = root.Deserialize(AhpJsonTypeInfo.Get(options)); + } + else if (root.TryGetProperty("automation", out _) && root.TryGetProperty("origin", out _) && root.TryGetProperty("sessions", out _)) { @@ -1509,6 +1523,7 @@ internal sealed class SnapshotStateConverter : JsonConverter public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) { + if (value.Tcp is not null) { JsonSerializer.Serialize(writer, value.Tcp, AhpJsonTypeInfo.Get(options)); return; } if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } @@ -1694,6 +1709,16 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string { type: 'terminal/commandExecuted', variantName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', variantName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', variantName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'tcp/input', variantName: 'TcpInput', tsInterface: 'TcpInputAction' }, + { type: 'tcp/data', variantName: 'TcpData', tsInterface: 'TcpDataAction' }, + { type: 'tcp/inputConsumed', variantName: 'TcpInputConsumed', tsInterface: 'TcpInputConsumedAction' }, + { type: 'tcp/dataConsumed', variantName: 'TcpDataConsumed', tsInterface: 'TcpDataConsumedAction' }, + { type: 'tcp/inputEof', variantName: 'TcpInputEof', tsInterface: 'TcpInputEofAction' }, + { type: 'tcp/dataEof', variantName: 'TcpDataEof', tsInterface: 'TcpDataEofAction' }, + { type: 'tcp/clientClose', variantName: 'TcpClientClose', tsInterface: 'TcpClientCloseAction' }, + { type: 'tcp/hostClose', variantName: 'TcpHostClose', tsInterface: 'TcpHostCloseAction' }, + { type: 'tcp/clientReset', variantName: 'TcpClientReset', tsInterface: 'TcpClientResetAction' }, + { type: 'tcp/hostReset', variantName: 'TcpHostReset', tsInterface: 'TcpHostResetAction' }, { type: 'annotations/set', variantName: 'AnnotationsSet', tsInterface: 'AnnotationsSetAction' }, { type: 'annotations/removed', variantName: 'AnnotationsRemoved', tsInterface: 'AnnotationsRemovedAction' }, { type: 'annotations/entrySet', variantName: 'AnnotationsEntrySet', tsInterface: 'AnnotationsEntrySetAction' }, @@ -2275,6 +2300,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; csName?: str { name: 'ReconnectReplayResult' }, { name: 'ReconnectSnapshotResult' }, { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, + { name: 'TcpConnectionSubscription' }, { name: 'SessionForkSource' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, // ChatSource union variants (upstream #334 renamed ChatForkSource -> diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 6a0e78ce8..a81d6c9ce 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -173,7 +173,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || - tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | TcpConnectionState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState' ) { @@ -716,6 +716,7 @@ function generateDiscriminatedUnion(project: Project, cfg: UnionConfig): string // ─── State File Generator ──────────────────────────────────────────────────── const STATE_ENUMS = [ + 'TcpDataEncoding', 'TcpEndpoint', 'TcpResetReason', 'TcpConnectionOpenFailureReason', 'PolicyState', 'SessionLifecycle', 'SessionStatus', 'ChatOriginKind', 'ChatInteractivity', 'PendingMessageKind', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', @@ -873,6 +874,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'AnnotationEntry' }, { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, + { name: 'TcpConnectionState' }, { name: 'TcpTarget' }, { name: 'TcpResetState' }, + { name: 'FlowControlledByteDirectionState' }, { name: 'TcpConnectionsCapability' }, + { name: 'TcpConnectionOpenErrorData' }, { name: 'ResourceChange' }, { name: 'AutomationSessionOrigin' }, { name: 'AutomationSchedule' }, @@ -1301,12 +1305,13 @@ func (o ChatOrigin) MarshalJSON() ([]byte, error) { function generateSnapshotState(): string { return `// SnapshotState is the state payload of a snapshot — root, session, // chat, terminal, changeset, resource-watch, annotations, automation catalogue, -// or automation-run state. The active +// automation-run, or TCP state. The active // variant is chosen by which pointer field is non-nil; UnmarshalJSON probes // for required fields in the canonical order -// (automationRun → automations → session → chat → terminal → changeset → +// (tcp → automationRun → automations → session → chat → terminal → changeset → // resourceWatch → annotations → root). type SnapshotState struct { +\tTcp *TcpConnectionState \`json:"-"\` \tRoot *RootState \`json:"-"\` \tSession *SessionState \`json:"-"\` \tChat *ChatState \`json:"-"\` @@ -1321,6 +1326,8 @@ type SnapshotState struct { // MarshalJSON encodes whichever variant is currently populated. func (s SnapshotState) MarshalJSON() ([]byte, error) { \tswitch { +\tcase s.Tcp != nil: +\t\treturn json.Marshal(s.Tcp) \tcase s.AutomationRun != nil: \t\treturn json.Marshal(s.AutomationRun) \tcase s.Automations != nil: @@ -1353,6 +1360,12 @@ func (s *SnapshotState) UnmarshalJSON(data []byte) error { \t\treturn err \t} \tswitch { +\tcase containsAll(probe, "input", "output", "target"): +\t\tvar v TcpConnectionState +\t\tif err := json.Unmarshal(data, &v); err != nil { +\t\t\treturn err +\t\t} +\t\ts.Tcp = &v \tcase containsAll(probe, "automation", "origin", "sessions"): \t\tvar v AutomationRunState \t\tif err := json.Unmarshal(data, &v); err != nil { @@ -1646,6 +1659,16 @@ const ACTION_VARIANTS: { { type: 'terminal/commandExecuted', variantName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', variantName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', variantName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'tcp/input', variantName: 'TcpInput', tsInterface: 'TcpInputAction' }, + { type: 'tcp/data', variantName: 'TcpData', tsInterface: 'TcpDataAction' }, + { type: 'tcp/inputConsumed', variantName: 'TcpInputConsumed', tsInterface: 'TcpInputConsumedAction' }, + { type: 'tcp/dataConsumed', variantName: 'TcpDataConsumed', tsInterface: 'TcpDataConsumedAction' }, + { type: 'tcp/inputEof', variantName: 'TcpInputEof', tsInterface: 'TcpInputEofAction' }, + { type: 'tcp/dataEof', variantName: 'TcpDataEof', tsInterface: 'TcpDataEofAction' }, + { type: 'tcp/clientClose', variantName: 'TcpClientClose', tsInterface: 'TcpClientCloseAction' }, + { type: 'tcp/hostClose', variantName: 'TcpHostClose', tsInterface: 'TcpHostCloseAction' }, + { type: 'tcp/clientReset', variantName: 'TcpClientReset', tsInterface: 'TcpClientResetAction' }, + { type: 'tcp/hostReset', variantName: 'TcpHostReset', tsInterface: 'TcpHostResetAction' }, { type: 'automation/createRequested', variantName: 'AutomationCreateRequested', tsInterface: 'AutomationCreateRequestedAction' }, { type: 'automation/updateRequested', variantName: 'AutomationUpdateRequested', tsInterface: 'AutomationUpdateRequestedAction' }, { type: 'automation/set', variantName: 'AutomationSet', tsInterface: 'AutomationSetAction' }, @@ -1767,9 +1790,10 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: str { name: 'AutomationCustomizationsCapability' }, { name: 'Implementation' }, { name: 'ReconnectParams' }, - { name: 'ReconnectReplayResult', omitDiscriminants: true }, - { name: 'ReconnectSnapshotResult', omitDiscriminants: true }, + { name: 'ReconnectReplayResult' }, + { name: 'ReconnectSnapshotResult' }, { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, + { name: 'TcpConnectionSubscription' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, { name: 'ForkChatSource' }, { name: 'SideChatSource' }, { name: 'CreateChatParams' }, { name: 'DisposeChatParams' }, @@ -2000,6 +2024,11 @@ function generateCommandsFile(project: Project): string { lines.push(''); lines.push(generateFixedDiscriminantMethods('ChatMoveToNewSessionDestination', 'kind', 'newSession', 'ChatMoveDestinationKind')); lines.push(''); + lines.push(generateFixedDiscriminantMethods('ReconnectReplayResult', 'type', 'replay', 'ReconnectResultType')); + lines.push(''); + lines.push(generateFixedDiscriminantMethods('ReconnectSnapshotResult', 'type', 'snapshot', 'ReconnectResultType')); + lines.push(''); + lines.push('// ─── ChatSource Union ─────────────────────────────────────────────────\n'); lines.push(generateDiscriminatedUnion(project, CHAT_SOURCE_UNION)); lines.push(''); @@ -2114,6 +2143,7 @@ const ( \tErrorCodeNotFound int32 = -32008 \tErrorCodePermissionDenied int32 = -32009 \tErrorCodeAlreadyExists int32 = -32010 +\tErrorCodeTcpConnectionOpenFailed int32 = -32012 ) // AhpErrorCode is the type alias used by AHP application error codes. diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 073366689..2108629a2 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -12,6 +12,7 @@ import { describe, it, before } from 'node:test'; import { strict as assert } from 'node:assert'; +import { isDeepStrictEqual } from 'node:util'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -115,6 +116,9 @@ function schemaAccepts( if ('const' in schema) { return value === schema.const; } + if (Array.isArray(schema.enum) && !schema.enum.some(option => isDeepStrictEqual(option, value))) { + return false; + } if (schema.contains && Array.isArray(value)) { const matches = value.filter(item => schemaAccepts(root, schema.contains as JsonNode, item)).length; @@ -149,7 +153,8 @@ function schemaAccepts( case 'integer': return typeof value === 'number' && (schema.type !== 'integer' || Number.isInteger(value)) && - (typeof schema.minimum !== 'number' || value >= schema.minimum); + (typeof schema.minimum !== 'number' || value >= schema.minimum) && + (typeof schema.maximum !== 'number' || value <= schema.maximum); case 'boolean': return typeof value === 'boolean'; case 'null': @@ -163,6 +168,54 @@ function schemaAccepts( } describe('generated JSON schemas', () => { + it('preserves TCP creation as an optional typed subscribe payload', () => { + const schema = loadSchema('commands.schema.json'); + const subscribe = { $ref: '#/$defs/SubscribeParams' }; + const create = { + type: 'tcpConnection', + host: 'localhost', + port: 3000, + encoding: 'base64', + receiveWindowBytes: 1048576, + maximumChunkSize: 32768, + }; + assert.equal(schemaAccepts(schema, subscribe, { channel: 'ahp-session:/s1' }), true); + assert.equal(schemaAccepts(schema, subscribe, { channel: 'ahp-session:/s1', create }), true); + assert.equal(schemaAccepts(schema, subscribe, { + channel: 'ahp-session:/s1', create: { ...create, type: 'unknown' }, + }), false); + const { host: _host, ...withoutHost } = create; + assert.equal(schemaAccepts(schema, subscribe, { channel: 'ahp-session:/s1', create: withoutHost }), false); + for (const port of [0, 65536, 1.5]) { + assert.equal(schemaAccepts(schema, subscribe, { channel: 'ahp-session:/s1', create: { ...create, port } }), false); + } + for (const receiveWindowBytes of [0, 4294967296, 0.5]) { + assert.equal(schemaAccepts(schema, subscribe, { + channel: 'ahp-session:/s1', create: { ...create, receiveWindowBytes }, + }), false); + } + for (const port of [1, 65535]) { + assert.equal(schemaAccepts(schema, subscribe, { channel: 'ahp-session:/s1', create: { ...create, port } }), true); + } + for (const receiveWindowBytes of [1, 4294967295]) { + assert.equal(schemaAccepts(schema, subscribe, { + channel: 'ahp-session:/s1', create: { ...create, receiveWindowBytes, maximumChunkSize: 1 }, + }), true); + } + }); + + it('preserves snapshot reconnect missing channels without making them mandatory for older hosts', () => { + const schema = loadSchema('commands.schema.json'); + const reconnect = { $ref: '#/$defs/ReconnectSnapshotResult' }; + assert.equal(schemaAccepts(schema, reconnect, { type: 'snapshot', snapshots: [] }), true); + assert.equal(schemaAccepts(schema, reconnect, { + type: 'snapshot', snapshots: [], missing: ['ahp-tcp:/t1'], + }), true); + assert.equal(schemaAccepts(schema, reconnect, { + type: 'snapshot', snapshots: [], missing: 'ahp-tcp:/t1', + }), false); + }); + for (const file of SCHEMA_FILES) { describe(file, () => { const schema = loadSchema(file); diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 1f58804b0..099de3418 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -36,6 +36,7 @@ interface JsonSchema { enum?: Array; const?: string | number | boolean; minimum?: number; + maximum?: number; oneOf?: JsonSchema[]; allOf?: JsonSchema[]; anyOf?: JsonSchema[]; @@ -407,14 +408,16 @@ function interfaceToSchema(iface: InterfaceDeclaration, project: Project): JsonS } propSchema.type = 'integer'; } - const minimum = getNumericPropertyTag(prop, 'minimum'); - if (minimum !== undefined) { - if (propSchema.type !== 'number' && propSchema.type !== 'integer') { - throw new Error( - `${prop.getSourceFile().getFilePath()}: ${name} uses a numeric schema constraint on ${typeText}`, - ); + for (const constraint of ['minimum', 'maximum'] as const) { + const value = getNumericPropertyTag(prop, constraint); + if (value !== undefined) { + if (propSchema.type !== 'number' && propSchema.type !== 'integer') { + throw new Error( + `${prop.getSourceFile().getFilePath()}: ${name} uses a numeric schema constraint on ${typeText}`, + ); + } + propSchema[constraint] = value; } - propSchema.minimum = minimum; } const uniqueItemsBy = getUniqueItemsByConstraints(prop); if (uniqueItemsBy) { diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 4594663cb..7da44d00b 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -153,7 +153,7 @@ function mapType(tsType: string): string { tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' || - tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' || + tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | TcpConnectionState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' || @@ -835,6 +835,7 @@ function generateSnapshotState(): string { */ @Serializable(with = SnapshotStateSerializer::class) sealed interface SnapshotState { + @JvmInline value class Tcp(val value: TcpConnectionState) : SnapshotState @JvmInline value class Root(val value: RootState) : SnapshotState @JvmInline value class Session(val value: SessionState) : SnapshotState @JvmInline value class Chat(val value: ChatState) : SnapshotState @@ -856,7 +857,8 @@ internal object SnapshotStateSerializer : KSerializer { val element = input.decodeJsonElement() val obj = element as? JsonObject ?: error("Expected JsonObject for SnapshotState") - // Try the most distinctive shape first. AutomationRunState has required + // Try the most distinctive shape first. TcpConnectionState has required + // \`input\`, \`output\`, and \`target\`; AutomationRunState has required // \`automation\`, \`origin\`, and \`sessions\`; AutomationState has // required \`entries\`; SessionState has required // \`lifecycle\`; ChatState has required \`turns\`; ChangesetState has @@ -866,6 +868,8 @@ internal object SnapshotStateSerializer : KSerializer { // key); TerminalState has required \`content\`; RootState is the // catch-all. return when { + obj.containsKey("input") && obj.containsKey("output") && obj.containsKey("target") -> + SnapshotState.Tcp(input.json.decodeFromJsonElement(TcpConnectionState.serializer(), element)) obj.containsKey("automation") && obj.containsKey("origin") && obj.containsKey("sessions") -> SnapshotState.AutomationRun(input.json.decodeFromJsonElement(AutomationRunState.serializer(), element)) obj.containsKey("entries") -> @@ -888,6 +892,7 @@ internal object SnapshotStateSerializer : KSerializer { val output = encoder as? JsonEncoder ?: error("SnapshotState can only be serialized to JSON") val element: JsonElement = when (value) { + is SnapshotState.Tcp -> output.json.encodeToJsonElement(TcpConnectionState.serializer(), value.value) is SnapshotState.Root -> output.json.encodeToJsonElement(RootState.serializer(), value.value) is SnapshotState.Session -> output.json.encodeToJsonElement(SessionState.serializer(), value.value) is SnapshotState.Chat -> output.json.encodeToJsonElement(ChatState.serializer(), value.value) @@ -965,6 +970,7 @@ internal object ToolResultContentSerializer : KSerializer { // ─── State File Generator ──────────────────────────────────────────────────── const STATE_ENUMS = [ + 'TcpDataEncoding', 'TcpEndpoint', 'TcpResetReason', 'TcpConnectionOpenFailureReason', 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', @@ -1041,6 +1047,8 @@ const STATE_STRUCTS = [ 'AnnotationsSummary', 'AnnotationsState', 'AnnotationOrigin', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', + 'TcpConnectionState', 'TcpTarget', 'TcpResetState', 'FlowControlledByteDirectionState', + 'TcpConnectionsCapability', 'TcpConnectionOpenErrorData', 'AutomationSessionOrigin', 'AutomationSchedule', 'AutomationScheduleTrigger', 'AutomationEventTrigger', 'AutomationTriggerEventDefinition', 'AutomationTriggerDefinition', @@ -1587,6 +1595,16 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'terminal/commandExecuted', caseName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', caseName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', caseName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'tcp/input', caseName: 'TcpInput', tsInterface: 'TcpInputAction' }, + { type: 'tcp/data', caseName: 'TcpData', tsInterface: 'TcpDataAction' }, + { type: 'tcp/inputConsumed', caseName: 'TcpInputConsumed', tsInterface: 'TcpInputConsumedAction' }, + { type: 'tcp/dataConsumed', caseName: 'TcpDataConsumed', tsInterface: 'TcpDataConsumedAction' }, + { type: 'tcp/inputEof', caseName: 'TcpInputEof', tsInterface: 'TcpInputEofAction' }, + { type: 'tcp/dataEof', caseName: 'TcpDataEof', tsInterface: 'TcpDataEofAction' }, + { type: 'tcp/clientClose', caseName: 'TcpClientClose', tsInterface: 'TcpClientCloseAction' }, + { type: 'tcp/hostClose', caseName: 'TcpHostClose', tsInterface: 'TcpHostCloseAction' }, + { type: 'tcp/clientReset', caseName: 'TcpClientReset', tsInterface: 'TcpClientResetAction' }, + { type: 'tcp/hostReset', caseName: 'TcpHostReset', tsInterface: 'TcpHostResetAction' }, { type: 'automation/createRequested', caseName: 'AutomationCreateRequested', tsInterface: 'AutomationCreateRequestedAction' }, { type: 'automation/updateRequested', caseName: 'AutomationUpdateRequested', tsInterface: 'AutomationUpdateRequestedAction' }, { type: 'automation/set', caseName: 'AutomationSet', tsInterface: 'AutomationSetAction' }, @@ -1764,6 +1782,7 @@ const COMMAND_STRUCTS = [ 'Implementation', 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', + 'TcpConnectionSubscription', 'CreateSessionParams', 'DisposeSessionParams', 'CreateChatParams', 'DisposeChatParams', 'ChatMoveToSessionDestination', 'ChatMoveToNewSessionDestination', 'MoveChatParams', 'MoveChatResult', @@ -2140,6 +2159,7 @@ function generateErrorsFile(project: Project): string { lines.push(' const val PERMISSION_DENIED: Int = -32009'); lines.push(' /** The target resource already exists and the operation does not allow overwriting */'); lines.push(' const val ALREADY_EXISTS: Int = -32010'); + lines.push(' const val TCP_CONNECTION_OPEN_FAILED: Int = -32012'); lines.push('}'); lines.push(''); diff --git a/scripts/generate-markdown.ts b/scripts/generate-markdown.ts index 334349962..c8f0dfd44 100644 --- a/scripts/generate-markdown.ts +++ b/scripts/generate-markdown.ts @@ -74,6 +74,7 @@ const DIR_TO_PAGE: Record = { 'channels-otlp': 'otlp', 'channels-automation': 'automation', 'channels-automation-run': 'automation-run', + 'channels-tcp': 'tcp', }; /** @@ -1117,6 +1118,29 @@ function generateResourceWatchChannelPage(project: Project): string { return lines.join('\n'); } +function generateTcpChannelPage(project: Project): string { + currentPage = 'tcp'; + const state = findChannelSourceFile(project, 'channels-tcp', 'state.ts'); + const actions = findChannelSourceFile(project, 'channels-tcp', 'actions.ts'); + const commands = findChannelSourceFile(project, 'channels-tcp', 'commands.ts'); + if (!state || !actions || !commands) throw new Error('Missing TCP channel declarations'); + return [ + GENERATED_HEADER, + '# TCP Channel\n', + stabilityIndex(state), + 'Private session-scoped TCP connections. See the [TCP channel specification](/specification/tcp-channel).\n', + '## State Types\n', + schemaLink('state.schema.json'), + emitStateTypesSection([state]), + '## Actions\n', + schemaLink('actions.schema.json'), + emitActionsSection([actions]), + '## Creation\n', + schemaLink('commands.schema.json'), + emitStateTypesSection([commands]), + ].join('\n'); +} + function generateOtlpChannelPage(project: Project): string { currentPage = 'otlp'; const stateSf = findChannelSourceFile(project, 'channels-otlp', 'state.ts'); @@ -1417,6 +1441,7 @@ export function generateMarkdownDocs(project: Project, outDir: string): void { { filename: 'changeset.md', generator: generateChangesetChannelPage }, { filename: 'annotations.md', generator: generateAnnotationsChannelPage }, { filename: 'resource-watch.md', generator: generateResourceWatchChannelPage }, + { filename: 'tcp.md', generator: generateTcpChannelPage }, { filename: 'otlp.md', generator: generateOtlpChannelPage }, { filename: 'automation.md', generator: generateAutomationChannelPage }, { filename: 'automation-run.md', generator: generateAutomationRunChannelPage }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 22eda3358..7e4c85bf6 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -163,7 +163,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' - || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | TcpConnectionState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -807,6 +807,7 @@ function generateStructFromInterface( // ─── State File Generator ──────────────────────────────────────────────────── const STATE_ENUMS = [ + 'TcpDataEncoding', 'TcpEndpoint', 'TcpResetReason', 'TcpConnectionOpenFailureReason', 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', @@ -985,6 +986,9 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'AnnotationEntry' }, { name: 'TelemetryCapabilities' }, { name: 'ResourceWatchState' }, + { name: 'TcpConnectionState' }, { name: 'TcpTarget' }, { name: 'TcpResetState' }, + { name: 'FlowControlledByteDirectionState' }, { name: 'TcpConnectionsCapability' }, + { name: 'TcpConnectionOpenErrorData' }, { name: 'ResourceChange' }, { name: 'AutomationSessionOrigin', omitDiscriminants: true }, { name: 'AutomationSchedule' }, @@ -1365,7 +1369,8 @@ ${unknownVariant}}`; function generateSnapshotState(): string { return `/// The state payload of a snapshot. /// -/// Deserialized by trying session first (has required \`lifecycle\`), then +/// Deserialized by trying TCP first (has required \`input\`, \`output\`, and +/// \`target\`), then session (has required \`lifecycle\`), then /// chat (has required \`turns\`), then terminal (has required \`content\`), /// then changeset (has required \`status\` and \`files\`), then resource-watch /// (has required \`root\` and \`recursive\`), then annotations (has required @@ -1374,6 +1379,7 @@ function generateSnapshotState(): string { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum SnapshotState { + Tcp(Box), Session(Box), Chat(Box), Terminal(Box), @@ -1603,6 +1609,16 @@ const ACTION_VARIANTS: { { type: 'terminal/commandExecuted', variantName: 'TerminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', variantName: 'TerminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', variantName: 'ResourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'tcp/input', variantName: 'TcpInput', tsInterface: 'TcpInputAction' }, + { type: 'tcp/data', variantName: 'TcpData', tsInterface: 'TcpDataAction' }, + { type: 'tcp/inputConsumed', variantName: 'TcpInputConsumed', tsInterface: 'TcpInputConsumedAction' }, + { type: 'tcp/dataConsumed', variantName: 'TcpDataConsumed', tsInterface: 'TcpDataConsumedAction' }, + { type: 'tcp/inputEof', variantName: 'TcpInputEof', tsInterface: 'TcpInputEofAction' }, + { type: 'tcp/dataEof', variantName: 'TcpDataEof', tsInterface: 'TcpDataEofAction' }, + { type: 'tcp/clientClose', variantName: 'TcpClientClose', tsInterface: 'TcpClientCloseAction' }, + { type: 'tcp/hostClose', variantName: 'TcpHostClose', tsInterface: 'TcpHostCloseAction' }, + { type: 'tcp/clientReset', variantName: 'TcpClientReset', tsInterface: 'TcpClientResetAction' }, + { type: 'tcp/hostReset', variantName: 'TcpHostReset', tsInterface: 'TcpHostResetAction' }, { type: 'automation/createRequested', variantName: 'AutomationCreateRequested', tsInterface: 'AutomationCreateRequestedAction', boxed: true }, { type: 'automation/updateRequested', variantName: 'AutomationUpdateRequested', tsInterface: 'AutomationUpdateRequestedAction', boxed: true }, { type: 'automation/set', variantName: 'AutomationSet', tsInterface: 'AutomationSetAction', boxed: true }, @@ -1688,6 +1704,7 @@ impl Serialize for ChatErrorAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; + lines.push('use crate::state::TcpResetReason;'); lines.push('#[allow(unused_imports)]'); lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, AnnotationOrigin, AutomationDefinition, AutomationDefinitionPatch, AutomationEntry, AutomationRunLifecycle, AutomationRunSummary, BackgroundWork, ChangesSummary, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, ErrorResponsePart, FileEditCollection, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); // ActionType enum @@ -1805,6 +1822,7 @@ const COMMAND_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: s { name: 'ReconnectReplayResult', omitDiscriminants: true }, { name: 'ReconnectSnapshotResult', omitDiscriminants: true }, { name: 'SubscribeParams' }, { name: 'SubscribeView' }, { name: 'SubscriptionDeliveryOptions' }, { name: 'SubscribeResult' }, + { name: 'TcpConnectionSubscription' }, { name: 'CreateSessionParams' }, { name: 'DisposeSessionParams' }, { name: 'ForkChatSource', omitDiscriminants: true }, { name: 'SideChatSource', omitDiscriminants: true }, { name: 'CreateChatParams' }, @@ -1873,7 +1891,7 @@ function generateCommandsFile(project: Project): string { lines.push('#[allow(unused_imports)]'); lines.push('use crate::actions::{ActionEnvelope, StateAction};'); lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn};'); + lines.push('use crate::state::{AgentSelection, AutomationDefinition, AutomationSchedule, AutomationSessionTemplate, AutomationTrigger, AutomationTriggerDefinition, ContentRef, Message, MessageAttachment, ModelSelection, SessionActiveClient, SessionConfigSchema, SessionSummary, SideChatSelection, Snapshot, SnapshotState, TelemetryCapabilities, TerminalClaim, TextRange, Turn, TcpConnectionsCapability, TcpDataEncoding};'); lines.push(''); lines.push('// ─── Enums ────────────────────────────────────────────────────────────\n'); @@ -1959,6 +1977,7 @@ function generateSubscribeParamsImplRust(): string { meta: None, delivery: None, view: None, + create: None, } } @@ -1969,6 +1988,7 @@ function generateSubscribeParamsImplRust(): string { meta: None, delivery: Some(delivery), view: None, + create: None, } } @@ -1979,6 +1999,7 @@ function generateSubscribeParamsImplRust(): string { meta: None, delivery: None, view: Some(view), + create: None, } } }`; @@ -2122,6 +2143,8 @@ pub mod ahp_error_codes { pub const ALREADY_EXISTS: i32 = -32010; /// An optimistic-concurrency precondition failed: a request's precondition token (e.g. \`ResourceWriteParams.if_match\`) no longer matches the resource's current state. pub const CONFLICT: i32 = -32011; + /// TCP connection creation failed. Data carries TcpConnectionOpenErrorData. + pub const TCP_CONNECTION_OPEN_FAILED: i32 = -32012; } /// Type alias: AHP application error code. diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 584d16bb3..3debb6517 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -119,7 +119,7 @@ function mapType(tsType: string, propName?: string, containerName?: string): str || tsType === 'RootState | SessionState | TerminalState | ChangesetState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState' || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState' - || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState' + || tsType === 'RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | TcpConnectionState' || tsType === 'RootState | SessionState | ChatState' || tsType === 'RootState | SessionState | ChatState | TerminalState' || tsType === 'RootState | SessionState | ChatState | TerminalState | ChangesetState' @@ -670,6 +670,7 @@ function generatePartialStructFromInterface( // ─── State File Generator ──────────────────────────────────────────────────── const STATE_ENUMS = [ + 'TcpDataEncoding', 'TcpEndpoint', 'TcpResetReason', 'TcpConnectionOpenFailureReason', 'PolicyState', 'PendingMessageKind', 'SessionLifecycle', 'SessionStatus', 'ChatOriginKind', 'ChatInteractivity', 'ChatInputAnswerState', 'ChatInputAnswerValueKind', 'ChatInputQuestionKind', 'ChatInputResponseKind', 'SessionInputRequestKind', @@ -746,6 +747,8 @@ const STATE_STRUCTS = [ 'AnnotationsSummary', 'AnnotationsState', 'AnnotationOrigin', 'Annotation', 'AnnotationEntry', 'TelemetryCapabilities', 'ResourceWatchState', 'ResourceChange', + 'TcpConnectionState', 'TcpTarget', 'TcpResetState', 'FlowControlledByteDirectionState', + 'TcpConnectionsCapability', 'TcpConnectionOpenErrorData', 'AutomationSessionOrigin', 'AutomationSchedule', 'AutomationScheduleTrigger', 'AutomationEventTrigger', 'AutomationTriggerEventDefinition', 'AutomationTriggerDefinition', @@ -1109,6 +1112,7 @@ public enum ToolInput: Codable, Sendable { function generateSnapshotState(): string { return `/// The state payload of a snapshot. public enum SnapshotState: Codable, Sendable { + case tcp(TcpConnectionState) case root(RootState) case session(SessionState) case chat(ChatState) @@ -1120,11 +1124,14 @@ public enum SnapshotState: Codable, Sendable { case automationRun(AutomationRunState) public init(from decoder: Decoder) throws { - // Try the most distinctive shapes first. SessionState has required + // Try the most distinctive shapes first. TcpConnectionState has required + // \`input\`, \`output\`, and \`target\`; SessionState has required // \`lifecycle\` / \`activeClients\` / \`chats\`; ChatState has required // \`turns\`; the remaining variants follow, with RootState as the // catch-all. - if let session = try? SessionState(from: decoder) { + if let tcp = try? TcpConnectionState(from: decoder) { + self = .tcp(tcp) + } else if let session = try? SessionState(from: decoder) { self = .session(session) } else if let chat = try? ChatState(from: decoder) { self = .chat(chat) @@ -1147,6 +1154,7 @@ public enum SnapshotState: Codable, Sendable { public func encode(to encoder: Encoder) throws { switch self { + case .tcp(let state): try state.encode(to: encoder) case .root(let state): try state.encode(to: encoder) case .session(let state): try state.encode(to: encoder) case .chat(let state): try state.encode(to: encoder) @@ -1485,6 +1493,16 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'terminal/commandExecuted', caseName: 'terminalCommandExecuted', tsInterface: 'TerminalCommandExecutedAction' }, { type: 'terminal/commandFinished', caseName: 'terminalCommandFinished', tsInterface: 'TerminalCommandFinishedAction' }, { type: 'resourceWatch/changed', caseName: 'resourceWatchChanged', tsInterface: 'ResourceWatchChangedAction' }, + { type: 'tcp/input', caseName: 'tcpInput', tsInterface: 'TcpInputAction' }, + { type: 'tcp/data', caseName: 'tcpData', tsInterface: 'TcpDataAction' }, + { type: 'tcp/inputConsumed', caseName: 'tcpInputConsumed', tsInterface: 'TcpInputConsumedAction' }, + { type: 'tcp/dataConsumed', caseName: 'tcpDataConsumed', tsInterface: 'TcpDataConsumedAction' }, + { type: 'tcp/inputEof', caseName: 'tcpInputEof', tsInterface: 'TcpInputEofAction' }, + { type: 'tcp/dataEof', caseName: 'tcpDataEof', tsInterface: 'TcpDataEofAction' }, + { type: 'tcp/clientClose', caseName: 'tcpClientClose', tsInterface: 'TcpClientCloseAction' }, + { type: 'tcp/hostClose', caseName: 'tcpHostClose', tsInterface: 'TcpHostCloseAction' }, + { type: 'tcp/clientReset', caseName: 'tcpClientReset', tsInterface: 'TcpClientResetAction' }, + { type: 'tcp/hostReset', caseName: 'tcpHostReset', tsInterface: 'TcpHostResetAction' }, { type: 'automation/createRequested', caseName: 'automationCreateRequested', tsInterface: 'AutomationCreateRequestedAction' }, { type: 'automation/updateRequested', caseName: 'automationUpdateRequested', tsInterface: 'AutomationUpdateRequestedAction' }, { type: 'automation/set', caseName: 'automationSet', tsInterface: 'AutomationSetAction' }, @@ -1671,6 +1689,7 @@ const COMMAND_STRUCTS = [ 'Implementation', 'ReconnectParams', 'ReconnectReplayResult', 'ReconnectSnapshotResult', 'SubscribeParams', 'SubscribeView', 'SubscriptionDeliveryOptions', 'SubscribeResult', + 'TcpConnectionSubscription', 'CreateSessionParams', 'DisposeSessionParams', 'CreateChatParams', 'DisposeChatParams', 'ChatMoveToSessionDestination', 'ChatMoveToNewSessionDestination', 'MoveChatParams', 'MoveChatResult', @@ -2029,6 +2048,7 @@ function generateErrorsFile(project: Project): string { lines.push(' public static let permissionDenied = -32009'); lines.push(' /// The target resource already exists and the operation does not allow overwriting'); lines.push(' public static let alreadyExists = -32010'); + lines.push(' public static let tcpConnectionOpenFailed = -32012'); lines.push('}'); lines.push(''); diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index b3a70ac6b..f2989b1a4 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -106,6 +106,16 @@ import type { AutomationRunSessionRemovedAction, AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction, + TcpInputAction, + TcpDataAction, + TcpInputConsumedAction, + TcpDataConsumedAction, + TcpInputEofAction, + TcpDataEofAction, + TcpClientCloseAction, + TcpHostCloseAction, + TcpClientResetAction, + TcpHostResetAction, } from './actions.js'; import { ActionType } from './actions.js'; @@ -428,6 +438,38 @@ export type ServerAutomationRunAction = | AutomationRunPrimarySessionChangedAction ; +/** Union of all TCP-scoped actions. */ +export type TcpAction = + | TcpInputAction + | TcpDataAction + | TcpInputConsumedAction + | TcpDataConsumedAction + | TcpInputEofAction + | TcpDataEofAction + | TcpClientCloseAction + | TcpHostCloseAction + | TcpClientResetAction + | TcpHostResetAction +; + +/** Union of TCP actions that clients may dispatch. */ +export type ClientTcpAction = + | TcpInputAction + | TcpDataConsumedAction + | TcpInputEofAction + | TcpClientCloseAction + | TcpClientResetAction +; + +/** Union of TCP actions that only the server may produce. */ +export type ServerTcpAction = + | TcpDataAction + | TcpInputConsumedAction + | TcpDataEofAction + | TcpHostCloseAction + | TcpHostResetAction +; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -538,4 +580,14 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.AutomationRunSessionRemoved]: false, [ActionType.AutomationRunPrimarySessionChanged]: false, [ActionType.AutomationRunCancelRequested]: true, + [ActionType.TcpInput]: true, + [ActionType.TcpData]: false, + [ActionType.TcpInputConsumed]: false, + [ActionType.TcpDataConsumed]: true, + [ActionType.TcpInputEof]: true, + [ActionType.TcpDataEof]: false, + [ActionType.TcpClientClose]: true, + [ActionType.TcpHostClose]: false, + [ActionType.TcpClientReset]: true, + [ActionType.TcpHostReset]: false, }; diff --git a/types/actions.ts b/types/actions.ts index 97ec41dc1..00fe8b62f 100644 --- a/types/actions.ts +++ b/types/actions.ts @@ -8,6 +8,7 @@ */ export * from './common/actions.js'; +export * from './channels-tcp/actions.js'; export * from './channels-root/actions.js'; export * from './channels-session/actions.js'; export * from './channels-chat/actions.js'; diff --git a/types/channels-tcp/actions.ts b/types/channels-tcp/actions.ts new file mode 100644 index 000000000..bc8f7c0be --- /dev/null +++ b/types/channels-tcp/actions.ts @@ -0,0 +1,139 @@ +/** + * TCP Channel Actions — Ordered bytes, cumulative credit, and stream lifetime. + * + * @module channels-tcp/actions + */ + +import { ActionType } from '../common/actions.js'; +import type { TcpResetReason } from './state.js'; + +/** + * Client bytes. Never apply optimistically to the authoritative reducer. + * Write to the destination only when accepted input.receivedBytes advances. + * @category TCP Actions + * @clientDispatchable + */ +export interface TcpInputAction { + type: ActionType.TcpInput; + /** + * Absolute decoded-byte offset. + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + offset: number; + /** Nonempty canonical padded RFC 4648 base64; no whitespace. */ + data: string; +} + +/** + * Host bytes. Deliver once, only when output.receivedBytes advances. + * @category TCP Actions + */ +export interface TcpDataAction { + type: ActionType.TcpData; + /** + * Absolute decoded-byte offset. + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + offset: number; + /** Nonempty canonical padded RFC 4648 base64; no whitespace. */ + data: string; +} + +/** + * Cumulative input bytes released from the host's bounded write buffer. + * Not an acknowledgment that the destination application processed the bytes. + * @category TCP Actions + */ +export interface TcpInputConsumedAction { + type: ActionType.TcpInputConsumed; + /** + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + consumedBytes: number; +} + +/** + * Cumulative output bytes released by the client's bounded stream consumer. + * @category TCP Actions + * @clientDispatchable + */ +export interface TcpDataConsumedAction { + type: ActionType.TcpDataConsumed; + /** + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + consumedBytes: number; +} + +/** + * Half-close client input after all preceding input bytes have been written. + * @category TCP Actions + * @clientDispatchable + */ +export interface TcpInputEofAction { + type: ActionType.TcpInputEof; + /** + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + finalOffset: number; +} + +/** + * Half-close host output after all preceding output bytes have been delivered. + * @category TCP Actions + */ +export interface TcpDataEofAction { + type: ActionType.TcpDataEof; + /** + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + finalOffset: number; +} + +/** + * Client's final close. Respond with hostClose if not already sent. + * @category TCP Actions + * @clientDispatchable + */ +export interface TcpClientCloseAction { + type: ActionType.TcpClientClose; +} + +/** + * Host's final close. Respond with clientClose if not already sent. + * @category TCP Actions + */ +export interface TcpHostCloseAction { + type: ActionType.TcpHostClose; +} + +/** + * Abort both directions and discard buffered payload. + * @category TCP Actions + * @clientDispatchable + */ +export interface TcpClientResetAction { + type: ActionType.TcpClientReset; + reason: TcpResetReason; +} + +/** + * Abort both directions and discard buffered payload. + * @category TCP Actions + */ +export interface TcpHostResetAction { + type: ActionType.TcpHostReset; + reason: TcpResetReason; +} diff --git a/types/channels-tcp/commands.ts b/types/channels-tcp/commands.ts new file mode 100644 index 000000000..9a119d434 --- /dev/null +++ b/types/channels-tcp/commands.ts @@ -0,0 +1,46 @@ +/** + * TCP Channel Creation — Atomic creation via `subscribe`. + * + * @module channels-tcp/commands + */ + +import type { TcpDataEncoding } from './state.js'; + +/** + * Creates and exclusively subscribes to one TCP connection. + * + * SubscribeParams.channel MUST identify the parent `ahp-session:` channel. + * The host returns the new `ahp-tcp:` URI in snapshot.resource, not the parent. + * It installs the subscription and sends the response before any TCP actions. + * Unknown creation kinds MUST be rejected, never treated as normal subscribe. + * + * @category TCP Commands + */ +export interface TcpConnectionSubscription { + type: 'tcpConnection'; + /** DNS name or IP literal, not a URL. */ + host: string; + /** + * Destination port. + * @integer + * @minimum 1 + * @maximum 65535 + */ + port: number; + /** Selected from InitializeResult.tcpConnections.encodings. */ + encoding: TcpDataEncoding; + /** + * Client receive window in decoded bytes. + * @integer + * @minimum 1 + * @maximum 4294967295 + */ + receiveWindowBytes: number; + /** + * Maximum decoded bytes per output action; MUST NOT exceed receiveWindowBytes. + * @integer + * @minimum 1 + * @maximum 4294967295 + */ + maximumChunkSize: number; +} diff --git a/types/channels-tcp/reducer.ts b/types/channels-tcp/reducer.ts new file mode 100644 index 000000000..d0b4360c0 --- /dev/null +++ b/types/channels-tcp/reducer.ts @@ -0,0 +1,113 @@ +/** + * TCP reference reducer. Socket side effects and origin/ownership checks belong + * to the adapter, not this pure state machine. + * + * @module channels-tcp/reducer + */ + +import { ActionType } from '../common/actions.js'; +import type { FlowControlledByteDirectionState } from '../common/state.js'; +import type { TcpAction } from '../action-origin.generated.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; +import { TcpEndpoint, type TcpConnectionState } from './state.js'; + +function requireTcp(condition: boolean, message: string): void { + if (!condition) {throw new Error(`Invalid TCP action: ${message}`);} +} + +function requireOffset(value: number): void { + requireTcp(Number.isSafeInteger(value) && value >= 0, 'offset must be a nonnegative safe integer'); +} + +function payloadLength(data: string, maximumChunkSize: number): number { + requireTcp(data.length > 0 && data.length <= 4 * Math.ceil(maximumChunkSize / 3), 'chunk size'); + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0; + requireTcp(data.length % 4 === 0 && !/[^A-Za-z0-9+/]/.test(data.slice(0, data.length - padding)), 'base64 encoding'); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + if (padding > 0) { + const last = alphabet.indexOf(data[data.length - padding - 1]); + requireTcp(last % (padding === 2 ? 16 : 4) === 0, 'noncanonical base64 padding bits'); + } + const length = data.length / 4 * 3 - padding; + requireTcp(length <= maximumChunkSize, 'chunk size'); + return length; +} + +function receive( + direction: FlowControlledByteDirectionState, + offset: number, + data: string, + senderClosed: boolean, +): FlowControlledByteDirectionState { + requireOffset(offset); + const end = offset + payloadLength(data, direction.maximumChunkSize); + requireOffset(end); + if (end <= direction.receivedBytes) {return direction;} + requireTcp(offset === direction.receivedBytes, 'gap or overlapping byte range'); + requireTcp(!senderClosed && direction.eofAtBytes === undefined, 'data after EOF or sender close'); + requireTcp(end - direction.consumedBytes <= direction.windowBytes, 'receive window exceeded'); + return { ...direction, receivedBytes: end }; +} + +function consume(direction: FlowControlledByteDirectionState, consumedBytes: number): FlowControlledByteDirectionState { + requireOffset(consumedBytes); + requireTcp(consumedBytes <= direction.receivedBytes, 'consuming bytes not received'); + if (consumedBytes <= direction.consumedBytes) {return direction;} + return { ...direction, consumedBytes }; +} + +function eof(direction: FlowControlledByteDirectionState, finalOffset: number, senderClosed: boolean): FlowControlledByteDirectionState { + requireOffset(finalOffset); + requireTcp(finalOffset === direction.receivedBytes, 'EOF offset'); + if (direction.eofAtBytes === finalOffset) {return direction;} + requireTcp(!senderClosed, 'EOF after sender close'); + return { ...direction, eofAtBytes: finalOffset }; +} + +/** + * Apply an accepted TCP action, retaining no payload. + * + * Invalid data, offsets, credit, or ordering throw before any state mutation. + * Adapters MUST catch this at the channel boundary, reset with protocolError, + * and close the socket; they MUST NOT continue or perform the rejected write. + * Duplicate ranges and stale cumulative credit are no-ops. Socket adapters + * must compare the relevant direction's receivedBytes before/after reducing + * and perform a write only when that counter advances. + */ +export function tcpReducer(state: TcpConnectionState, action: TcpAction, log?: (msg: string) => void): TcpConnectionState { + if (state.reset) {return state;} + let input = state.input; + let output = state.output; + switch (action.type) { + case ActionType.TcpInput: + input = receive(input, action.offset, action.data, state.clientClosed); + break; + case ActionType.TcpData: + output = receive(output, action.offset, action.data, state.hostClosed); + break; + case ActionType.TcpInputConsumed: + input = consume(input, action.consumedBytes); + break; + case ActionType.TcpDataConsumed: + output = consume(output, action.consumedBytes); + break; + case ActionType.TcpInputEof: + input = eof(input, action.finalOffset, state.clientClosed); + break; + case ActionType.TcpDataEof: + output = eof(output, action.finalOffset, state.hostClosed); + break; + case ActionType.TcpClientClose: + return state.clientClosed ? state : { ...state, clientClosed: true }; + case ActionType.TcpHostClose: + return state.hostClosed ? state : { ...state, hostClosed: true }; + case ActionType.TcpClientReset: + return { ...state, reset: { source: TcpEndpoint.Client, reason: action.reason } }; + case ActionType.TcpHostReset: + return { ...state, reset: { source: TcpEndpoint.Host, reason: action.reason } }; + default: + softAssertNever(action, log); + return state; + } + return input === state.input && output === state.output ? state : { ...state, input, output }; +} diff --git a/types/channels-tcp/state.ts b/types/channels-tcp/state.ts new file mode 100644 index 000000000..ce934c412 --- /dev/null +++ b/types/channels-tcp/state.ts @@ -0,0 +1,128 @@ +/** + * TCP Channel State — Private, session-scoped `ahp-tcp:` connections in the host's network. + * + * Stability: 1 - Experimental + * + * @module channels-tcp/state + */ + +import type { FlowControlledByteDirectionState, URI } from '../common/state.js'; + +/** + * Payload encodings advertised by the host. + * @category TCP Types + * @nonexhaustive + */ +export const enum TcpDataEncoding { + Base64 = 'base64', +} + +/** + * Endpoint that closes or resets a connection. + * @category TCP Types + * @exhaustive + */ +export const enum TcpEndpoint { + Client = 'client', + Host = 'host', +} + +/** + * Why a connection was aborted. + * @category TCP Types + * @nonexhaustive + */ +export const enum TcpResetReason { + ConnectionReset = 'connectionReset', + ConnectionAborted = 'connectionAborted', + ProtocolError = 'protocolError', + ReplayUnavailable = 'replayUnavailable', + PolicyRevoked = 'policyRevoked', + SessionDisposed = 'sessionDisposed', + InternalError = 'internalError', +} + +/** + * Host support for private, session-scoped TCP channels. + * Presence on initialize is required before using subscribe.create. + * @category TCP Types + */ +export interface TcpConnectionsCapability { + /** Supported encodings. The base64 profile MUST be supported. */ + encodings: TcpDataEncoding[]; + /** + * Informational limit; runtime policy may impose a lower limit. + * @integer + * @minimum 1 + * @maximum 9007199254740991 + */ + maximumConnectionsPerClient?: number; +} + +/** @category TCP Types */ +export interface TcpTarget { + /** DNS name or IP literal, resolved and connected in the host endpoint's network. */ + host: string; + /** + * Destination port. + * @integer + * @minimum 1 + * @maximum 65535 + */ + port: number; +} + +/** @category TCP Types */ +export interface TcpResetState { + source: TcpEndpoint; + reason: TcpResetReason; +} + +/** + * Expected connection establishment failures. + * @category TCP Types + * @nonexhaustive + */ +export const enum TcpConnectionOpenFailureReason { + ConnectionFailed = 'connectionFailed', + NameResolutionFailed = 'nameResolutionFailed', + ResourceShortage = 'resourceShortage', + SessionNotReady = 'sessionNotReady', +} + +/** + * Required detail for TcpConnectionOpenFailed (-32012). + * Policy denial and malformed requests use PermissionDenied and InvalidParams. + * @category TCP Types + */ +export interface TcpConnectionOpenErrorData { + reason: TcpConnectionOpenFailureReason; + retryable?: boolean; +} + +/** + * State of one host-assigned `ahp-tcp:` channel. + * + * Payload is never stored in this state. Only the creating authenticated + * logical client may observe or dispatch to the channel. Reconnect requires + * the original sockets, local stream state, and complete action replay; + * a snapshot cannot restore this channel. + * + * Close flags record the two-sided handshake. Either flag means closing; + * both mean closed. A present reset terminates the connection immediately, + * independently of the close history. + * + * @category TCP Types + */ +export interface TcpConnectionState { + session: URI; + target: TcpTarget; + encoding: TcpDataEncoding; + /** Client to destination socket. */ + input: FlowControlledByteDirectionState; + /** Destination socket to client. */ + output: FlowControlledByteDirectionState; + clientClosed: boolean; + hostClosed: boolean; + reset?: TcpResetState; +} diff --git a/types/commands.ts b/types/commands.ts index 700bbfa0b..3bfd44073 100644 --- a/types/commands.ts +++ b/types/commands.ts @@ -15,3 +15,4 @@ export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; export * from './channels-automation/commands.js'; +export * from './channels-tcp/commands.js'; diff --git a/types/common/actions.ts b/types/common/actions.ts index 14c462417..8f391dcca 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -7,6 +7,18 @@ */ import type { URI } from './state.js'; +import type { + TcpInputAction, + TcpDataAction, + TcpInputConsumedAction, + TcpDataConsumedAction, + TcpInputEofAction, + TcpDataEofAction, + TcpClientCloseAction, + TcpHostCloseAction, + TcpClientResetAction, + TcpHostResetAction, +} from '../channels-tcp/actions.js'; import type { RootAgentsChangedAction, @@ -248,6 +260,16 @@ export const enum ActionType { AutomationRunSessionRemoved = 'automationRun/sessionRemoved', AutomationRunPrimarySessionChanged = 'automationRun/primarySessionChanged', AutomationRunCancelRequested = 'automationRun/cancelRequested', + TcpInput = 'tcp/input', + TcpData = 'tcp/data', + TcpInputConsumed = 'tcp/inputConsumed', + TcpDataConsumed = 'tcp/dataConsumed', + TcpInputEof = 'tcp/inputEof', + TcpDataEof = 'tcp/dataEof', + TcpClientClose = 'tcp/clientClose', + TcpHostClose = 'tcp/hostClose', + TcpClientReset = 'tcp/clientReset', + TcpHostReset = 'tcp/hostReset', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -386,4 +408,14 @@ export type StateAction = | AutomationRunSessionSetAction | AutomationRunSessionRemovedAction | AutomationRunPrimarySessionChangedAction - | AutomationRunCancelRequestedAction; + | AutomationRunCancelRequestedAction + | TcpInputAction + | TcpDataAction + | TcpInputConsumedAction + | TcpDataConsumedAction + | TcpInputEofAction + | TcpDataEofAction + | TcpClientCloseAction + | TcpHostCloseAction + | TcpClientResetAction + | TcpHostResetAction; diff --git a/types/common/commands.ts b/types/common/commands.ts index b7adb7c60..fd2546593 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -19,6 +19,8 @@ import type { AutomationState, } from '../channels-automation/state.js'; import type { TelemetryCapabilities } from '../channels-otlp/state.js'; +import type { TcpConnectionsCapability } from '../channels-tcp/state.js'; +import type { TcpConnectionSubscription } from '../channels-tcp/commands.js'; // ─── BaseParams ────────────────────────────────────────────────────────────── @@ -294,6 +296,8 @@ export interface InitializeResult { * @see {@link /guide/automations | Automations Guide} */ automations?: AutomationCapabilities; + /** Enables atomic creation of session-scoped, replay-only TCP channels. */ + tcpConnections?: TcpConnectionsCapability; } /** @@ -463,6 +467,12 @@ export interface ReconnectSnapshotResult { type: ReconnectResultType.Snapshot; /** Fresh snapshots for each subscription */ snapshots: Snapshot[]; + /** + * Subscriptions that cannot be restored. Hosts supporting TCP MUST list all + * requested TCP channels here and dispose their sockets on snapshot fallback. + * Omitted by older hosts; absence does not authorize snapshot-restoring TCP. + */ + missing?: URI[]; } /** Result of the `reconnect` command. */ @@ -501,6 +511,12 @@ export interface SubscribeParams extends BaseParams { * default snapshot. Clients MUST tolerate receiving more state than requested. */ view?: SubscribeView; + /** + * Atomically create a private child channel and subscribe to it. + * Requires the advertised tcpConnections capability. channel identifies + * the parent session; snapshot.resource identifies the created TCP channel. + */ + create?: TcpConnectionSubscription; } /** diff --git a/types/common/errors.ts b/types/common/errors.ts index 66afca0f2..29184fd6f 100644 --- a/types/common/errors.ts +++ b/types/common/errors.ts @@ -9,6 +9,7 @@ import type { ProtectedResourceMetadata } from './state.js'; import type { ResourceRequestParams } from './commands.js'; +import type { TcpConnectionOpenErrorData } from '../channels-tcp/state.js'; // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -93,6 +94,8 @@ export const AhpErrorCodes = { * fresh token or surface the conflict to the user. */ Conflict: -32011, + /** TCP creation failed; data MUST contain TcpConnectionOpenErrorData. */ + TcpConnectionOpenFailed: -32012, } as const; /** Union type of all AHP application error codes. */ @@ -171,6 +174,7 @@ export interface AhpErrorDetailsMap { [AhpErrorCodes.AuthRequired]: AuthRequiredErrorData; [AhpErrorCodes.PermissionDenied]: PermissionDeniedErrorData; [AhpErrorCodes.UnsupportedProtocolVersion]: UnsupportedProtocolVersionErrorData; + [AhpErrorCodes.TcpConnectionOpenFailed]: TcpConnectionOpenErrorData; } /** AHP error codes that carry a structured `data` payload. */ diff --git a/types/common/reducer-helpers.ts b/types/common/reducer-helpers.ts index 02a9d46e2..8b461a375 100644 --- a/types/common/reducer-helpers.ts +++ b/types/common/reducer-helpers.ts @@ -20,6 +20,8 @@ import type { ClientAutomationAction, AutomationRunAction, ClientAutomationRunAction, + TcpAction, + ClientTcpAction, } from '../action-origin.generated.js'; import { IS_CLIENT_DISPATCHABLE } from '../action-origin.generated.js'; @@ -44,6 +46,6 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction | TcpAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | ClientTcpAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/types/common/state.ts b/types/common/state.ts index b6288a35d..ee88ea613 100644 --- a/types/common/state.ts +++ b/types/common/state.ts @@ -16,12 +16,59 @@ import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; +import type { TcpConnectionState } from '../channels-tcp/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── /** A URI string (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`). */ export type URI = string; +/** + * Bounded byte credit in one direction of a stream. + * All counters are nonnegative safe integers (at most 2^53 - 1). + * 0 <= consumedBytes <= receivedBytes and + * receivedBytes - consumedBytes <= windowBytes. + * + * @category Common Types + */ +export interface FlowControlledByteDirectionState { + /** + * Maximum accepted-but-not-consumed decoded bytes. + * @integer + * @minimum 1 + * @maximum 4294967295 + */ + windowBytes: number; + /** + * Maximum decoded bytes per chunk; MUST NOT exceed windowBytes. + * @integer + * @minimum 1 + * @maximum 4294967295 + */ + maximumChunkSize: number; + /** + * Cumulative accepted bytes. + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + receivedBytes: number; + /** + * Cumulative bytes released by the bounded consumer. + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + consumedBytes: number; + /** + * Present after EOF; equals receivedBytes permanently. + * @integer + * @minimum 0 + * @maximum 9007199254740991 + */ + eofAtBytes?: number; +} + /** * A string that may optionally be rendered as Markdown. * @@ -352,7 +399,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | TcpConnectionState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/types/index.ts b/types/index.ts index 048e196fe..4b4dbcbf9 100644 --- a/types/index.ts +++ b/types/index.ts @@ -31,5 +31,6 @@ export { resourceWatchReducer, automationReducer, automationRunReducer, + tcpReducer, isClientDispatchable, } from './reducers.js'; diff --git a/types/messages.test.ts b/types/messages.test.ts index 7abe8b024..7f9f71e3d 100644 --- a/types/messages.test.ts +++ b/types/messages.test.ts @@ -35,6 +35,7 @@ function readChannelSources(baseName: string): string { 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-tcp', ]; return dirs .map(dir => { diff --git a/types/reducers.test.ts b/types/reducers.test.ts index cc90200a3..4886cd98e 100644 --- a/types/reducers.test.ts +++ b/types/reducers.test.ts @@ -1,7 +1,9 @@ /** * Reducer unit tests — driven by JSON fixtures for cross-language parity. * - * Fixture format: { description, reducer, initial, actions, expected } + * Fixture format: { description, reducer, initial, actions, expected, expectedError? } + * When expectedError is present, only the final action must throw that message; + * expected is the state after the preceding actions (the rejected action must not mutate it). * Fixtures live in types/test-cases/reducers/*.json and can be consumed by * any language implementation to verify reducer parity. * @@ -27,15 +29,19 @@ import { resourceWatchReducer, automationReducer, automationRunReducer, + tcpReducer, isClientDispatchable, } from './reducers.js'; import { IS_CLIENT_DISPATCHABLE } from './action-origin.generated.js'; +import type { TcpAction } from './action-origin.generated.js'; import { ActionType } from './actions.js'; -import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState } from './state.js'; +import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState, TcpConnectionState } from './state.js'; import { SessionStatus, TurnState, MessageKind, + TcpDataEncoding, + TcpResetReason, } from './state.js'; const root = resolve(dirname(fileURLToPath(import.meta.url))); @@ -58,6 +64,7 @@ function readChannelSources(baseName: string): string { 'channels-resource-watch', 'channels-automation', 'channels-automation-run', + 'channels-tcp', ]; return dirs .map(dir => { @@ -73,14 +80,15 @@ function readChannelSources(baseName: string): string { // ─── Fixture Loading ───────────────────────────────────────────────────────── -type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState; +type FixtureState = RootState | SessionState | ChatState | TerminalState | ChangesetState | AnnotationsState | ResourceWatchState | AutomationState | AutomationRunState | TcpConnectionState; interface Fixture { description: string; - reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun'; + reducer: 'root' | 'session' | 'chat' | 'terminal' | 'changeset' | 'annotations' | 'resourceWatch' | 'automation' | 'automationRun' | 'tcp'; initial: FixtureState; actions: unknown[]; expected: FixtureState; + expectedError?: string; } /** @@ -114,25 +122,39 @@ describe('reducer fixtures', () => { for (const fixture of fixtures) { it(fixture.description, () => { let state = fixture.initial; - for (const action of fixture.actions) { - if (fixture.reducer === 'root') { - state = rootReducer(state as RootState, action as any); - } else if (fixture.reducer === 'chat') { - state = chatReducer(state as ChatState, action as any); - } else if (fixture.reducer === 'terminal') { - state = terminalReducer(state as TerminalState, action as any); - } else if (fixture.reducer === 'changeset') { - state = changesetReducer(state as ChangesetState, action as any); - } else if (fixture.reducer === 'annotations') { - state = annotationsReducer(state as AnnotationsState, action as any); - } else if (fixture.reducer === 'resourceWatch') { - state = resourceWatchReducer(state as ResourceWatchState, action as any); - } else if (fixture.reducer === 'automation') { - state = automationReducer(state as AutomationState, action as any); - } else if (fixture.reducer === 'automationRun') { - state = automationRunReducer(state as AutomationRunState, action as any); + if (fixture.expectedError !== undefined) { + assert.ok(fixture.actions.length > 0, 'expectedError requires a final action'); + } + for (const [index, action] of fixture.actions.entries()) { + const apply = () => { + if (fixture.reducer === 'root') { + state = rootReducer(state as RootState, action as any); + } else if (fixture.reducer === 'chat') { + state = chatReducer(state as ChatState, action as any); + } else if (fixture.reducer === 'terminal') { + state = terminalReducer(state as TerminalState, action as any); + } else if (fixture.reducer === 'changeset') { + state = changesetReducer(state as ChangesetState, action as any); + } else if (fixture.reducer === 'annotations') { + state = annotationsReducer(state as AnnotationsState, action as any); + } else if (fixture.reducer === 'resourceWatch') { + state = resourceWatchReducer(state as ResourceWatchState, action as any); + } else if (fixture.reducer === 'automation') { + state = automationReducer(state as AutomationState, action as any); + } else if (fixture.reducer === 'automationRun') { + state = automationRunReducer(state as AutomationRunState, action as any); + } else if (fixture.reducer === 'tcp') { + state = tcpReducer(state as TcpConnectionState, action as TcpAction); + } else if (fixture.reducer === 'session') { + state = sessionReducer(state as SessionState, action as any); + } else { + assert.fail(`Unknown reducer: ${fixture.reducer}`); + } + }; + if (fixture.expectedError !== undefined && index === fixture.actions.length - 1) { + assert.throws(apply, { message: fixture.expectedError }); } else { - state = sessionReducer(state as SessionState, action as any); + apply(); } } assert.deepStrictEqual(state, fixture.expected); @@ -210,6 +232,22 @@ describe('isClientDispatchable', () => { const action = { type: ActionType.SessionReady, session: 'x' } as const; assert.equal(isClientDispatchable(action), false); }); + + it('classifies TCP actions by endpoint', () => { + const actions: TcpAction[] = [ + { type: ActionType.TcpInput, offset: 0, data: 'AA==' }, + { type: ActionType.TcpDataConsumed, consumedBytes: 0 }, + { type: ActionType.TcpInputEof, finalOffset: 0 }, + { type: ActionType.TcpClientClose }, + { type: ActionType.TcpClientReset, reason: TcpResetReason.ProtocolError }, + { type: ActionType.TcpData, offset: 0, data: 'AA==' }, + { type: ActionType.TcpInputConsumed, consumedBytes: 0 }, + { type: ActionType.TcpDataEof, finalOffset: 0 }, + { type: ActionType.TcpHostClose }, + { type: ActionType.TcpHostReset, reason: TcpResetReason.ProtocolError }, + ]; + assert.deepStrictEqual(actions.map(isClientDispatchable), [true, true, true, true, true, false, false, false, false, false]); + }); }); // ─── Immutability Checks ───────────────────────────────────────────────────── @@ -238,3 +276,92 @@ describe('reducer immutability', () => { assert.deepStrictEqual(state.turns, original); }); }); + +// ─── TCP Runtime Checks ────────────────────────────────────────────────────── +// +// Large generated payloads, non-JSON numbers, identity, and byte side effects +// stay here; portable TCP state transitions and validation use shared fixtures. + +function initialTcpState(): TcpConnectionState { + return { + session: 'ahp-session:/s1', + target: { host: 'localhost', port: 3000 }, + encoding: TcpDataEncoding.Base64, + input: { windowBytes: 8, maximumChunkSize: 6, receivedBytes: 0, consumedBytes: 0 }, + output: { windowBytes: 8, maximumChunkSize: 6, receivedBytes: 0, consumedBytes: 0 }, + clientClosed: false, + hostClosed: false, + }; +} + +describe('TCP runtime checks', () => { + it('accepts large chunks in both directions with exact decoded accounting', () => { + for (const size of [32768, 1048576, 4194304]) { + const data = Buffer.alloc(size, 255).toString('base64'); + for (const [type, direction] of [[ActionType.TcpInput, 'input'], [ActionType.TcpData, 'output']] as const) { + const state = initialTcpState(); + state[direction].windowBytes = size; + state[direction].maximumChunkSize = size; + const next = tcpReducer(state, { type, offset: 0, data }); + assert.equal(next[direction].receivedBytes, size); + assert.throws(() => tcpReducer(next, { type, offset: size, data: 'AA==' }), /window/); + } + } + }); + + it('rejects non-JSON numeric offsets', () => { + for (const offset of [NaN, Infinity, -Infinity]) { + assert.throws(() => tcpReducer(initialTcpState(), { + type: ActionType.TcpInput, offset, data: 'AA==', + }), /safe integer/); + } + }); + + it('preserves the original state and reuses unchanged directions and no-op states', () => { + const state = initialTcpState(); + const before = structuredClone(state); + const action = { type: ActionType.TcpInput, offset: 0, data: 'AP+A' } as const; + const next = tcpReducer(state, action); + assert.deepStrictEqual(state, before); + assert.equal(next.output, state.output); + assert.equal(tcpReducer(next, action), next); + const duplex = tcpReducer(next, { ...action, type: ActionType.TcpData }); + for (const type of [ActionType.TcpInputConsumed, ActionType.TcpDataConsumed] as const) { + const consumed = tcpReducer(duplex, { type, consumedBytes: 3 }); + assert.equal(tcpReducer(consumed, { type, consumedBytes: 2 }), consumed); + assert.equal(tcpReducer(consumed, { type, consumedBytes: 3 }), consumed); + } + for (const type of [ActionType.TcpClientClose, ActionType.TcpHostClose] as const) { + const closed = tcpReducer(next, { type }); + assert.equal(tcpReducer(closed, { type }), closed); + } + const ended = tcpReducer(next, { type: ActionType.TcpInputEof, finalOffset: 3 }); + assert.equal(tcpReducer(ended, { type: ActionType.TcpInputEof, finalOffset: 3 }), ended); + const reset = tcpReducer(next, { type: ActionType.TcpHostReset, reason: TcpResetReason.ConnectionReset }); + assert.equal(tcpReducer(reset, action), reset); + }); + + it('logs unknown future actions rather than silently discarding them', () => { + const warnings: string[] = []; + const action: TcpAction = JSON.parse('{"type":"tcp/future"}'); + const state = initialTcpState(); + assert.equal(tcpReducer(state, action, message => warnings.push(message)), state); + assert.match(warnings[0], /tcp\/future/); + }); + + it('does not duplicate socket writes when unacknowledged input is resent', () => { + let state = initialTcpState(); + const writes: Buffer[] = []; + const accept = (action: Extract) => { + const next = tcpReducer(state, action); + if (next.input.receivedBytes > state.input.receivedBytes) writes.push(Buffer.from(action.data, 'base64')); + state = next; + }; + const first = { type: ActionType.TcpInput, offset: 0, data: 'AP+A' } as const; + accept(first); + accept(first); + accept({ type: ActionType.TcpInput, offset: 3, data: 'AQI=' }); + assert.deepStrictEqual(Buffer.concat(writes), Buffer.from([0, 255, 128, 1, 2])); + assert.equal(writes.length, 2); + }); +}); diff --git a/types/reducers.ts b/types/reducers.ts index f6cbdb31f..31cba0644 100644 --- a/types/reducers.ts +++ b/types/reducers.ts @@ -14,4 +14,5 @@ export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js'; export { automationReducer } from './channels-automation/reducer.js'; export { automationRunReducer } from './channels-automation-run/reducer.js'; +export { tcpReducer } from './channels-tcp/reducer.js'; export { softAssertNever, isClientDispatchable } from './common/reducer-helpers.js'; diff --git a/types/state.ts b/types/state.ts index 28b13bfd0..f121490d2 100644 --- a/types/state.ts +++ b/types/state.ts @@ -18,3 +18,4 @@ export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; export * from './channels-automation/state.js'; export * from './channels-automation-run/state.js'; +export * from './channels-tcp/state.js'; diff --git a/types/test-cases/reducers/286-tcp-binary-input-counts-decoded-bytes-without-payload.json b/types/test-cases/reducers/286-tcp-binary-input-counts-decoded-bytes-without-payload.json new file mode 100644 index 000000000..bbcab6f4a --- /dev/null +++ b/types/test-cases/reducers/286-tcp-binary-input-counts-decoded-bytes-without-payload.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: binary input counts decoded bytes without payload", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AP+AAQI=" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 5, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/287-tcp-canonical-base64-1-bytes.json b/types/test-cases/reducers/287-tcp-canonical-base64-1-bytes.json new file mode 100644 index 000000000..c4a39fd45 --- /dev/null +++ b/types/test-cases/reducers/287-tcp-canonical-base64-1-bytes.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: canonical base64 1 bytes", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 1, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/288-tcp-canonical-base64-2-bytes.json b/types/test-cases/reducers/288-tcp-canonical-base64-2-bytes.json new file mode 100644 index 000000000..b44756633 --- /dev/null +++ b/types/test-cases/reducers/288-tcp-canonical-base64-2-bytes.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: canonical base64 2 bytes", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAA=" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 2, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/289-tcp-canonical-base64-3-bytes.json b/types/test-cases/reducers/289-tcp-canonical-base64-3-bytes.json new file mode 100644 index 000000000..6a34848d3 --- /dev/null +++ b/types/test-cases/reducers/289-tcp-canonical-base64-3-bytes.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: canonical base64 3 bytes", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAA" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 3, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/290-tcp-canonical-base64-6-bytes.json b/types/test-cases/reducers/290-tcp-canonical-base64-6-bytes.json new file mode 100644 index 000000000..82e871818 --- /dev/null +++ b/types/test-cases/reducers/290-tcp-canonical-base64-6-bytes.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: canonical base64 6 bytes", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAAAA" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 6, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/291-tcp-credit-exhaustion-release-and-stale-credit.json b/types/test-cases/reducers/291-tcp-credit-exhaustion-release-and-stale-credit.json new file mode 100644 index 000000000..7b47413f1 --- /dev/null +++ b/types/test-cases/reducers/291-tcp-credit-exhaustion-release-and-stale-credit.json @@ -0,0 +1,104 @@ +{ + "description": "TCP: credit exhaustion release and stale credit", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/input", + "offset": 4, + "data": "AAAAAA==" + }, + { + "type": "tcp/data", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/data", + "offset": 4, + "data": "AAAAAA==" + }, + { + "type": "tcp/inputConsumed", + "consumedBytes": 4 + }, + { + "type": "tcp/inputConsumed", + "consumedBytes": 3 + }, + { + "type": "tcp/inputConsumed", + "consumedBytes": 4 + }, + { + "type": "tcp/dataConsumed", + "consumedBytes": 4 + }, + { + "type": "tcp/dataConsumed", + "consumedBytes": 3 + }, + { + "type": "tcp/dataConsumed", + "consumedBytes": 4 + }, + { + "type": "tcp/input", + "offset": 8, + "data": "AAAAAA==" + }, + { + "type": "tcp/data", + "offset": 8, + "data": "AAAAAA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 12, + "consumedBytes": 4 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 12, + "consumedBytes": 4 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/292-tcp-duplicate-input-ranges-are-noops.json b/types/test-cases/reducers/292-tcp-duplicate-input-ranges-are-noops.json new file mode 100644 index 000000000..8cf1e329b --- /dev/null +++ b/types/test-cases/reducers/292-tcp-duplicate-input-ranges-are-noops.json @@ -0,0 +1,65 @@ +{ + "description": "TCP: duplicate input ranges are noops", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 4, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/293-tcp-half-close-is-independent-and-idempotent.json b/types/test-cases/reducers/293-tcp-half-close-is-independent-and-idempotent.json new file mode 100644 index 000000000..d741ecba5 --- /dev/null +++ b/types/test-cases/reducers/293-tcp-half-close-is-independent-and-idempotent.json @@ -0,0 +1,73 @@ +{ + "description": "TCP: half close is independent and idempotent", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/inputEof", + "finalOffset": 0 + }, + { + "type": "tcp/inputEof", + "finalOffset": 0 + }, + { + "type": "tcp/data", + "offset": 0, + "data": "AA==" + }, + { + "type": "tcp/dataEof", + "finalOffset": 1 + }, + { + "type": "tcp/dataEof", + "finalOffset": 1 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0, + "eofAtBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 1, + "consumedBytes": 0, + "eofAtBytes": 1 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/294-tcp-client-close-permits-crossing-output-and-credit.json b/types/test-cases/reducers/294-tcp-client-close-permits-crossing-output-and-credit.json new file mode 100644 index 000000000..ae2d9d679 --- /dev/null +++ b/types/test-cases/reducers/294-tcp-client-close-permits-crossing-output-and-credit.json @@ -0,0 +1,71 @@ +{ + "description": "TCP: client close permits crossing output and credit", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/clientClose" + }, + { + "type": "tcp/clientClose" + }, + { + "type": "tcp/data", + "offset": 0, + "data": "AA==" + }, + { + "type": "tcp/hostClose" + }, + { + "type": "tcp/hostClose" + }, + { + "type": "tcp/dataConsumed", + "consumedBytes": 1 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 1, + "consumedBytes": 1 + }, + "clientClosed": true, + "hostClosed": true + } +} diff --git a/types/test-cases/reducers/295-tcp-host-close-permits-crossing-input.json b/types/test-cases/reducers/295-tcp-host-close-permits-crossing-input.json new file mode 100644 index 000000000..5fd7d7ca4 --- /dev/null +++ b/types/test-cases/reducers/295-tcp-host-close-permits-crossing-input.json @@ -0,0 +1,58 @@ +{ + "description": "TCP: host close permits crossing input", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/hostClose" + }, + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 1, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true + } +} diff --git a/types/test-cases/reducers/296-tcp-client-reset-is-terminal.json b/types/test-cases/reducers/296-tcp-client-reset-is-terminal.json new file mode 100644 index 000000000..7bb3e7226 --- /dev/null +++ b/types/test-cases/reducers/296-tcp-client-reset-is-terminal.json @@ -0,0 +1,71 @@ +{ + "description": "TCP: client reset is terminal", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/hostClose" + }, + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + }, + { + "type": "tcp/clientReset", + "reason": "connectionReset" + }, + { + "type": "tcp/input", + "offset": 1, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 1, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true, + "reset": { + "source": "client", + "reason": "connectionReset" + } + } +} diff --git a/types/test-cases/reducers/297-tcp-host-reset-is-terminal.json b/types/test-cases/reducers/297-tcp-host-reset-is-terminal.json new file mode 100644 index 000000000..bd9d913b1 --- /dev/null +++ b/types/test-cases/reducers/297-tcp-host-reset-is-terminal.json @@ -0,0 +1,71 @@ +{ + "description": "TCP: host reset is terminal", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/hostClose" + }, + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + }, + { + "type": "tcp/hostReset", + "reason": "connectionReset" + }, + { + "type": "tcp/input", + "offset": 1, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 1, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true, + "reset": { + "source": "host", + "reason": "connectionReset" + } + } +} diff --git a/types/test-cases/reducers/298-tcp-output-credit-eof-full-history.json b/types/test-cases/reducers/298-tcp-output-credit-eof-full-history.json new file mode 100644 index 000000000..0b2601bbd --- /dev/null +++ b/types/test-cases/reducers/298-tcp-output-credit-eof-full-history.json @@ -0,0 +1,69 @@ +{ + "description": "TCP: output credit eof full history", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/data", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/dataConsumed", + "consumedBytes": 4 + }, + { + "type": "tcp/data", + "offset": 4, + "data": "AQI=" + }, + { + "type": "tcp/dataEof", + "finalOffset": 6 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 6, + "consumedBytes": 4, + "eofAtBytes": 6 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/299-tcp-output-credit-eof-replayed-suffix.json b/types/test-cases/reducers/299-tcp-output-credit-eof-replayed-suffix.json new file mode 100644 index 000000000..0c5c34e79 --- /dev/null +++ b/types/test-cases/reducers/299-tcp-output-credit-eof-replayed-suffix.json @@ -0,0 +1,64 @@ +{ + "description": "TCP: output credit eof replayed suffix", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 4, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/dataConsumed", + "consumedBytes": 4 + }, + { + "type": "tcp/data", + "offset": 4, + "data": "AQI=" + }, + { + "type": "tcp/dataEof", + "finalOffset": 6 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 6, + "consumedBytes": 4, + "eofAtBytes": 6 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/300-tcp-unknown-action-is-noop.json b/types/test-cases/reducers/300-tcp-unknown-action-is-noop.json new file mode 100644 index 000000000..42f7219a4 --- /dev/null +++ b/types/test-cases/reducers/300-tcp-unknown-action-is-noop.json @@ -0,0 +1,53 @@ +{ + "description": "TCP: unknown action is noop", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/future" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + } +} diff --git a/types/test-cases/reducers/301-tcp-rejects-empty-payload.json b/types/test-cases/reducers/301-tcp-rejects-empty-payload.json new file mode 100644 index 000000000..3dc148d77 --- /dev/null +++ b/types/test-cases/reducers/301-tcp-rejects-empty-payload.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects empty payload", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: chunk size" +} diff --git a/types/test-cases/reducers/302-tcp-rejects-incomplete-base64-group.json b/types/test-cases/reducers/302-tcp-rejects-incomplete-base64-group.json new file mode 100644 index 000000000..d76e7a0d6 --- /dev/null +++ b/types/test-cases/reducers/302-tcp-rejects-incomplete-base64-group.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects incomplete base64 group", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "A" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/303-tcp-rejects-missing-base64-padding.json b/types/test-cases/reducers/303-tcp-rejects-missing-base64-padding.json new file mode 100644 index 000000000..73055d53a --- /dev/null +++ b/types/test-cases/reducers/303-tcp-rejects-missing-base64-padding.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects missing base64 padding", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/304-tcp-rejects-excess-base64-padding.json b/types/test-cases/reducers/304-tcp-rejects-excess-base64-padding.json new file mode 100644 index 000000000..e572f78b9 --- /dev/null +++ b/types/test-cases/reducers/304-tcp-rejects-excess-base64-padding.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects excess base64 padding", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA===" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/305-tcp-rejects-padding-only.json b/types/test-cases/reducers/305-tcp-rejects-padding-only.json new file mode 100644 index 000000000..b1d43d990 --- /dev/null +++ b/types/test-cases/reducers/305-tcp-rejects-padding-only.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects padding only", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "====" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/306-tcp-rejects-three-padding-characters.json b/types/test-cases/reducers/306-tcp-rejects-three-padding-characters.json new file mode 100644 index 000000000..6781ed641 --- /dev/null +++ b/types/test-cases/reducers/306-tcp-rejects-three-padding-characters.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects three padding characters", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "A===" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/307-tcp-rejects-interior-padding.json b/types/test-cases/reducers/307-tcp-rejects-interior-padding.json new file mode 100644 index 000000000..6b1994255 --- /dev/null +++ b/types/test-cases/reducers/307-tcp-rejects-interior-padding.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects interior padding", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA==AAAA" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/308-tcp-rejects-noncanonical-two-padding-bits.json b/types/test-cases/reducers/308-tcp-rejects-noncanonical-two-padding-bits.json new file mode 100644 index 000000000..d67f1489b --- /dev/null +++ b/types/test-cases/reducers/308-tcp-rejects-noncanonical-two-padding-bits.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects noncanonical two padding bits", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AB==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: noncanonical base64 padding bits" +} diff --git a/types/test-cases/reducers/309-tcp-rejects-noncanonical-one-padding-bits.json b/types/test-cases/reducers/309-tcp-rejects-noncanonical-one-padding-bits.json new file mode 100644 index 000000000..74bbd9e9e --- /dev/null +++ b/types/test-cases/reducers/309-tcp-rejects-noncanonical-one-padding-bits.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects noncanonical one padding bits", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAB=" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: noncanonical base64 padding bits" +} diff --git a/types/test-cases/reducers/310-tcp-rejects-url-base64-alphabet.json b/types/test-cases/reducers/310-tcp-rejects-url-base64-alphabet.json new file mode 100644 index 000000000..bedee2e07 --- /dev/null +++ b/types/test-cases/reducers/310-tcp-rejects-url-base64-alphabet.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects url base64 alphabet", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA-_" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/311-tcp-rejects-base64-whitespace.json b/types/test-cases/reducers/311-tcp-rejects-base64-whitespace.json new file mode 100644 index 000000000..8eaac796b --- /dev/null +++ b/types/test-cases/reducers/311-tcp-rejects-base64-whitespace.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects base64 whitespace", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA==\n" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/312-tcp-rejects-invalid-base64-alphabet.json b/types/test-cases/reducers/312-tcp-rejects-invalid-base64-alphabet.json new file mode 100644 index 000000000..274e5e2d3 --- /dev/null +++ b/types/test-cases/reducers/312-tcp-rejects-invalid-base64-alphabet.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects invalid base64 alphabet", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "!!!!!!!!" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: base64 encoding" +} diff --git a/types/test-cases/reducers/313-tcp-rejects-oversized-unpadded-chunk.json b/types/test-cases/reducers/313-tcp-rejects-oversized-unpadded-chunk.json new file mode 100644 index 000000000..0d5c64f9e --- /dev/null +++ b/types/test-cases/reducers/313-tcp-rejects-oversized-unpadded-chunk.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects oversized unpadded chunk", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAAAAAAAA" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: chunk size" +} diff --git a/types/test-cases/reducers/314-tcp-rejects-oversized-padded-chunk.json b/types/test-cases/reducers/314-tcp-rejects-oversized-padded-chunk.json new file mode 100644 index 000000000..bb1fa6ecd --- /dev/null +++ b/types/test-cases/reducers/314-tcp-rejects-oversized-padded-chunk.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects oversized padded chunk", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAAAAAA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: chunk size" +} diff --git a/types/test-cases/reducers/315-tcp-rejects-decoded-chunk-over-limit.json b/types/test-cases/reducers/315-tcp-rejects-decoded-chunk-over-limit.json new file mode 100644 index 000000000..5af1c3198 --- /dev/null +++ b/types/test-cases/reducers/315-tcp-rejects-decoded-chunk-over-limit.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects decoded chunk over limit", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 1, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAA" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 1, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: chunk size" +} diff --git a/types/test-cases/reducers/316-tcp-rejects-input-window-overflow.json b/types/test-cases/reducers/316-tcp-rejects-input-window-overflow.json new file mode 100644 index 000000000..e977405c7 --- /dev/null +++ b/types/test-cases/reducers/316-tcp-rejects-input-window-overflow.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects input window overflow", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 8, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 8, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 8, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: receive window exceeded" +} diff --git a/types/test-cases/reducers/317-tcp-rejects-input-unreceived-credit.json b/types/test-cases/reducers/317-tcp-rejects-input-unreceived-credit.json new file mode 100644 index 000000000..cfcad128b --- /dev/null +++ b/types/test-cases/reducers/317-tcp-rejects-input-unreceived-credit.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: rejects input unreceived credit", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/inputConsumed", + "consumedBytes": 1 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: consuming bytes not received" +} diff --git a/types/test-cases/reducers/318-tcp-rejects-input-data-after-eof.json b/types/test-cases/reducers/318-tcp-rejects-input-data-after-eof.json new file mode 100644 index 000000000..df9143b38 --- /dev/null +++ b/types/test-cases/reducers/318-tcp-rejects-input-data-after-eof.json @@ -0,0 +1,58 @@ +{ + "description": "TCP: rejects input data after eof", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0, + "eofAtBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0, + "eofAtBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: data after EOF or sender close" +} diff --git a/types/test-cases/reducers/319-tcp-rejects-input-data-after-close.json b/types/test-cases/reducers/319-tcp-rejects-input-data-after-close.json new file mode 100644 index 000000000..f0be7ce04 --- /dev/null +++ b/types/test-cases/reducers/319-tcp-rejects-input-data-after-close.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects input data after close", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": true, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": true, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: data after EOF or sender close" +} diff --git a/types/test-cases/reducers/320-tcp-rejects-input-eof-after-close.json b/types/test-cases/reducers/320-tcp-rejects-input-eof-after-close.json new file mode 100644 index 000000000..ff67731e3 --- /dev/null +++ b/types/test-cases/reducers/320-tcp-rejects-input-eof-after-close.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: rejects input eof after close", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": true, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/inputEof", + "finalOffset": 0 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": true, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: EOF after sender close" +} diff --git a/types/test-cases/reducers/321-tcp-rejects-input-wrong-eof-offset.json b/types/test-cases/reducers/321-tcp-rejects-input-wrong-eof-offset.json new file mode 100644 index 000000000..63108b66a --- /dev/null +++ b/types/test-cases/reducers/321-tcp-rejects-input-wrong-eof-offset.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: rejects input wrong eof offset", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/inputEof", + "finalOffset": 1 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: EOF offset" +} diff --git a/types/test-cases/reducers/322-tcp-rejects-output-window-overflow.json b/types/test-cases/reducers/322-tcp-rejects-output-window-overflow.json new file mode 100644 index 000000000..87b0ea369 --- /dev/null +++ b/types/test-cases/reducers/322-tcp-rejects-output-window-overflow.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects output window overflow", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 8, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/data", + "offset": 8, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 8, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: receive window exceeded" +} diff --git a/types/test-cases/reducers/323-tcp-rejects-output-unreceived-credit.json b/types/test-cases/reducers/323-tcp-rejects-output-unreceived-credit.json new file mode 100644 index 000000000..d90740626 --- /dev/null +++ b/types/test-cases/reducers/323-tcp-rejects-output-unreceived-credit.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: rejects output unreceived credit", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/dataConsumed", + "consumedBytes": 1 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: consuming bytes not received" +} diff --git a/types/test-cases/reducers/324-tcp-rejects-output-data-after-eof.json b/types/test-cases/reducers/324-tcp-rejects-output-data-after-eof.json new file mode 100644 index 000000000..fef7ecd0c --- /dev/null +++ b/types/test-cases/reducers/324-tcp-rejects-output-data-after-eof.json @@ -0,0 +1,58 @@ +{ + "description": "TCP: rejects output data after eof", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0, + "eofAtBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/data", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0, + "eofAtBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: data after EOF or sender close" +} diff --git a/types/test-cases/reducers/325-tcp-rejects-output-data-after-close.json b/types/test-cases/reducers/325-tcp-rejects-output-data-after-close.json new file mode 100644 index 000000000..9bf83419e --- /dev/null +++ b/types/test-cases/reducers/325-tcp-rejects-output-data-after-close.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects output data after close", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true + }, + "actions": [ + { + "type": "tcp/data", + "offset": 0, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true + }, + "expectedError": "Invalid TCP action: data after EOF or sender close" +} diff --git a/types/test-cases/reducers/326-tcp-rejects-output-eof-after-close.json b/types/test-cases/reducers/326-tcp-rejects-output-eof-after-close.json new file mode 100644 index 000000000..0e53b2a06 --- /dev/null +++ b/types/test-cases/reducers/326-tcp-rejects-output-eof-after-close.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: rejects output eof after close", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true + }, + "actions": [ + { + "type": "tcp/dataEof", + "finalOffset": 0 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": true + }, + "expectedError": "Invalid TCP action: EOF after sender close" +} diff --git a/types/test-cases/reducers/327-tcp-rejects-output-wrong-eof-offset.json b/types/test-cases/reducers/327-tcp-rejects-output-wrong-eof-offset.json new file mode 100644 index 000000000..b0193721a --- /dev/null +++ b/types/test-cases/reducers/327-tcp-rejects-output-wrong-eof-offset.json @@ -0,0 +1,55 @@ +{ + "description": "TCP: rejects output wrong eof offset", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/dataEof", + "finalOffset": 1 + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: EOF offset" +} diff --git a/types/test-cases/reducers/328-tcp-rejects-partial-overlap.json b/types/test-cases/reducers/328-tcp-rejects-partial-overlap.json new file mode 100644 index 000000000..2b6bd3f0f --- /dev/null +++ b/types/test-cases/reducers/328-tcp-rejects-partial-overlap.json @@ -0,0 +1,61 @@ +{ + "description": "TCP: rejects partial overlap", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/input", + "offset": 2, + "data": "AAAAAA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 4, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: gap or overlapping byte range" +} diff --git a/types/test-cases/reducers/329-tcp-rejects-gap.json b/types/test-cases/reducers/329-tcp-rejects-gap.json new file mode 100644 index 000000000..fc9849a2b --- /dev/null +++ b/types/test-cases/reducers/329-tcp-rejects-gap.json @@ -0,0 +1,61 @@ +{ + "description": "TCP: rejects gap", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0, + "data": "AAAAAA==" + }, + { + "type": "tcp/input", + "offset": 5, + "data": "AAAAAA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 4, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: gap or overlapping byte range" +} diff --git a/types/test-cases/reducers/330-tcp-rejects-negative-offset.json b/types/test-cases/reducers/330-tcp-rejects-negative-offset.json new file mode 100644 index 000000000..e055d4b3b --- /dev/null +++ b/types/test-cases/reducers/330-tcp-rejects-negative-offset.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects negative offset", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": -1, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: offset must be a nonnegative safe integer" +} diff --git a/types/test-cases/reducers/331-tcp-rejects-fractional-offset.json b/types/test-cases/reducers/331-tcp-rejects-fractional-offset.json new file mode 100644 index 000000000..a47584a9d --- /dev/null +++ b/types/test-cases/reducers/331-tcp-rejects-fractional-offset.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects fractional offset", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 0.5, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: offset must be a nonnegative safe integer" +} diff --git a/types/test-cases/reducers/332-tcp-rejects-unsafe-offset.json b/types/test-cases/reducers/332-tcp-rejects-unsafe-offset.json new file mode 100644 index 000000000..10ac9cc73 --- /dev/null +++ b/types/test-cases/reducers/332-tcp-rejects-unsafe-offset.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects unsafe offset", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 9007199254740992, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: offset must be a nonnegative safe integer" +} diff --git a/types/test-cases/reducers/333-tcp-rejects-offset-overflow.json b/types/test-cases/reducers/333-tcp-rejects-offset-overflow.json new file mode 100644 index 000000000..79a7d5ddd --- /dev/null +++ b/types/test-cases/reducers/333-tcp-rejects-offset-overflow.json @@ -0,0 +1,56 @@ +{ + "description": "TCP: rejects offset overflow", + "reducer": "tcp", + "initial": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 9007199254740991, + "consumedBytes": 9007199254740991 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "actions": [ + { + "type": "tcp/input", + "offset": 9007199254740991, + "data": "AA==" + } + ], + "expected": { + "session": "ahp-session:/s1", + "target": { + "host": "localhost", + "port": 3000 + }, + "encoding": "base64", + "input": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 9007199254740991, + "consumedBytes": 9007199254740991 + }, + "output": { + "windowBytes": 8, + "maximumChunkSize": 6, + "receivedBytes": 0, + "consumedBytes": 0 + }, + "clientClosed": false, + "hostClosed": false + }, + "expectedError": "Invalid TCP action: offset must be a nonnegative safe integer" +} diff --git a/types/test-cases/round-trips/053-initialize-tcp-capability.json b/types/test-cases/round-trips/053-initialize-tcp-capability.json new file mode 100644 index 000000000..a82c10ec7 --- /dev/null +++ b/types/test-cases/round-trips/053-initialize-tcp-capability.json @@ -0,0 +1,18 @@ +{ + "name": "initialize-tcp-capability", + "group": "A", + "description": "Optional TCP capability survives initialize across generated clients.", + "type": "InitializeResult", + "input": { + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "tcpConnections": { "encodings": ["base64"], "maximumConnectionsPerClient": 64 } + }, + "acceptableOutputs": [{ + "protocolVersion": "0.9.0", + "serverSeq": 0, + "snapshots": [], + "tcpConnections": { "encodings": ["base64"], "maximumConnectionsPerClient": 64 } + }] +} diff --git a/types/test-cases/round-trips/054-action-envelope-tcp-input.json b/types/test-cases/round-trips/054-action-envelope-tcp-input.json new file mode 100644 index 000000000..d10aa396f --- /dev/null +++ b/types/test-cases/round-trips/054-action-envelope-tcp-input.json @@ -0,0 +1,18 @@ +{ + "name": "action-envelope-tcp-input", + "group": "A", + "description": "TCP binary input and client origin survive action-envelope round trips.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-tcp:/t1", + "action": { "type": "tcp/input", "offset": 4294967297, "data": "AP+A" }, + "serverSeq": 43, + "origin": { "clientId": "c1", "clientSeq": 7 } + }, + "acceptableOutputs": [{ + "channel": "ahp-tcp:/t1", + "action": { "type": "tcp/input", "offset": 4294967297, "data": "AP+A" }, + "serverSeq": 43, + "origin": { "clientId": "c1", "clientSeq": 7 } + }] +} diff --git a/types/test-cases/round-trips/055-action-envelope-tcp-host-reset.json b/types/test-cases/round-trips/055-action-envelope-tcp-host-reset.json new file mode 100644 index 000000000..16a841858 --- /dev/null +++ b/types/test-cases/round-trips/055-action-envelope-tcp-host-reset.json @@ -0,0 +1,16 @@ +{ + "name": "action-envelope-tcp-host-reset", + "group": "A", + "description": "Host TCP reset is a typed action, preserving its reason.", + "type": "ActionEnvelope", + "input": { + "channel": "ahp-tcp:/t1", + "action": { "type": "tcp/hostReset", "reason": "policyRevoked" }, + "serverSeq": 44 + }, + "acceptableOutputs": [{ + "channel": "ahp-tcp:/t1", + "action": { "type": "tcp/hostReset", "reason": "policyRevoked" }, + "serverSeq": 44 + }] +} diff --git a/types/test-cases/round-trips/056-tcp-connection-snapshot.json b/types/test-cases/round-trips/056-tcp-connection-snapshot.json new file mode 100644 index 000000000..5fa77f191 --- /dev/null +++ b/types/test-cases/round-trips/056-tcp-connection-snapshot.json @@ -0,0 +1,34 @@ +{ + "name": "tcp-connection-snapshot", + "group": "A", + "description": "A private TCP snapshot preserves counters above 32 bits, directional EOF and reset details without becoming root state.", + "type": "Snapshot", + "input": { + "resource": "ahp-tcp:/t1", + "fromSeq": 42, + "state": { + "session": "ahp-session:/s1", + "target": { "host": "::1", "port": 3000 }, + "encoding": "base64", + "input": { "windowBytes": 1024, "maximumChunkSize": 256, "receivedBytes": 4294967297, "consumedBytes": 4294967297, "eofAtBytes": 4294967297 }, + "output": { "windowBytes": 1024, "maximumChunkSize": 256, "receivedBytes": 0, "consumedBytes": 0 }, + "clientClosed": true, + "hostClosed": false, + "reset": { "source": "host", "reason": "replayUnavailable" } + } + }, + "acceptableOutputs": [{ + "resource": "ahp-tcp:/t1", + "fromSeq": 42, + "state": { + "session": "ahp-session:/s1", + "target": { "host": "::1", "port": 3000 }, + "encoding": "base64", + "input": { "windowBytes": 1024, "maximumChunkSize": 256, "receivedBytes": 4294967297, "consumedBytes": 4294967297, "eofAtBytes": 4294967297 }, + "output": { "windowBytes": 1024, "maximumChunkSize": 256, "receivedBytes": 0, "consumedBytes": 0 }, + "clientClosed": true, + "hostClosed": false, + "reset": { "source": "host", "reason": "replayUnavailable" } + } + }] +} diff --git a/types/test-cases/round-trips/057-subscribe-create-tcp-connection.json b/types/test-cases/round-trips/057-subscribe-create-tcp-connection.json new file mode 100644 index 000000000..d20eb4ece --- /dev/null +++ b/types/test-cases/round-trips/057-subscribe-create-tcp-connection.json @@ -0,0 +1,30 @@ +{ + "name": "subscribe-create-tcp-connection", + "group": "A", + "description": "Atomic TCP creation preserves its typed parameters, literal discriminator, zero delivery latency and unsigned 32-bit receive limits.", + "type": "SubscribeParams", + "input": { + "channel": "ahp-session:/s1", + "delivery": { "maxLatencyMs": 0 }, + "create": { + "type": "tcpConnection", + "host": "::1", + "port": 65535, + "encoding": "base64", + "receiveWindowBytes": 4294967295, + "maximumChunkSize": 32768 + } + }, + "acceptableOutputs": [{ + "channel": "ahp-session:/s1", + "delivery": { "maxLatencyMs": 0 }, + "create": { + "type": "tcpConnection", + "host": "::1", + "port": 65535, + "encoding": "base64", + "receiveWindowBytes": 4294967295, + "maximumChunkSize": 32768 + } + }] +} diff --git a/types/test-cases/round-trips/058-reconnect-snapshot-missing-tcp.json b/types/test-cases/round-trips/058-reconnect-snapshot-missing-tcp.json new file mode 100644 index 000000000..8df939cab --- /dev/null +++ b/types/test-cases/round-trips/058-reconnect-snapshot-missing-tcp.json @@ -0,0 +1,16 @@ +{ + "name": "reconnect-snapshot-missing-tcp", + "group": "A", + "description": "Snapshot fallback preserves the TCP channels that cannot be restored without inventing payload snapshots.", + "type": "ReconnectResult", + "input": { + "type": "snapshot", + "snapshots": [], + "missing": ["ahp-tcp:/t1"] + }, + "acceptableOutputs": [{ + "type": "snapshot", + "snapshots": [], + "missing": ["ahp-tcp:/t1"] + }] +} diff --git a/types/test-cases/round-trips/059-tcp-connection-open-error-data.json b/types/test-cases/round-trips/059-tcp-connection-open-error-data.json new file mode 100644 index 000000000..1028a2029 --- /dev/null +++ b/types/test-cases/round-trips/059-tcp-connection-open-error-data.json @@ -0,0 +1,14 @@ +{ + "name": "tcp-connection-open-error-data", + "group": "A", + "description": "Structured TCP open-error data preserves its reason and an explicitly false retryable flag.", + "type": "TcpConnectionOpenErrorData", + "input": { + "reason": "nameResolutionFailed", + "retryable": false + }, + "acceptableOutputs": [{ + "reason": "nameResolutionFailed", + "retryable": false + }] +} diff --git a/types/test-cases/round-trips/060-reconnect-replay-tcp-data.json b/types/test-cases/round-trips/060-reconnect-replay-tcp-data.json new file mode 100644 index 000000000..8581be770 --- /dev/null +++ b/types/test-cases/round-trips/060-reconnect-replay-tcp-data.json @@ -0,0 +1,24 @@ +{ + "name": "reconnect-replay-tcp-data", + "group": "A", + "description": "Replay retains the result discriminator, ordered TCP payload and required empty missing list.", + "type": "ReconnectResult", + "input": { + "type": "replay", + "actions": [{ + "channel": "ahp-tcp:/t1", + "action": { "type": "tcp/data", "offset": 0, "data": "AA==" }, + "serverSeq": 43 + }], + "missing": [] + }, + "acceptableOutputs": [{ + "type": "replay", + "actions": [{ + "channel": "ahp-tcp:/t1", + "action": { "type": "tcp/data", "offset": 0, "data": "AA==" }, + "serverSeq": 43 + }], + "missing": [] + }] +} diff --git a/types/test-cases/round-trips/061-reconnect-snapshot-without-missing.json b/types/test-cases/round-trips/061-reconnect-snapshot-without-missing.json new file mode 100644 index 000000000..7d1658d45 --- /dev/null +++ b/types/test-cases/round-trips/061-reconnect-snapshot-without-missing.json @@ -0,0 +1,14 @@ +{ + "name": "reconnect-snapshot-without-missing", + "group": "A", + "description": "Older hosts' snapshot fallback remains valid without the optional missing field and re-encodes without inventing it.", + "type": "ReconnectResult", + "input": { + "type": "snapshot", + "snapshots": [] + }, + "acceptableOutputs": [{ + "type": "snapshot", + "snapshots": [] + }] +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 3709de851..23a078ab6 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -183,6 +183,16 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.AutomationRunSessionRemoved]: '0.8.0', [ActionType.AutomationRunPrimarySessionChanged]: '0.8.0', [ActionType.AutomationRunCancelRequested]: '0.8.0', + [ActionType.TcpInput]: '0.9.0', + [ActionType.TcpData]: '0.9.0', + [ActionType.TcpInputConsumed]: '0.9.0', + [ActionType.TcpDataConsumed]: '0.9.0', + [ActionType.TcpInputEof]: '0.9.0', + [ActionType.TcpDataEof]: '0.9.0', + [ActionType.TcpClientClose]: '0.9.0', + [ActionType.TcpHostClose]: '0.9.0', + [ActionType.TcpClientReset]: '0.9.0', + [ActionType.TcpHostReset]: '0.9.0', }; /** From e170d157c9ce9f0dc2bb266b09f97e5936172172 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Thu, 1 Oct 2026 15:40:37 -0700 Subject: [PATCH 2/2] clean --- .../AgentHostProtocol.Tests/ClientTests.cs | 229 +++++++++++++----- .../typescript/src/client/tcp-connection.ts | 5 + .../typescript/test/tcp-connection.test.ts | 5 + 3 files changed, 179 insertions(+), 60 deletions(-) diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs index cef6225b5..446f3ab01 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs @@ -105,17 +105,24 @@ public sealed class ClientTests private static TcpConnectionSubscription TcpCreation() => new() { - Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, - ReceiveWindowBytes = 4, MaximumChunkSize = 2, + Type = "tcpConnection", + Host = "localhost", + Port = 3000, + Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 4, + MaximumChunkSize = 2, }; private static Snapshot TcpSnapshot(string resource = "ahp-tcp:/created") => new() { - Resource = resource, FromSeq = 0, State = new SnapshotState + Resource = resource, + FromSeq = 0, + State = new SnapshotState { Tcp = new TcpConnectionState { - Session = "ahp-session:/s1", Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Session = "ahp-session:/s1", + Target = new TcpTarget { Host = "localhost", Port = 3000 }, Encoding = TcpDataEncoding.Base64, Input = new FlowControlledByteDirectionState { WindowBytes = 4, MaximumChunkSize = 2 }, Output = new FlowControlledByteDirectionState { WindowBytes = 4, MaximumChunkSize = 2 }, @@ -130,7 +137,8 @@ public sealed class ClientTests var multi = new MultiHostClient(); var add = multi.AddHostAsync(new HostConfig { - Id = new HostId("tcp"), ClientId = "owner", + Id = new HostId("tcp"), + ClientId = "owner", ReconnectPolicy = autoReconnect ? new ReconnectPolicy { InitialBackoff = TimeSpan.FromMilliseconds(1), MaxBackoff = TimeSpan.FromMilliseconds(10) } : ReconnectPolicy.Disabled, @@ -144,7 +152,8 @@ public sealed class ClientTests var initial = await servers.Reader.ReadAsync(token); await TcpResponse(initial, await TcpRequest(initial, "initialize", token), new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, Snapshots = new(), + ProtocolVersion = ProtocolVersion.Current, + Snapshots = new(), TcpConnections = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }, }, token); await TcpHostSessions(initial, token); @@ -180,7 +189,8 @@ await handle.DispatchAsync(new StateAction(new SessionTitleChangedAction { Type Assert.False(write.IsCompleted); await FakeHost.SendNotificationAsync(oldServer, "action", new ActionEnvelope { - Channel = ProtocolVersion.RootResourceUri, ServerSeq = 50, + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = 50, Action = new StateAction(new RootActiveSessionsChangedAction { Type = ActionType.RootActiveSessionsChanged, ActiveSessions = 1 }), }, token); while (multi.Host(id)!.ServerSeq != 50) await Task.Delay(1, token); @@ -195,7 +205,9 @@ await handle.DispatchAsync(new StateAction(new SessionTitleChangedAction { Type Assert.Contains(connection.Resource, parameters.Subscriptions); await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult { - Type = ReconnectResultType.Replay, Missing = new(), Actions = new() + Type = ReconnectResultType.Replay, + Missing = new(), + Actions = new() { new ActionEnvelope { Channel = connection.Resource, ServerSeq = 3, Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }) }, @@ -269,7 +281,8 @@ await server.SendAsync(Ser.EncodeMessage(new JsonRpcMessage { ErrorResponse = new JsonRpcErrorResponse { - Id = request.Id, Error = new JsonRpcErrorObject { Code = -32601, Message = "reconnect unavailable" }, + Id = request.Id, + Error = new JsonRpcErrorObject { Code = -32601, Message = "reconnect unavailable" }, }, }), token); } @@ -380,10 +393,18 @@ private static async Task TcpUnsubscribe(MemTransport server, string resource, C private static async Task TcpPush(MemTransport server, long sequence, StateAction action, CancellationToken token, ActionOrigin? origin = null, string? rejectionReason = null, string channel = "ahp-tcp:/created") => await server.SendAsync(Ser.EncodeMessage(new JsonRpcMessage { - Notification = new JsonRpcNotification { Method = "action", Params = Ser.SerializeToElement(new ActionEnvelope + Notification = new JsonRpcNotification { - Channel = channel, ServerSeq = sequence, Action = action, Origin = origin, RejectionReason = rejectionReason, - }) }, + Method = "action", + Params = Ser.SerializeToElement(new ActionEnvelope + { + Channel = channel, + ServerSeq = sequence, + Action = action, + Origin = origin, + RejectionReason = rejectionReason, + }) + }, }), token); private static async Task TcpUnrelatedBurst(AhpClient client, MemTransport server, long firstSequence, CancellationToken token) @@ -411,7 +432,8 @@ public async Task TcpScopedCreationAndActiveTraffic() var initialize = client.InitializeAsync("owner", cancellationToken: token); await TcpResponse(server, await TcpRequest(server, "initialize", token), new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, Snapshots = new(), + ProtocolVersion = ProtocolVersion.Current, + Snapshots = new(), TcpConnections = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }, }, token); await initialize; @@ -446,7 +468,10 @@ public async Task TcpScopedReconnectIsolatesTrafficAndReportsOwnedOverflow(bool await using var fresh = AhpClient.Connect(side, new ClientConfig { SubscriptionBufferCapacity = 2 }); var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams { - Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 0, Subscriptions = new(), + Channel = ProtocolVersion.RootResourceUri, + ClientId = "owner", + LastSeenServerSeq = 0, + Subscriptions = new(), }, new[] { connection }, token); var request = await TcpRequest(server, "reconnect", token); await TcpUnrelatedBurst(fresh, server, 2, token); @@ -458,7 +483,8 @@ public async Task TcpScopedReconnectIsolatesTrafficAndReportsOwnedOverflow(bool } await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult { - Type = ReconnectResultType.Replay, Missing = new(), + Type = ReconnectResultType.Replay, + Missing = new(), Actions = overflow ? new() : new() { new ActionEnvelope { Channel = connection.Resource, ServerSeq = 1, @@ -491,26 +517,39 @@ private static async Task OpenTcp(AhpClient client, MemTransport var initialize = client.InitializeAsync("owner", cancellationToken: token); await TcpResponse(server, await TcpRequest(server, "initialize", token), new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, Snapshots = new(), + ProtocolVersion = ProtocolVersion.Current, + Snapshots = new(), TcpConnections = new TcpConnectionsCapability { Encodings = new() { TcpDataEncoding.Base64 } }, }, token); await initialize; var open = client.OpenTcpConnectionAsync("ahp-session:/s1", new TcpConnectionSubscription { - Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, - ReceiveWindowBytes = Math.Max(4, maximumChunkSize), MaximumChunkSize = maximumChunkSize, + Type = "tcpConnection", + Host = "localhost", + Port = 3000, + Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = Math.Max(4, maximumChunkSize), + MaximumChunkSize = maximumChunkSize, }, token); var direction = new FlowControlledByteDirectionState { WindowBytes = Math.Max(4, maximumChunkSize), MaximumChunkSize = maximumChunkSize, ReceivedBytes = invalidSnapshot ? 1 : 0 }; await TcpResponse(server, await TcpRequest(server, "subscribe", token), new SubscribeResult { - Snapshot = new Snapshot { Resource = "ahp-tcp:/created", FromSeq = 0, State = new SnapshotState + Snapshot = new Snapshot { - Tcp = new TcpConnectionState + Resource = "ahp-tcp:/created", + FromSeq = 0, + State = new SnapshotState { - Session = "ahp-session:/s1", Target = new TcpTarget { Host = "localhost", Port = 3000 }, - Encoding = TcpDataEncoding.Base64, Input = direction, Output = direction, - }, - } }, + Tcp = new TcpConnectionState + { + Session = "ahp-session:/s1", + Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Encoding = TcpDataEncoding.Base64, + Input = direction, + Output = direction, + }, + } + }, }, token); if (firstAction) await TcpPush(server, 1, new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }), token); @@ -534,7 +573,8 @@ public void TcpCreationRequiresCanonicalDiscriminator() TcpProtocol.ValidateRequest("ahp-session:/s1", create, capability); Assert.Equal("tcpConnection", Ser.SerializeToElement(new SubscribeParams { - Channel = "ahp-session:/s1", Create = create, + Channel = "ahp-session:/s1", + Create = create, }).GetProperty("create").GetProperty("type").GetString()); Assert.Throws(() => TcpProtocol.ValidateRequest("ahp-session:/s1", create with { Type = "tcp" }, capability)); @@ -577,14 +617,20 @@ public async Task TcpSingleClientReconnectFiltersReturnedReplayAtCallerCheckpoin var (oldSide, oldServer) = MemTransport.CreatePair(); await using var old = AhpClient.Connect(oldSide); var connection = await OpenTcp(old, oldServer, token); - await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 10, - Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 0 }) }); + await connection.AcceptAsync(new ActionEnvelope + { + Channel = connection.Resource, + ServerSeq = 10, + Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 0 }) + }); await old.ShutdownAsync(preserveTcpConnections: true, cancellationToken: token); var (side, server) = MemTransport.CreatePair(); await using var fresh = AhpClient.Connect(side); var parameters = new ReconnectParams { - Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 100, + Channel = ProtocolVersion.RootResourceUri, + ClientId = "owner", + LastSeenServerSeq = 100, Subscriptions = new() { "ahp-session:/s1" }, }; var reconnect = fresh.ReconnectTcpConnectionsAsync(parameters, new[] { connection }, token); @@ -592,15 +638,25 @@ await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, Assert.Equal(10, Ser.Deserialize(request.Params!.Value).LastSeenServerSeq); var actions = new System.Collections.Generic.List(); for (long sequence = 11; sequence <= 101; sequence++) - actions.Add(new ActionEnvelope { Channel = sequence == 50 ? connection.Resource : "ahp-session:/s1", ServerSeq = sequence, + actions.Add(new ActionEnvelope + { + Channel = sequence == 50 ? connection.Resource : "ahp-session:/s1", + ServerSeq = sequence, Action = sequence == 50 ? new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) - : new StateAction(new SessionTitleChangedAction { Type = ActionType.SessionTitleChanged, Title = $"title-{sequence}" }) }); - actions.Add(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 102, - Action = new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 2 }) }); + : new StateAction(new SessionTitleChangedAction { Type = ActionType.SessionTitleChanged, Title = $"title-{sequence}" }) + }); + actions.Add(new ActionEnvelope + { + Channel = connection.Resource, + ServerSeq = 102, + Action = new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 2 }) + }); await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult { - Type = ReconnectResultType.Replay, Actions = actions, Missing = new() { "ahp-session:/missing" }, + Type = ReconnectResultType.Replay, + Actions = actions, + Missing = new() { "ahp-session:/missing" }, }), token); var returned = Assert.IsType((await reconnect.WaitAsync(token)).Value); Assert.Equal(100, parameters.LastSeenServerSeq); @@ -673,13 +729,22 @@ public async Task TcpLocalCloseRetainsCrossingTrafficUntilBothDirectionsDrain() Assert.Equal(new byte[] { 7, 8 }, await read.WaitAsync(token)); var credit = await TcpDispatch(server, token); Assert.Equal(2, Assert.IsType(credit.Action.Value).ConsumedBytes); - await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 4, - Action = new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }) }); + await connection.AcceptAsync(new ActionEnvelope + { + Channel = connection.Resource, + ServerSeq = 4, + Action = new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }) + }); Assert.False(connection.IsClosed); Assert.False(drain.IsCompleted); Assert.Equal(1, client.EventListenerCount); - await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 5, - Action = credit.Action, Origin = new ActionOrigin { ClientId = "owner", ClientSeq = credit.ClientSeq } }); + await connection.AcceptAsync(new ActionEnvelope + { + Channel = connection.Resource, + ServerSeq = 5, + Action = credit.Action, + Origin = new ActionOrigin { ClientId = "owner", ClientSeq = credit.ClientSeq } + }); Assert.False(connection.IsClosed); await TcpPush(server, 6, new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }), token); await TcpUnsubscribe(server, connection.Resource, token); @@ -704,8 +769,12 @@ public async Task TcpAdapterRejectsStaleCreationAndDetachesCancelledSetup() using var cancellation = new CancellationTokenSource(); var open = client.OpenTcpConnectionAsync("ahp-session:/s1", new TcpConnectionSubscription { - Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, - ReceiveWindowBytes = 4, MaximumChunkSize = 2, + Type = "tcpConnection", + Host = "localhost", + Port = 3000, + Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 4, + MaximumChunkSize = 2, }, cancellation.Token); _ = await TcpRequest(server, "subscribe", token); cancellation.Cancel(); @@ -733,8 +802,12 @@ public async Task TcpResetOrDisposeTerminatesClosingStream(bool reset) Assert.False(drain.IsCompleted); if (reset) { - await connection.AcceptAsync(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 1, - Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) }); + await connection.AcceptAsync(new ActionEnvelope + { + Channel = connection.Resource, + ServerSeq = 1, + Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) + }); await TcpPush(server, 2, new StateAction(new TcpHostResetAction { Type = ActionType.TcpHostReset, Reason = TcpResetReason.ProtocolError }), token); await TcpUnsubscribe(server, connection.Resource, token); await Assert.ThrowsAsync(() => connection.ReadAsync(token)); @@ -770,13 +843,18 @@ public async Task TcpCloseWhileSuspendedReplaysAndDrainsBeforeRelease() await using var fresh = AhpClient.Connect(side); var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams { - Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 0, Subscriptions = new(), + Channel = ProtocolVersion.RootResourceUri, + ClientId = "owner", + LastSeenServerSeq = 0, + Subscriptions = new(), }, new[] { connection }, token); var request = await TcpRequest(server, "reconnect", token); Assert.Contains(connection.Resource, Ser.Deserialize(request.Params!.Value).Subscriptions); await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult { - Type = ReconnectResultType.Replay, Missing = new(), Actions = new() + Type = ReconnectResultType.Replay, + Missing = new(), + Actions = new() { new() { Channel = connection.Resource, ServerSeq = 1, Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "Bwg=" }) }, new() { Channel = connection.Resource, ServerSeq = 2, Action = new StateAction(new TcpHostCloseAction { Type = ActionType.TcpHostClose }) }, @@ -812,8 +890,12 @@ public async Task TcpAdapterReleasesLateCreationWithoutUnsubscribingParent(bool using var cancellation = new CancellationTokenSource(); var open = client.OpenTcpConnectionAsync("ahp-session:/s1", new TcpConnectionSubscription { - Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, - ReceiveWindowBytes = 4, MaximumChunkSize = 2, + Type = "tcpConnection", + Host = "localhost", + Port = 3000, + Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 4, + MaximumChunkSize = 2, }, cancellation.Token); var request = await TcpRequest(server, "subscribe", token); if (cancel) cancellation.Cancel(); @@ -887,13 +969,18 @@ await oldClient.DispatchAsync("ahp-session:/s1", await using var fresh = AhpClient.Connect(side); var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams { - Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", Subscriptions = new(), LastSeenServerSeq = 20, + Channel = ProtocolVersion.RootResourceUri, + ClientId = "owner", + Subscriptions = new(), + LastSeenServerSeq = 20, }, new[] { connection }, token); var request = await TcpRequest(server, "reconnect", token); Assert.Equal(3, Ser.Deserialize(request.Params!.Value).LastSeenServerSeq); await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult { - Type = ReconnectResultType.Replay, Missing = new(), Actions = new() + Type = ReconnectResultType.Replay, + Missing = new(), + Actions = new() { new() { Channel = connection.Resource, ServerSeq = 4, Action = new StateAction(new TcpInputConsumedAction { Type = ActionType.TcpInputConsumed, ConsumedBytes = 2 }) }, }, @@ -1048,7 +1135,9 @@ await Assert.ThrowsAsync(() => fresh.ReconnectTcpConn new[] { connection }, token)); var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams { - Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", LastSeenServerSeq = 20, + Channel = ProtocolVersion.RootResourceUri, + ClientId = "owner", + LastSeenServerSeq = 20, Subscriptions = new() { "ahp-session:/s1" }, }, new[] { connection }, token); var request = await TcpRequest(server, "reconnect", token); @@ -1061,11 +1150,18 @@ await Assert.ThrowsAsync(() => fresh.ReconnectTcpConn Origin = new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq } }, }; if (acknowledged) - actions.Add(new ActionEnvelope { Channel = connection.Resource, ServerSeq = 2, Action = original.Action, - Origin = new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq } }); + actions.Add(new ActionEnvelope + { + Channel = connection.Resource, + ServerSeq = 2, + Action = original.Action, + Origin = new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq } + }); await TcpResponse(server, request, new ReconnectResult(new ReconnectReplayResult { - Type = ReconnectResultType.Replay, Actions = actions, Missing = new(), + Type = ReconnectResultType.Replay, + Actions = actions, + Missing = new(), }), token); await TcpPush(server, 3, original.Action, token, new ActionOrigin { ClientId = "owner", ClientSeq = original.ClientSeq }); await TcpPush(server, 4, new StateAction(new TcpDataEofAction { Type = ActionType.TcpDataEof, FinalOffset = 2 }), token); @@ -1100,7 +1196,10 @@ public async Task TcpAdapterReconnectSnapshotOrMissingFailsClosed(bool snapshot) await using var fresh = AhpClient.Connect(freshSide); var reconnect = fresh.ReconnectTcpConnectionsAsync(new ReconnectParams { - Channel = ProtocolVersion.RootResourceUri, ClientId = "owner", Subscriptions = new(), LastSeenServerSeq = 0, + Channel = ProtocolVersion.RootResourceUri, + ClientId = "owner", + Subscriptions = new(), + LastSeenServerSeq = 0, }, new[] { connection }, token); var request = await TcpRequest(server, "reconnect", token); var result = snapshot @@ -1292,12 +1391,17 @@ public async Task StrictEventsPreserveFirstTcpActionBeforeCreateReturns() Snapshot = new Snapshot { Resource = "ahp-tcp:/created", - State = new SnapshotState { Tcp = new TcpConnectionState + State = new SnapshotState { - Session = parameters.Channel, - Target = new TcpTarget { Host = "localhost", Port = 3000 }, - Encoding = TcpDataEncoding.Base64, Input = direction, Output = direction, - } }, + Tcp = new TcpConnectionState + { + Session = parameters.Channel, + Target = new TcpTarget { Host = "localhost", Port = 3000 }, + Encoding = TcpDataEncoding.Base64, + Input = direction, + Output = direction, + } + }, FromSeq = 0, }, }; @@ -1312,7 +1416,8 @@ await serverSide.SendAsync(Ser.EncodeMessage(new JsonRpcMessage Method = "action", Params = Ser.SerializeToElement(new ActionEnvelope { - Channel = "ahp-tcp:/created", ServerSeq = 1, + Channel = "ahp-tcp:/created", + ServerSeq = 1, Action = new StateAction(new TcpDataAction { Type = ActionType.TcpData, Offset = 0, Data = "AA==" }), }), }, @@ -1324,8 +1429,12 @@ await serverSide.SendAsync(Ser.EncodeMessage(new JsonRpcMessage Channel = "ahp-session:/s1", Create = new TcpConnectionSubscription { - Type = "tcpConnection", Host = "localhost", Port = 3000, Encoding = TcpDataEncoding.Base64, - ReceiveWindowBytes = 8, MaximumChunkSize = 8, + Type = "tcpConnection", + Host = "localhost", + Port = 3000, + Encoding = TcpDataEncoding.Base64, + ReceiveWindowBytes = 8, + MaximumChunkSize = 8, }, }, cts.Token); await server; diff --git a/clients/typescript/src/client/tcp-connection.ts b/clients/typescript/src/client/tcp-connection.ts index ead8ab17a..c6f3975ea 100644 --- a/clients/typescript/src/client/tcp-connection.ts +++ b/clients/typescript/src/client/tcp-connection.ts @@ -64,6 +64,11 @@ function requireTcp(condition: boolean, message: string): asserts condition { /** @internal Shared wire validation for consumers and hosts; host policy is separate. */ export function validateTcpRequest(session: string, create: TcpConnectionSubscription): void { requireTcp(typeof session === 'string' && session.startsWith('ahp-session:'), 'TCP creation requires a parent session'); + validateTcpCreation(create); +} + +/** @internal TCP options independent of the embedding host's session identity. */ +export function validateTcpCreation(create: TcpConnectionSubscription): void { requireTcp(!!create && create.type === 'tcpConnection', 'Invalid TCP creation kind'); requireTcp(typeof create.host === 'string' && create.host.length > 0 && !/[\s/\0]/.test(create.host), 'Invalid TCP host'); requireTcp(Number.isInteger(create.port) && create.port >= 1 && create.port <= 65535, 'Invalid TCP port'); diff --git a/clients/typescript/test/tcp-connection.test.ts b/clients/typescript/test/tcp-connection.test.ts index 82cf9ee77..4109fec68 100644 --- a/clients/typescript/test/tcp-connection.test.ts +++ b/clients/typescript/test/tcp-connection.test.ts @@ -598,6 +598,11 @@ test('a late reconnect response cannot resurrect a stream abandoned on timeout', test('TCP creation validates wire parameters before dispatch', async t => { const h = await setup(t); + for (const parent of ['', 'copilotcli:/session', 'ahp-tcp:/connection']) { + await assert.rejects(h.client.openTcpConnection(parent, create), { + name: 'TcpConnectionError', reason: TcpResetReason.ProtocolError, message: 'TCP creation requires a parent session', + }); + } for (const host of ['', ' ', 'bad host', 'localhost\n', 'local\0host', 'host/path', 'https://localhost']) { await assert.rejects(h.client.openTcpConnection(session, { ...create, host }), { name: 'TcpConnectionError', reason: TcpResetReason.ProtocolError, message: 'Invalid TCP host',