From 4a2007e98ba8f2549d0e7ec98b31cc954ad8d028 Mon Sep 17 00:00:00 2001 From: Snowy117 Date: Sun, 20 Sep 2026 21:48:17 +0800 Subject: [PATCH 1/3] feat(runtime): explicit transport quiescence boundary and session state TCP/UDP teardown relied on implicit, unowned background work and implicit session state; make both explicit. Ownership: DurableCaptureBundle owns the native pools and the single shared SetupExecutor; the coordinators take them as required ctor deps and never dispose injected collaborators. TCP: relay DisposeAsync awaits Completion (fault observed and swallowed); the acceptor awaits its terminal relay observation and owns the session lifetime, so the store's "await AcceptLoop" is a real quiescence wait; attach failure now tears the session down outside the setup try; RegisterSession releases the claimed listener/alias/token on a construction fault; the setup-failure cooldown is written inside the store inflight section. UDP: per-session linked lifetime CTS (idle expiry no longer fabricates a receive failure); UdpSessionState/UdpTeardownReason enums; SendSpanAsync returns bool and drops fail-closed (counted, rate-limited) instead of throwing; the coordinator drains in-flight failure teardowns on dispose. Gates: format clean, Release build 0 warnings, 744 tests green, jb 0 issues. --- .trellis/spec/backend/error-handling.md | 9 + .trellis/spec/backend/quality-guidelines.md | 14 ++ .trellis/spec/backend/tcp-local-redirect.md | 111 ++++++++++ .trellis/spec/backend/udp-relay.md | 95 ++++++++ .../09-20-transport-lifecycle/check.jsonl | 6 + .../tasks/09-20-transport-lifecycle/design.md | 206 ++++++++++++++++++ .../09-20-transport-lifecycle/implement.jsonl | 8 + .../09-20-transport-lifecycle/implement.md | 120 ++++++++++ .../tasks/09-20-transport-lifecycle/prd.md | 178 +++++++++++++++ .../tasks/09-20-transport-lifecycle/task.json | 26 +++ .../Perf/UdpSessionBenchmarks.cs | 13 +- .../Stability/GcSoakScenario.cs | 6 +- .../Stability/SessionFootprintScenario.cs | 8 +- .../Stability/UdpBurstScenario.cs | 9 +- .../Stability/UdpLossScenario.cs | 42 +++- src/WinForward.Cli/DurableCaptureBundle.cs | 6 +- src/WinForward.Cli/TcpRedirectComposer.cs | 6 +- src/WinForward.Cli/UdpProxyComposer.cs | 10 +- .../TcpRedirect/TcpProxyCoordinator.cs | 76 ++++--- .../TcpRedirect/TcpProxyRelay.cs | 26 ++- .../TcpRedirect/TcpRedirectAcceptor.cs | 128 +++++++---- .../TcpRedirect/TcpRedirectOptions.cs | 16 +- .../TcpRedirect/TcpRedirectSession.cs | 17 +- .../TcpRedirect/TcpRedirectSessionStore.cs | 26 ++- .../TcpRedirect/TcpRedirectSetup.cs | 27 ++- .../UdpProxy/IUdpSessionSlotHost.cs | 6 +- .../UdpProxy/UdpProxyCoordinator.Send.cs | 37 +++- .../UdpProxy/UdpProxyCoordinator.cs | 115 +++++++--- .../UdpProxy/UdpProxyLogging.cs | 14 ++ .../UdpProxy/UdpProxyOptions.cs | 22 +- .../UdpProxy/UdpProxySession.cs | 115 +++++++--- .../UdpProxy/UdpSessionSetup.cs | 9 +- .../UdpProxy/UdpSessionState.cs | 38 ++++ .../UdpProxy/UdpTeardownReason.cs | 22 ++ .../CoordinatorOwnershipTests.cs | 92 ++++++++ .../DurableCaptureBundleTests.cs | 4 +- .../FlowDispatcherExecutorTests.cs | 2 +- .../HotPathAllocationGateTests.cs | 10 +- .../IdleExpirySweeperFailureTests.cs | 2 +- .../NdisPacketActionExecutorLoggingTests.cs | 7 +- .../RuntimeDiagnosticLoggingTests.cs | 4 +- .../Socks5UdpAssociateTests.cs | 3 +- .../TcpFragmentHandlingTests.cs | 2 +- .../TcpPendingSynSetupTests.cs | 94 +++++++- .../TcpProxyCoordinatorCapacityTests.cs | 30 +-- .../TcpProxyCoordinatorConcurrencyTests.cs | 18 +- .../TcpProxyCoordinatorLifecycleTests.cs | 28 +-- .../TcpProxyCoordinatorRewriteTests.cs | 30 +-- .../TcpProxyRelayTests.cs | 23 ++ .../TcpRedirectAcceptorTests.cs | 44 ++++ .../TcpRedirectSessionStoreTests.cs | 90 ++++++++ .../TcpRedirectSessionTests.cs | 63 ++++++ .../TcpRedirectSetupTests.cs | 53 +++++ .../TcpRelayObservationTests.cs | 41 ++-- .../TcpReversePrefilterTests.cs | 2 +- .../TestHelpers/RecordingLogger.cs | 8 +- .../TestHelpers/TcpCoordinatorFakes.cs | 49 ++++- .../TestHelpers/TestPools.cs | 26 +++ .../TestHelpers/UdpCoordinatorFakes.cs | 24 ++ .../UdpProxyCoordinatorLifecycleTests.cs | 25 ++- .../UdpProxyCoordinatorTests.cs | 40 +++- .../UdpProxySessionTests.cs | 53 +++++ .../UdpReceiveResilienceTests.cs | 8 +- tests/WinForward.Core.Tests/UdpRelayTests.cs | 4 +- .../UdpSessionSetupTests.cs | 51 ++++- .../UdpSetupCooldownTests.cs | 8 +- .../UdpSetupQueueBudgetTests.cs | 10 +- .../UdpSetupQueueTests.cs | 12 +- 68 files changed, 2191 insertions(+), 336 deletions(-) create mode 100644 .trellis/tasks/09-20-transport-lifecycle/check.jsonl create mode 100644 .trellis/tasks/09-20-transport-lifecycle/design.md create mode 100644 .trellis/tasks/09-20-transport-lifecycle/implement.jsonl create mode 100644 .trellis/tasks/09-20-transport-lifecycle/implement.md create mode 100644 .trellis/tasks/09-20-transport-lifecycle/prd.md create mode 100644 .trellis/tasks/09-20-transport-lifecycle/task.json create mode 100644 src/WinForward.Runtime/UdpProxy/UdpSessionState.cs create mode 100644 src/WinForward.Runtime/UdpProxy/UdpTeardownReason.cs create mode 100644 tests/WinForward.Core.Tests/CoordinatorOwnershipTests.cs create mode 100644 tests/WinForward.Core.Tests/TcpRedirectAcceptorTests.cs create mode 100644 tests/WinForward.Core.Tests/TcpRedirectSessionStoreTests.cs create mode 100644 tests/WinForward.Core.Tests/TcpRedirectSessionTests.cs create mode 100644 tests/WinForward.Core.Tests/TcpRedirectSetupTests.cs create mode 100644 tests/WinForward.Core.Tests/TestHelpers/TestPools.cs diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md index 4a7ed66..7e22a3b 100644 --- a/.trellis/spec/backend/error-handling.md +++ b/.trellis/spec/backend/error-handling.md @@ -26,3 +26,12 @@ - Keying UDP state by PID, DNS transaction ID, or only the local port mixes independent datagrams. - Returning an existing association solely because its relay alias matches can cross-wire two original flows. + +- **A UDP ready-path send against an expiring or faulted session is a counted, rate-limited + fail-closed drop, not an exception** (task 09-20-transport-lifecycle, 2026-09-20): + `UdpProxySession.SendSpanAsync` returns `ValueTask`; `false` is counted + (`RuntimeCounters.UdpFailClosedDrop`) with a 5 s-throttled `udp.send.dropped + reason=sessionUnavailable`, and the sender never removes the slot (idle expiry is the + sweeper's, a fault is the failure handler's). Teardown reasons (`SetupFailure`, `Expiry`, + `Fault`, `Shutdown`) are explicit data; only a genuine setup failure arms the 1 s setup + cooldown. diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index 1921437..1a350d9 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -116,3 +116,17 @@ finally } ``` + +--- + +## Composition Ownership And Dispose Quiescence (wired 2026-09-20, task 09-20-transport-lifecycle) + +- `DurableCaptureBundle` (Cli) creates and disposes the native buffer pools and the **single + shared** `SetupExecutor`; TCP and UDP coordinators take them as **required non-null + constructor dependencies** and never dispose injected collaborators. +- `DisposeAsync` on a coordinator/pump quiesces only its own background work (awaits the tasks + it started) and disposes only internally-constructed state. There is no global task registry; + the quiescence boundary is an ownership-await tree. +- Ownership is expressed in the constructor signature, not in `_ownsX` flags or create-if-null + branches — a coordinator that cannot run without a pool must fail construction, not + fabricate one. diff --git a/.trellis/spec/backend/tcp-local-redirect.md b/.trellis/spec/backend/tcp-local-redirect.md index 25934f8..98fa456 100644 --- a/.trellis/spec/backend/tcp-local-redirect.md +++ b/.trellis/spec/backend/tcp-local-redirect.md @@ -195,3 +195,114 @@ if (Tombstones.TryHit(reverseSource, reverseDestination, now) || Tombstones.TryHit(key, now)) return TcpRedirectOutcome.Dropped; ``` + +--- + +## Relay/redirect quiescence, attach-failure teardown, and setup-fault release (wired 2026-09-20, task 09-20-transport-lifecycle) + +### 1. Scope / Trigger + +- Trigger: any change to TCP relay/acceptor teardown, `TcpRedirectSessionStore` lifetime + disposal, the acceptor's attach-failure branch, `TcpRedirectSetup.RegisterSession`, or + `TcpProxyCoordinator`'s background SYN-setup tail. + +### 2. Signatures + +- `TcpProxyRelay.DisposeAsync()` — after disposing the local socket and the control + connection, it `await`s `Completion` inside a contained catch. Returning from it means no + pump task is running and `Completion` is completed; the fault the disposal itself + manufactures is observed and swallowed. +- `TcpRedirectAcceptor.RunAcceptLoopAsync(...)` — awaits both terminal steps + (`ObserveRelayCompletionAsync` then `DrainRedundantConnectionsAsync`); no `_ =` discards. + The loop owns `session.DisposeLifetime()`, so `session.AcceptLoop` spans the whole session + lifetime and the store's `await session.AcceptLoop` is a true quiescence wait. +- `TcpRedirectSessionStore.DisposeCoreAsync()` — awaits each `session.AcceptLoop` before + disposing that session's lifetime CTS (`TcpRedirectSession.DisposeLifetime()`, exactly once, + tolerant of an already-disposed lifetime). +- `TcpRedirectSetup.RegisterSessionAsync(...)` -> `ValueTask`. +- `TcpRelayFaultObserver.Observe(relay, logger)` — exactly one registration per discard path. + +### 3. Contracts + +- **Ownership-await quiescence**: dispose returns only after the tasks it owns have finished. + `TcpProxyRelay.DisposeAsync` -> `Completion`; the acceptor's accept loop -> its terminal + observation + redundant-accept drain; the store's dispose -> every session `AcceptLoop`. + No global task registry is used. +- **Lifetime CTS is disposed after its last reader**. `Retire()` cancels the session lifetime; + the CTS *dispose* is deferred until after `await session.AcceptLoop`, so the accept loop and + the client-reset path that reads `session.Token` can never touch a disposed + `CancellationTokenSource`. An `IsDisposed` gate makes late teardown entries + (`TearDownSessionAsync`, `FailAssociationAsync`, `RemoveExpiredAsync`, `TryRegister`) no-ops. +- **Fault observer is per discard path, not deduplicated globally**. The two call sites — + `TcpProxyRelay.DisposeAsync` and the acceptor's attach-failure branch — are **distinct + discard paths** (the acceptor may discard an arbitrary `ITcpRelay` whose `DisposeAsync` need + not observe). Exactly one registration per path; never two on one path (`ContinueWith`-based + registration is not idempotent). Preserve the .NET gotcha: read `task.Exception` before any + `IsEnabled` gate. +- **Attach failure leaves no half-open session**: when `tryAttachRelay` returns false the + accepted connection is closed and the relay discarded **outside** the setup `try`, then the + session is torn down. A throw from that disposal must never fall into + `HandleRelaySetupFailureAsync` (which would inject a client reset against a retired session). +- **RegisterSession releases on partial failure**: a construction fault after the + listener/alias was claimed releases it via `store.ReleaseAssociationAsync` exactly once + (the success path releases nothing). +- **Setup cooldown is written inside the store inflight section**: the pending-index entry + removal and its setup-failure cooldown write complete before `ExitSetup()` unblocks the + store's dispose drain, so a post-drain `RemoveAll` can never run in between. + +### 4. Validation & Error Matrix + +| Condition | Required result | +|---|---| +| `TcpProxyRelay.DisposeAsync` returns | `Completion` completed, no pump running, disposal fault observed | +| Acceptor reaches end of accept loop | terminal observation + redundant-accept drain awaited (not discarded) | +| Store dispose while a session is mid-teardown | `AcceptLoop` awaited before the lifetime CTS is disposed | +| Late teardown entry after store dispose | no-op via `IsDisposed` gate | +| `tryAttachRelay` returns false | relay discarded + accepted closed + session torn down; store session count 0 | +| Disposal throw on the attach-failure path | contained (warn); never re-enters `HandleRelaySetupFailureAsync` | +| Construction fault inside `RegisterSessionAsync` | claimed listener/alias/self-traffic token released exactly once, exception surfaces | +| Genuine setup failure completes | cooldown written before `ExitSetup()`; a store dispose racing it leaves no cooldown/charge | + +### 5. Good/Base/Bad Cases + +- Good: disposing a relay against a loopback socket returns with `Completion` completed and + both pump buffers returned. +- Base: a store dispose racing an in-flight setup drains it and leaves zero active/charged + setups and no cooldown. +- Bad: discarding `ObserveRelayCompletionAsync` with `_ =`; disposing the lifetime CTS before + `AcceptLoop` finishes; deduplicating the two distinct fault-observer registrations into one + call site; writing the setup cooldown after `ExitSetup()`. + +### 6. Tests Required + +- `TcpProxyRelayTests.DisposeLeavesCompletionCompletedAndReturnsPumpBuffers`. +- `TcpRelayObservationTests` — exactly one `tcp.relay.faulted` per discard path (all three), + plus the debug-gate suppression case. +- `TcpRedirectAcceptorTests.UnattachableRelayIsDiscardedAndTheSessionIsTornDown`. +- `TcpRedirectSetupTests.RegistrationFaultReleasesTheClaimedListenerAliasAndToken`. +- `TcpRedirectSessionTests` / `TcpRedirectSessionStoreTests` — deferred lifetime dispose + (no `ObjectDisposedException`), idempotence, post-dispose teardown re-entry. +- `TcpPendingSynSetupTests.DisposeClearsTheCooldownAnEarlierFailureArmed`, + `DisposeRacingAnInFlightSetupLeavesNoCooldownOrCharge`, and + `LaunchedSetupItemCarriesTheShutdownTokenSoParkedSetupsUnwindOnDispose`. + +### 7. Wrong vs Correct + +#### Wrong + +```csharp +// Fire-and-forget end handling: the store's "await AcceptLoop" is not a quiescence wait, +// and the lifetime CTS may be disposed while the loop still reads session.Token. +_ = ObserveRelayCompletionAsync(session); +await DrainRedundantConnectionsAsync(); +DisposeLifetime(); +``` + +#### Correct + +```csharp +// The accept loop owns its terminal steps and the lifetime; the store's dispose awaits +// session.AcceptLoop before disposing the CTS. +await ObserveRelayCompletionAsync(session); +await DrainRedundantConnectionsAsync(); +``` diff --git a/.trellis/spec/backend/udp-relay.md b/.trellis/spec/backend/udp-relay.md index 6c2be89..838d3ee 100644 --- a/.trellis/spec/backend/udp-relay.md +++ b/.trellis/spec/backend/udp-relay.md @@ -300,3 +300,98 @@ coordinator/reinjector already honor — on a jumbo-capable ABI every payload ### 6. Migration note xUnit `Assert.Equal` generic inference does not apply the `IPAddress` → `IPAddressValue` implicit conversion; migrated assertions cast explicitly (`(IPAddressValue?)expected`). `Assert.Equal(IPAddress, IPAddressValue)` still compiles elsewhere (e.g. `UdpPacketView` assertions) through inference participation — do not mistake those sites for unmigrated ones. + +--- + +## UDP session lifetime, teardown reason, and fail-closed send drop (wired 2026-09-20, task 09-20-transport-lifecycle) + +### 1. Scope / Trigger + +- Trigger: any change to `UdpProxySession`'s receive loop / activity guard / disposal, the + coordinator's slot removal and setup cooldown, or the ready-path send result. + +### 2. Signatures + +- `UdpSessionState { SettingUp, Active, Expiring, Faulted, Disposed }` and + `UdpTeardownReason { SetupFailure, Expiry, Fault, Shutdown }` + (`UdpProxy/UdpSessionState.cs`, `UdpProxy/UdpTeardownReason.cs`). +- `UdpProxySession.SendSpanAsync(Endpoint destination, ReadOnlySpan payload, CancellationToken)` + -> `ValueTask` (`true` = sent; `false` = not sent because the session is expiring or + has failed). +- `UdpProxySession.State` — computed under the session `_activityGate`. +- `UdpProxyCoordinator.RemoveSlotAsync(slot, UdpTeardownReason)`; the cooldown is armed only + for `SetupFailure`. + +### 3. Contracts + +- **Per-session lifetime token**: `UdpProxySession` owns + `_lifetime = CancellationTokenSource.CreateLinkedTokenSource(context.Shutdown)` (mirroring + `TcpRedirectSession.Lifetime`). The receive loop and every `InjectAsync` route through + `_lifetime.Token`; catch guards `when (_lifetime.IsCancellationRequested)` treat + cancellation as **normal teardown** (idle expiry OR shutdown). `_receiveFailure` is written + only by the generic catch, so **idle expiry no longer manufactures a `_receiveFailure`** and + the fire-and-forget failure handler no longer fires on expiry. +- **Expiry cancels outside the activity lock**: `TryBeginExpiry` cancels `_lifetime` *outside* + `_activityGate` (the lock is non-reentrant; a cancellation callback must not self-deadlock). +- **Send reports sent/not-sent instead of throwing**: the `_expiring` / `_receiveFailure` + preconditions return `false`; no `IOException` reaches the dispatcher for those. The + coordinator counts a rate-limited fail-closed drop (`RuntimeCounters.UdpFailClosedDrop`, + `udp.send.dropped reason=sessionUnavailable`, 5 s throttle) and does **not** remove the slot + (Expiry -> the sweeper owns removal; Fault -> the failure handler owns removal). A genuine + transport exception keeps the existing remove-slot + rethrow path. +- **Teardown reason is data**: every slot removal passes a `UdpTeardownReason`; only + `SetupFailure` arms the 1 s setup cooldown. Teardown logging carries the reason. +- **Owned drain for the residual handler**: the coordinator tracks in-flight receive-failure + teardowns and awaits them in its `DisposeCoreAsync` (`DrainInFlightTeardownsAsync`); the + receive loop still does **not** await the handler (re-entrancy hazard). + +### 4. Validation & Error Matrix + +| Condition | Required result | +|---|---| +| Idle-expiry admitted | `_lifetime` cancelled; loop exits as normal teardown; no `_receiveFailure`, no failure handler | +| Coordinator shutdown cancels the loop | normal teardown (no `_receiveFailure`) | +| Genuine socket fault | `_receiveFailure` set, `State == Faulted`, failure handler fires once | +| Send against an expiring/faulted session | `false`; counted rate-limited drop; slot NOT removed by the sender | +| Send transport throws | existing remove-slot + rethrow | +| Slot removed for `SetupFailure` | setup cooldown armed | +| Slot removed for `Expiry`/`Fault`/`Shutdown` | no cooldown | +| Coordinator `DisposeAsync` with an in-flight failure teardown | drained before dispose completes | +| Session teardown after `_lifetime` disposal | lifetime disposed exactly once | + +### 5. Good/Base/Bad Cases + +- Good: an idle session expires cleanly — the receive loop observes cancellation, the handler + never fires, and a datagram racing the expiry is dropped without an exception. +- Base: a real socket fault sets `Faulted`, fires the handler once, and the coordinator drains + that teardown on dispose. +- Bad: throwing `IOException` from `SendSpanAsync` for the expiry precondition; cancelling + `_lifetime` while holding `_activityGate`; arming the cooldown for `Expiry`. + +### 6. Tests Required + +- `UdpProxySessionTests` — idle expiry records no failure / ends the loop / refuses the send; + a genuine fault sets `Faulted` + fires the handler once. +- `UdpProxyCoordinatorTests.SessionStateReportsSettingUpWhileDialingThenActiveWhenReady`. +- `UdpSessionSetupTests` — setup failure maps to `SetupFailure`, a cancelled dial to + `Shutdown` (fake host records `RemovedReason`). +- Existing ready-path, skip-class, connreset, budget-credit, and dispose-drain suites stay + green; `HotPathAllocationGateTests.EstablishedUdpDatagramPathAllocatesNoManagedBytes` + unchanged. + +### 7. Wrong vs Correct + +#### Wrong + +```csharp +// Precondition failure as an exception: internal state leaks out as a packet-path throw, +// and the sender would tear the slot down even though the sweeper owns expiry. +lock (_activityGate) { if (_expiring) throw new IOException("session is expiring"); } +``` + +#### Correct + +```csharp +// Report sent/not-sent; the coordinator counts a fail-closed drop and leaves the slot alone. +lock (_activityGate) { if (_expiring || Volatile.Read(ref _receiveFailure) is not null) return false; } +``` diff --git a/.trellis/tasks/09-20-transport-lifecycle/check.jsonl b/.trellis/tasks/09-20-transport-lifecycle/check.jsonl new file mode 100644 index 0000000..90a2044 --- /dev/null +++ b/.trellis/tasks/09-20-transport-lifecycle/check.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verification gate: format (exit 0, empty), zero-warning Release build, green Release tests, and a zero-Issue jb inspectcode report; the check must confirm no global suppressions were added."} +{"file": ".trellis/spec/backend/hot-path.md", "reason": "Confirm the SendSpanAsync signature change adds no allocation and the per-packet dispatch path is unchanged; re-run the allocation gates."} +{"file": ".trellis/spec/backend/tcp-local-redirect.md", "reason": "Verify the store/accept-loop/relay teardown semantics and tombstone behavior match the documented TCP contracts after §1.1-§1.4 and §4."} +{"file": ".trellis/spec/backend/udp-relay.md", "reason": "Verify the per-session lifetime, teardown reason, and drop policy match the documented UDP session contracts after §1.5-§2."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Confirm fail-closed behavior and exactly-once resource release across the new teardown paths."} +{"file": ".trellis/spec/backend/index.md", "reason": "Spec index to confirm every touched layer has a matching spec section and no contract was left undocumented."} diff --git a/.trellis/tasks/09-20-transport-lifecycle/design.md b/.trellis/tasks/09-20-transport-lifecycle/design.md new file mode 100644 index 0000000..469727a --- /dev/null +++ b/.trellis/tasks/09-20-transport-lifecycle/design.md @@ -0,0 +1,206 @@ +# Design — Transport lifecycle hardening + +Technical design for `.trellis/tasks/09-20-transport-lifecycle`. Requirements live in `prd.md`; +this file records boundaries, contracts, decisions, and tradeoffs. Execution ordering lives in +`implement.md`. Line numbers were captured from the 2026-09-20 review; re-verify each at edit time. + +## Scope + +Three coupled changes to the TCP / UDP / SOCKS5 transport lifecycle: + +1. One explicit quiescence boundary per coordinator (R1, R2). +2. UDP session lifecycle as an explicit state model with a teardown reason (R3, R4, R5). +3. Ownership consolidation into composition, removing the `_ownsX` flags (R6), plus the concrete + lifecycle-hole fixes (R7). + +Non-goals: local mux/pooling, new features, config/API change, line-count-only refactors. + +## Verified composition facts + +- `DurableCaptureBundle` creates all four native pools and the single `SetupExecutor` + (`DurableCaptureBundle.cs:113,116,124,186,190`) and disposes them + (`:132-134`, `:144-146`, `:199-200`, `:210-211`, `:365-366`, `:377-379`). +- `SetupExecutor` is a **single instance shared by both coordinators**: created once at + `DurableCaptureBundle.cs:124`, injected into TCP via `TcpRedirectComposer.cs:53` and UDP via + `UdpProxyComposer.cs:65`. `Socks5AddressCache` is likewise shared. +- Therefore no single coordinator can own the executor; composition must be its owner. +- The two composer doc comments claiming "the coordinator owns the pools exactly as it always has" + (`TcpRedirectComposer.cs:16`, `UdpProxyComposer.cs:16`) are **inaccurate** and must be corrected. +- Idle cadence: UDP relay idle threshold = 2 min, sweep interval = 1 min + (`IdleExpirySweeper.cs:49`, `:46`); TCP redirect idle = 5 min, relay = 2 min (`:47-48`). + +## Decision Log + +### D1 — Ownership model: composition owns; coordinators borrow (DECIDED 2026-09-20) + +Chosen **Option A** (user decision). Full rationale in §3. + +### D2 — Quiescence mechanism: ownership-await tree (DECIDED 2026-09-20) + +Chosen **Option A** (user decision). Full rationale in §1. No shared/global task registry; the +await tree already covers accept loops, receive loops, and setup completions. + +### D3 — UDP expiry-vs-in-flight-send: explicit drop (DECIDED 2026-09-20) + +Chosen **Option B** (user decision). On the `_expiring` race, do **not** throw; count + rate-limited +log the drop. Rationale: + +- A flow can only reach `_expiring` after being idle ≥ 2 min (`IdleExpirySweeper.cs:49`), so the + losing datagram belongs to an already-dying flow. +- The race window is the purely synchronous gap between `_expiring = true` + (`UdpProxySession.cs:173`) and slot removal (`UdpProxyCoordinator.cs:385`) — sub-microsecond. +- Estimated incremental loss: `δ / T_sweep ≈ 1e-6 / 60 ≈ 1.7e-8` per idle→resume event; expected 0, + bounded ~1 datagram per event. Once the slot is gone, resumed traffic re-setups and is delivered. +- Option A (re-buffer into a replacement setup) was rejected: it re-dials SOCKS5 for a straggler of + a just-expired idle flow and requires a synchronous copy of the native span. + +## Design + +### 1. Quiescence boundary (R1, R2) — ownership-await tree + +**1.1 TCP relay pump gets an owner.** `TcpProxyRelay.DisposeAsync` (`TcpProxyRelay.cs:302-311`) +becomes: idempotent `Interlocked` guard → dispose `_localSocket` → `await _control.DisposeAsync()` → +`try { await Completion } catch (Exception) { }`. Disposal closes the socket/upstream, which +terminates the pumps; awaiting `Completion` makes the boundary real. The fault disposal manufactures +is already logged by `TcpRelayFaultObserver`, so the await swallows it. New contract: after +`TcpProxyRelay.DisposeAsync` completes, no pump task is running and `Completion` is completed. + +**1.2 Fault observer per discard path (corrected during Phase B).** The two +`TcpRelayFaultObserver.Observe` call sites — `TcpProxyRelay.DisposeAsync` and +`TcpRedirectAcceptor`'s attach-failure branch — are **distinct discard paths, not duplicates**: +the acceptor may discard an arbitrary `ITcpRelay` whose `DisposeAsync` need not observe. Keep +exactly one registration per discard path (verified by `TcpRelayObservationTests` / +`AttachFailureObservesFaultedRelayCompletion`). `ContinueWith`-based registration is not +idempotent, so no single path may register twice. Preserve the documented .NET gotcha: the +observer must read `task.Exception` before any `IsEnabled` logger gate (attaching an +`OnlyOnFaulted` continuation does not mark the exception observed). + +**1.3 Acceptor end-handling gets an owner.** Today +`TcpRedirectAcceptor.RunAcceptLoopAsync` (`TcpRedirectAcceptor.cs:18`) does +`_ = ObserveRelayCompletionAsync(...)` (`:84`) then a terminal `DrainRedundantConnectionsAsync` +(`:85`). Change it to **await** both as its terminal steps (no discard). Then `session.AcceptLoop` +spans the whole session lifetime, so the store's existing `await session.AcceptLoop` +(`TcpRedirectSessionStore.cs:191`) becomes a true quiescence wait and no callback can re-enter the +store after disposal. Teardown stays idempotent via `TryRetireSessionUnderGate`'s `ReferenceEquals` +guard (`TcpRedirectSessionStore.cs:209-213`). + +**1.4 Store lifetime-disposal ordering.** `ReleaseRetiredAsync` must not `DisposeLifetime()` +(`TcpRedirectSessionStore.cs:249`) while the accept loop can still read `session.Token` +(`ClientResetInjector.cs:36`, `TcpRedirectAcceptor.cs:72`). `Retire()` already cancels the lifetime +(`TcpRedirectSession.cs:28`); defer only the CTS **dispose** to `DisposeCoreAsync` after +`await session.AcceptLoop`. Add an `IsDisposed` gate so any late teardown entry no-ops. + +**1.5 UDP per-session lifetime token (enabler for R3).** `UdpProxySession` gains +`CancellationTokenSource _lifetime = CreateLinkedTokenSource(context.Shutdown)`, mirroring +`TcpRedirectSession.Lifetime` (`TcpRedirectSession.cs:19`). The receive loop (`UdpProxySession.cs:210`) +and injections (`:268`) use `_lifetime.Token`; `BeginExpiry` cancels it; `DisposeAsync` cancels and +disposes it. Catch guards become `when (_lifetime.IsCancellationRequested)` → normal teardown +(expiry *or* shutdown); the generic catch sets `_receiveFailure` only for genuine faults. This +removes the spurious `_receiveFailure` on idle expiry (today the ODE guard at `:242` depends on +`_shutdown`, which is not cancelled during expiry). + +**1.6 Residual UDP receive-failure handler.** Today `_ = receiveFailureHandler(this)` +(`UdpProxySession.cs:259`) is unobserved. After 1.5 it fires only on genuine faults. Give it an owner +in the coordinator: the UDP coordinator tracks in-flight slot-removal tasks in a per-coordinator set +and awaits that set in `DisposeCoreAsync`. This is the per-coordinator fallback D2 permits; never a +global registry. The loop itself must keep **not** awaiting the handler (that would re-enter session +disposal, per `UdpProxySession.cs:257-258`). + +### 2. UDP session state model + teardown reason (R3, R4, R5) + +**2.1 Explicit states.** Document and expose the state vocabulary: +- Slot level (under coordinator `_gate`): `SettingUp` (`slot.Session == null`) → `Ready` + (`slot.Ready == true`, session attached). +- Session level (under `_activityGate`): `Active` → `Expiring` → `Faulted` / `Disposed`. +Introduce `enum UdpSessionState { SettingUp, Active, Expiring, Faulted, Disposed }` and a `State` +accessor computed under the owning lock, with the transition rules documented in one place. The +existing fields (`Ready`, `_expiring`, `_receiveFailure`, `_lifetime`) remain the implementation; +the enum makes the model explicit without a risky rewrite. + +**2.2 Teardown reason as data.** Replace `RemoveSlotAsync`'s `armCooldown` bool +(`UdpProxyCoordinator.cs:377`) with `enum UdpTeardownReason { SetupFailure, Expiry, Fault, Shutdown }`. +`SetupFailure` arms the cooldown (`:397-400`); the rest do not. Logging and metrics key off the +reason instead of inferring from `OperationCanceledException`. + +**2.3 Send returns sent/not-sent instead of throwing (R4).** `UdpProxySession.SendSpanAsync` +(`UdpProxySession.cs:116`) changes to `ValueTask`: `true` = sent; `false` = not sent because +`_expiring` (`:122`) or `_receiveFailure` (`:121`). No `IOException` for these precondition cases. +`UdpProxyCoordinator.SendOnReadySessionSpanAsync` (`UdpProxyCoordinator.Send.cs:82`) treats `false` as +a counted, rate-limited drop and does **not** remove the slot (Expiry → the sweeper owns removal; +Faulted → the failure handler owns removal). A genuine `_transport.SendSpanAsync` exception keeps the +existing remove-slot + rethrow path (`:95`, `:130-134`). + +**2.4 Per-setup cancellation (R5).** TCP: assign `item._cancellationToken = _store.ShutdownToken` in +`LaunchSetup` (`TcpProxyCoordinator.cs:224-229`) so `TrySetCanceled(item._cancellationToken)` +(`:232`) carries a meaningful token (parity with `UdpProxyCoordinator.cs:125`). UDP: setup keeps +`_shutdown`; session cancellation is now the per-session `_lifetime` (1.5). This distinguishes +shutdown, per-setup, and per-session cancellation. + +### 3. Ownership consolidation (R6) + +Coordinators take **required non-null** pools/executor. Delete `_ownsSynCopyPool` / +`_ownsSetupExecutor` (`TcpProxyCoordinator.cs:67-69`) and `_ownsSetupQueuePool` / +`_ownsReceiveWindowPool` / `_ownsSetupExecutor` (`UdpProxyCoordinator.cs:64-69`), their self-creation +branches, and the creation-failure pool disposal (the bundle already owns those paths: +`DurableCaptureBundle.cs:132-134,144-146,199-200,210-211,365-366,377-379`). New `DisposeAsync` +contract: *quiesce own background work and dispose only internally-constructed state; never dispose +injected collaborators.* Add a `_disposed` guard to `TcpProxyCoordinator` (absent today: +`TcpProxyCoordinator.cs:636-650`; the store is already single-flight). Fix the two misleading +composer comments. + +### 4. Concrete lifecycle-hole fixes (R7) + +- **Attach-failure leaves a half-open session** (`TcpRedirectAcceptor.cs:73-82`): on + `tryAttachRelay` false, close the accepted connection and call `tearDownSession(session)`; move the + relay/accepted disposal outside the outer `try` so a throw there does not fall into + `HandleRelaySetupFailureAsync` against a retired/attached session. +- **`RegisterSession` partial failure** (`TcpRedirectSetup.cs:167-169`): wrap self-traffic-token + registration + session construction so a throw releases the already-claimed listener/alias via + `store.ReleaseAssociationAsync`. +- **Cooldown re-written after `RemoveAll`** (`TcpProxyCoordinator.cs:304` vs `:307`, `:647`): run + `_pendingSyn.Complete(..., writeCooldown, ...)` before `ExitSetup()` unblocks the drain, or gate + cooldown writes on `!store.IsDisposed`. +- **Late `Token` read** is covered structurally by 1.4. + +## Interfaces / signatures changed + +| Member | Change | +|---|---| +| `TcpProxyRelay.DisposeAsync` | awaits `Completion` (behavioral) | +| `UdpProxySession.SendSpanAsync` | `ValueTask` → `ValueTask` | +| `UdpProxyCoordinator.RemoveSlotAsync` | `bool armCooldown` → `UdpTeardownReason reason` | +| new `enum UdpTeardownReason`, `enum UdpSessionState` | added (internal) | +| `TcpProxyCoordinator.LaunchSetup` | sets `item._cancellationToken` | +| coordinator ctors | mandatory non-null pool/executor deps | +| `TcpRedirectComposer.cs:16`, `UdpProxyComposer.cs:16` | comment correction | + +## Compatibility + +- No public API / config / schema change; `ITcpRelay` keeps its shape. +- Hot path: no new allocation (bool return is a struct; the relay await runs only on the cold + teardown path). Existing zero-allocation gates and batching contracts must stay green. +- File-size and structure rules unchanged (≤400 effective lines, interface beside implementation). + +## Test strategy + +- **Relay quiescence**: disposing a relay against a real loopback socket leaves `Completion` + completed and no pump task running. +- **Fault observer**: registered exactly once per relay (assert single observation). +- **Store quiescence**: after `DisposeAsync`, no callback re-enters teardown (tombstone/reset + counters frozen). +- **UDP expiry**: idle-expiry records no `_receiveFailure` and fires no handler; the loop ends via + `_lifetime` cancellation (normal path). +- **UDP send race**: expiry-vs-send returns `false` (counted drop) with no exception reaching the + dispatcher. +- **Cancellation**: TCP setup item token equals the store shutdown token. +- **Ownership**: a fake pool/executor with a dispose counter proves coordinator `DisposeAsync` does + not dispose injected collaborators; repeated `DisposeAsync` is a no-op. +- **Attach-failure**: store session count returns to 0. +- **Regression**: full Release test suite green; format + inspectcode gates clean. + +## Rollback shape + +Each phase in `implement.md` is one commit and independently revertible. Exception: the §3 constructor +change ripples into tests, so it lands before the phases that add new tests. If the UDP send-result +change (§2.3) proves too invasive, it can be dropped while keeping §1.5 (which alone removes the +false-failure path). diff --git a/.trellis/tasks/09-20-transport-lifecycle/implement.jsonl b/.trellis/tasks/09-20-transport-lifecycle/implement.jsonl new file mode 100644 index 0000000..a3313f8 --- /dev/null +++ b/.trellis/tasks/09-20-transport-lifecycle/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/backend/hot-path.md", "reason": "Allocation/posture contracts the lifecycle changes must not disturb: the UdpProxySession.SendSpanAsync signature change (§2.3) and the capture-path dispatch are hot-path-adjacent; zero-alloc gates and batching contracts must stay green."} +{"file": ".trellis/spec/backend/udp-relay.md", "reason": "UDP coordinator/session contracts (slot ownership, setup queue budget, cooldown, idle expiry, receive window, teardown) that §1.5/§1.6/§2 (per-session lifetime token, teardown reason, send result) must preserve exactly."} +{"file": ".trellis/spec/backend/tcp-local-redirect.md", "reason": "TCP coordinator/session/pending-SYN contracts (store single gate, retire/tombstone, acceptor/relay lifecycle, SYN setup pipeline) that §1.1-§1.4 and §4 touch."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Fail-closed and resource-release-in-acquisition-order rules; the new send-result path (§2.3) must not weaken fail-closed behavior, and the hole fixes (§4) must release exactly once."} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "File/type organization this task obeys: ≤400 effective lines, filename = main type, interface beside implementation, deep-module split discipline (behavior-zero moves) for the new enums/drain type."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Binding quality gate and suppression policy: format/build/test/inspectcode must be clean; any analyzer suppression must be narrowed with a documented reason."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Rate-limited drop/warn logging vocabulary the new explicit-drop path (§2.3) and teardown-reason logging (§2.2) must follow."} +{"file": ".trellis/spec/backend/traffic-policy-lifecycle.md", "reason": "Flow/decision lifetime and idle-expiry semantics shared with the sweeper, so the UDP/TCP session lifecycle changes stay consistent with the policy lifecycle."} diff --git a/.trellis/tasks/09-20-transport-lifecycle/implement.md b/.trellis/tasks/09-20-transport-lifecycle/implement.md new file mode 100644 index 0000000..61ea1de --- /dev/null +++ b/.trellis/tasks/09-20-transport-lifecycle/implement.md @@ -0,0 +1,120 @@ +# Implement — Transport lifecycle hardening + +Execution plan for `.trellis/tasks/09-20-transport-lifecycle`. Requirements: `prd.md`. Design and +decisions (D1–D3): `design.md`. All line numbers below were captured during the 2026-09-20 review — +**re-read each site before editing** (they drift). + +## Conventions + +- One commit per phase; each phase is independently revertible. +- Behavior-preserving moves where possible; no line-count-only churn. +- Run the per-phase validation after every phase; run the full gate once in Phase E. +- Never suppress a diagnostic globally. Narrow `#pragma` / `.editorconfig` with a reason only. + +## Validation commands + +```bash +# Per-phase (fast feedback) +dotnet build WinForward.slnx -c Release +dotnet test WinForward.slnx -c Release + +# Phase E / pre-commit (full gate — do not pipe; piping hides the exit code) +dotnet format WinForward.slnx --severity info --verify-no-changes --no-restore +jb inspectcode -f=Xml -e=HINT -o=/tmp/jb-inspectcode.xml WinForward.slnx # parse XML: zero +``` + +## Phase A — Ownership consolidation (R6 / design §3) + +Rationale for going first: it changes coordinator constructor shapes and ripples into test +construction sites, so land it before phases that add tests. + +- [ ] A1. `TcpProxyCoordinator`: make `SynCopyPool` / `SetupExecutor` required, non-null; delete the + `_ownsSynCopyPool` / `_ownsSetupExecutor` flags and the self-creation branches + (`TcpProxyCoordinator.cs:67-69`); make `DisposeAsyncCore` never dispose injected collaborators. +- [ ] A2. Add a `_disposed` guard to `TcpProxyCoordinator.DisposeAsync` (`TcpProxyCoordinator.cs:636-650`) + so repeated disposal is a no-op. +- [ ] A3. `UdpProxyCoordinator`: same for `SetupQueuePool` / `ReceiveWindowPool` / `SetupExecutor`, + deleting `_ownsSetupQueuePool` / `_ownsReceiveWindowPool` / `_ownsSetupExecutor` + (`UdpProxyCoordinator.cs:64-69`). +- [ ] A4. Move any creation-failure pool disposal reliance to the bundle (verify + `DurableCaptureBundle.cs:132-134,144-146,199-200,210-211,365-366,377-379` still cover it). +- [ ] A5. Correct the composer doc comments (`TcpRedirectComposer.cs:16`, `UdpProxyComposer.cs:16`). +- [ ] A6. Update test construction sites (`InternalsVisibleTo` already configured) to pass pools/executor. +- [ ] A7. Test: fake pool/executor with a dispose counter — coordinator `DisposeAsync` leaves the + counter at 0; second `DisposeAsync` is a no-op. +- [ ] Gate: build + tests green. + +Rollback point: revert Phase A commit (test construction sites revert with it). + +## Phase B — TCP quiescence (R1, R2 / design §1.1–1.4) + +- [ ] B1. `TcpProxyRelay.DisposeAsync` (`TcpProxyRelay.cs:302-311`): after disposing socket/control, + `try { await Completion } catch { /* observed by TcpRelayFaultObserver */ }`. +- [ ] B2. Remove the duplicate fault-observer registration; keep exactly one site (design §1.2). +- [ ] B3. `TcpRedirectAcceptor.RunAcceptLoopAsync` (`TcpRedirectAcceptor.cs:18`): `await` + `ObserveRelayCompletionAsync` and `DrainRedundantConnectionsAsync` as terminal steps; drop the + `_ =` discards (`:84`). +- [ ] B4. Defer `DisposeLifetime()` (`TcpRedirectSessionStore.cs:249`) to after + `await session.AcceptLoop` in `DisposeCoreAsync` (`:191`); add the `IsDisposed` teardown gate. +- [ ] B5. Tests: relay dispose leaves `Completion` completed; single fault observation; after store + `DisposeAsync` no teardown re-entry (frozen tombstone/reset counters). +- [ ] Gate: build + tests green. + +Rollback point: revert Phase B commit. + +## Phase C — UDP lifecycle (R3, R4, R5 / design §1.5, §2) + +- [ ] C1. Add `UdpProxySession._lifetime` (linked to `context.Shutdown`); route the receive loop + (`UdpProxySession.cs:210`) and injections (`:268`) through it; cancel on `BeginExpiry` and in + `DisposeAsync`; switch catch guards to `when (_lifetime.IsCancellationRequested)`. +- [ ] C2. Add `enum UdpSessionState` + explicit transition documentation (design §2.1). +- [ ] C3. Add `enum UdpTeardownReason`; change `RemoveSlotAsync` (`UdpProxyCoordinator.cs:377`) to take + the reason; arm cooldown only for `SetupFailure`. +- [ ] C4. `UdpProxySession.SendSpanAsync` → `ValueTask`; `SendOnReadySessionSpanAsync` + (`UdpProxyCoordinator.Send.cs:82`) counts/logs the `false` drop without removing the slot. +- [ ] C5. Owning drain for the residual receive-failure handler (design §1.6): coordinator tracks + in-flight removal tasks and awaits them in `DisposeCoreAsync`. +- [ ] C6. TCP: set `item._cancellationToken = _store.ShutdownToken` in `LaunchSetup` + (`TcpProxyCoordinator.cs:224-229`). +- [ ] C7. Tests: idle expiry records no `_receiveFailure`; expiry-vs-send returns false with no + exception; setup token equals store shutdown token. +- [ ] Gate: build + tests green. + +Rollback point: revert Phase C commit. If §2.3 (C4) proves too invasive, it can be dropped +independently while keeping C1. + +## Phase D — Lifecycle holes (R7 / design §4) + +- [ ] D1. Attach-failure: tear down the session and restructure the disposal block + (`TcpRedirectAcceptor.cs:73-82`). +- [ ] D2. `RegisterSession` (`TcpRedirectSetup.cs:167-169`): release the claimed listener/alias on a + registration/construction throw. +- [ ] D3. Fix the cooldown-after-`RemoveAll` ordering (`TcpProxyCoordinator.cs:304` vs `:307`, `:647`). +- [ ] D4. Tests: attach-failure returns store session count to 0; setup-failure leak guarded. +- [ ] Gate: build + tests green. + +Rollback point: revert Phase D commit. + +## Phase E — Full quality gate + +- [ ] E1. `dotnet format WinForward.slnx --severity info --verify-no-changes --no-restore` — exit 0, + empty output. +- [ ] E2. `dotnet build WinForward.slnx -c Release` — zero warnings. +- [ ] E3. `dotnet test WinForward.slnx -c Release` — green (baseline + new tests). +- [ ] E4. `jb inspectcode -f=Xml -e=HINT -o=/tmp/jb-inspectcode.xml WinForward.slnx` — parse the XML + and confirm **zero** `` entries. +- [ ] E5. Dispatch `trellis-check` for the spec-compliance + cross-layer review. + +## Review gates + +- After each phase: build + tests green (A/B/C/D). +- After Phase D: full gate (E1–E4) + `trellis-check` (E5) before any commit. +- Do not start implementation before the user reviews this plan and `task.py start` is run. + +## Risks / notes + +- B3 changes the accept loop's terminal semantics; confirm nothing relies on `AcceptLoop` completing + early. +- C5 must not make the receive loop await the handler (re-entrancy — see `UdpProxySession.cs:257-258`). +- C4 changes an internal signature used by the capture hot path; verify no allocation is added and + the zero-alloc gates stay green. diff --git a/.trellis/tasks/09-20-transport-lifecycle/prd.md b/.trellis/tasks/09-20-transport-lifecycle/prd.md new file mode 100644 index 0000000..bdac49a --- /dev/null +++ b/.trellis/tasks/09-20-transport-lifecycle/prd.md @@ -0,0 +1,178 @@ +# Transport lifecycle hardening: explicit quiescence boundary and session state + +## Goal + +Harden the transport lifecycle across three subsystems — TCP relay, UDP session, and the shared +SOCKS5 control/relay transport — so that teardown is explicit and bounded rather than resting on +undocumented ordering contracts. Concretely: (1) give each coordinator one quiescence boundary +that tracks and awaits every background task before `DisposeAsync` completes; (2) model the UDP +session lifecycle as an explicit state machine with a teardown *reason* instead of implicit flag +fields; (3) consolidate pool/executor ownership into composition and remove the `_ownsX` boolean +flags; and (4) close the concrete lifecycle holes found in the 2026-09-20 review. + +This is a child of `08-30-proxy-perf-stability`: it is a stability/robustness hardening item, not a +performance feature. It carries no new user-visible behavior and no config/schema changes. + +## Source Material + +- 2026-09-20 transport-lifecycle review (this session): full read of + `src/WinForward.Runtime/TcpRedirect/*`, `src/WinForward.Runtime/UdpProxy/*`, + `src/WinForward.Runtime/Socks5/Socks5ControlConnection.cs`, + `src/WinForward.Runtime/Socks5/Socks5UdpTransport.cs`, plus the composition in + `src/WinForward.Cli/DurableCaptureBundle.cs`. +- Existing contracts to preserve: `.trellis/spec/backend/hot-path.md`, + `.trellis/spec/backend/quality-guidelines.md`, + `.trellis/spec/backend/tcp-local-redirect.md`, `.trellis/spec/backend/udp-relay.md`, + `.trellis/spec/backend/directory-structure.md`. + +## Requirements + +### R1 — One explicit quiescence boundary per coordinator (primary) + +Every background task started by the capture/TCP/UDP runtime must be registered and awaited before +its owner's `DisposeAsync` completes. No fire-and-forget task may outlive the store/coordinator it +calls back into. Required coverage includes: + +- TCP relay pump (`TcpProxyRelay.Completion`). +- Accept loops (`TcpRedirectAcceptor.RunAcceptLoopAsync`). +- Relay-completion observers (`ObserveRelayCompletionAsync`) and fault observers. +- UDP receive loops and the fire-and-forget setup/receive-failure handlers. +- Deferred dispose work (`RunDisposeAsync`). + +Today these are `_ =`-discarded or unawaited; disposal can therefore return while callbacks are +still able to re-enter already-disposed state. + +### R2 — Relay teardown awaits pump completion; fault observer registered once + +`TcpProxyRelay.DisposeAsync` must await `Completion` (or the store's teardown must), so an observed +relay end cannot invoke client reset / session teardown after the store is disposed. The relay fault +observer must be attached exactly once (currently attached from both `TcpProxyRelay.cs:308` and +`TcpRedirectAcceptor.cs:78`). + +### R3 — UDP session lifecycle is an explicit state machine with a teardown reason + +The per-slot implicit state (`Session` / `Ready` / `_expiring` / `_activeSends` / `_receiveFailure`) +must become an explicit, documented state model. A normal idle-expiry teardown must NOT: + +- route through the fault catch and set `_receiveFailure`, +- fire an unobserved fire-and-forget handler, or +- depend on `_shutdown.IsCancellationRequested` to distinguish "normal" from "fault". + +Teardown *reason* (Normal / Expiry / Fault / Shutdown / Capacity) must be carried as data, not +inferred from cancellation flags. + +### R4 — Expiry vs in-flight send must not drop the datagram or surface an internal exception + +When expiry races a concurrent send, the caller must not receive an `IOException` naming internal +state, and the datagram must not be silently dropped without either delivery or an explicit, +documented policy (e.g. re-buffering into the replacement setup). Current behavior surfaces +`IOException("SOCKS5 UDP relay session is expiring.")` up to the packet dispatcher. + +### R5 — Cancellation semantics stop overloading a single token + +Coordinator shutdown cancellation, per-session receive cancellation, and per-setup cancellation must +be distinguishable. Remove or correctly wire the dead per-item cancellation token on the TCP setup +work item (currently always `CancellationToken.None`). + +### R6 — Ownership consolidated into composition; no `_ownsX` flags + +Pool/executor ownership must have exactly one owner. Either composition owns them and coordinators +never dispose them, or coordinators own everything they use — one choice, applied consistently, with +the `_ownsSynCopyPool` / `_ownsSetupExecutor` / `_ownsSetupQueuePool` / `_ownsReceiveWindowPool` +flags removed. `TcpProxyCoordinator` must gain its own disposed guard so repeated `DisposeAsync` is +a no-op rather than re-running teardown and re-disposing injected resources. + +### R7 — Close the concrete lifecycle holes + +- Attach-failure path must tear the session down instead of leaving a half-open `Redirecting` + session until the idle sweep. +- No code path may read a session's `Token` after its lifetime CTS is disposed. +- `RegisterSession` must release an already-claimed listener/alias when self-traffic registration or + session construction throws. +- The setup-complete vs `RemoveAll()` ordering must not re-write a cooldown entry after cooldown + state has been cleared at shutdown. + +### R8 — No hot-path regression + +These changes must be behavior-preserving on the packet data path. The existing zero-allocation +gates and per-iteration batching contracts must remain green; no allocation, delegate, or lock may +be added to a steady-state packet path. + +## Constraints + +- **Performance-first**: packet-path code stays allocation-free; no LINQ/delegate conversions on + hot paths (repo-wide rule). Any task-tracking added to hot paths must be allocation-free (e.g. + pre-sized arrays / pooled registration), not a per-packet allocation. +- **Compatibility**: no config-schema, CLI, or public-API changes; no new user-visible behavior. +- **Quality gates**: `dotnet format WinForward.slnx --severity info --verify-no-changes --no-restore` + clean, `dotnet build -c Release` zero-warning, `dotnet test -c Release` green, and + `jb inspectcode -e=HINT` report with zero `` entries. +- **Structure**: keep every `.cs` file within the ≤400 effective-line rule; new seams follow the + established "interface beside its implementation" and "physical move, logic untouched" discipline. +- **Platform**: Windows-only runtime paths stay guarded by injectable seams so they remain testable + on the Linux CI host. + +## Acceptance Criteria + +- [ ] A test proves that disposing the TCP coordinator/store joins all relay and accept-loop tasks: + after dispose returns, no callback re-enters store teardown (no reset injection / tombstone + write after dispose), asserted with a real relay-socket loopback scenario or a tracked-task + fake. +- [ ] `TcpProxyRelay.DisposeAsync` awaits `Completion`; a regression test pins that the pump task is + completed when dispose returns. +- [ ] The relay fault observer is registered exactly once per relay; a test asserts single + observation. +- [ ] An idle-expiry teardown of a UDP session leaves no `_receiveFailure` recorded, fires no + failure handler, and is indistinguishable in tests from a clean teardown. +- [ ] A concurrent "expiry vs send" test shows the datagram is not dropped against policy: it is + either delivered or buffered into the replacement setup, and no internal-state `IOException` + reaches the dispatcher. +- [ ] Repeated `TcpProxyCoordinator.DisposeAsync` is idempotent (no double-dispose of injected + pool/executor), verified by a test. +- [ ] The `_ownsX` flags no longer exist; ownership is enforced by a single composition path, with a + test or composition assertion covering the owned-vs-injected matrix. +- [ ] Attach-failure leaves no session in the store (asserted by inspecting store session count after + the failure path). +- [ ] All existing tests stay green; the Release build is zero-warning; the format and inspectcode + gates are clean. + +## Notes + +### Review findings (evidence, per requirement) + +- R1: `TcpRedirectSessionStore.cs:156` (`_ = RunDisposeAsync`), + `TcpRedirectAcceptor.cs:84` (`_ = ObserveRelayCompletionAsync`), + `TcpProxyRelay.cs:284,289` (`_ = ShutdownSend`, pump continuation), + `TcpRelayFaultObserver.cs:27` (`ContinueWith`), `UdpProxySession.cs:259` + (`_ = receiveFailureHandler(this)`). +- R2: `TcpProxyRelay.cs:302-311` (dispose does not touch `Completion`; pump started at + `TcpProxyRelay.cs:126`); double observe at `TcpProxyRelay.cs:308` + `TcpRedirectAcceptor.cs:78`. +- R3: `UdpProxySession.cs:185-188` (dispose transport then await loop), `:242` (ODE guard depends on + `_shutdown`), `:246-259` (generic catch → `_receiveFailure` → fire-and-forget); + `UdpProxyCoordinator.cs:427-433` (slot as four bare fields). +- R4: `UdpProxySession.cs:122` (throws `IOException` when `_expiring`), surfaced at + `NdisPacketActionExecutor.cs:461`; race via `TryBeginExpiry` at `UdpProxySession.cs:170-175` and + slot removal at `UdpProxyCoordinator.cs:354`. +- R5: `TcpProxyCoordinator.cs:224-232` (`item._cancellationToken` never assigned; `TrySetCanceled` + passes `None`). +- R6: `TcpProxyCoordinator.cs:67-69` + `:636-650` (no disposed guard), + `UdpProxyCoordinator.cs:64-69`, production disposal at `DurableCaptureBundle.cs:347-384`. +- R7: `TcpRedirectAcceptor.cs:73-82` (attach-failure leaves session), + `TcpRedirectSessionStore.cs:249` vs `ClientResetInjector.cs:36` (late `Token` read), + `TcpRedirectSetup.cs:167-169` (register-then-construct with no cleanup), + `TcpProxyCoordinator.cs:304` vs `:307` + `:647` (cooldown re-written after `RemoveAll`). +- Lock discipline is already sound (store gate never held across await; documented acyclic order + store→table→tombstone at `TcpRedirectSessionStore.cs:22-25`; UDP `_gate` likewise) and must not + regress. + +### Explicitly out of scope + +- Local connection pool + stream multiplexing (`09-06-local-mux-transport`). +- Any new feature, config knob, or schema change. +- Line-count-only refactors of files that already meet the ≤400 rule. + +### Open questions (resolve during planning) + +- Buffer-into-replacement-setup (R4) vs a documented drop policy — which is acceptable? +- Single background-task tracker shared across subsystems vs one per coordinator? +- Does R6 pick "composition owns" (current production shape) or "coordinator owns"? diff --git a/.trellis/tasks/09-20-transport-lifecycle/task.json b/.trellis/tasks/09-20-transport-lifecycle/task.json new file mode 100644 index 0000000..23e7974 --- /dev/null +++ b/.trellis/tasks/09-20-transport-lifecycle/task.json @@ -0,0 +1,26 @@ +{ + "id": "transport-lifecycle", + "name": "transport-lifecycle", + "title": "Transport lifecycle hardening: explicit quiescence boundary and session state", + "description": "Harden TCP relay / UDP session / SOCKS5 transport teardown: give each coordinator one explicit quiescence boundary (track and await every background task on dispose), model UDP session lifecycle as an explicit state machine with a teardown-reason, and consolidate pool/executor ownership into composition (remove _ownsX flags). Findings sourced from the 2026-09-20 transport-lifecycle review (see prd.md).", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "qmazon", + "assignee": "qmazon", + "createdAt": "2026-09-20", + "completedAt": null, + "branch": "feat/transport-lifecycle", + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-30-proxy-perf-stability", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/benchmarks/WinForward.Benchmarks/Perf/UdpSessionBenchmarks.cs b/benchmarks/WinForward.Benchmarks/Perf/UdpSessionBenchmarks.cs index 79811b6..2b87d00 100644 --- a/benchmarks/WinForward.Benchmarks/Perf/UdpSessionBenchmarks.cs +++ b/benchmarks/WinForward.Benchmarks/Perf/UdpSessionBenchmarks.cs @@ -5,6 +5,7 @@ using BenchmarkDotNet.Attributes; using WinForward.Benchmarks.Stability; using WinForward.Configuration; +using WinForward.Core; using WinForward.Protocols; using WinForward.Runtime; using WinForward.Runtime.Socks5; @@ -55,7 +56,11 @@ public async Task CleanupAsync() [Benchmark] public async Task PopulateSessionsAsync() { - await using var coordinator = new UdpProxyCoordinator(new Socks5UdpTransportFactory(new SelfTrafficRegistry(), UdpFrameBuilder.DefaultMaximumEthernetFrame), NoopUdpResponseSink.Instance, new UdpProxyOptions { Capacity = Sessions }); + const int maximumFrameSize = UdpFrameBuilder.DefaultMaximumEthernetFrame; + using var setupQueuePool = new NativeBufferPool(maximumFrameSize); + using var receiveWindowPool = new NativeBufferPool(UdpProxyCoordinator.ReceiveWindowSize(maximumFrameSize)); + using var setupExecutor = new SetupExecutor(); + await using var coordinator = new UdpProxyCoordinator(new Socks5UdpTransportFactory(new SelfTrafficRegistry(), maximumFrameSize), NoopUdpResponseSink.Instance, setupQueuePool, receiveWindowPool, setupExecutor, new UdpProxyOptions { Capacity = Sessions }); var forwardedBaseline = _server.RelayForwarded; for (var index = 0; index < Sessions; index++) { @@ -79,7 +84,11 @@ public async Task PopulateSessionsAsync() public async Task PopulateSessionsNoopTransportAsync() { var factory = new BenchmarkUdpTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, NoopUdpResponseSink.Instance, new UdpProxyOptions { Capacity = Sessions }); + const int maximumFrameSize = UdpFrameBuilder.DefaultMaximumEthernetFrame; + using var setupQueuePool = new NativeBufferPool(maximumFrameSize); + using var receiveWindowPool = new NativeBufferPool(UdpProxyCoordinator.ReceiveWindowSize(maximumFrameSize)); + using var setupExecutor = new SetupExecutor(); + await using var coordinator = new UdpProxyCoordinator(factory, NoopUdpResponseSink.Instance, setupQueuePool, receiveWindowPool, setupExecutor, new UdpProxyOptions { Capacity = Sessions }); var sendsBaseline = factory.Sends; for (var index = 0; index < Sessions; index++) { diff --git a/benchmarks/WinForward.Benchmarks/Stability/GcSoakScenario.cs b/benchmarks/WinForward.Benchmarks/Stability/GcSoakScenario.cs index 6248fb6..eb66bbf 100644 --- a/benchmarks/WinForward.Benchmarks/Stability/GcSoakScenario.cs +++ b/benchmarks/WinForward.Benchmarks/Stability/GcSoakScenario.cs @@ -103,15 +103,17 @@ private static async Task RunCoreAsync( await using var udpServer = new LoopbackSocks5UdpServer(drain.Endpoint); await using var tcpServer = new LoopbackSocks5TcpServer(); var udpSink = new CountingUdpResponseSink(); + using var setupExecutor = new SetupExecutor(); var coordinator = new UdpProxyCoordinator( new Socks5UdpTransportFactory(new SelfTrafficRegistry(), maximumFrameSize), udpSink, + udpSetupPool, + udpWindowPool, + setupExecutor, new UdpProxyOptions { Capacity = Math.Max(options.Flows, 1), MaximumFrameSize = maximumFrameSize, - ReceiveWindowPool = udpWindowPool, - SetupQueuePool = udpSetupPool, }); var udpProxyServer = new Socks5Server("gc-soak", "127.0.0.1", checked((ushort)udpServer.ControlEndpoint.Port), Username: null, Password: null); var tcpProxyServer = new Socks5Server("gc-soak", "127.0.0.1", checked((ushort)tcpServer.Endpoint.Port), Username: null, Password: null); diff --git a/benchmarks/WinForward.Benchmarks/Stability/SessionFootprintScenario.cs b/benchmarks/WinForward.Benchmarks/Stability/SessionFootprintScenario.cs index e9139f9..2f9db33 100644 --- a/benchmarks/WinForward.Benchmarks/Stability/SessionFootprintScenario.cs +++ b/benchmarks/WinForward.Benchmarks/Stability/SessionFootprintScenario.cs @@ -2,6 +2,8 @@ using System.Globalization; using WinForward.Configuration; using WinForward.Core; +using WinForward.Protocols; +using WinForward.Runtime; using WinForward.Runtime.Socks5; using WinForward.Runtime.UdpProxy; @@ -24,7 +26,11 @@ public static async Task RunAsync(StabilityContext context, SoakOptions _) private static async Task RunOneAsync(StabilityContext context, int sessions) { var factory = new CountingTransportFactory(new BenchmarkUdpTransportFactory()); - var coordinator = new UdpProxyCoordinator(factory, NoopUdpResponseSink.Instance, new UdpProxyOptions { Capacity = sessions }); + const int maximumFrameSize = UdpFrameBuilder.DefaultMaximumEthernetFrame; + using var setupQueuePool = new NativeBufferPool(maximumFrameSize); + using var receiveWindowPool = new NativeBufferPool(UdpProxyCoordinator.ReceiveWindowSize(maximumFrameSize)); + using var setupExecutor = new SetupExecutor(); + var coordinator = new UdpProxyCoordinator(factory, NoopUdpResponseSink.Instance, setupQueuePool, receiveWindowPool, setupExecutor, new UdpProxyOptions { Capacity = sessions }); var server = new Socks5Server("benchmark", "127.0.0.1", 1080, Username: null, Password: null); using var process = Process.GetCurrentProcess(); var workingSetBefore = process.WorkingSet64; diff --git a/benchmarks/WinForward.Benchmarks/Stability/UdpBurstScenario.cs b/benchmarks/WinForward.Benchmarks/Stability/UdpBurstScenario.cs index 0ed5a72..1ea6348 100644 --- a/benchmarks/WinForward.Benchmarks/Stability/UdpBurstScenario.cs +++ b/benchmarks/WinForward.Benchmarks/Stability/UdpBurstScenario.cs @@ -53,9 +53,16 @@ public static async Task RunAsync(StabilityContext context, SoakOptions options) // switch's enabled state, flipped by editing the constant for a loss-localization session. var productEvents = CaptureProductEvents ? new CountingRuntimeLogger() : null; // ReSharper restore HeuristicUnreachableCode, CSharpWarnings::CS0162 + const int maximumFrameSize = UdpFrameBuilder.DefaultMaximumEthernetFrame; + using var setupQueuePool = new NativeBufferPool(maximumFrameSize); + using var receiveWindowPool = new NativeBufferPool(UdpProxyCoordinator.ReceiveWindowSize(maximumFrameSize)); + using var setupExecutor = new SetupExecutor(); var coordinator = new UdpProxyCoordinator( - new Socks5UdpTransportFactory(new SelfTrafficRegistry(), UdpFrameBuilder.DefaultMaximumEthernetFrame), + new Socks5UdpTransportFactory(new SelfTrafficRegistry(), maximumFrameSize), sink, + setupQueuePool, + receiveWindowPool, + setupExecutor, new UdpProxyOptions { Capacity = backgroundFlows + burstFlows, diff --git a/benchmarks/WinForward.Benchmarks/Stability/UdpLossScenario.cs b/benchmarks/WinForward.Benchmarks/Stability/UdpLossScenario.cs index 1755d0c..eccb1f0 100644 --- a/benchmarks/WinForward.Benchmarks/Stability/UdpLossScenario.cs +++ b/benchmarks/WinForward.Benchmarks/Stability/UdpLossScenario.cs @@ -41,11 +41,8 @@ public static async Task RunAsync(StabilityContext context, SoakOptions options) // switch's enabled state, flipped by editing the constant for a loss-localization session. var productEvents = CaptureProductEvents ? new CountingRuntimeLogger() : null; // ReSharper restore HeuristicUnreachableCode, CSharpWarnings::CS0162 - var coordinator = new UdpProxyCoordinator(new Socks5UdpTransportFactory(new SelfTrafficRegistry(), UdpFrameBuilder.DefaultMaximumEthernetFrame), sink, new UdpProxyOptions - { - Capacity = options.Flows, - Logger = (IRuntimeLogger?)productEvents ?? NullRuntimeLogger.Instance, - }); + using var scope = new CoordinatorScope(sink, options.Flows, (IRuntimeLogger?)productEvents ?? NullRuntimeLogger.Instance); + var coordinator = scope.Coordinator; SenderStats stats; try { @@ -158,6 +155,41 @@ private static async Task RunWindowAsync(UdpProxyCoordinator coordi private sealed record SenderStats(long SentDatagrams, long SendLoopOverflows, double ElapsedSeconds); + /// + /// Owns the borrowed native pools and setup executor a coordinator requires (Phase A / R6) and + /// disposes them after the coordinator, since a coordinator never disposes its collaborators. + /// + private sealed class CoordinatorScope : IDisposable + { + private readonly NativeBufferPool _setupQueuePool; + private readonly NativeBufferPool _receiveWindowPool; + private readonly SetupExecutor _setupExecutor; + + public CoordinatorScope(IUdpResponseSink sink, int capacity, IRuntimeLogger logger) + { + const int maximumFrameSize = UdpFrameBuilder.DefaultMaximumEthernetFrame; + _setupQueuePool = new NativeBufferPool(maximumFrameSize); + _receiveWindowPool = new NativeBufferPool(UdpProxyCoordinator.ReceiveWindowSize(maximumFrameSize)); + _setupExecutor = new SetupExecutor(); + Coordinator = new UdpProxyCoordinator( + new Socks5UdpTransportFactory(new SelfTrafficRegistry(), maximumFrameSize), + sink, + _setupQueuePool, + _receiveWindowPool, + _setupExecutor, + new UdpProxyOptions { Capacity = capacity, Logger = logger }); + } + + public UdpProxyCoordinator Coordinator { get; } + + public void Dispose() + { + _setupExecutor.Dispose(); + _receiveWindowPool.Dispose(); + _setupQueuePool.Dispose(); + } + } + private sealed class CountingUdpResponseSink : IUdpResponseSink { private long _injected; diff --git a/src/WinForward.Cli/DurableCaptureBundle.cs b/src/WinForward.Cli/DurableCaptureBundle.cs index d8abaff..dfd9149 100644 --- a/src/WinForward.Cli/DurableCaptureBundle.cs +++ b/src/WinForward.Cli/DurableCaptureBundle.cs @@ -109,7 +109,7 @@ internal static async ValueTask CreateAsync( // session gate and the redirect table's bounded capacity (design §4). var redirectTable = new TcpRedirectTable(capacity: configuration.TcpFlowCapacity); // One native pool backs retained SYNs and association reset templates (B1/B2); the - // coordinator owns it when none is injected, so production passes it and disposes it here. + // bundle owns it and the coordinator borrows it, so it is disposed here after teardown. var synCopyPool = new NativeBufferPool(NdisApiAbi.MaximumEthernetFrame); RegisterPool(runtimeCounters, SynCopyPoolName, synCopyPool); // One native pool backs the two per-direction relay pump windows (B11). @@ -181,8 +181,8 @@ private static async ValueTask BuildWithUdpAsync( const int maximumFrameSize = NdisApiAbi.MaximumEthernetFrame; var udpTargets = new UdpAdapterTargetSource(); await UdpProxyComposer.PrimeSocks5AddressCacheAsync(configuration, addressCache, logger).ConfigureAwait(false); - // One native pool backs every queued setup datagram (B4); the coordinator owns it when - // none is injected, so production passes it and disposes it here after release. + // One native pool backs every queued setup datagram (B4); the bundle owns it and the + // coordinator borrows it, so it is disposed here after release. var udpDatagramPool = new NativeBufferPool(maximumFrameSize); RegisterPool(counters, UdpDatagramPoolName, udpDatagramPool); // One native pool backs every session receive window (B11); its size comes from the diff --git a/src/WinForward.Cli/TcpRedirectComposer.cs b/src/WinForward.Cli/TcpRedirectComposer.cs index 8e74ede..c0a703a 100644 --- a/src/WinForward.Cli/TcpRedirectComposer.cs +++ b/src/WinForward.Cli/TcpRedirectComposer.cs @@ -13,7 +13,7 @@ namespace WinForward.Cli; /// The bundle-created collaborators the durable TCP coordinator is wired from (P3): the redirect /// table, the shared native pools and setup executor, and the shared SOCKS5 address cache. /// Creation, registration, rollback, and disposal stay in ; -/// the coordinator owns the pools exactly as it always has. +/// the coordinator borrows the pools and the executor and never disposes them. /// internal sealed record TcpRedirectComposition( TcpRedirectTable RedirectTable, @@ -44,12 +44,12 @@ internal static TcpProxyCoordinator Create( composition.RedirectTable, selfTraffic, new WindowsAdapterLocalAddressProvider(), + composition.SynCopyPool, + composition.SetupExecutor, new TcpRedirectOptions { Logger = logger, Capacity = configuration.TcpFlowCapacity, HealthSignal = healthSignal, - SynCopyPool = composition.SynCopyPool, - SetupExecutor = composition.SetupExecutor, }); } diff --git a/src/WinForward.Cli/UdpProxyComposer.cs b/src/WinForward.Cli/UdpProxyComposer.cs index eda454c..22eec65 100644 --- a/src/WinForward.Cli/UdpProxyComposer.cs +++ b/src/WinForward.Cli/UdpProxyComposer.cs @@ -12,8 +12,8 @@ namespace WinForward.Cli; /// The bundle-created collaborators the durable UDP coordinator is wired from (P3): the /// refreshable reinjection-target snapshot, the pinned frame cap, the shared native pools and /// setup executor, and the shared SOCKS5 address cache. Creation, registration, rollback, and -/// disposal stay in ; the coordinator owns the pools exactly -/// as it always has. +/// disposal stay in ; the coordinator borrows the pools and +/// the executor and never disposes them. /// internal sealed record UdpProxyComposition( UdpAdapterTargetSource Targets, @@ -56,12 +56,12 @@ internal static UdpProxyCoordinator Create( => new( new Socks5UdpTransportFactory(selfTraffic, composition.MaximumFrameSize, composition.AddressCache), new UdpResponseReinjector(reinjector, composition.Targets, maximumFrameSize: composition.MaximumFrameSize, logger: logger, healthSignal: healthSignal), + composition.SetupQueuePool, + composition.ReceiveWindowPool, + composition.SetupExecutor, new UdpProxyOptions { Logger = logger, MaximumFrameSize = composition.MaximumFrameSize, - ReceiveWindowPool = composition.ReceiveWindowPool, - SetupQueuePool = composition.SetupQueuePool, - SetupExecutor = composition.SetupExecutor, }); } diff --git a/src/WinForward.Runtime/TcpRedirect/TcpProxyCoordinator.cs b/src/WinForward.Runtime/TcpRedirect/TcpProxyCoordinator.cs index 4f2ae25..29c46a5 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpProxyCoordinator.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpProxyCoordinator.cs @@ -28,9 +28,7 @@ public sealed class TcpProxyCoordinator : IAsyncDisposable, ITcpReverseHandler private readonly ITcpRedirectInjector _injector; private readonly NdisPacketBufferPool _framePool; private readonly NativeBufferPool _synCopyPool; - private readonly bool _ownsSynCopyPool; private readonly ISetupExecutor _setupExecutor; - private readonly bool _ownsSetupExecutor; private readonly Func _setupHandler; private readonly IRuntimeLogger _logger; private readonly TimeProvider _timeProvider; @@ -39,6 +37,8 @@ public sealed class TcpProxyCoordinator : IAsyncDisposable, ITcpReverseHandler private readonly ClientResetInjector _clientReset; private readonly TcpRedirectAcceptor _acceptor; private readonly TcpPendingSynSetupIndex _pendingSyn = new(); + private readonly Lock _disposeGate = new(); + private Task? _disposeTask; private long _capacityRejectionCount; private long _reportedCapacityRejectionCount; @@ -49,6 +49,8 @@ public TcpProxyCoordinator( TcpRedirectTable table, SelfTrafficRegistry selfTraffic, IAdapterLocalAddressProvider localAddresses, + NativeBufferPool synCopyPool, + ISetupExecutor setupExecutor, TcpRedirectOptions? options = null) { ArgumentNullException.ThrowIfNull(listenerFactory); @@ -57,16 +59,16 @@ public TcpProxyCoordinator( ArgumentNullException.ThrowIfNull(table); ArgumentNullException.ThrowIfNull(selfTraffic); ArgumentNullException.ThrowIfNull(localAddresses); + ArgumentNullException.ThrowIfNull(synCopyPool); + ArgumentNullException.ThrowIfNull(setupExecutor); options ??= new TcpRedirectOptions(); var capacity = options.Capacity ?? 16_384; if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(options), capacity, "Capacity must be positive."); Table = table; _injector = injector; _framePool = NdisPacketBufferPool.Shared; - _synCopyPool = options.SynCopyPool ?? new NativeBufferPool(NdisApiAbi.MaximumEthernetFrame); - _ownsSynCopyPool = options.SynCopyPool is null; - _setupExecutor = options.SetupExecutor ?? new SetupExecutor(); - _ownsSetupExecutor = options.SetupExecutor is null; + _synCopyPool = synCopyPool; + _setupExecutor = setupExecutor; _setupHandler = SetupPendingAsync; _logger = options.Logger ?? NullRuntimeLogger.Instance; Capacity = capacity; @@ -227,6 +229,7 @@ private bool LaunchSetup(FlowKey key, PendingSynSetup entry, byte[] frame, Socks item._server = server; item._tcp._entry = entry; item._tcp._frame = frame; + item._cancellationToken = _store.ShutdownToken; if (!_setupExecutor.TryEnqueue(item)) { entry.SetupCompletionSource.TrySetCanceled(item._cancellationToken); @@ -253,7 +256,6 @@ private async Task SetupPendingAsync(SetupWorkItem item) var entry = item._tcp._entry!; var frame = item._tcp._frame!; var server = item._server!; - var writeCooldown = false; try { _store.EnterSetup(); @@ -266,6 +268,27 @@ private async Task SetupPendingAsync(SetupWorkItem item) return; } + try + { + var writeCooldown = await RunSetupPipelineAsync(entry, frame, server).ConfigureAwait(false); + // The entry removal and its cooldown write must complete before ExitSetup unblocks + // the store's dispose drain: the coordinator's post-drain RemoveAll clears the index, + // so a write racing the drain would otherwise re-arm a cooldown on a disposed index + // (D3). + _pendingSyn.Complete(key, entry, writeCooldown, _timeProvider.GetUtcNow()); + } + finally + { + _store.ExitSetup(); + } + } + + /// + /// The setup body of ; returns whether a genuine failure (not + /// shutdown cancellation) should arm the per-flow setup cooldown. + /// + private async ValueTask RunSetupPipelineAsync(PendingSynSetup entry, byte[] frame, Socks5Server server) + { try { var packet = new CapturedFlowPacket(new PacketLease(frame), entry.Context, entry.Metadata, entry.PacketSequence, entry.FlowGeneration); @@ -274,37 +297,33 @@ private async Task SetupPendingAsync(SetupWorkItem item) { // Fail-closed null return: the pipeline already logged, released its listener // and alias, and wrote the grace tombstone where one applies. - writeCooldown = !_store.ShutdownToken.IsCancellationRequested; + return !_store.ShutdownToken.IsCancellationRequested; } - else if (setup.Session is null) + + if (setup.Session is null) { // A concurrent caller claimed this flow first; the redundant listener was already // released. Re-inject the retained copy against the existing association without // creating a new session. await ReinjectExistingFlowDataAsync(packet, setup.Association, _store.ShutdownToken).ConfigureAwait(false); + return false; } - else - { - setup.Session.AcceptLoop = _acceptor.RunAcceptLoopAsync(setup.Session); - TcpRedirectLogging.LogDebug(_logger, "tcp.redirect.created", setup.Session, "created"); - } + + setup.Session.AcceptLoop = _acceptor.RunAcceptLoopAsync(setup.Session); + TcpRedirectLogging.LogDebug(_logger, "tcp.redirect.created", setup.Session, "created"); + return false; } catch (OperationCanceledException) { // Shutdown cancellation: the store's teardown released whatever the pipeline had // acquired; no cooldown (mirrors the UDP setup contract). + return false; } catch (Exception exception) { _logger.Warn($"TCP redirect setup failed: {exception.GetType().Name}: {exception.Message}"); - writeCooldown = !_store.ShutdownToken.IsCancellationRequested; + return !_store.ShutdownToken.IsCancellationRequested; } - finally - { - _store.ExitSetup(); - } - - _pendingSyn.Complete(key, entry, writeCooldown, _timeProvider.GetUtcNow()); } #pragma warning disable RCS1229 // Deliberate non-async warm entry (hot-path.md #3): the per-packet path must not pay an async state machine; synchronous failures before the returned ValueTask are part of the warm contract (cold tails live in async helpers). @@ -634,18 +653,23 @@ internal bool HoldsFlow(FlowKey key) internal TcpResetCooldownTable CapacityResetCooldowns => _clientReset.CapacityResets; public ValueTask DisposeAsync() - => DisposeAsyncCore(); + { + lock (_disposeGate) + { + _disposeTask ??= DisposeCoreAsync(); + return new ValueTask(_disposeTask); + } + } - private async ValueTask DisposeAsyncCore() + private async Task DisposeCoreAsync() { // The store's dispose drains every started background setup (R8 moved EnterSetup into // the task, so the inflight counter covers them); the pending drain afterwards closes the // window between a task's final ExitSetup and its entry removal, crediting every // retained copy exactly once and dropping cooldowns so a disposed coordinator leaves no - // per-flow state behind. + // per-flow state behind. Injected collaborators (the syn-copy pool, the setup executor) + // are borrowed, not owned: composition disposes them after the coordinator's drain. await _store.DisposeAsync().ConfigureAwait(false); _pendingSyn.RemoveAll(); - if (_ownsSynCopyPool) _synCopyPool.Dispose(); - if (_ownsSetupExecutor) _setupExecutor.Dispose(); } } diff --git a/src/WinForward.Runtime/TcpRedirect/TcpProxyRelay.cs b/src/WinForward.Runtime/TcpRedirect/TcpProxyRelay.cs index 14d8734..d4193e3 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpProxyRelay.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpProxyRelay.cs @@ -299,14 +299,34 @@ private enum PumpResult Stalled, } - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) return ValueTask.CompletedTask; + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; // The dispose path discards the relay without ever awaiting its completion — and the // disposal itself faults an in-flight pump read — so the fault observer must be hooked // before the sockets go away (S3). TcpRelayFaultObserver.Observe(this, _logger); _localSocket.Dispose(); - return _control.DisposeAsync(); + try + { + await _control.DisposeAsync().ConfigureAwait(false); + } + finally + { + // Owning the pump boundary (R1): closing the local socket and the control stream + // terminates both pumps, so awaiting Completion makes DisposeAsync a real quiescence + // point — once it returns, no pump task is running. The fault the disposal manufactures + // is already observed by TcpRelayFaultObserver, so the await swallows it here. + try + { + await Completion.ConfigureAwait(false); + } +#pragma warning disable RCS1075 // The disposal-manufactured pump fault is observed by TcpRelayFaultObserver. + catch (Exception) + { + // Disposal-manufactured pump fault; observed by TcpRelayFaultObserver. + } +#pragma warning restore RCS1075 + } } } diff --git a/src/WinForward.Runtime/TcpRedirect/TcpRedirectAcceptor.cs b/src/WinForward.Runtime/TcpRedirect/TcpRedirectAcceptor.cs index d38dcb3..78375bb 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpRedirectAcceptor.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpRedirectAcceptor.cs @@ -17,33 +17,44 @@ internal sealed class TcpRedirectAcceptor(ITcpProxyRelayFactory relayFactory, IR public async Task RunAcceptLoopAsync(TcpRedirectSession session) { - var token = session.Token; - while (!token.IsCancellationRequested) + try { - ITcpAcceptedConnection accepted; - try - { - accepted = await session.Listener.AcceptAsync(token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (token.IsCancellationRequested) - { - return; - } - catch (ObjectDisposedException) - { - // The listener was disposed during shutdown. - return; - } - catch (Exception acceptEx) + var token = session.Token; + while (!token.IsCancellationRequested) { - // L3: a transient accept error is retried after a bounded delay, never a tight - // busy-loop. Cancellation and a disposed listener already break out above. - logger.Warn($"TCP redirect accept failed ({acceptEx.GetType().Name}); retrying after a bounded delay."); - await BoundedRetryDelayAsync(token).ConfigureAwait(false); - continue; - } + ITcpAcceptedConnection accepted; + try + { + accepted = await session.Listener.AcceptAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return; + } + catch (ObjectDisposedException) + { + // The listener was disposed during shutdown. + return; + } + catch (Exception acceptEx) + { + // L3: a transient accept error is retried after a bounded delay, never a tight + // busy-loop. Cancellation and a disposed listener already break out above. + logger.Warn($"TCP redirect accept failed ({acceptEx.GetType().Name}); retrying after a bounded delay."); + await BoundedRetryDelayAsync(token).ConfigureAwait(false); + continue; + } - if (!await TryEstablishRelayAsync(session, accepted).ConfigureAwait(false)) return; + if (!await TryEstablishRelayAsync(session, accepted).ConfigureAwait(false)) return; + } + } + finally + { + // This loop is the only reader of session.Token (directly and through + // ClientResetInjector's session overload), so it owns the lifetime CTS disposal: the + // store defers DisposeLifetime until the loop ends and a retire can never pull the + // CTS out from under a concurrent Token read (R1). + session.DisposeLifetime(); } } @@ -55,6 +66,7 @@ public async Task RunAcceptLoopAsync(TcpRedirectSession session) private async Task TryEstablishRelayAsync(TcpRedirectSession session, ITcpAcceptedConnection accepted) { var token = session.Token; + ITcpRelay? unattachedRelay; try { if (accepted.RemoteEndPoint != session.Association.AcceptedPeerEndpoint) @@ -72,18 +84,20 @@ private async Task TryEstablishRelayAsync(TcpRedirectSession session, ITcp var relay = await relayFactory.EstablishAsync(session.Association.OriginalDestination, accepted, session.Server, token).ConfigureAwait(false); if (!tryAttachRelay(session, relay)) { - // The relay is discarded without an owner that would await its completion; - // observe it now so a later fault never surfaces as an unobserved task - // exception (S3). - TcpRelayFaultObserver.Observe(relay, logger); - await relay.DisposeAsync().ConfigureAwait(false); - await accepted.DisposeAsync().ConfigureAwait(false); + unattachedRelay = relay; + } + else + { + TcpRedirectLogging.LogDebug(logger, "tcp.relay.started", session, "established"); + // Both terminal drains own the session's remaining lifetime: the relay-completion + // observer runs the teardown, the redundant-accept drain spans until that teardown + // disposes the listener. Awaiting both (no discarded tasks) makes session.AcceptLoop + // the store's true quiescence wait (R1/R2). + var relayCompletion = ObserveRelayCompletionAsync(session, relay, token); + await DrainRedundantConnectionsAsync(session, token).ConfigureAwait(false); + await relayCompletion.ConfigureAwait(false); return false; } - TcpRedirectLogging.LogDebug(logger, "tcp.relay.started", session, "established"); - _ = ObserveRelayCompletionAsync(session, relay, token); - await DrainRedundantConnectionsAsync(session, token).ConfigureAwait(false); - return false; } catch (OperationCanceledException) when (token.IsCancellationRequested) { @@ -95,6 +109,44 @@ private async Task TryEstablishRelayAsync(TcpRedirectSession session, ITcp await clientReset.HandleRelaySetupFailureAsync(session, accepted, exception).ConfigureAwait(false); return false; } + + // The session could no longer own a relay (it was retired, or the store is disposing, in + // the accept-to-attach window), so the relay and the accepted connection have no owner and + // the redirect must not be left half-open. This runs outside the setup try: a disposal + // fault here is not a relay setup failure, and routing it through the reset/fail handler + // would inject against an already retired session (R7). + // The relay is discarded without an owner that would await its completion; observe it now + // so a later fault never surfaces as an unobserved task exception (S3). + TcpRelayFaultObserver.Observe(unattachedRelay, logger); + await DiscardUnattachedRelayAsync(unattachedRelay, accepted).ConfigureAwait(false); + await tearDownSession(session).ConfigureAwait(false); + return false; + } + + /// + /// Best-effort disposal of a relay that could not be attached and of its accepted connection. + /// Both disposals are contained so a fault here can never escape into the caller's + /// setup-failure handling against a retired session. + /// + private async ValueTask DiscardUnattachedRelayAsync(ITcpRelay relay, ITcpAcceptedConnection accepted) + { + try + { + await relay.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + logger.Warn($"TCP redirect relay disposal after a failed attach failed ({exception.GetType().Name})."); + } + + try + { + await accepted.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + logger.Warn($"TCP redirect accepted-connection disposal after a failed attach failed ({exception.GetType().Name})."); + } } /// @@ -145,14 +197,14 @@ private static async ValueTask BoundedRetryDelayAsync(CancellationToken token) private async Task ObserveRelayCompletionAsync(TcpRedirectSession session, ITcpRelay relay, CancellationToken token) { - // Fire-and-forget (S5): nothing awaits this task, so a throw here would surface as an - // unobserved task exception. The end handling itself is best-effort and must never block - // the teardown that follows. + // Best-effort end handling: neither an unobserved task exception nor an error in the + // teardown that follows may escape. The token bounds the wait so a relay whose completion + // never settles cannot hold the accept loop open past its own cancellation. try { try { - await relay.Completion.ConfigureAwait(false); + await relay.Completion.WaitAsync(token).ConfigureAwait(false); } catch (OperationCanceledException) when (token.IsCancellationRequested) { diff --git a/src/WinForward.Runtime/TcpRedirect/TcpRedirectOptions.cs b/src/WinForward.Runtime/TcpRedirect/TcpRedirectOptions.cs index f6b1f35..040a003 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpRedirectOptions.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpRedirectOptions.cs @@ -1,12 +1,10 @@ -using WinForward.Core; - namespace WinForward.Runtime.TcpRedirect; /// -/// Construction options for : every optional dependency in one -/// named record, with defaults matching the coordinator's historical behavior. A pool or the -/// setup executor that is not injected is created by the coordinator and then owned (and -/// disposed) by it; an injected instance is never disposed by the coordinator. +/// Construction options for : the optional dependencies in one +/// named record, with defaults matching the coordinator's historical behavior. The shared native +/// pool and setup executor are required constructor parameters owned by composition; the +/// coordinator only borrows them and never disposes them. /// public sealed record TcpRedirectOptions { @@ -21,10 +19,4 @@ public sealed record TcpRedirectOptions /// Optional sink for client-visible failure health signals; null reports to the shared no-op. public IInterceptionHealthSignal? HealthSignal { get; init; } - - /// Shared pool backing the retained SYN copies; created if absent. - public NativeBufferPool? SynCopyPool { get; init; } - - /// Shared setup executor; created if absent. - public ISetupExecutor? SetupExecutor { get; init; } } diff --git a/src/WinForward.Runtime/TcpRedirect/TcpRedirectSession.cs b/src/WinForward.Runtime/TcpRedirect/TcpRedirectSession.cs index eb87a40..7027d63 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpRedirectSession.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpRedirectSession.cs @@ -18,6 +18,7 @@ internal sealed class TcpRedirectSession(TcpRedirectAssociation association, ITc public long FlowGeneration { get; } = flowGeneration; private CancellationTokenSource Lifetime { get; } = CancellationTokenSource.CreateLinkedTokenSource(shutdown); private int _retired; + private int _lifetimeDisposed; public ITcpRelay? Relay { get; set; } public Task? AcceptLoop { get; set; } public CancellationToken Token => Lifetime.Token; @@ -25,8 +26,20 @@ internal sealed class TcpRedirectSession(TcpRedirectAssociation association, ITc public void Retire() { - if (Interlocked.Exchange(ref _retired, 1) == 0) Lifetime.Cancel(); + if (Interlocked.Exchange(ref _retired, 1) != 0 || Volatile.Read(ref _lifetimeDisposed) != 0) return; + try + { + Lifetime.Cancel(); + } + catch (ObjectDisposedException) + { + // The linked shutdown cancellation ended the accept loop, which owns the lifetime CTS + // disposal, before this retire ran — the token is already cancelled either way. + } } - public void DisposeLifetime() => Lifetime.Dispose(); + public void DisposeLifetime() + { + if (Interlocked.Exchange(ref _lifetimeDisposed, 1) == 0) Lifetime.Dispose(); + } } diff --git a/src/WinForward.Runtime/TcpRedirect/TcpRedirectSessionStore.cs b/src/WinForward.Runtime/TcpRedirect/TcpRedirectSessionStore.cs index d9d8444..c54b0b2 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpRedirectSessionStore.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpRedirectSessionStore.cs @@ -117,6 +117,7 @@ public async ValueTask RemoveExpiredAsync(DateTimeOffset now, TimeSpan idle RetiredSession[] expired; lock (_gate) { + if (_disposed) return 0; expired = [.. _sessions.Values .Where(session => session.Association.Phase == RelayPhase.Redirecting && now - session.Association.LastActivityUtc >= idleTimeout) .Select(RetireSessionUnderGate)]; @@ -187,10 +188,15 @@ private async Task DisposeCoreAsync() { await ReleaseRetiredAsync(retired).ConfigureAwait(false); var session = retired.Session; - if (session.AcceptLoop is null) continue; - try { await session.AcceptLoop.ConfigureAwait(false); } - catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) { /* cancellation is the expected shutdown path */ } - catch (ObjectDisposedException) { /* the listener was already disposed during shutdown */ } + if (session.AcceptLoop is not null) + { + try { await session.AcceptLoop.ConfigureAwait(false); } + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) { /* cancellation is the expected shutdown path */ } + catch (ObjectDisposedException) { /* the listener was already disposed during shutdown */ } + } + // The accept loop owns the lifetime CTS disposal (R1); a session whose loop was never + // launched has no other owner, so it is released here after the quiescence wait. + session.DisposeLifetime(); } _shutdown.Dispose(); @@ -201,6 +207,7 @@ public ValueTask TearDownSessionAsync(TcpRedirectSession session) RetiredSession? retired; lock (_gate) { + if (_disposed) return ValueTask.CompletedTask; retired = TryRetireSessionUnderGate(session); } return retired is not null ? ReleaseRetiredAsync(retired) : ValueTask.CompletedTask; @@ -246,7 +253,10 @@ private async ValueTask ReleaseRetiredAsync(RetiredSession retired) try { await retired.Relay.DisposeAsync().ConfigureAwait(false); } catch (Exception exception) { logger.Warn($"TCP redirect relay disposal failed ({exception.GetType().Name})."); } } - session.DisposeLifetime(); + // The lifetime CTS is disposed by the accept loop once it ends (it is the only reader of + // session.Token), so a retire can never pull the CTS out from under a concurrent Token + // read (R1). A session whose loop was never launched is disposed here. + if (session.AcceptLoop is null) session.DisposeLifetime(); } public bool TryAttachRelay(TcpRedirectSession session, ITcpRelay relay) @@ -263,7 +273,11 @@ public bool TryAttachRelay(TcpRedirectSession session, ITcpRelay relay) public async ValueTask FailAssociationAsync(TcpRedirectAssociation association) { TcpRedirectSession? session; - lock (_gate) _sessions.TryGetValue(association.OriginalKey, out session); + lock (_gate) + { + if (_disposed) return; + _sessions.TryGetValue(association.OriginalKey, out session); + } if (session is not null) await TearDownSessionAsync(session).ConfigureAwait(false); else RemoveAssociationFromTable(association); } diff --git a/src/WinForward.Runtime/TcpRedirect/TcpRedirectSetup.cs b/src/WinForward.Runtime/TcpRedirect/TcpRedirectSetup.cs index e3dbe04..3d7c9fe 100644 --- a/src/WinForward.Runtime/TcpRedirect/TcpRedirectSetup.cs +++ b/src/WinForward.Runtime/TcpRedirect/TcpRedirectSetup.cs @@ -106,7 +106,7 @@ internal sealed class TcpRedirectSetup(ITcpRedirectListenerFactory listenerFacto private async ValueTask CompleteNewRedirectAsync(CapturedFlowPacket packet, ITcpRedirectListener listener, TcpRedirectAssociation association, Endpoint translatedTuple, Socks5Server server, CancellationToken cancellationToken) { - var session = RegisterSession(listener, association, translatedTuple, server, packet.FlowGeneration); + var session = await RegisterSessionAsync(listener, association, translatedTuple, server, packet.FlowGeneration).ConfigureAwait(false); if (session is null) { await store.ReleaseAssociationAsync(listener, association, selfTrafficToken: null).ConfigureAwait(false); @@ -162,10 +162,27 @@ internal sealed class TcpRedirectSetup(ITcpRedirectListenerFactory listenerFacto return new RedirectSetup(association, session); } - private TcpRedirectSession? RegisterSession(ITcpRedirectListener listener, TcpRedirectAssociation association, Endpoint translatedTuple, Socks5Server server, long flowGeneration) + /// + /// Registers the session's self-traffic token and constructs the session. A failure between + /// the claim and a registered session (a hard fault while registering, or while linking the + /// shutdown token) would otherwise leak the claimed listener/alias and the token, so every + /// fault releases them exactly once through the store and surfaces unchanged. The + /// already-disposed-store shape is not a fault: + /// returns null and owns its own release. + /// + private async ValueTask RegisterSessionAsync(ITcpRedirectListener listener, TcpRedirectAssociation association, Endpoint translatedTuple, Socks5Server server, long flowGeneration) { - var selfTrafficToken = selfTraffic.Register(new SelfTrafficRegistry.SelfTrafficKey(TransportProtocol.Tcp, translatedTuple, translatedTuple)); - var session = new TcpRedirectSession(association, listener, selfTrafficToken, server, flowGeneration, store.ShutdownToken); - return store.TryRegister(session); + SelfTrafficRegistry.SelfTrafficToken? selfTrafficToken = null; + try + { + selfTrafficToken = selfTraffic.Register(new SelfTrafficRegistry.SelfTrafficKey(TransportProtocol.Tcp, translatedTuple, translatedTuple)); + var session = new TcpRedirectSession(association, listener, selfTrafficToken, server, flowGeneration, store.ShutdownToken); + return store.TryRegister(session); + } + catch + { + await store.ReleaseAssociationAsync(listener, association, selfTrafficToken).ConfigureAwait(false); + throw; + } } } diff --git a/src/WinForward.Runtime/UdpProxy/IUdpSessionSlotHost.cs b/src/WinForward.Runtime/UdpProxy/IUdpSessionSlotHost.cs index d5407f6..cb100ff 100644 --- a/src/WinForward.Runtime/UdpProxy/IUdpSessionSlotHost.cs +++ b/src/WinForward.Runtime/UdpProxy/IUdpSessionSlotHost.cs @@ -21,9 +21,9 @@ internal interface IUdpSessionSlotHost /// One flush-dequeue step: verifies slot ownership, dequeues the next buffered datagram (releasing its budget charge exactly once; the caller releases the lease), or flips the slot ready when the queue drains. (UdpSessionSetup.FlushStep Step, NativeLease Lease, int Length, DateTimeOffset EnqueuedAt) DequeueForFlush(FlowKey flow, UdpProxyCoordinator.UdpSessionSlot slot); - /// Removes the flow's slot when it is still the exact instance, releasing the session and draining queued datagrams fail-closed; returns true when this caller owned the removal. - Task RemoveSlotAsync(FlowKey flow, UdpProxyCoordinator.UdpSessionSlot slot, bool armCooldown); + /// Removes the flow's slot when it is still the exact instance, releasing the session and draining queued datagrams fail-closed; returns true when this caller owned the removal. Only arms the setup cooldown. + Task RemoveSlotAsync(FlowKey flow, UdpProxyCoordinator.UdpSessionSlot slot, UdpTeardownReason reason); - /// The receive-failure teardown: removes the session's slot (no cooldown) so the flow can set up anew. + /// The receive-failure teardown: removes the session's slot (reason , no cooldown) so the flow can set up anew. Task RemoveReceiveFailedSessionAsync(UdpProxySession session); } diff --git a/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.Send.cs b/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.Send.cs index 1acfe69..2460d5c 100644 --- a/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.Send.cs +++ b/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.Send.cs @@ -76,12 +76,13 @@ internal ValueTask TrySendSpanAsync(FlowKey flow, Socks5Server server, Rea /// The ready-session send bridge: a non-async method (the payload span must not cross an /// await) that starts the send and either completes it inline — the warm shape the transport /// finishes synchronously — or hands only the send tail to the async continuation. Caller - /// cancellation propagates untouched; any other send failure removes the slot first and then - /// rethrows the original exception. + /// cancellation propagates untouched; a genuine transport failure removes the slot first and + /// then rethrows the original exception, while a session that refuses the datagram because it + /// is expiring or faulted is a counted drop that leaves the slot to its state owner. /// private ValueTask SendOnReadySessionSpanAsync(FlowKey flow, UdpSessionSlot slot, UdpProxySession session, ReadOnlySpan payload, long packetSequence, CancellationToken cancellationToken) { - ValueTask send; + ValueTask send; try { send = session.SendSpanAsync(flow.Remote, payload, cancellationToken); @@ -99,17 +100,25 @@ private ValueTask SendOnReadySessionSpanAsync(FlowKey flow, UdpSessionSlot if (send.IsCompletedSuccessfully) { + // Steady-state path: the session completed inline, so consuming its result here is a + // plain allocation-free read (the same guarded-inline shape as NdisCapture's handler + // result, which suppresses the sibling VSTHRD002); the await path is the tail below. +#pragma warning disable VSTHRD103, MA0042 // The ValueTask is already complete (checked above), so reading it cannot block; MA0042's await guidance does not apply to the guarded-inline fast path. + var sent = send.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD103, MA0042 + if (!sent) return ValueTask.FromResult(DropSessionUnavailable(flow)); LogSpanDatagramSent(flow, session, packetSequence, payload.Length); return ValueTask.FromResult(true); } return SendSpanTailAsync(send, flow, slot, session, packetSequence, payload.Length, cancellationToken); } - private async ValueTask SendSpanTailAsync(ValueTask send, FlowKey flow, UdpSessionSlot slot, UdpProxySession session, long packetSequence, int payloadLength, CancellationToken cancellationToken) + private async ValueTask SendSpanTailAsync(ValueTask send, FlowKey flow, UdpSessionSlot slot, UdpProxySession session, long packetSequence, int payloadLength, CancellationToken cancellationToken) { + bool sent; try { - await send.ConfigureAwait(false); + sent = await send.ConfigureAwait(false); } catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && !_shutdown.IsCancellationRequested) { @@ -118,18 +127,32 @@ private async ValueTask SendSpanTailAsync(ValueTask send, FlowKey flow, Ud } catch (Exception exception) { - await _slotHost.RemoveSlotAsync(flow, slot, armCooldown: false).ConfigureAwait(false); + await _slotHost.RemoveSlotAsync(flow, slot, UdpTeardownReason.Fault).ConfigureAwait(false); ExceptionDispatchInfo.Capture(exception).Throw(); return false; } + if (!sent) return DropSessionUnavailable(flow); LogSpanDatagramSent(flow, session, packetSequence, payloadLength); return true; } + /// + /// Records a datagram the session refused because it is expiring or faulted. The slot is + /// deliberately left in place — the sweeper owns an expiring session's removal and the + /// failure handler a faulted one's — so this is a counted, rate-limited drop rather than a + /// transport failure. + /// + private bool DropSessionUnavailable(FlowKey flow) + { + RuntimeCounters.Shared.Increment(RuntimeCounters.UdpFailClosedDrop); + if (_sessionUnavailableDropLog.ShouldEmit()) UdpProxyLogging.LogSessionUnavailableDrop(_logger, flow); + return false; + } + private async ValueTask RemoveSlotSpanAsync(FlowKey flow, UdpSessionSlot slot, Exception exception) { - await _slotHost.RemoveSlotAsync(flow, slot, armCooldown: false).ConfigureAwait(false); + await _slotHost.RemoveSlotAsync(flow, slot, UdpTeardownReason.Fault).ConfigureAwait(false); ExceptionDispatchInfo.Capture(exception).Throw(); return false; } diff --git a/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.cs b/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.cs index 3ba2670..e016fce 100644 --- a/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.cs +++ b/src/WinForward.Runtime/UdpProxy/UdpProxyCoordinator.cs @@ -18,11 +18,7 @@ public sealed partial class UdpProxyCoordinator : IAsyncDisposable, IUdpSessionS private readonly UdpSessionSetup _setup; private readonly IUdpSessionSlotHost _slotHost; private readonly NativeBufferPool _setupQueuePool; - private readonly bool _ownsSetupQueuePool; - private readonly NativeBufferPool _receiveWindowPool; - private readonly bool _ownsReceiveWindowPool; private readonly ISetupExecutor _setupExecutor; - private readonly bool _ownsSetupExecutor; private readonly Func _setupHandler; private readonly Lock _gate = new(); private readonly CancellationTokenSource _shutdown = new(); @@ -32,16 +28,33 @@ public sealed partial class UdpProxyCoordinator : IAsyncDisposable, IUdpSessionS private Task? _disposeTask; private bool _disposed; + /// + /// Receive-failure teardowns the sessions started without awaiting (awaiting them in the + /// receive loop would re-enter session disposal). Guarded by ; drained by + /// so the coordinator, not the faulting session, owns their + /// completion. + /// + private readonly List _inFlightTeardowns = []; + + /// Rate limit for the deprecated-session send drop diagnostic (one line per window). + private readonly RuntimeLogThrottle _sessionUnavailableDropLog = new(TimeSpan.FromSeconds(5)); + /// Upper bound on eagerly seeded dictionary capacity; growth beyond it stays lazy. private const int MaximumPreSeedCapacity = 1_024; public UdpProxyCoordinator( IUdpProxyTransportFactory transportFactory, IUdpResponseSink responseSink, + NativeBufferPool setupQueuePool, + NativeBufferPool receiveWindowPool, + ISetupExecutor setupExecutor, UdpProxyOptions? options = null) { ArgumentNullException.ThrowIfNull(transportFactory); ArgumentNullException.ThrowIfNull(responseSink); + ArgumentNullException.ThrowIfNull(setupQueuePool); + ArgumentNullException.ThrowIfNull(receiveWindowPool); + ArgumentNullException.ThrowIfNull(setupExecutor); options ??= new UdpProxyOptions(); var capacity = options.Capacity; var timeProvider = options.TimeProvider; @@ -60,13 +73,9 @@ public UdpProxyCoordinator( _beforeExpiryRecheck = options.BeforeExpiryRecheck; _logger = options.Logger ?? NullRuntimeLogger.Instance; _budget = new UdpSetupQueueBudget(setupQueueGlobalByteBudget, _logger, _timeProvider); - _setupQueuePool = options.SetupQueuePool ?? new NativeBufferPool(maximumFrameSize); - _ownsSetupQueuePool = options.SetupQueuePool is null; + _setupQueuePool = setupQueuePool; + _setupExecutor = setupExecutor; var receiveBufferSize = ReceiveWindowSize(maximumFrameSize); - _receiveWindowPool = options.ReceiveWindowPool ?? new NativeBufferPool(receiveBufferSize); - _ownsReceiveWindowPool = options.ReceiveWindowPool is null; - _setupExecutor = options.SetupExecutor ?? new SetupExecutor(); - _ownsSetupExecutor = options.SetupExecutor is null; _setupHandler = RunSessionSetupAsync; _slotHost = this; _setup = new UdpSessionSetup( @@ -75,7 +84,7 @@ public UdpProxyCoordinator( responseSink, timeProvider, _logger, - _receiveWindowPool, + receiveWindowPool, receiveBufferSize, _slotHost); } @@ -89,6 +98,20 @@ public int SessionCount /// The session budget this coordinator was constructed with (heartbeat diagnostics). public int Capacity { get; } + /// + /// One flow's lifecycle state (see ): the slot-level + /// while the flow has a slot without an attached + /// session (the relay is dialing), otherwise the attached session's own state. A flow with no + /// slot at all also reports : its next datagram starts a + /// fresh setup. + /// + internal UdpSessionState SessionState(FlowKey flow) + { + UdpProxySession? session; + lock (_gate) session = _sessions.TryGetValue(flow, out var slot) ? slot.Session : null; + return session?.State ?? UdpSessionState.SettingUp; + } + /// /// The coordinator's observable counters as one snapshot: the live setup-failure cooldown /// count (bounded by capacity), the aggregate setup-queue bytes charged against the @@ -266,15 +289,43 @@ private async Task DisposeCoreAsync() if (slot.Session is { } session) await session.DisposeAsync().ConfigureAwait(false); } + await DrainInFlightTeardownsAsync().ConfigureAwait(false); + // Every started setup task was awaited above and released the limiter in its finally; // tasks that start later observe the cancelled shutdown token before acquiring it. _setup.DisposeLimiter(); _shutdown.Dispose(); // Every queued lease was drained above and every in-flight flush lease was released by - // the awaited setup tasks, so the pool owns nothing outstanding when it is disposed. - if (_ownsSetupQueuePool) _setupQueuePool.Dispose(); - if (_ownsReceiveWindowPool) _receiveWindowPool.Dispose(); - if (_ownsSetupExecutor) _setupExecutor.Dispose(); + // the awaited setup tasks. The rented pools and the setup executor are borrowed from + // composition, which owns and disposes them after this coordinator's drain. + } + + /// + /// Awaits the receive-failure teardowns the sessions started fire-and-forget. Disposing every + /// session in also drained each receive loop, so every handler + /// that will ever run has registered before this snapshot is taken. + /// + private async Task DrainInFlightTeardownsAsync() + { + Task[] inFlightTeardowns; + lock (_gate) + { + inFlightTeardowns = [.. _inFlightTeardowns]; + _inFlightTeardowns.Clear(); + } + foreach (var teardown in inFlightTeardowns) + { + try + { + await teardown.ConfigureAwait(false); + } + catch (Exception exception) + { + // The teardown's own removal path already handles its failure; disposal still + // finishes (the fault surfaces through the session's recorded receive failure). + _logger.Warn($"UDP receive-failure teardown faulted during coordinator disposal: {exception.GetType().Name}: {exception.Message}"); + } + } } /// @@ -351,7 +402,7 @@ public async ValueTask RemoveExpiredAsync(DateTimeOffset now, TimeSpan idle foreach (var (slot, session) in idle) { if (!session.TryBeginExpiry(now, idleTimeout)) continue; - if (!await _slotHost.RemoveSlotAsync(session.Flow, slot, armCooldown: false).ConfigureAwait(false)) + if (!await _slotHost.RemoveSlotAsync(session.Flow, slot, UdpTeardownReason.Expiry).ConfigureAwait(false)) { session.CancelExpiry(); continue; @@ -368,13 +419,14 @@ public async ValueTask RemoveExpiredAsync(DateTimeOffset now, TimeSpan idle /// to is removed only when it is still the exact slot instance /// (a newer generation may have replaced it); the resolved /// session's association is released and any datagrams still queued for setup are dropped - /// fail-closed in the same critical section. When is set - /// and the coordinator is not shutting down, a setup-failure cooldown is armed so - /// the next datagram does not immediately hammer a dead SOCKS5 server. Session disposal runs - /// outside the gate. Returns true when this caller owned the removal; per-call-site logging - /// and expiry bookkeeping stay with callers. + /// fail-closed in the same critical section. Only + /// arms the setup-failure cooldown (and never + /// during shutdown), so the next datagram does not immediately hammer a dead SOCKS5 server + /// while an expiry, a transient fault, or a cancellation leaves the flow free to set up + /// again. Session disposal runs outside the gate. Returns true when this caller owned the + /// removal; per-call-site logging and expiry bookkeeping stay with callers. /// - async Task IUdpSessionSlotHost.RemoveSlotAsync(FlowKey flow, UdpSessionSlot slot, bool armCooldown) + async Task IUdpSessionSlotHost.RemoveSlotAsync(FlowKey flow, UdpSessionSlot slot, UdpTeardownReason reason) { UdpProxySession? session = null; var owned = false; @@ -394,7 +446,7 @@ async Task IUdpSessionSlotHost.RemoveSlotAsync(FlowKey flow, UdpSessionSlo dropped++; } if (dropped > 0) _budget.NoteDrop(flow, dropped); - if (armCooldown && !_shutdown.IsCancellationRequested) + if (reason == UdpTeardownReason.SetupFailure && !_shutdown.IsCancellationRequested) { _cooldowns.Write(flow, _timeProvider.GetUtcNow()); } @@ -405,7 +457,20 @@ async Task IUdpSessionSlotHost.RemoveSlotAsync(FlowKey flow, UdpSessionSlo return owned; } - async Task IUdpSessionSlotHost.RemoveReceiveFailedSessionAsync(UdpProxySession session) + Task IUdpSessionSlotHost.RemoveReceiveFailedSessionAsync(UdpProxySession session) + { + var teardown = RemoveReceiveFailedSessionCoreAsync(session); + lock (_gate) + { + // Prune finished entries so the set stays proportional to genuinely in-flight + // teardowns; the fault path is cold, so the scan costs nothing on the hot path. + _inFlightTeardowns.RemoveAll(static teardownTask => teardownTask.IsCompleted); + _inFlightTeardowns.Add(teardown); + } + return teardown; + } + + private async Task RemoveReceiveFailedSessionCoreAsync(UdpProxySession session) { UdpSessionSlot? slot = null; lock (_gate) @@ -413,7 +478,7 @@ async Task IUdpSessionSlotHost.RemoveReceiveFailedSessionAsync(UdpProxySession s if (_sessions.TryGetValue(session.Flow, out var current) && ReferenceEquals(current.Session, session)) slot = current; } if (slot is null) return; - if (!await _slotHost.RemoveSlotAsync(session.Flow, slot, armCooldown: false).ConfigureAwait(false)) return; + if (!await _slotHost.RemoveSlotAsync(session.Flow, slot, UdpTeardownReason.Fault).ConfigureAwait(false)) return; UdpProxyLogging.LogDebug(_logger, "udp.session.closed", session.Flow, session.FlowGeneration, session.Association, serverName: null); } diff --git a/src/WinForward.Runtime/UdpProxy/UdpProxyLogging.cs b/src/WinForward.Runtime/UdpProxy/UdpProxyLogging.cs index 1b79f9d..0cd8dea 100644 --- a/src/WinForward.Runtime/UdpProxy/UdpProxyLogging.cs +++ b/src/WinForward.Runtime/UdpProxy/UdpProxyLogging.cs @@ -39,4 +39,18 @@ public static void LogSetupFailure(IRuntimeLogger logger, FlowKey flow, Exceptio new("destination", flow.Remote), new("reason", exception.GetType().Name)); } + + /// + /// The ready-session send was refused because the session is expiring or faulted. The caller + /// counts the drop and owns the rate limit; this side only formats. + /// + public static void LogSessionUnavailableDrop(IRuntimeLogger logger, FlowKey flow) + { + if (!logger.IsEnabled(RuntimeLogLevel.Debug)) return; + logger.Event(RuntimeLogLevel.Debug, "udp.send.dropped", + new("protocol", flow.Protocol), + new("source", flow.Local), + new("destination", flow.Remote), + new("reason", "sessionUnavailable")); + } } diff --git a/src/WinForward.Runtime/UdpProxy/UdpProxyOptions.cs b/src/WinForward.Runtime/UdpProxy/UdpProxyOptions.cs index ec2c568..35708c7 100644 --- a/src/WinForward.Runtime/UdpProxy/UdpProxyOptions.cs +++ b/src/WinForward.Runtime/UdpProxy/UdpProxyOptions.cs @@ -1,15 +1,14 @@ -using WinForward.Core; using WinForward.Protocols; namespace WinForward.Runtime.UdpProxy; /// -/// Construction options for : every optional dependency in one -/// named record, with defaults matching the coordinator's historical behavior. A pool or the -/// setup executor that is not injected is created by the coordinator and then owned (and -/// disposed) by it; an injected instance is never disposed by the coordinator. The two -/// members are test seams (reached through InternalsVisibleTo) and are -/// never set by production composition. +/// Construction options for : the optional dependencies in one +/// named record, with defaults matching the coordinator's historical behavior. The shared native +/// pools and setup executor are required constructor parameters owned by composition; the +/// coordinator only borrows them and never disposes them. The two +/// members are test seams (reached through InternalsVisibleTo) and are never set by +/// production composition. /// public sealed record UdpProxyOptions { @@ -25,15 +24,6 @@ public sealed record UdpProxyOptions /// The clock driving setup cooldowns, datagram TTLs, and activity stamps (injectable for fake-time tests). public TimeProvider TimeProvider { get; init; } = TimeProvider.System; - /// Shared pool backing every session's receive window; created if absent. - public NativeBufferPool? ReceiveWindowPool { get; init; } - - /// Shared pool backing every queued setup datagram; created if absent. - public NativeBufferPool? SetupQueuePool { get; init; } - - /// Shared setup executor; created if absent. - public ISetupExecutor? SetupExecutor { get; init; } - /// Test seam: awaited between the expiry snapshot and the per-session recheck; null in production. internal Func? BeforeExpiryRecheck { get; init; } diff --git a/src/WinForward.Runtime/UdpProxy/UdpProxySession.cs b/src/WinForward.Runtime/UdpProxy/UdpProxySession.cs index 93cb8ff..ad95f6d 100644 --- a/src/WinForward.Runtime/UdpProxy/UdpProxySession.cs +++ b/src/WinForward.Runtime/UdpProxy/UdpProxySession.cs @@ -43,7 +43,7 @@ internal sealed class UdpProxySession : IAsyncDisposable private static readonly TimeSpan s_activityPropagationInterval = TimeSpan.FromMilliseconds(100); private readonly IUdpProxyTransport _transport; private readonly IUdpResponseSink _sink; - private readonly CancellationToken _shutdown; + private readonly CancellationTokenSource _lifetime; private readonly TimeProvider _timeProvider; private readonly Action _activityObserver; private readonly IRuntimeLogger _logger; @@ -64,6 +64,7 @@ internal sealed class UdpProxySession : IAsyncDisposable private long _skippedConnectionReset; private long _skippedDomainDestination; private bool _expiring; + private bool _disposed; private int _activeSends; public UdpProxySession(UdpProxySessionContext context) @@ -81,7 +82,7 @@ public UdpProxySession(UdpProxySessionContext context) _transport = context.Transport; _sink = context.Sink; ClientMac = context.ClientMac; - _shutdown = context.Shutdown; + _lifetime = CancellationTokenSource.CreateLinkedTokenSource(context.Shutdown); _timeProvider = context.TimeProvider; _activityObserver = context.ActivityObserver; _logger = context.Logger; @@ -95,6 +96,25 @@ public UdpProxySession(UdpProxySessionContext context) public UdpAssociation Association { get; } public DateTimeOffset LastActivityUtc => new(Interlocked.Read(ref _lastActivityTicks), TimeSpan.Zero); + /// + /// The session-level lifecycle state, read under the activity gate (the lock that owns + /// _expiring, _receiveFailure, and _disposed); see + /// for the transition rules. Slot-level + /// is derived by the coordinator from the slot before a session exists. + /// + internal UdpSessionState State + { + get + { + lock (_activityGate) + { + if (_disposed) return UdpSessionState.Disposed; + if (Volatile.Read(ref _receiveFailure) is not null) return UdpSessionState.Faulted; + return _expiring ? UdpSessionState.Expiring : UdpSessionState.Active; + } + } + } + /// /// The client's Ethernet source MAC recorded from the first datagram of the flow. Forwarded /// (VM-originated) flows use it as the destination MAC of rebuilt responses so the vSwitch @@ -108,18 +128,19 @@ public void Start(Func receiveFailureHandler) } /// - /// Sends one datagram through the shared transport. The entry is non-async because the payload - /// span must not cross an await — the transport consumes it synchronously (SOCKS5 encode into - /// its reusable send buffer) before any asynchronous socket operation, and only the send tail - /// continues asynchronously without the span. + /// Sends one datagram through the shared transport, returning whether it was handed off: + /// means the session refused it because it is expiring or already + /// faulted (the owner of that state — the sweeper for expiry, the failure handler for a fault + /// — owns the slot removal, so the caller just counts the drop). The entry is non-async + /// because the payload span must not cross an await — the transport consumes it synchronously + /// (SOCKS5 encode into its reusable send buffer) before any asynchronous socket operation, and + /// only the send tail continues asynchronously without the span. /// - public ValueTask SendSpanAsync(Endpoint destination, ReadOnlySpan payload, CancellationToken cancellationToken) + public ValueTask SendSpanAsync(Endpoint destination, ReadOnlySpan payload, CancellationToken cancellationToken) { lock (_activityGate) { - var failure = Volatile.Read(ref _receiveFailure); - if (failure is not null) throw new IOException("SOCKS5 UDP relay session is no longer usable.", failure); - if (_expiring) throw new IOException("SOCKS5 UDP relay session is expiring."); + if (Volatile.Read(ref _receiveFailure) is not null || _expiring) return ValueTask.FromResult(false); _activeSends++; } @@ -138,17 +159,18 @@ public ValueTask SendSpanAsync(Endpoint destination, ReadOnlySpan payload, { TouchActivity(); lock (_activityGate) _activeSends--; - return ValueTask.CompletedTask; + return ValueTask.FromResult(true); } return FinishSpanSendAsync(send); } - private async ValueTask FinishSpanSendAsync(ValueTask send) + private async ValueTask FinishSpanSendAsync(ValueTask send) { try { await send.ConfigureAwait(false); TouchActivity(); + return true; } finally { @@ -158,6 +180,7 @@ private async ValueTask FinishSpanSendAsync(ValueTask send) public ValueTask DisposeAsync() { + lock (_activityGate) _disposed = true; lock (_disposeGate) { _disposeTask ??= DisposeCoreAsync(); @@ -169,10 +192,15 @@ internal bool TryBeginExpiry(DateTimeOffset now, TimeSpan idleTimeout) { lock (_activityGate) { - if (_expiring || _activeSends != 0 || now - LastActivityUtc < idleTimeout) return false; + if (_disposed || _expiring || _activeSends != 0 || now - LastActivityUtc < idleTimeout) return false; _expiring = true; - return true; } + + // Cancelling the per-session lifetime (not the coordinator shutdown) ends the receive loop + // as a normal teardown: an idle-expired session must not record a receive failure for the + // cancellation it asked for. + CancelLifetime(); + return true; } internal void CancelExpiry() @@ -180,21 +208,46 @@ internal void CancelExpiry() lock (_activityGate) _expiring = false; } + /// + /// Cancels the session lifetime, tolerating a lifetime already disposed by the owning disposal + /// path (a sweep racing disposal); the session is gone either way. + /// + private void CancelLifetime() + { + try + { + _lifetime.Cancel(); + } + catch (ObjectDisposedException exception) + { + // A concurrent disposal already spent this lifetime. + GC.KeepAlive(exception); + } + } + private async Task DisposeCoreAsync() { - await _transport.DisposeAsync().ConfigureAwait(false); - if (_receiveLoop is not null) + CancelLifetime(); + try { - try { await _receiveLoop.ConfigureAwait(false); } - catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) - { - // Cancellation is the expected shutdown path. - } - catch (ObjectDisposedException) + await _transport.DisposeAsync().ConfigureAwait(false); + if (_receiveLoop is not null) { - // Disposal already tore the receive loop down; nothing left to observe here. + try { await _receiveLoop.ConfigureAwait(false); } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + // Cancellation is the expected teardown path (idle expiry or a coordinator shutdown). + } + catch (ObjectDisposedException) + { + // Disposal already tore the receive loop down; nothing left to observe here. + } } } + finally + { + _lifetime.Dispose(); + } } private async Task ReceiveLoopAsync(Func receiveFailureHandler) @@ -202,12 +255,12 @@ private async Task ReceiveLoopAsync(Func receiveFailureHa var lease = _receiveWindowPool.Rent(); try { - while (!_shutdown.IsCancellationRequested) + while (!_lifetime.IsCancellationRequested) { Socks5UdpReceiveResult receive; try { - receive = await _transport.ReceiveAsync(lease.Memory[.._receiveBufferSize], _shutdown).ConfigureAwait(false); + receive = await _transport.ReceiveAsync(lease.Memory[.._receiveBufferSize], _lifetime.Token).ConfigureAwait(false); } catch (SocketException exception) when (exception.SocketErrorCode == SocketError.ConnectionReset) { @@ -235,13 +288,13 @@ private async Task ReceiveLoopAsync(Func receiveFailureHa await InjectResponseAsync(Endpoint.From(address, response.DestinationPort), response).ConfigureAwait(false); } } - catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { - // Normal shutdown path. + // Normal teardown path: idle expiry or a coordinator shutdown. } - catch (ObjectDisposedException) when (_shutdown.IsCancellationRequested) + catch (ObjectDisposedException) when (_lifetime.IsCancellationRequested) { - // Disposal closes the receive socket during shutdown. + // Disposal closes the receive socket during teardown. } catch (Exception exception) { @@ -265,9 +318,9 @@ private async Task InjectResponseAsync(Endpoint source, Socks5UdpDatagram respon { try { - await _sink.InjectAsync(Flow, source, response.Payload, ClientMac, _shutdown).ConfigureAwait(false); + await _sink.InjectAsync(Flow, source, response.Payload, ClientMac, _lifetime.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { throw; } diff --git a/src/WinForward.Runtime/UdpProxy/UdpSessionSetup.cs b/src/WinForward.Runtime/UdpProxy/UdpSessionSetup.cs index 3e88e29..3cad46b 100644 --- a/src/WinForward.Runtime/UdpProxy/UdpSessionSetup.cs +++ b/src/WinForward.Runtime/UdpProxy/UdpSessionSetup.cs @@ -110,7 +110,7 @@ internal async Task CreateSessionAsync(FlowKey flow, Socks5Server server, long f // setup cooldown, and the slot removal ride this frame at no extra cost. // Shutdown cancellation keeps the no-cooldown semantics the observer had. UdpProxyLogging.LogSetupFailure(logger, flow, exception); - await host.RemoveSlotAsync(flow, slot, armCooldown: exception is not OperationCanceledException).ConfigureAwait(false); + await host.RemoveSlotAsync(flow, slot, exception is OperationCanceledException ? UdpTeardownReason.Shutdown : UdpTeardownReason.SetupFailure).ConfigureAwait(false); } finally { @@ -162,7 +162,12 @@ private async Task FlushSetupQueueAsync(FlowKey flow, UdpProxyCoordinator.UdpSes // await; the lease is released exactly once on every exit below. try { - await session.SendSpanAsync(flow.Remote, lease.Span[..length], cancellationToken).ConfigureAwait(false); + if (!await session.SendSpanAsync(flow.Remote, lease.Span[..length], cancellationToken).ConfigureAwait(false)) + { + // The session is expiring or faulted: it (not the setup) owns the remaining + // queued datagrams' fate, so the flush stops without touching the slot. + return; + } } finally { diff --git a/src/WinForward.Runtime/UdpProxy/UdpSessionState.cs b/src/WinForward.Runtime/UdpProxy/UdpSessionState.cs new file mode 100644 index 0000000..023667c --- /dev/null +++ b/src/WinForward.Runtime/UdpProxy/UdpSessionState.cs @@ -0,0 +1,38 @@ +namespace WinForward.Runtime.UdpProxy; + +/// +/// The explicit lifecycle vocabulary of one UDP flow's session. Two levels feed it, each observed +/// under its own lock: +/// +/// Slot level (the coordinator's _gate): the flow's slot exists without +/// an attached session while its relay is dialing (); the setup pipeline +/// attaches the session and later flips the slot ready, at which point the session level takes +/// over. +/// Session level (the session's activity gate): is the +/// steady state; idle expiry admits (the sweeper owns the removal, and the +/// per-session lifetime is cancelled so the receive loop ends as a normal teardown); a genuine +/// receive or send fault yields (the failure handler owns the removal); +/// is terminal. +/// +/// Transitions are one-way — SettingUp → Active → Expiring → Disposed and +/// Active → Faulted → Disposed — except that CancelExpiry can return an +/// session to when the sweep loses a removal race (the winning owner then +/// disposes it). +/// +internal enum UdpSessionState +{ + /// The flow has a slot but no session yet: its relay is dialing and datagrams are queued. + SettingUp, + + /// The session is live and relaying. + Active, + + /// Idle expiry was admitted; the sweeper owns the slot removal. + Expiring, + + /// A genuine receive or send fault was recorded; the failure handler owns the slot removal. + Faulted, + + /// Session disposal was admitted; terminal. + Disposed, +} diff --git a/src/WinForward.Runtime/UdpProxy/UdpTeardownReason.cs b/src/WinForward.Runtime/UdpProxy/UdpTeardownReason.cs new file mode 100644 index 0000000..14784b7 --- /dev/null +++ b/src/WinForward.Runtime/UdpProxy/UdpTeardownReason.cs @@ -0,0 +1,22 @@ +namespace WinForward.Runtime.UdpProxy; + +/// +/// Why a UDP flow's session slot is being torn down. The reason is data, not an inference: only +/// arms the setup-failure cooldown (a dead SOCKS5 server must not be +/// hammered at datagram rate), while an idle expiry, a transient fault, and a shutdown all leave +/// the flow free to set up again immediately. +/// +internal enum UdpTeardownReason +{ + /// The relay setup failed against a reachable-looking server: arm the setup cooldown. + SetupFailure, + + /// Idle expiry: the sweeper owns the removal. + Expiry, + + /// A genuine receive or send fault: the failure handler owns the removal. + Fault, + + /// Shutdown or caller cancellation of a setup/dial. + Shutdown, +} diff --git a/tests/WinForward.Core.Tests/CoordinatorOwnershipTests.cs b/tests/WinForward.Core.Tests/CoordinatorOwnershipTests.cs new file mode 100644 index 0000000..041157d --- /dev/null +++ b/tests/WinForward.Core.Tests/CoordinatorOwnershipTests.cs @@ -0,0 +1,92 @@ +using WinForward.NdisApi; +using WinForward.Runtime; +using WinForward.Runtime.TcpRedirect; +using WinForward.Runtime.UdpProxy; +using Xunit; + +namespace WinForward.Core.Tests; + +/// +/// R6 ownership consolidation: a coordinator borrows its native pools and setup executor from +/// composition and must never dispose them, and repeated disposal is single-flight. +/// +public sealed class CoordinatorOwnershipTests +{ + [Fact] + public async Task TcpCoordinatorDisposeDoesNotDisposeInjectedCollaboratorsAndIsSingleFlight() + { + using var synCopyPool = new NativeBufferPool(NdisApiAbi.MaximumEthernetFrame, capacity: 4); + var warmLease = synCopyPool.Rent(); + warmLease.Dispose(); + Assert.Equal(1, synCopyPool.Count); + + using var executor = new CountingSetupExecutor(); + var coordinator = TcpCoordinatorFakes.CreateCoordinator( + new FakeListenerFactory(), + new FakeRelayFactory(), + new FakeInjector(), + new TcpRedirectTable(), + new SelfTrafficRegistry(), + new FakeLocalAddressProvider(), + synCopyPool: synCopyPool, + setupExecutor: executor); + + var firstDispose = AsTask(coordinator.DisposeAsync()); + var secondDispose = AsTask(coordinator.DisposeAsync()); + Assert.Same(firstDispose, secondDispose); + + await secondDispose; + + Assert.Equal(0, executor.DisposeCount); + Assert.Equal(1, synCopyPool.Count); + } + + [Fact] + public async Task UdpCoordinatorDisposeDoesNotDisposeInjectedCollaborators() + { + using var setupQueuePool = new NativeBufferPool(1500, capacity: 4); + using var receiveWindowPool = new NativeBufferPool(UdpProxyCoordinator.ReceiveWindowSize(1500), capacity: 4); + setupQueuePool.Rent().Dispose(); + receiveWindowPool.Rent().Dispose(); + Assert.Equal(1, setupQueuePool.Count); + Assert.Equal(1, receiveWindowPool.Count); + + using var executor = new CountingSetupExecutor(); + var coordinator = UdpCoordinatorFakes.CreateCoordinator( + new FakeTransportFactory(), + new FakeResponseSink(), + setupQueuePool: setupQueuePool, + receiveWindowPool: receiveWindowPool, + setupExecutor: executor); + + await coordinator.DisposeAsync(); + await coordinator.DisposeAsync(); + + Assert.Equal(0, executor.DisposeCount); + Assert.Equal(1, setupQueuePool.Count); + Assert.Equal(1, receiveWindowPool.Count); + } + + private static Task AsTask(ValueTask value) => value.AsTask(); + + private sealed class CountingSetupExecutor : ISetupExecutor + { + private int _disposeCount; + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public SetupWorkItem RentItem(Func handler) + { + ArgumentNullException.ThrowIfNull(handler); + throw new NotSupportedException("The ownership tests never exercise setup work."); + } + + public bool TryEnqueue(SetupWorkItem item) + { + ArgumentNullException.ThrowIfNull(item); + throw new NotSupportedException("The ownership tests never exercise setup work."); + } + + public void Dispose() => Interlocked.Increment(ref _disposeCount); + } +} diff --git a/tests/WinForward.Core.Tests/DurableCaptureBundleTests.cs b/tests/WinForward.Core.Tests/DurableCaptureBundleTests.cs index ba9b256..eed31e0 100644 --- a/tests/WinForward.Core.Tests/DurableCaptureBundleTests.cs +++ b/tests/WinForward.Core.Tests/DurableCaptureBundleTests.cs @@ -38,8 +38,8 @@ private static DurableCaptureBundle CreateBundle(RecordingRuntimeLogger logger) var executor = new NdisPacketActionExecutor(new FakeReinjector()); var udpTargets = new UdpAdapterTargetSource(); var sweeper = new IdleExpirySweeper(dispatcher, tcp: null, udp: null, logger: logger); - var udp = new UdpProxyCoordinator(new FakeTransportFactory(), new FakeResponseSink()); - var tcp = new TcpProxyCoordinator( + var udp = UdpCoordinatorFakes.CreateCoordinator(new FakeTransportFactory(), new FakeResponseSink()); + var tcp = TcpCoordinatorFakes.CreateCoordinator( new FakeListenerFactory(), new FakeRelayFactory(), new FakeInjector(), diff --git a/tests/WinForward.Core.Tests/FlowDispatcherExecutorTests.cs b/tests/WinForward.Core.Tests/FlowDispatcherExecutorTests.cs index 093044d..01b6762 100644 --- a/tests/WinForward.Core.Tests/FlowDispatcherExecutorTests.cs +++ b/tests/WinForward.Core.Tests/FlowDispatcherExecutorTests.cs @@ -413,7 +413,7 @@ public async Task ExecutorBlockAndProxyNeverReinject() public async Task ExecutorPassesTcpPacketWhenCoordinatorReportsNotRelevant() { var reinjector = new FakeReinjector(); - await using var coordinator = new TcpProxyCoordinator( + await using var coordinator = TcpCoordinatorFakes.CreateCoordinator( new ThrowingRedirectListenerFactory(), new ThrowingRelayFactory(), new ThrowingRedirectInjector(), new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider()); var executor = new NdisPacketActionExecutor(reinjector, tcpProxy: coordinator); diff --git a/tests/WinForward.Core.Tests/HotPathAllocationGateTests.cs b/tests/WinForward.Core.Tests/HotPathAllocationGateTests.cs index b734103..d8f8e85 100644 --- a/tests/WinForward.Core.Tests/HotPathAllocationGateTests.cs +++ b/tests/WinForward.Core.Tests/HotPathAllocationGateTests.cs @@ -31,7 +31,7 @@ public async Task MidFlowRewriteAndInjectAllocatesNoManagedBytes() { var injector = new CountingInjector(); var listenerFactory = new FakeListenerFactory(); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider()); await using (coordinator) { await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); @@ -58,7 +58,7 @@ public async Task ReverseRewriteAndInjectAllocatesNoManagedBytes() { var injector = new CountingInjector(); var listenerFactory = new FakeListenerFactory(); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider()); await using (coordinator) { await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); @@ -84,7 +84,7 @@ public async Task ReverseRewriteAndInjectAllocatesNoManagedBytes() public async Task EstablishedUdpDatagramPathAllocatesNoManagedBytes() { var factory = new CountingTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new NoopResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new NoopResponseSink()); var reinjector = new FakeReinjector(); var executor = new NdisPacketActionExecutor(reinjector, udpProxy: coordinator); @@ -131,7 +131,7 @@ public async Task UdpSetupEnqueuePathAllocatesNoManagedBytes() var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new StalledTransportFactory(gate.Task); using var pool = new NativeBufferPool(64, capacity: 64); - await using var coordinator = new UdpProxyCoordinator(factory, new NoopResponseSink(), new UdpProxyOptions { MaximumFrameSize = 64, SetupQueuePool = pool }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new NoopResponseSink(), new UdpProxyOptions { MaximumFrameSize = 64 }, setupQueuePool: pool); var flow = FlowKey.Create(Endpoint.From(s_clientIpv4, 53000), Endpoint.From(s_destIpv4, 53), TransportProtocol.Udp, FlowOriginKind.Host); var payload = new byte[32]; @@ -219,7 +219,7 @@ public async Task SynRetentionWithWarmSynCopyPoolAllocatesNoManagedBytes() var injector = new CountingInjector(); var listenerFactory = new GatedListenerFactory(); using var synCopyPool = new NativeBufferPool(NdisApiAbi.MaximumEthernetFrame, capacity: 8); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider(), new TcpRedirectOptions { SynCopyPool = synCopyPool }); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider(), synCopyPool: synCopyPool); try { var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); diff --git a/tests/WinForward.Core.Tests/IdleExpirySweeperFailureTests.cs b/tests/WinForward.Core.Tests/IdleExpirySweeperFailureTests.cs index 59f94a9..1831943 100644 --- a/tests/WinForward.Core.Tests/IdleExpirySweeperFailureTests.cs +++ b/tests/WinForward.Core.Tests/IdleExpirySweeperFailureTests.cs @@ -24,7 +24,7 @@ public async Task SweepFailureLogsRateLimitedWarnAndKeepsSweeping() var logger = new RecordingRuntimeLogger(); var transportFactory = new ParkedTransportFactory(); var sweepFailures = 0; - var coordinator = new UdpProxyCoordinator( + var coordinator = UdpCoordinatorFakes.CreateCoordinator( transportFactory, new NoopResponseSink(), new UdpProxyOptions diff --git a/tests/WinForward.Core.Tests/NdisPacketActionExecutorLoggingTests.cs b/tests/WinForward.Core.Tests/NdisPacketActionExecutorLoggingTests.cs index aab8d08..e614826 100644 --- a/tests/WinForward.Core.Tests/NdisPacketActionExecutorLoggingTests.cs +++ b/tests/WinForward.Core.Tests/NdisPacketActionExecutorLoggingTests.cs @@ -6,7 +6,6 @@ using WinForward.Runtime; using WinForward.Runtime.Capture; using WinForward.Runtime.TcpRedirect; -using WinForward.Runtime.UdpProxy; using Xunit; using static WinForward.Core.Tests.TcpCoordinatorFakes; @@ -39,7 +38,7 @@ public async Task UninitializedTcpProxyWarnKeepsNotInitializedText() public async Task CoordinatorBlockedWarnCarriesRedirectReasonInsteadOfUninitializedText() { var logger = new RecordingRuntimeLogger(); - await using var coordinator = new TcpProxyCoordinator( + await using var coordinator = CreateCoordinator( new FakeListenerFactory(), new FakeRelayFactory(), new FakeInjector(), @@ -70,7 +69,7 @@ public async Task UdpParseFailureWarnCarriesParseReason() { var logger = new RecordingRuntimeLogger(); var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var executor = new NdisPacketActionExecutor(new FakeReinjector(), logger, udpProxy: coordinator); // A TCP frame on a UDP-decided flow cannot be parsed as a UDP datagram. @@ -89,7 +88,7 @@ public async Task UdpParseFailureWarnCarriesParseReason() public async Task UdpHandlingFailureWarnIsRateLimitedPerWindow() { var logger = new RecordingRuntimeLogger(); - var coordinator = new UdpProxyCoordinator(new FakeTransportFactory(), new FakeResponseSink()); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(new FakeTransportFactory(), new FakeResponseSink()); await coordinator.DisposeAsync(); var executor = new NdisPacketActionExecutor(new FakeReinjector(), logger, udpProxy: coordinator); diff --git a/tests/WinForward.Core.Tests/RuntimeDiagnosticLoggingTests.cs b/tests/WinForward.Core.Tests/RuntimeDiagnosticLoggingTests.cs index 8f9f489..d770c25 100644 --- a/tests/WinForward.Core.Tests/RuntimeDiagnosticLoggingTests.cs +++ b/tests/WinForward.Core.Tests/RuntimeDiagnosticLoggingTests.cs @@ -29,7 +29,7 @@ public async Task RelaySetupFailureWarnCarriesDiagnostics() { var logger = new RecordingRuntimeLogger(); var listenerFactory = new FakeListenerFactory(); - await using var coordinator = new TcpProxyCoordinator( + await using var coordinator = CreateCoordinator( listenerFactory, new FakeRelayFactory(throwOnEstablish: true), new FakeInjector(), @@ -58,7 +58,7 @@ public async Task UnrelatedPeerWarnCarriesEndpoints() { var logger = new RecordingRuntimeLogger(); var listenerFactory = new FakeListenerFactory(); - await using var coordinator = new TcpProxyCoordinator( + await using var coordinator = CreateCoordinator( listenerFactory, new FakeRelayFactory(), new FakeInjector(), diff --git a/tests/WinForward.Core.Tests/Socks5UdpAssociateTests.cs b/tests/WinForward.Core.Tests/Socks5UdpAssociateTests.cs index f1917a0..9b929ff 100644 --- a/tests/WinForward.Core.Tests/Socks5UdpAssociateTests.cs +++ b/tests/WinForward.Core.Tests/Socks5UdpAssociateTests.cs @@ -4,7 +4,6 @@ using WinForward.Protocols; using WinForward.Runtime; using WinForward.Runtime.Socks5; -using WinForward.Runtime.UdpProxy; using Xunit; using static WinForward.Core.Tests.AsyncTestExtensions; using static WinForward.Core.Tests.Socks5TestServer; @@ -52,7 +51,7 @@ public async Task CoordinatorSendsIpv6DestinationThroughIpv4RelayAfterAllZeroAss serverCancellation.Token); var socksServer = new Socks5Server("test", controlEndpoint.Address.ToString(), checked((ushort)controlEndpoint.Port), Username: null, Password: null); var registry = new SelfTrafficRegistry(); - var coordinator = new UdpProxyCoordinator(new Socks5UdpTransportFactory(registry, UdpFrameBuilder.DefaultMaximumEthernetFrame), new NoopResponseSink()); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(new Socks5UdpTransportFactory(registry, UdpFrameBuilder.DefaultMaximumEthernetFrame), new NoopResponseSink()); var destination = Endpoint.From(IPAddress.Parse("2001:db8::53"), 5353); var flow = FlowKey.Create( Endpoint.From(IPAddress.Parse("2001:db8::10"), 53000), diff --git a/tests/WinForward.Core.Tests/TcpFragmentHandlingTests.cs b/tests/WinForward.Core.Tests/TcpFragmentHandlingTests.cs index 8be680d..6d97bba 100644 --- a/tests/WinForward.Core.Tests/TcpFragmentHandlingTests.cs +++ b/tests/WinForward.Core.Tests/TcpFragmentHandlingTests.cs @@ -263,7 +263,7 @@ private static FragmentHarness Create(bool forwarded, AddressFamilyKind addressF ? new FakeLocalAddressProvider(s_forwardLocal) : new FakeLocalAddressProvider(); var logger = new RecordingRuntimeLogger(); - var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, localAddresses, new TcpRedirectOptions { Logger = logger }); + var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, localAddresses, new TcpRedirectOptions { Logger = logger }); var reinjector = new CountingReinjector(); var executor = new NdisPacketActionExecutor(reinjector, logger, tcpProxy: coordinator); // Forwarded flows only evaluate adapter-qualified rules, so each shape needs its own diff --git a/tests/WinForward.Core.Tests/TcpPendingSynSetupTests.cs b/tests/WinForward.Core.Tests/TcpPendingSynSetupTests.cs index 51b413c..80d8997 100644 --- a/tests/WinForward.Core.Tests/TcpPendingSynSetupTests.cs +++ b/tests/WinForward.Core.Tests/TcpPendingSynSetupTests.cs @@ -47,6 +47,33 @@ public async ValueTask CreateAsync(AddressFamilyKind addre public void Release() => _release.TrySetResult(); } + /// + /// Captures the launched setup item instead of running it, so a test can inspect the item the + /// coordinator handed to the pool and then prove the parked work is cancelled by shutdown. + /// + private sealed class RecordingSetupExecutor : ISetupExecutor + { + private readonly TaskCompletionSource _enqueued = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Enqueued => _enqueued.Task; + + public SetupWorkItem RentItem(Func handler) + { + ArgumentNullException.ThrowIfNull(handler); + return new SetupWorkItem { _handler = handler }; + } + + public bool TryEnqueue(SetupWorkItem item) + { + _enqueued.TrySetResult(item); + return true; + } + + public void Dispose() + { + } + } + private static bool TryRetain(TcpPendingSynSetupIndex index, NativeBufferPool pool, FlowKey key, int byteCount, DateTimeOffset now, out PendingSynSetup? created) { var lease = pool.Rent(); @@ -242,7 +269,8 @@ public async Task CoordinatorCapRejectionFailsClosedWithTrace() var listenerFactory = new CancellableGatedListenerFactory(); var logger = new RecordingRuntimeLogger(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); + using var setupExecutor = new SetupExecutor(); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }, setupExecutor: setupExecutor); // Every launched setup parks inside the gated factory, so entries accumulate to the cap. for (var port = 53000; port < 53000 + 1024; port++) @@ -263,6 +291,24 @@ public async Task CoordinatorCapRejectionFailsClosedWithTrace() Assert.Equal(0, coordinator.Diagnostics.PendingSetupChargedBytes); } + [Fact] + public async Task LaunchedSetupItemCarriesTheShutdownTokenSoParkedSetupsUnwindOnDispose() + { + var listenerFactory = new GatedListenerFactory(); + using var setupExecutor = new RecordingSetupExecutor(); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), new TcpRedirectTable(), new SelfTrafficRegistry(), new FakeLocalAddressProvider(), setupExecutor: setupExecutor); + + var outcome = await coordinator.HandleSynAsync(MakeSynPacket(s_client, s_destination, 53000, 443), s_server, CancellationToken.None); + + Assert.Equal(TcpRedirectOutcome.SetupPending, outcome); + var item = await setupExecutor.Enqueued.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.False(item._cancellationToken.IsCancellationRequested); + + await coordinator.DisposeAsync(); + + Assert.True(item._cancellationToken.IsCancellationRequested); + } + [Fact] public async Task SynDispatchDoesNotWaitForListenerAllocation() { @@ -270,7 +316,7 @@ public async Task SynDispatchDoesNotWaitForListenerAllocation() // still parked — the historical inline setup would have blocked on the bind forever. var listenerFactory = new GatedListenerFactory(); var table = new TcpRedirectTable(); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); var dispatch = coordinator.HandleSynAsync(MakeSynPacket(s_client, s_destination, 53000, 443), s_server, CancellationToken.None).AsTask(); await dispatch.WaitAsync(TimeSpan.FromSeconds(2)); @@ -295,7 +341,7 @@ public async Task SweepExpiresStuckPendingEntryWhileSetupRemainsInFlight() // its launch-time copy, and dispose afterwards leaves zero charge and no cooldown. var listenerFactory = new CancellableGatedListenerFactory(); var table = new TcpRedirectTable(); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); await coordinator.HandleSynAsync(MakeSynPacket(s_client, s_destination, 53000, 443), s_server, CancellationToken.None); await listenerFactory.CreateStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); @@ -317,4 +363,46 @@ public async Task SweepExpiresStuckPendingEntryWhileSetupRemainsInFlight() Assert.Equal(0, coordinator.Diagnostics.PendingSetupChargedBytes); Assert.Equal(0, coordinator.Diagnostics.PendingSetupCooldownCount); } + + [Fact] + public async Task DisposeClearsTheCooldownAnEarlierFailureArmed() + { + // D3: a genuine setup failure arms the cooldown while the index is live, and disposal's + // index teardown clears it — the removed entry can never re-arm a cooldown afterwards. + var table = new TcpRedirectTable(); + var coordinator = CreateCoordinator(new FakeListenerFactory(throwOnCreate: true), new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + + await coordinator.HandleSynAsync(MakeSynPacket(s_client, s_destination, 53000, 443), s_server, CancellationToken.None); + await coordinator.DrainPendingSetupsAsync(); + + Assert.Equal(0, coordinator.Diagnostics.PendingSetupActiveCount); + Assert.Equal(1, coordinator.Diagnostics.PendingSetupCooldownCount); + + await coordinator.DisposeAsync(); + + Assert.Equal(0, coordinator.Diagnostics.PendingSetupCooldownCount); + Assert.Equal(0, coordinator.Diagnostics.PendingSetupChargedBytes); + } + + [Fact] + public async Task DisposeRacingAnInFlightSetupLeavesNoCooldownOrCharge() + { + // D3: a setup still parked in the listener factory when disposal begins completes its entry + // removal (with a shutdown-cancelled cooldown decision) before ExitSetup unblocks the + // drain, so the post-drain RemoveAll is never raced by a late write. + var listenerFactory = new GatedListenerFactory(); + var table = new TcpRedirectTable(); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + + await coordinator.HandleSynAsync(MakeSynPacket(s_client, s_destination, 53000, 443), s_server, CancellationToken.None); + await listenerFactory.CreateStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + var dispose = coordinator.DisposeAsync().AsTask(); + listenerFactory.Release(); + await dispose; + + Assert.Equal(0, coordinator.Diagnostics.PendingSetupActiveCount); + Assert.Equal(0, coordinator.Diagnostics.PendingSetupChargedBytes); + Assert.Equal(0, coordinator.Diagnostics.PendingSetupCooldownCount); + } } diff --git a/tests/WinForward.Core.Tests/TcpProxyCoordinatorCapacityTests.cs b/tests/WinForward.Core.Tests/TcpProxyCoordinatorCapacityTests.cs index 4d0a3d2..7818754 100644 --- a/tests/WinForward.Core.Tests/TcpProxyCoordinatorCapacityTests.cs +++ b/tests/WinForward.Core.Tests/TcpProxyCoordinatorCapacityTests.cs @@ -24,7 +24,7 @@ public async Task CapacityBoundBlocksFailClosed() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(capacity: 1); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Capacity = 1 }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Capacity = 1 }); var first = await coordinator.HandleSynAsync(MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server, CancellationToken.None); await coordinator.DrainPendingSetupsAsync(); @@ -47,7 +47,7 @@ public async Task CapacityRejectionIsCountedTracedAndSummarizedAtInfoLevel() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(capacity: 1); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger, Capacity = 1 }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger, Capacity = 1 }); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); Assert.Equal(TcpRedirectOutcome.Blocked, await coordinator.HandleSynAsync(MakeSynPacket(IPAddress.Parse("192.0.2.11"), IPAddress.Parse("192.0.2.99"), 53001, 80), s_server, CancellationToken.None)); @@ -74,7 +74,7 @@ public async Task OmittedCapacityKeepsLegacyDefaultBudget() { var listenerFactory = new FakeListenerFactory(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); Assert.Equal(0, coordinator.Diagnostics.CapacityRejectionCount); @@ -92,7 +92,7 @@ public async Task ReverseInjectionWin32FailureFailsExplicitlyWithReasonTombstone var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -134,7 +134,7 @@ public async Task SynInjectionWin32FailureFailsExplicitlyWithoutReset() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); Assert.Equal(TcpRedirectOutcome.SetupPending, await coordinator.HandleSynAsync(MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server, CancellationToken.None)); await coordinator.DrainPendingSetupsAsync(); @@ -163,7 +163,7 @@ public async Task LateForwardPacketAfterTeardownIsDroppedWithinGrace() var relayFactory = new CompletableRelayFactory(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -190,7 +190,7 @@ public async Task LateReversePacketAfterTeardownIsDroppedWithinGrace() var relayFactory = new CompletableRelayFactory(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -243,7 +243,7 @@ public async Task LatePacketFallsBackToNotRelevantAfterGraceExpiry() var relayFactory = new CompletableRelayFactory(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -271,7 +271,7 @@ public async Task RelaySetupFailureTombstonesTheFlow() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var relayFactory = new FakeRelayFactory(throwOnEstablish: true); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -292,7 +292,7 @@ public async Task ExecutorSilentlyConsumesTombstoneHitWithTraceAndNoReinjection( var logger = new RecordingRuntimeLogger(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -330,7 +330,7 @@ public async Task HoldsFlowTracksSessionAndGraceWindow() var relayFactory = new CompletableRelayFactory(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var key = MakeHostFlowKey(); Assert.False(coordinator.HoldsFlow(key)); @@ -363,7 +363,7 @@ public async Task HoldsFlowGraceBoundaryFollowsInjectedClock() var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var listenerFactory = new FakeListenerFactory(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider(), new TcpRedirectOptions { TimeProvider = time }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider(), new TcpRedirectOptions { TimeProvider = time }); // TryHit is `now < ExpiryUtc`, so a deadline one grace period after the fake now is inside // the window; advancing past the deadline (tombstone untouched) closes it. @@ -412,7 +412,7 @@ public async Task CapacityRejectedSynInjectsSingleRstPerTuplePerCooldownWindow() var selfTraffic = new SelfTrafficRegistry(); var logger = new RecordingRuntimeLogger(); var table = new TcpRedirectTable(capacity: 1); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger, Capacity = 1 }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger, Capacity = 1 }); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var rejectedTuple = FlowKey.Create(Endpoint.From(IPAddress.Parse("192.0.2.11"), 53001), Endpoint.From(IPAddress.Parse("192.0.2.99"), 80), TransportProtocol.Tcp, FlowOriginKind.Host); @@ -456,7 +456,7 @@ public async Task CapacityResetFollowsOriginDirectionMatrix() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(capacity: 1); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Capacity = 1 }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Capacity = 1 }); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var hostSyn = MakeSynPacket(IPAddress.Parse("192.0.2.11"), IPAddress.Parse("192.0.2.99"), 53001, 80); @@ -482,7 +482,7 @@ public async Task CapacityResetInjectionFailureWarnsWithoutChangingOutcome() var selfTraffic = new SelfTrafficRegistry(); var logger = new RecordingRuntimeLogger(); var table = new TcpRedirectTable(capacity: 1); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger, Capacity = 1 }); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger, Capacity = 1 }); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); Assert.Equal(TcpRedirectOutcome.Blocked, await coordinator.HandleSynAsync(MakeSynPacket(IPAddress.Parse("192.0.2.11"), IPAddress.Parse("192.0.2.99"), 53001, 80), s_server, CancellationToken.None)); diff --git a/tests/WinForward.Core.Tests/TcpProxyCoordinatorConcurrencyTests.cs b/tests/WinForward.Core.Tests/TcpProxyCoordinatorConcurrencyTests.cs index 840c1c4..303a6bd 100644 --- a/tests/WinForward.Core.Tests/TcpProxyCoordinatorConcurrencyTests.cs +++ b/tests/WinForward.Core.Tests/TcpProxyCoordinatorConcurrencyTests.cs @@ -23,7 +23,7 @@ public async Task SynClaimIsExactlyOnce() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var packet = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); var first = await coordinator.HandleSynAsync(packet, s_server, CancellationToken.None); @@ -62,7 +62,7 @@ public async Task TranslatedTupleAliasCollisionIsRejectedFailClosed() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var first = await coordinator.HandleSynAsync(MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server, CancellationToken.None); await coordinator.DrainPendingSetupsAsync(); @@ -86,7 +86,7 @@ public async Task SelfTrafficRegistryContainsListenerTupleBeforeInjection() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); @@ -104,7 +104,7 @@ public async Task ConcurrentSynBurstOnOneFlowUsesOneListener() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var packet = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); const int count = 32; @@ -138,7 +138,7 @@ public async Task ConcurrentSynBurstWhileListenerSetupIsParkedIsAbsorbedIntoOneS var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var packet = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); const int count = 8; @@ -173,7 +173,7 @@ public async Task PreClaimedAssociationReinjectsAgainstExistingClaim() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var key = FlowKey.Create(Endpoint.From(s_clientIpv4, 53000), Endpoint.From(s_destIpv4, 443), TransportProtocol.Tcp, FlowOriginKind.Host); var now = DateTimeOffset.UtcNow; Assert.True(table.TryClaim(key, key.Remote, 0x1234, Endpoint.From(IPAddress.Loopback, 42000), forwardLocalAddress: null, now, out _)); @@ -195,7 +195,7 @@ public async Task ExpiryRemovesIdleAssociations() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); Assert.Equal(1, table.Count); @@ -216,7 +216,7 @@ public async Task RemoveExpiredAsyncExpiresOnlyRedirectingNotRelayingSessions() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); // Session 1 remains Redirecting (no accepted connection ever relayed). await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); @@ -242,7 +242,7 @@ public async Task ExpiryRacingRelayEstablishmentCannotAttachDetachedRelay() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var listener = Assert.Single(listenerFactory.Listeners); diff --git a/tests/WinForward.Core.Tests/TcpProxyCoordinatorLifecycleTests.cs b/tests/WinForward.Core.Tests/TcpProxyCoordinatorLifecycleTests.cs index b5c2b52..e84c041 100644 --- a/tests/WinForward.Core.Tests/TcpProxyCoordinatorLifecycleTests.cs +++ b/tests/WinForward.Core.Tests/TcpProxyCoordinatorLifecycleTests.cs @@ -23,7 +23,7 @@ public async Task ListenerAllocationFailureFailsClosedWithCooldown() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var outcome = await coordinator.HandleSynAsync(MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server, CancellationToken.None); @@ -45,7 +45,7 @@ public async Task RelaySetupFailureBlocksAndReleasesAlias() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var relayFactory = new FakeRelayFactory(throwOnEstablish: true); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -69,7 +69,7 @@ public async Task RelaySetupFailureInjectsClientResetWhenSequencesKnown() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var relayFactory = new FakeRelayFactory(throwOnEstablish: true); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -105,7 +105,7 @@ public async Task ForwardedRelayFailureInjectsClientResetTowardOriginAdapter() var table = new TcpRedirectTable(); var relayFactory = new FakeRelayFactory(throwOnEstablish: true); var forwardLocal = IPAddress.Parse("192.0.2.1"); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(forwardLocal)); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(forwardLocal)); var client = IPAddress.Parse("192.0.2.10"); var syn = MakeForwardedSynPacket(client, s_destIpv4, 53000, 443); @@ -139,7 +139,7 @@ public async Task RelayFailureResetAcknowledgesObservedClientSequence() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var relayFactory = new FakeRelayFactory(throwOnEstablish: true); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -181,7 +181,7 @@ public async Task RelayFailureResetSequenceCoversObservedServerData() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var relayFactory = new FakeRelayFactory(throwOnEstablish: true); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -218,7 +218,7 @@ public async Task ExistingFlowInjectionFailureReleasesAssociation() var injector = new FakeInjector(throwOnCall: 2); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -237,7 +237,7 @@ public async Task CancelledRetransmitDoesNotRetireSharedRedirect() var injector = new FakeInjector(throwIfCanceled: true); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -258,7 +258,7 @@ public async Task CancellationAfterSynDoesNotStopSharedAcceptLoop() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); using var cancellation = new CancellationTokenSource(); @@ -286,7 +286,7 @@ public async Task RetireRemovesTableAliasAndArmsTombstoneBeforeListenerDisposalC var relayFactory = new CompletableRelayFactory(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var listener = Assert.Single(listenerFactory.Listeners); @@ -317,7 +317,7 @@ public async Task ShutdownDisposesAllSessionsAndListeners() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(IPAddress.Parse("192.0.2.10"), IPAddress.Parse("192.0.2.53"), 53000, 443), s_server); await HandleSynSettledAsync(coordinator, MakeSynPacket(IPAddress.Parse("192.0.2.11"), IPAddress.Parse("192.0.2.54"), 53001, 443), s_server); @@ -335,7 +335,7 @@ public async Task DisposeWaitsForInFlightSetupAndReleasesLateListener() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); // R8: the SYN dispatch returns immediately; the background setup task is the part that // parks inside the gated listener factory, and dispose must drain it (the inflight-setup @@ -359,7 +359,7 @@ public async Task UnrelatedAcceptedConnectionIsClosedBeforeRelaySetup() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var listener = Assert.Single(listenerFactory.Listeners); @@ -384,7 +384,7 @@ public async Task AcceptLoopDoesNotRunAwayOnTransientAcceptError() var listenerFactory = new SingleListenerFactory(throwingListener); var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, new TcpRedirectTable(), selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); diff --git a/tests/WinForward.Core.Tests/TcpProxyCoordinatorRewriteTests.cs b/tests/WinForward.Core.Tests/TcpProxyCoordinatorRewriteTests.cs index c369a0b..c189510 100644 --- a/tests/WinForward.Core.Tests/TcpProxyCoordinatorRewriteTests.cs +++ b/tests/WinForward.Core.Tests/TcpProxyCoordinatorRewriteTests.cs @@ -41,7 +41,7 @@ public async Task ReversePacketWithClassifierOrientationResolvesToOriginalFlow() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -61,7 +61,7 @@ public async Task ReversePacketResolvesToOriginalFlow() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -81,7 +81,7 @@ public async Task IPv4AndIPv6SynsAllocateMatchingFamilyListener() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv6, s_destIpv6, 53001, 443), s_server); @@ -98,7 +98,7 @@ public async Task ReverseRewriteRestoresOriginalRemoteEndpoint() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -129,7 +129,7 @@ public async Task ForwardedFlowSynRewritesTowardAdapterLocalListener() var table = new TcpRedirectTable(); var forwardLocal = IPAddress.Parse("192.0.2.1"); var localAddresses = new FakeLocalAddressProvider(forwardLocal); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, localAddresses); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, localAddresses); var client = IPAddress.Parse("192.0.2.10"); var syn = MakeForwardedSynPacket(client, s_destIpv4, 53000, 443, f => { f[0] = 0xAA; f[6] = 0xBB; }); @@ -156,7 +156,7 @@ public async Task ForwardedFlowWithoutLocalAddressFailsClosed() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeForwardedSynPacket(IPAddress.Parse("192.0.2.10"), s_destIpv4, 53000, 443); Assert.Equal(TcpRedirectOutcome.SetupPending, await coordinator.HandleSynAsync(syn, s_server, CancellationToken.None)); @@ -175,7 +175,7 @@ public async Task ForwardedFlowAcceptsClientTuplePeer() var relayFactory = new FakeRelayFactory(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(IPAddress.Parse("192.0.2.1"))); + await using var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(IPAddress.Parse("192.0.2.1"))); var client = IPAddress.Parse("192.0.2.10"); var syn = MakeForwardedSynPacket(client, s_destIpv4, 53000, 443); @@ -198,7 +198,7 @@ public async Task ForwardedFlowReverseInjectsTowardOriginAdapter() var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); var forwardLocal = IPAddress.Parse("192.0.2.1"); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(forwardLocal)); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider(forwardLocal)); var client = IPAddress.Parse("192.0.2.10"); var syn = MakeForwardedSynPacket(client, s_destIpv4, 53000, 443); @@ -230,7 +230,7 @@ public async Task SynRewriteParseFailureLeavesFrameByteIdentical() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var packet = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); Assert.True(MemoryMarshal.TryGetArray(packet.Lease.Frame, out var segment)); // An ARP ethertype cannot pass the rewrite's parse stage, so the rewrite fails before @@ -256,7 +256,7 @@ public async Task HostFlowReverseInjectsTowardMstcp() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var syn = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443); await HandleSynSettledAsync(coordinator, syn, s_server); @@ -280,7 +280,7 @@ public async Task HandleReverseIfApplicableAsyncReturnsNotRelevantForUdp() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var listenerPort = Assert.Single(listenerFactory.Listeners).TranslatedTuple.Port; @@ -306,7 +306,7 @@ public async Task Ipv6ReverseFrameWithCollidingPortDoesNotMatchIpv4Association() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var listenerPort = Assert.Single(listenerFactory.Listeners).TranslatedTuple.Port; @@ -327,7 +327,7 @@ public async Task SameFamilyUnrelatedReverseTupleDoesNotMatchAssociation() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443), s_server); var listenerPort = Assert.Single(listenerFactory.Listeners).TranslatedTuple.Port; @@ -350,7 +350,7 @@ public async Task SynWithPayloadIsRedirectedLikeBareSyn() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var packet = MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443, [0xde, 0xad, 0xbe, 0xef]); var outcome = await coordinator.HandlePacketAsync(packet, s_server, CancellationToken.None); @@ -383,7 +383,7 @@ public async Task RetransmittedSynWithPayloadReusesAssociation() var injector = new FakeInjector(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new FakeRelayFactory(), injector, table, selfTraffic, new FakeLocalAddressProvider()); var payload = new byte[] { 0x01, 0x02, 0x03 }; await HandleSynSettledAsync(coordinator, MakeSynPacket(s_clientIpv4, s_destIpv4, 53000, 443, payload), s_server); diff --git a/tests/WinForward.Core.Tests/TcpProxyRelayTests.cs b/tests/WinForward.Core.Tests/TcpProxyRelayTests.cs index 17b8f6b..9adafbe 100644 --- a/tests/WinForward.Core.Tests/TcpProxyRelayTests.cs +++ b/tests/WinForward.Core.Tests/TcpProxyRelayTests.cs @@ -172,6 +172,29 @@ public async Task PumpWindowPoolRentsOneLeasePerDirectionAndReturnsBothOnComplet Assert.Equal(0, pool.Stats.DisposedCount); } + [Fact] + public async Task DisposeLeavesCompletionCompletedAndReturnsPumpBuffers() + { + // R1: the relay owns its pump lifetimes. DisposeAsync must not return while a pump is + // still running: it awaits the completion, so the disposal-manufactured fault is observed + // and swallowed rather than surfacing as an unobserved task exception, and both pooled + // direction buffers are already back by the time it returns. + using var pool = new NativeBufferPool(TcpProxyRelayFactory.PumpBufferSize, capacity: 4); + var (localPeer, relayLocal) = await CreateSocketPairAsync(); + using var local = localPeer; + await using var relay = new TcpProxyRelay(relayLocal, new FaultingStream(), new NoopAsyncDisposable(), pumpBufferPool: pool); + + await WaitForAsync(() => pool.Stats.Outstanding == 2); + await local.SendAsync(new byte[] { 1 }, SocketFlags.None); + + // ReSharper disable once DisposeOnUsingVariable // The test awaits this DisposeAsync to assert the post-disposal state; the await using stays as the dispose-on-failure safety net and the repeat disposal is an idempotent no-op. + await relay.DisposeAsync(); + + Assert.True(relay.Completion.IsCompleted); + await WaitForAsync(() => pool.Stats.Outstanding == 0); + Assert.Equal(0, pool.Stats.DisposedCount); + } + private static async Task<(Socket Peer, Socket Relay)> CreateSocketPairAsync() { var listener = new TcpListener(IPAddress.Loopback, 0); diff --git a/tests/WinForward.Core.Tests/TcpRedirectAcceptorTests.cs b/tests/WinForward.Core.Tests/TcpRedirectAcceptorTests.cs new file mode 100644 index 0000000..84cf0f3 --- /dev/null +++ b/tests/WinForward.Core.Tests/TcpRedirectAcceptorTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using WinForward.Runtime.TcpRedirect; +using Xunit; +using static WinForward.Core.Tests.TcpCoordinatorFakes; + +namespace WinForward.Core.Tests; + +/// +/// The accept-loop attach contract (R7): a relay the store refuses to attach is discarded together +/// with its accepted connection and the session is torn down, so a refused attach can never leave a +/// half-open redirect registered. +/// +public sealed class TcpRedirectAcceptorTests +{ + [Fact] + public async Task UnattachableRelayIsDiscardedAndTheSessionIsTornDown() + { + var table = new TcpRedirectTable(); + var logger = new RecordingRuntimeLogger(); + var store = new TcpRedirectSessionStore(table, logger, capacity: 8, TimeProvider.System); + var listener = new FakeListener(Endpoint.From(IPAddress.Loopback, 40_000)); + var association = CreateHostAssociation(listener.TranslatedTuple); + var session = CreateSession(association, listener); + Assert.NotNull(store.TryRegister(session)); + var relayFactory = new CompletableRelayFactory(); + var acceptor = new TcpRedirectAcceptor( + relayFactory, + logger, + new ClientResetInjector(new FakeInjector(), logger, store.TearDownSessionAsync, store.FailAssociationAsync), + tryAttachRelay: static (_, _) => false, + tearDownSession: store.TearDownSessionAsync); + + var accepted = new FakeAcceptedConnection(association.AcceptedPeerEndpoint); + await listener.AcceptChannel.Writer.WriteAsync(accepted); + + await acceptor.RunAcceptLoopAsync(session); + + Assert.Equal(0, store.SessionCount); + Assert.Equal(0, table.Count); + Assert.True(listener.IsDisposed); + Assert.True(accepted.IsDisposed); + Assert.True(relayFactory.Relay is { IsDisposed: true }); + } +} diff --git a/tests/WinForward.Core.Tests/TcpRedirectSessionStoreTests.cs b/tests/WinForward.Core.Tests/TcpRedirectSessionStoreTests.cs new file mode 100644 index 0000000..e97cbd1 --- /dev/null +++ b/tests/WinForward.Core.Tests/TcpRedirectSessionStoreTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using WinForward.Runtime.TcpRedirect; +using Xunit; + +namespace WinForward.Core.Tests; + +/// +/// R2: the store is the single teardown authority. DisposeAsync is single-flight and, once it has +/// begun, a late teardown or fail-closed release must not re-enter — no second relay release, no +/// second session, no second tombstone. +/// +public sealed class TcpRedirectSessionStoreTests +{ + [Fact] + public async Task DisposeIsSingleFlightAndLateTeardownNeverReEnters() + { + var table = new TcpRedirectTable(capacity: 8); + var store = new TcpRedirectSessionStore(table, new RecordingRuntimeLogger(), capacity: 8, TimeProvider.System); + var association = ClaimAssociation(table); + var listener = new FakeListener(association.TranslatedListenerTuple); + var session = TcpCoordinatorFakes.CreateSession(association, listener); + Assert.Same(session, store.TryRegister(session)); + var relay = new CountingRelay(); + Assert.True(store.TryAttachRelay(session, relay)); + + var first = store.DisposeAsync(); + var second = store.DisposeAsync(); + await first; + await second; + + Assert.True(store.IsDisposed); + Assert.True(listener.IsDisposed); + Assert.Equal(0, store.SessionCount); + Assert.Equal(1, relay.DisposeCount); + Assert.Equal(0, table.Count); + Assert.True(store.Tombstones.TryHit(association.OriginalKey, DateTimeOffset.UtcNow)); + + await store.TearDownSessionAsync(session); + await store.FailAssociationAsync(association); + await store.RemoveExpiredAsync(DateTimeOffset.UtcNow, TimeSpan.Zero, prunePending: null); + + Assert.Equal(0, store.SessionCount); + Assert.Equal(1, relay.DisposeCount); + } + + [Fact] + public async Task RegistrationLosingTheDisposeRaceLeavesTheAssociationReleasable() + { + // The setup path can lose the registration race: the store was disposed while the session + // was being prepared (its table alias is already claimed). TryRegister must retire it, and + // the caller's ReleaseAssociationAsync must still consume the alias and the token. + var table = new TcpRedirectTable(capacity: 8); + var store = new TcpRedirectSessionStore(table, new RecordingRuntimeLogger(), capacity: 8, TimeProvider.System); + var association = ClaimAssociation(table); + var listener = new FakeListener(association.TranslatedListenerTuple); + var session = TcpCoordinatorFakes.CreateSession(association, listener); + + await store.DisposeAsync(); + Assert.Null(store.TryRegister(session)); + + await store.ReleaseAssociationAsync(listener, association, session.SelfTrafficToken); + + Assert.True(session.IsRetired); + Assert.Equal(0, table.Count); + Assert.True(store.Tombstones.TryHit(association.OriginalKey, DateTimeOffset.UtcNow)); + } + + private static TcpRedirectAssociation ClaimAssociation(TcpRedirectTable table) + { + var local = Endpoint.From(IPAddress.Parse("192.0.2.10"), 53000); + var remote = Endpoint.From(IPAddress.Parse("192.0.2.53"), 443); + var key = FlowKey.Create(local, remote, TransportProtocol.Tcp, FlowOriginKind.Host); + Assert.True(table.TryClaim(key, key.Remote, 0, Endpoint.From(IPAddress.Loopback, 40000), forwardLocalAddress: null, DateTimeOffset.UtcNow, out var association)); + return association!; + } + + private sealed class CountingRelay : ITcpRelay + { + private int _disposeCount; + + public Task Completion => Task.CompletedTask; + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref _disposeCount); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/WinForward.Core.Tests/TcpRedirectSessionTests.cs b/tests/WinForward.Core.Tests/TcpRedirectSessionTests.cs new file mode 100644 index 0000000..c508824 --- /dev/null +++ b/tests/WinForward.Core.Tests/TcpRedirectSessionTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using WinForward.Runtime.TcpRedirect; +using Xunit; + +namespace WinForward.Core.Tests; + +/// +/// R1: the accept loop owns the lifetime CTS disposal — its finally runs when the linked shutdown +/// cancellation ends the loop first, so a retire that arrives afterwards must tolerate an already +/// disposed source. Retire and DisposeLifetime must also be idempotent in either order. +/// +public sealed class TcpRedirectSessionTests +{ + [Fact] + public void RetireAfterTheLinkedShutdownAlreadyEndedTheLoopDoesNotThrow() + { + using var shutdown = new CancellationTokenSource(); + var session = CreateSession(shutdown.Token); + var lifetime = session.Token; + + shutdown.Cancel(); + session.DisposeLifetime(); + session.Retire(); + + Assert.True(session.IsRetired); + Assert.True(lifetime.IsCancellationRequested); + } + + [Fact] + public void RetireCancelsTheLifetimeAndDisposeLifetimeIsIdempotent() + { + var session = CreateSession(CancellationToken.None); + var lifetime = session.Token; + + session.Retire(); + session.Retire(); + + Assert.True(session.IsRetired); + Assert.True(lifetime.IsCancellationRequested); + + session.DisposeLifetime(); + session.DisposeLifetime(); + } + + [Fact] + public void RetireAfterDisposeLifetimeLeavesTheDisposedSourceUntouched() + { + using var shutdown = new CancellationTokenSource(); + var session = CreateSession(shutdown.Token); + var lifetime = session.Token; + + session.DisposeLifetime(); + session.Retire(); + + Assert.False(lifetime.IsCancellationRequested); + } + + private static TcpRedirectSession CreateSession(CancellationToken shutdown) + { + var association = TcpCoordinatorFakes.CreateHostAssociation(Endpoint.From(IPAddress.Loopback, 40000)); + return TcpCoordinatorFakes.CreateSession(association, new FakeListener(association.TranslatedListenerTuple), shutdown); + } +} diff --git a/tests/WinForward.Core.Tests/TcpRedirectSetupTests.cs b/tests/WinForward.Core.Tests/TcpRedirectSetupTests.cs new file mode 100644 index 0000000..52ff4aa --- /dev/null +++ b/tests/WinForward.Core.Tests/TcpRedirectSetupTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using WinForward.Configuration; +using WinForward.Runtime; +using WinForward.Runtime.TcpRedirect; +using Xunit; +using static WinForward.Core.Tests.TcpCoordinatorFakes; + +namespace WinForward.Core.Tests; + +/// +/// The registration contract of the redirect setup (R7): a fault between the flow claim and a +/// registered session releases the claimed listener, the table alias, and the self-traffic token +/// exactly once, so a hard fault cannot leave half-registered redirect state behind. +/// +public sealed class TcpRedirectSetupTests +{ + private static readonly Socks5Server s_server = new("test", "127.0.0.1", 1080, Username: null, Password: null); + + [Fact] + public async Task RegistrationFaultReleasesTheClaimedListenerAliasAndToken() + { + var table = new TcpRedirectTable(); + var selfTraffic = new SelfTrafficRegistry(); + var logger = new RecordingRuntimeLogger(); + var store = new TcpRedirectSessionStore(table, logger, capacity: 8, TimeProvider.System); + var listenerFactory = new FakeListenerFactory(); + var setup = new TcpRedirectSetup( + listenerFactory, + table, + selfTraffic, + new FakeLocalAddressProvider(), + new FakeInjector(), + logger, + store, + new ClientResetInjector(new FakeInjector(), logger, store.TearDownSessionAsync, store.FailAssociationAsync), + TestPools.SynCopyPool, + TimeProvider.System); + // A disposed store's shutdown token is the deterministic stand-in for a hard fault between + // the claim and a registered session; registration itself cannot be made to fail on demand. + await store.DisposeAsync(); + var packet = MakeSynPacket(IPAddress.Parse("192.0.2.10"), IPAddress.Parse("192.0.2.53"), 53000, 443); + + await Assert.ThrowsAsync(() => setup.SetupNewRedirectAsync(packet, s_server, CancellationToken.None).AsTask()); + + Assert.Equal(0, table.Count); + var listener = Assert.Single(listenerFactory.Listeners); + Assert.True(listener.IsDisposed); + Assert.False(selfTraffic.IsOwned(OwnershipContext(listener.TranslatedTuple))); + } + + private static FlowContext OwnershipContext(Endpoint translatedTuple) + => new(FlowKey.Create(translatedTuple, translatedTuple, TransportProtocol.Tcp, FlowOriginKind.Host), ProcessName: null, ProcessPath: null, AdapterId: null, AdapterName: null, translatedTuple.Port); +} diff --git a/tests/WinForward.Core.Tests/TcpRelayObservationTests.cs b/tests/WinForward.Core.Tests/TcpRelayObservationTests.cs index ffab39c..6c70145 100644 --- a/tests/WinForward.Core.Tests/TcpRelayObservationTests.cs +++ b/tests/WinForward.Core.Tests/TcpRelayObservationTests.cs @@ -2,7 +2,6 @@ using System.Net.Sockets; using System.Runtime.Versioning; using WinForward.Configuration; -using WinForward.Runtime; using WinForward.Runtime.TcpRedirect; using Xunit; @@ -11,8 +10,8 @@ namespace WinForward.Core.Tests; /// /// S3: a faulted relay completion must be observed on every path that discards a relay without /// awaiting it — the acceptor's attach-failure branch and the relay's own dispose. Both are -/// asserted through the debug event the fault observer emits, so no unobserved-exception -/// finalizer timing is involved. +/// asserted through the debug event the fault observer emits; the exception-before-gate ordering +/// is asserted separately through a differential unobserved-fault probe. /// public sealed class TcpRelayObservationTests { @@ -35,7 +34,7 @@ public async Task AttachFailureObservesFaultedRelayCompletion() // The discarded relay was disposed and its completion observed before the fault lands. Assert.True(relay.IsDisposed); relay.Fault(new IOException("pump died")); - AssertContainsFaultedEvent(logger); + AssertExactlyOneFaultedEvent(logger); } [Fact] @@ -56,7 +55,7 @@ public async Task AttachFailureObservesAlreadyFaultedRelayCompletion() await listener.AcceptChannel.Writer.WriteAsync(new FakeAcceptedConnection(session.Association.AcceptedPeerEndpoint), CancellationToken.None); await acceptor.RunAcceptLoopAsync(session); - AssertContainsFaultedEvent(logger); + AssertExactlyOneFaultedEvent(logger); } [Fact] @@ -73,7 +72,7 @@ public async Task RelayDisposeObservesFaultedCompletion() await relay.DisposeAsync(); await Assert.ThrowsAnyAsync(() => relay.Completion.WaitAsync(TimeSpan.FromSeconds(2))); - AssertContainsFaultedEvent(logger); + AssertExactlyOneFaultedEvent(logger); } [Fact] @@ -97,19 +96,31 @@ public async Task RelayDisposeObservesNothingWhenCompletionSucceeds() Assert.DoesNotContain(logger.Events, e => string.Equals(e.Name, "tcp.relay.faulted", StringComparison.Ordinal)); } - private static void AssertContainsFaultedEvent(RecordingRuntimeLogger logger) => - Assert.Contains(logger.Events, e => string.Equals(e.Name, "tcp.relay.faulted", StringComparison.Ordinal) - && e.Fields.Any(field => string.Equals(field.Key, "error", StringComparison.Ordinal))); + [Fact] + public void FaultObserverSuppressesTheEventWhenDebugLoggingIsDisabled() + { + // S3: the production default threshold (info) disables debug, so the discard paths must not + // depend on the event being emitted — the continuation still consumes the fault (it reads + // task.Exception before consulting IsEnabled in TcpRelayFaultObserver.Observe) and simply + // skips the event. + var logger = new RecordingRuntimeLogger(level => level != RuntimeLogLevel.Debug); + var relay = new FaultableRelay(); + TcpRelayFaultObserver.Observe(relay, logger); + + relay.Fault(new IOException("pump died")); + + Assert.DoesNotContain(logger.Events, e => string.Equals(e.Name, "tcp.relay.faulted", StringComparison.Ordinal)); + } + + private static void AssertExactlyOneFaultedEvent(RecordingRuntimeLogger logger) => + Assert.Equal(1, logger.Events.Count(e => string.Equals(e.Name, "tcp.relay.faulted", StringComparison.Ordinal) + && e.Fields.Any(field => string.Equals(field.Key, "error", StringComparison.Ordinal)))); private static TcpRedirectSession CreateSession(out FakeListener listener) { - var client = IPAddress.Parse("192.0.2.10"); - var destination = IPAddress.Parse("192.0.2.53"); - var key = FlowKey.Create(Endpoint.From(client, 53000), Endpoint.From(destination, 443), TransportProtocol.Tcp, FlowOriginKind.Host); - var association = new TcpRedirectAssociation(key, key.Remote, 0x1234, Endpoint.From(IPAddress.Loopback, 40000), forwardLocalAddress: null, 1, DateTimeOffset.UtcNow); + var association = TcpCoordinatorFakes.CreateHostAssociation(Endpoint.From(IPAddress.Loopback, 40000)); listener = new FakeListener(association.TranslatedListenerTuple); - var token = new SelfTrafficRegistry().Register(new SelfTrafficRegistry.SelfTrafficKey(TransportProtocol.Tcp, association.TranslatedListenerTuple, association.TranslatedListenerTuple)); - return new TcpRedirectSession(association, listener, token, new Socks5Server("primary", "127.0.0.1", 1080, Username: null, Password: null), 0, CancellationToken.None); + return TcpCoordinatorFakes.CreateSession(association, listener); } private static async Task<(Socket Peer, Socket Relay)> CreateSocketPairAsync() diff --git a/tests/WinForward.Core.Tests/TcpReversePrefilterTests.cs b/tests/WinForward.Core.Tests/TcpReversePrefilterTests.cs index 5e41f35..159e519 100644 --- a/tests/WinForward.Core.Tests/TcpReversePrefilterTests.cs +++ b/tests/WinForward.Core.Tests/TcpReversePrefilterTests.cs @@ -94,7 +94,7 @@ public async Task WantsPacketRequiresTcpAndAClaimedListenerSourcePort() { var table = new TcpRedirectTable(); var listenerFactory = new FakeListenerFactory(); - await using var coordinator = new TcpProxyCoordinator(listenerFactory, new CompletableRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); + await using var coordinator = CreateCoordinator(listenerFactory, new CompletableRelayFactory(), new FakeInjector(), table, new SelfTrafficRegistry(), new FakeLocalAddressProvider()); Assert.True(TryClaimListener(table, MakeOriginalKey(53000), IPAddress.Loopback, 40000)); // Stage (b): TCP with a claimed source port diverts; any other shape stays warm. The UDP diff --git a/tests/WinForward.Core.Tests/TestHelpers/RecordingLogger.cs b/tests/WinForward.Core.Tests/TestHelpers/RecordingLogger.cs index f1c7289..0347ea3 100644 --- a/tests/WinForward.Core.Tests/TestHelpers/RecordingLogger.cs +++ b/tests/WinForward.Core.Tests/TestHelpers/RecordingLogger.cs @@ -7,9 +7,11 @@ namespace WinForward.Core.Tests; /// Captures every structured event and plain-text line with its level; /// derives from the recorded lines so sink tests can assert the rate-limited warning fired. /// Recording is lock-guarded and / return snapshots, so a -/// test may enumerate while a background capture/proxy thread is still logging. +/// test may enumerate while a background capture/proxy thread is still logging. The optional +/// isEnabled predicate lets a test model a threshold (the production default disables +/// debug), while recording itself stays unconditional. /// -internal sealed class RecordingRuntimeLogger : IRuntimeLogger +internal sealed class RecordingRuntimeLogger(Func? isEnabled = null) : IRuntimeLogger { private readonly Lock _gate = new(); private readonly List<(RuntimeLogLevel Level, string Name, RuntimeLogField[] Fields)> _events = []; @@ -33,7 +35,7 @@ internal sealed class RecordingRuntimeLogger : IRuntimeLogger public int WarnCount => Lines.Count(line => line.Level == RuntimeLogLevel.Warn); - public bool IsEnabled(RuntimeLogLevel level) => true; + public bool IsEnabled(RuntimeLogLevel level) => isEnabled?.Invoke(level) ?? true; public void Trace(string message) => Add(RuntimeLogLevel.Trace, message); diff --git a/tests/WinForward.Core.Tests/TestHelpers/TcpCoordinatorFakes.cs b/tests/WinForward.Core.Tests/TestHelpers/TcpCoordinatorFakes.cs index 50374b9..61bcc3c 100644 --- a/tests/WinForward.Core.Tests/TestHelpers/TcpCoordinatorFakes.cs +++ b/tests/WinForward.Core.Tests/TestHelpers/TcpCoordinatorFakes.cs @@ -6,6 +6,7 @@ using WinForward.Runtime; using WinForward.Runtime.Capture; using WinForward.Runtime.TcpRedirect; +using WinForward.Windows; using Xunit; using static WinForward.Core.Tests.AsyncTestExtensions; using static WinForward.Core.Tests.FrameBuilders; @@ -87,6 +88,52 @@ internal static CapturedFlowPacket MakeForwardTcpPacket(IPAddress client, IPAddr internal static FlowKey MakeHostFlowKey() => FlowKey.Create(Endpoint.From(s_clientIpv4, 53000), Endpoint.From(s_destIpv4, 443), TransportProtocol.Tcp, FlowOriginKind.Host); + /// + /// Builds a coordinator whose borrowed syn-copy pool and setup executor default to the + /// process-wide instances; tests that assert pool accounting pass + /// their own through or . + /// + internal static TcpProxyCoordinator CreateCoordinator( + ITcpRedirectListenerFactory listenerFactory, + ITcpProxyRelayFactory relayFactory, + ITcpRedirectInjector injector, + TcpRedirectTable table, + SelfTrafficRegistry selfTraffic, + IAdapterLocalAddressProvider localAddresses, + TcpRedirectOptions? options = null, + NativeBufferPool? synCopyPool = null, + ISetupExecutor? setupExecutor = null) + => new( + listenerFactory, + relayFactory, + injector, + table, + selfTraffic, + localAddresses, + synCopyPool ?? TestPools.SynCopyPool, + setupExecutor ?? TestPools.SetupExecutor, + options); + + /// + /// Builds a host-shape redirect association for the standard client/destination pair against + /// ; the tests that exercise session and store + /// lifetime use it without a coordinator. + /// + internal static TcpRedirectAssociation CreateHostAssociation(Endpoint translatedListenerTuple) + => CreateHostAssociation(Endpoint.From(s_clientIpv4, 53000), Endpoint.From(s_destIpv4, 443), translatedListenerTuple); + + private static TcpRedirectAssociation CreateHostAssociation(Endpoint local, Endpoint remote, Endpoint translatedListenerTuple) + { + var key = FlowKey.Create(local, remote, TransportProtocol.Tcp, FlowOriginKind.Host); + return new TcpRedirectAssociation(key, key.Remote, 0x1234, translatedListenerTuple, forwardLocalAddress: null, 1, DateTimeOffset.UtcNow); + } + + internal static TcpRedirectSession CreateSession(TcpRedirectAssociation association, ITcpRedirectListener listener, CancellationToken shutdown = default) + { + var token = new SelfTrafficRegistry().Register(new SelfTrafficRegistry.SelfTrafficKey(TransportProtocol.Tcp, association.TranslatedListenerTuple, association.TranslatedListenerTuple)); + return new TcpRedirectSession(association, listener, token, new Socks5Server("primary", "127.0.0.1", 1080, Username: null, Password: null), 0, shutdown); + } + internal static DispatcherHarness CreateDispatcherHarness() { var listenerFactory = new FakeListenerFactory(); @@ -95,7 +142,7 @@ internal static DispatcherHarness CreateDispatcherHarness() var logger = new RecordingRuntimeLogger(); var selfTraffic = new SelfTrafficRegistry(); var table = new TcpRedirectTable(); - var coordinator = new TcpProxyCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); + var coordinator = CreateCoordinator(listenerFactory, relayFactory, injector, table, selfTraffic, new FakeLocalAddressProvider(), new TcpRedirectOptions { Logger = logger }); var server = new Socks5Server("primary", "127.0.0.1", 1080, Username: null, Password: null); var servers = new Dictionary(StringComparer.OrdinalIgnoreCase) { [server.Name] = server }; diff --git a/tests/WinForward.Core.Tests/TestHelpers/TestPools.cs b/tests/WinForward.Core.Tests/TestHelpers/TestPools.cs new file mode 100644 index 0000000..0c480b7 --- /dev/null +++ b/tests/WinForward.Core.Tests/TestHelpers/TestPools.cs @@ -0,0 +1,26 @@ +using WinForward.NdisApi; +using WinForward.Runtime; +using WinForward.Runtime.UdpProxy; + +namespace WinForward.Core.Tests; + +/// +/// Process-wide native pools and one shared setup executor for coordinator construction in tests +/// (Phase A / R6): a coordinator borrows its pools and executor from composition and never +/// disposes them, so tests that do not assert pool behavior share these long-lived instances +/// instead of owning a per-test one. The shared UDP pools are sized for the largest frame cap any +/// test pins; tests that assert pool accounting pass their own pool. +/// +internal static class TestPools +{ + /// Largest frame cap a test pins on a coordinator; shared UDP pools must fit every default-sized site. + private const int MaximumTestFrameSize = 4096; + + internal static NativeBufferPool SynCopyPool { get; } = new(NdisApiAbi.MaximumEthernetFrame); + + internal static NativeBufferPool UdpSetupQueuePool { get; } = new(MaximumTestFrameSize); + + internal static NativeBufferPool UdpReceiveWindowPool { get; } = new(UdpProxyCoordinator.ReceiveWindowSize(MaximumTestFrameSize)); + + internal static ISetupExecutor SetupExecutor { get; } = new SetupExecutor(); +} diff --git a/tests/WinForward.Core.Tests/TestHelpers/UdpCoordinatorFakes.cs b/tests/WinForward.Core.Tests/TestHelpers/UdpCoordinatorFakes.cs index 5620b6d..498c32c 100644 --- a/tests/WinForward.Core.Tests/TestHelpers/UdpCoordinatorFakes.cs +++ b/tests/WinForward.Core.Tests/TestHelpers/UdpCoordinatorFakes.cs @@ -1,9 +1,33 @@ using System.Net.Sockets; using WinForward.Configuration; +using WinForward.Runtime; using WinForward.Runtime.Socks5; +using WinForward.Runtime.UdpProxy; namespace WinForward.Core.Tests; +/// +/// Builds a UDP coordinator whose borrowed pools and setup executor default to the process-wide +/// instances; tests that assert pool accounting pass their own. +/// +internal static class UdpCoordinatorFakes +{ + internal static UdpProxyCoordinator CreateCoordinator( + IUdpProxyTransportFactory transportFactory, + IUdpResponseSink responseSink, + UdpProxyOptions? options = null, + NativeBufferPool? setupQueuePool = null, + NativeBufferPool? receiveWindowPool = null, + ISetupExecutor? setupExecutor = null) + => new( + transportFactory, + responseSink, + setupQueuePool ?? TestPools.UdpSetupQueuePool, + receiveWindowPool ?? TestPools.UdpReceiveWindowPool, + setupExecutor ?? TestPools.SetupExecutor, + options); +} + /// /// Coordinator-lifecycle fakes: a factory whose first CreateAsync blocks until failed /// (cancelled waiter), one that stalls until cancelled (disposal semantics), one that reuses a diff --git a/tests/WinForward.Core.Tests/UdpProxyCoordinatorLifecycleTests.cs b/tests/WinForward.Core.Tests/UdpProxyCoordinatorLifecycleTests.cs index 0e45a4a..e632c58 100644 --- a/tests/WinForward.Core.Tests/UdpProxyCoordinatorLifecycleTests.cs +++ b/tests/WinForward.Core.Tests/UdpProxyCoordinatorLifecycleTests.cs @@ -24,7 +24,7 @@ await WaitForAsync(() => public async Task RemoveExpiredDisposesIdleSessionAndReleasesAssociation() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); await WaitForAsync(() => factory.Transports.Count == 1); @@ -46,7 +46,7 @@ public async Task RemoveExpiredDisposesIdleSessionAndReleasesAssociation() public async Task RemoveExpiredKeepsActiveSession() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); await WaitForAsync(() => factory.Transports.Count == 1); @@ -62,7 +62,7 @@ public async Task RemoveExpiredKeepsActiveSession() public async Task FailedSetupReleasesSlotAndCapacityForOtherFlows() { var factory = new GatedTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 1 }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 1 }); var first = CreateFlow("192.0.2.53"); var second = CreateFlow("192.0.2.54"); @@ -82,7 +82,7 @@ public async Task FailedSetupReleasesSlotAndCapacityForOtherFlows() public async Task ReceiveFaultDisposesAndRemovesSessionWithoutAnotherSend() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 1 }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 1 }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -102,10 +102,11 @@ public async Task ImmediateReceiveFaultRemovesSessionAfterCoordinatorRegistratio { var factory = new ImmediateFaultTransportFactory(); using var pool = new NativeBufferPool(1537); - await using var coordinator = new UdpProxyCoordinator( + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator( factory, new FakeResponseSink(), - new UdpProxyOptions { Capacity = 1, ReceiveWindowPool = pool }); + new UdpProxyOptions { Capacity = 1 }, + receiveWindowPool: pool); var flow = CreateFlow("192.0.2.53"); // The datagram is accepted and buffered; the receive fault surfaces through the @@ -122,7 +123,7 @@ public async Task ImmediateReceiveFaultRemovesSessionAfterCoordinatorRegistratio public async Task CoordinatorDisposalPreservesSetupCancellation() { var factory = new CancellationAwareTransportFactory(); - var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); Assert.True(await coordinator.TrySendSpanAsync(CreateFlow("192.0.2.53"), s_server, [1], default, CancellationToken.None)); await factory.CreateStarted.Task.WaitAsync(CancellationToken.None); @@ -134,7 +135,7 @@ public async Task CoordinatorDisposalPreservesSetupCancellation() public async Task ConcurrentDisposalIsSingleFlightAndRejectsNewSends() { var factory = new FakeTransportFactory(); - var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var firstDispose = DisposeCoordinatorAsync(coordinator); var secondDispose = DisposeCoordinatorAsync(coordinator); @@ -150,7 +151,7 @@ public async Task SuccessfulSendRefreshesAssociationAndAvoidsStaleExpiry() { var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 1, TimeProvider = time }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 1, TimeProvider = time }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -172,7 +173,7 @@ public async Task ActiveSessionRetainsRelayAliasAfterItsCreationTimestampExpires { var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var factory = new CollidingAliasTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 2, TimeProvider = time }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 2, TimeProvider = time }); var first = CreateFlow("192.0.2.53"); var second = CreateFlow("192.0.2.54"); @@ -230,7 +231,7 @@ public async Task ExpirySnapshotDoesNotDisposeSessionWhoseSendRefreshesActivity( var factory = new FakeTransportFactory(); TaskCompletionSource? snapshotTaken = null; TaskCompletionSource? resumeSweep = null; - await using var coordinator = new UdpProxyCoordinator( + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator( factory, new FakeResponseSink(), new UdpProxyOptions @@ -272,7 +273,7 @@ public async Task ExpirySnapshotDoesNotDisposeSessionWhoseReceiveRefreshesActivi var sink = new FakeResponseSink(); TaskCompletionSource? snapshotTaken = null; TaskCompletionSource? resumeSweep = null; - await using var coordinator = new UdpProxyCoordinator( + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator( factory, sink, new UdpProxyOptions diff --git a/tests/WinForward.Core.Tests/UdpProxyCoordinatorTests.cs b/tests/WinForward.Core.Tests/UdpProxyCoordinatorTests.cs index 8743fc8..b7288b6 100644 --- a/tests/WinForward.Core.Tests/UdpProxyCoordinatorTests.cs +++ b/tests/WinForward.Core.Tests/UdpProxyCoordinatorTests.cs @@ -20,7 +20,7 @@ public async Task SameFlowBurstUsesOneTransportAndPreservesDatagrams() { var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); - await using var coordinator = new UdpProxyCoordinator(factory, sink); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [0x12, 0x34], default, CancellationToken.None)); @@ -45,7 +45,7 @@ public async Task ConcurrentSameFlowBurstUsesOneTransportWithoutResponseCrossWir { var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); - await using var coordinator = new UdpProxyCoordinator(factory, sink); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink); var flow = CreateFlow("192.0.2.53"); const int count = 32; @@ -78,7 +78,7 @@ await WaitForAsync(() => public async Task ConcurrentDifferentRemoteEndpointsRemainOnDistinctTransports() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var sends = s_remoteAddresses .Select((address, index) => coordinator.TrySendSpanAsync(CreateFlow(address), s_server, [(byte)index], default, CancellationToken.None).AsTask()) .ToArray(); @@ -92,7 +92,7 @@ public async Task ConcurrentDifferentRemoteEndpointsRemainOnDistinctTransports() public async Task Ipv6OriginalFlowCanUseIpv4RelayAliasWithoutFlowKeyMismatch() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = FlowKey.Create(Endpoint.From(IPAddress.Parse("2001:db8::10"), 53000), Endpoint.From(IPAddress.Parse("2001:db8::53"), 53), TransportProtocol.Udp, FlowOriginKind.Host); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -114,7 +114,7 @@ await WaitForAsync(() => public async Task Ipv6OriginalFlowStillSupportsMatchingIpv6Relay() { var factory = new FakeTransportFactory(AddressFamily.InterNetworkV6); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = FlowKey.Create(Endpoint.From(IPAddress.Parse("2001:db8::10"), 53000), Endpoint.From(IPAddress.Parse("2001:db8::53"), 53), TransportProtocol.Udp, FlowOriginKind.Host); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -128,7 +128,7 @@ public async Task Ipv6OriginalFlowStillSupportsMatchingIpv6Relay() public async Task DifferentRemoteEndpointsCreateDistinctTransports() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); Assert.True(await coordinator.TrySendSpanAsync(CreateFlow("192.0.2.53"), s_server, [1], default, CancellationToken.None)); Assert.True(await coordinator.TrySendSpanAsync(CreateFlow("192.0.2.54"), s_server, [2], default, CancellationToken.None)); @@ -142,7 +142,7 @@ public async Task RelayResponseKeepsOriginalFlowIdentity() { var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); - await using var coordinator = new UdpProxyCoordinator(factory, sink); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); await WaitForAsync(() => factory.Transports.Count == 1); @@ -167,7 +167,7 @@ public async Task RelayResponseCarriesRecordedClientMac() // every response sink call so forwarded responses can be rebuilt toward the client. var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); - await using var coordinator = new UdpProxyCoordinator(factory, sink); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink); var flow = CreateFlow("192.0.2.53"); var clientMac = new byte[] { 0x02, 0x00, 0x00, 0x00, 0x00, 0x0a }; @@ -190,10 +190,11 @@ public async Task ReceiveBufferIsBoundedAndReturnedWhenCoordinatorStops() { using var pool = new NativeBufferPool(1537); var factory = new FakeTransportFactory(); - var coordinator = new UdpProxyCoordinator( + var coordinator = UdpCoordinatorFakes.CreateCoordinator( factory, new FakeResponseSink(), - new UdpProxyOptions { Capacity = 1, MaximumFrameSize = 1514, ReceiveWindowPool = pool }); + new UdpProxyOptions { Capacity = 1, MaximumFrameSize = 1514 }, + receiveWindowPool: pool); Assert.True(await coordinator.TrySendSpanAsync(CreateFlow("192.0.2.53"), s_server, [1], default, CancellationToken.None)); // The receive window is rented when the background setup starts the session's receive loop. @@ -206,6 +207,25 @@ public async Task ReceiveBufferIsBoundedAndReturnedWhenCoordinatorStops() Assert.Equal(0, pool.Stats.Outstanding); } + [Fact] + public async Task SessionStateReportsSettingUpWhileDialingThenActiveWhenReady() + { + var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factory = new DelayedTransportFactory(gate.Task); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { TimeProvider = time }); + var flow = CreateFlow("192.0.2.53"); + + Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); + Assert.Equal(UdpSessionState.SettingUp, coordinator.SessionState(flow)); + + gate.SetResult(); + await WaitForAsync(() => coordinator.SessionState(flow) == UdpSessionState.Active); + + await coordinator.DisposeAsync(); + Assert.Equal(UdpSessionState.SettingUp, coordinator.SessionState(flow)); + } + [Theory] [InlineData(1536, 1537, false)] [InlineData(1537, 1537, true)] diff --git a/tests/WinForward.Core.Tests/UdpProxySessionTests.cs b/tests/WinForward.Core.Tests/UdpProxySessionTests.cs index 6e7c1f0..589d60b 100644 --- a/tests/WinForward.Core.Tests/UdpProxySessionTests.cs +++ b/tests/WinForward.Core.Tests/UdpProxySessionTests.cs @@ -79,6 +79,59 @@ public async Task SendSpanAsyncForwardsTheSessionEndpointToTheTransportUnchanged Assert.Equal((ushort)53, sent.Destination.Port); } + [Fact] + public async Task IdleExpiryEndsTheReceiveLoopWithoutRecordingAFailure() + { + // R3: the receive loop reads through the session lifetime token, so an admitted idle + // expiry cancels it as normal teardown — no receive failure is recorded and the + // coordinator's failure handler stays untouched. + var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); + var failureHandlerCalls = 0; + var session = CreateSession(time, [], new FakeTransport(System.Net.Sockets.AddressFamily.InterNetwork, 40000)); + await using (session) + { + session.Start(_ => + { + Interlocked.Increment(ref failureHandlerCalls); + return Task.CompletedTask; + }); + + Assert.True(session.TryBeginExpiry(time.GetUtcNow(), TimeSpan.Zero)); + Assert.Equal(UdpSessionState.Expiring, session.State); + // The expiry-admitted session refuses the send instead of throwing at the dispatcher. + Assert.False(await session.SendSpanAsync(session.Flow.Remote, [1], CancellationToken.None)); + + await session.DisposeAsync(); + } + + Assert.Equal(UdpSessionState.Disposed, session.State); + Assert.Equal(0, failureHandlerCalls); + } + + [Fact] + public async Task GenuineReceiveFaultRecordsFaultedAndFiresTheFailureHandler() + { + var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); + var transport = new FakeTransport(System.Net.Sockets.AddressFamily.InterNetwork, 40000); + var handled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var session = CreateSession(time, [], transport); + await using (session) + { + session.Start(faulted => + { + handled.TrySetResult(faulted); + return Task.CompletedTask; + }); + + transport.Received.Writer.TryComplete(new IOException("relay read failed")); + + Assert.Same(session, await handled.Task.WaitAsync(TimeSpan.FromSeconds(2), TimeProvider.System)); + Assert.Equal(UdpSessionState.Faulted, session.State); + // A faulted session refuses further sends so the caller can fail the datagram closed. + Assert.False(await session.SendSpanAsync(session.Flow.Remote, [1], CancellationToken.None)); + } + } + private static UdpProxySession CreateSession(TimeProvider time, List propagationStamps, FakeTransport? transport = null) { var flow = FlowKey.Create( diff --git a/tests/WinForward.Core.Tests/UdpReceiveResilienceTests.cs b/tests/WinForward.Core.Tests/UdpReceiveResilienceTests.cs index 39af02b..a8921dd 100644 --- a/tests/WinForward.Core.Tests/UdpReceiveResilienceTests.cs +++ b/tests/WinForward.Core.Tests/UdpReceiveResilienceTests.cs @@ -28,7 +28,7 @@ public async Task ReceiveLoopSurvivesMalformedUnexpectedAndOversizedRelayDatagra var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, sink, new UdpProxyOptions { Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink, new UdpProxyOptions { Logger = logger }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [0], default, CancellationToken.None)); @@ -74,7 +74,7 @@ public async Task InjectionFailureSkipsOneResponseWithoutKillingTheSession() { var factory = new FakeTransportFactory(); var sink = new ThrowingResponseSink(); - await using var coordinator = new UdpProxyCoordinator(factory, sink); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -203,7 +203,7 @@ public async Task ReceiveLoopSurvivesTransportConnectionReset() var factory = new SingleTransportFactory(transport); var sink = new FakeResponseSink(); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, sink, new UdpProxyOptions { Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink, new UdpProxyOptions { Logger = logger }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -239,7 +239,7 @@ public async Task DomainTypedResponseIsCountedAndSkipped() var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, sink, new UdpProxyOptions { Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink, new UdpProxyOptions { Logger = logger }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); diff --git a/tests/WinForward.Core.Tests/UdpRelayTests.cs b/tests/WinForward.Core.Tests/UdpRelayTests.cs index 2ee41f3..ae330e6 100644 --- a/tests/WinForward.Core.Tests/UdpRelayTests.cs +++ b/tests/WinForward.Core.Tests/UdpRelayTests.cs @@ -416,7 +416,7 @@ public async Task ExecutorRoutesUdpProxyDatagramsThroughCoordinatorWithoutReinje var factory = new FakeTransportFactory(); var sink = new FakeResponseSink(); - await using var coordinator = new UdpProxyCoordinator(factory, sink); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, sink); var reinjector = new FakeReinjector(); var executor = new NdisPacketActionExecutor(reinjector, udpProxy: coordinator); var flow = FlowKey.Create(Endpoint.From(source, 53000), Endpoint.From(destination, 53), TransportProtocol.Udp, FlowOriginKind.Host); @@ -442,7 +442,7 @@ await WaitForAsync(() => public async Task ExecutorFailsClosedWhenUdpFrameCannotBeParsed() { var factory = new FakeTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var executor = new NdisPacketActionExecutor(new FakeReinjector(), udpProxy: coordinator); var flow = FlowKey.Create(Endpoint.From(IPAddress.Parse("192.0.2.10"), 53000), Endpoint.From(IPAddress.Parse("192.0.2.53"), 53), TransportProtocol.Udp, FlowOriginKind.Host); var packet = new CapturedFlowPacket(new PacketLease(new byte[] { 0xff, 0xff, 0xff }), new FlowContext(flow, ProcessName: null, ProcessPath: null, AdapterId: null, AdapterName: null, 53), new PacketCaptureMetadata(NdisApiAbi.PacketFlagOnSend, 7)); diff --git a/tests/WinForward.Core.Tests/UdpSessionSetupTests.cs b/tests/WinForward.Core.Tests/UdpSessionSetupTests.cs index a0ff6ec..3ad36c7 100644 --- a/tests/WinForward.Core.Tests/UdpSessionSetupTests.cs +++ b/tests/WinForward.Core.Tests/UdpSessionSetupTests.cs @@ -50,6 +50,50 @@ public async Task FlushDropsTtlExpiredDatagramThroughTheSlotHostSeam() setup.DisposeLimiter(); } + [Fact] + public async Task SetupFailureReportsTheSetupFailureTeardownReason() + { + var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); + var flow = CreateFlow("192.0.2.53"); + var factory = new FailingTransportFactory(); + using var receiveWindowPool = new NativeBufferPool(ReceiveBufferSize, capacity: 4); + using var setupQueuePool = new NativeBufferPool(64, capacity: 4); + var host = new TtlExpiredDequeueHost(setupQueuePool, time.GetUtcNow()); + using var shutdown = new CancellationTokenSource(); + var setup = new UdpSessionSetup(factory, new UdpAssociationTable(), new FakeResponseSink(), time, NullRuntimeLogger.Instance, receiveWindowPool, ReceiveBufferSize, host); + var slot = new UdpProxyCoordinator.UdpSessionSlot(); + + await setup.CreateSessionAsync(flow, s_server, flowGeneration: 1, MacAddress.Invalid, slot, shutdown.Token); + + // A genuine dial failure is what arms the setup cooldown, so its teardown reason is the + // only one that must arm it. + Assert.Equal(UdpTeardownReason.SetupFailure, host.RemovedReason); + setup.DisposeLimiter(); + } + + [Fact] + public async Task CancelledSetupReportsTheShutdownTeardownReason() + { + var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); + var flow = CreateFlow("192.0.2.53"); + var factory = new GatedTransportFactory(); + using var receiveWindowPool = new NativeBufferPool(ReceiveBufferSize, capacity: 4); + using var setupQueuePool = new NativeBufferPool(64, capacity: 4); + var host = new TtlExpiredDequeueHost(setupQueuePool, time.GetUtcNow()); + using var shutdown = new CancellationTokenSource(); + var setup = new UdpSessionSetup(factory, new UdpAssociationTable(), new FakeResponseSink(), time, NullRuntimeLogger.Instance, receiveWindowPool, ReceiveBufferSize, host); + var slot = new UdpProxyCoordinator.UdpSessionSlot(); + + var pending = setup.CreateSessionAsync(flow, s_server, flowGeneration: 1, MacAddress.Invalid, slot, shutdown.Token); + await factory.CreateStarted.Task; + factory.Fail(new OperationCanceledException("setup cancelled (synthetic).")); + await pending; + + // A cancelled dial must not arm the cooldown; only a genuine setup failure may. + Assert.Equal(UdpTeardownReason.Shutdown, host.RemovedReason); + setup.DisposeLimiter(); + } + /// /// A slot host whose first flush-dequeue step hands back an entry older than the setup TTL /// (rented from the setup queue pool), then reports not-owner so the flush stops — exactly @@ -61,6 +105,7 @@ private sealed class TtlExpiredDequeueHost(NativeBufferPool setupQueuePool, Date public int DequeueCalls; public UdpProxySession? AttachedSession; + public UdpTeardownReason? RemovedReason; public void AttachSession(UdpProxyCoordinator.UdpSessionSlot slot, UdpProxySession session) { @@ -80,7 +125,11 @@ public void AttachSession(UdpProxyCoordinator.UdpSessionSlot slot, UdpProxySessi return (UdpSessionSetup.FlushStep.Dequeued, lease, 1, enqueuedAt); } - public Task RemoveSlotAsync(FlowKey flow, UdpProxyCoordinator.UdpSessionSlot slot, bool armCooldown) => Task.FromResult(true); + public Task RemoveSlotAsync(FlowKey flow, UdpProxyCoordinator.UdpSessionSlot slot, UdpTeardownReason reason) + { + RemovedReason = reason; + return Task.FromResult(true); + } public Task RemoveReceiveFailedSessionAsync(UdpProxySession session) => Task.CompletedTask; } diff --git a/tests/WinForward.Core.Tests/UdpSetupCooldownTests.cs b/tests/WinForward.Core.Tests/UdpSetupCooldownTests.cs index acba08e..55f00bc 100644 --- a/tests/WinForward.Core.Tests/UdpSetupCooldownTests.cs +++ b/tests/WinForward.Core.Tests/UdpSetupCooldownTests.cs @@ -25,7 +25,7 @@ public async Task FailedSetupEntersCooldownAndRetriesAfterOneSecond() var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var factory = new FailingTransportFactory(); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, TimeProvider = time, Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, TimeProvider = time, Logger = logger }); var flow = CreateFlow("192.0.2.53"); // Accepted (buffered); the failure itself surfaces through the background setup task. @@ -50,7 +50,7 @@ public async Task SetupCooldownsAreBoundedAndEvictTheOldestAtCapacity() // bound while every recent flow keeps its cooldown (eviction, never refusal). var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var factory = new FailingTransportFactory(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 4, TimeProvider = time }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 4, TimeProvider = time }); const int flowCount = 5; var flows = Enumerable.Range(0, flowCount).Select(index => CreateFlow(string.Create(CultureInfo.InvariantCulture, $"192.0.2.{index + 1}"))).ToArray(); @@ -86,7 +86,7 @@ public async Task SetupConcurrencyCapQueuesFlowsBeyondTheCapUntilASlotFrees() var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, Logger = logger }); const int cappedFlows = 8; var flows = Enumerable.Range(0, cappedFlows + 1).Select(index => CreateFlow(string.Create(CultureInfo.InvariantCulture, $"192.0.2.{index + 1}"))).ToArray(); @@ -130,7 +130,7 @@ public async Task SetupFlashCrowdOfDistinctFlowsQueuesThroughTheCapWithoutLoss() var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new BarrierTransportFactory(barrier, concurrentSetupCap); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = flowCount, Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = flowCount, Logger = logger }); var flows = Enumerable.Range(0, flowCount).Select(index => CreateFlow(string.Create(CultureInfo.InvariantCulture, $"198.51.100.{index + 1}"))).ToArray(); var sends = Enumerable.Range(0, flowCount) diff --git a/tests/WinForward.Core.Tests/UdpSetupQueueBudgetTests.cs b/tests/WinForward.Core.Tests/UdpSetupQueueBudgetTests.cs index e1b07fc..e484368 100644 --- a/tests/WinForward.Core.Tests/UdpSetupQueueBudgetTests.cs +++ b/tests/WinForward.Core.Tests/UdpSetupQueueBudgetTests.cs @@ -25,7 +25,7 @@ public async Task GlobalSetupBudgetRejectsBeyondTheAggregateAndCreditsBackOnFlus // back so the budget recovers once the setup completes. var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 4096, SetupQueueGlobalByteBudget = 4096 }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 4096, SetupQueueGlobalByteBudget = 4096 }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, new byte[3000], default, CancellationToken.None)); @@ -62,7 +62,7 @@ public async Task SetupFailureCreditsBackThePendingBudget() // observable after the failure teardown drains the queue. var factory = new GatedTransportFactory(); using var pool = new NativeBufferPool(4096, capacity: 8); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 4096, SetupQueueGlobalByteBudget = 4096, SetupQueuePool = pool }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 4096, SetupQueueGlobalByteBudget = 4096 }, setupQueuePool: pool); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, new byte[3000], default, CancellationToken.None)); @@ -83,7 +83,7 @@ public async Task DisposeCreditsBackDatagramsStillQueuedForSetup() var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); using var pool = new NativeBufferPool(4096, capacity: 8); - var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 4096, SetupQueueGlobalByteBudget = 4096, SetupQueuePool = pool }); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 4096, SetupQueueGlobalByteBudget = 4096 }, setupQueuePool: pool); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, new byte[3000], default, CancellationToken.None)); @@ -109,7 +109,7 @@ public async Task SetupQueueLeasesReturnToThePoolAcrossDropOldestFlushAndTtlDrop var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); using var pool = new NativeBufferPool(1514, capacity: 64); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, TimeProvider = time, SetupQueuePool = pool }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, TimeProvider = time }, setupQueuePool: pool); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -145,7 +145,7 @@ public async Task SetupQueueLeaseIsReleasedWhenTheDatagramExceedsTheFrameCap() // B4 bounds refusal: a datagram larger than the pinned frame cap cannot be copied into a // pooled lease; it is rejected fail-closed with its lease and budget charge released. using var pool = new NativeBufferPool(64, capacity: 8); - await using var coordinator = new UdpProxyCoordinator(new FakeTransportFactory(), new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 64, SetupQueuePool = pool }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(new FakeTransportFactory(), new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, MaximumFrameSize = 64 }, setupQueuePool: pool); var flow = CreateFlow("192.0.2.53"); Assert.False(await coordinator.TrySendSpanAsync(flow, s_server, new byte[100], default, CancellationToken.None)); diff --git a/tests/WinForward.Core.Tests/UdpSetupQueueTests.cs b/tests/WinForward.Core.Tests/UdpSetupQueueTests.cs index 3dbe48e..3651e71 100644 --- a/tests/WinForward.Core.Tests/UdpSetupQueueTests.cs +++ b/tests/WinForward.Core.Tests/UdpSetupQueueTests.cs @@ -30,7 +30,7 @@ public async Task FirstDatagramDoesNotAwaitAStalledSetupAndDatagramsRelayInFifoO // (5 s): a setup stalled beyond it legitimately drops its buffered datagrams by contract. var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task, TimeSpan.FromSeconds(2)); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = CreateFlow("192.0.2.53"); var stopwatch = Stopwatch.StartNew(); @@ -59,7 +59,7 @@ public async Task SetupQueueOverflowDropsOldestAndDeliversRetainedDatagramsInFif var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); var logger = new RecordingRuntimeLogger(); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Logger = logger }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Logger = logger }); var flow = CreateFlow("192.0.2.53"); // 40 datagrams against a 32-packet queue: the eight oldest are dropped, the newest 32 @@ -100,7 +100,7 @@ public async Task DatagramsCannotBypassTheSetupQueueAroundTheFlush() // slot's ready transition and the queue-empty check share one critical section. var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -138,7 +138,7 @@ public async Task FlushDropsSetupDatagramsOlderThanTheTtl() var time = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, TimeProvider = time }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = 16, TimeProvider = time }); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); @@ -178,7 +178,7 @@ public async Task LimiterQueueWaitDoesNotExpireTheTriggeringDatagram() var occupantGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); const int occupants = 8; var factory = new StagedGateTransportFactory(occupantGate, occupants); - await using var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = occupants + 8, TimeProvider = time }); + await using var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink(), new UdpProxyOptions { Capacity = occupants + 8, TimeProvider = time }); var flows = Enumerable.Range(0, occupants + 1).Select(index => CreateFlow(string.Create(CultureInfo.InvariantCulture, $"192.0.2.{index + 1}"))).ToArray(); for (var index = 0; index < occupants; index++) @@ -250,7 +250,7 @@ public async Task DisposeDropsDatagramsStillQueuedForSetup() { var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new DelayedTransportFactory(gate.Task); - var coordinator = new UdpProxyCoordinator(factory, new FakeResponseSink()); + var coordinator = UdpCoordinatorFakes.CreateCoordinator(factory, new FakeResponseSink()); var flow = CreateFlow("192.0.2.53"); Assert.True(await coordinator.TrySendSpanAsync(flow, s_server, [1], default, CancellationToken.None)); From e7b3b2fe5dfc90e18454f43cfc8b412bfee7b457 Mon Sep 17 00:00:00 2001 From: Snowy117 Date: Sun, 20 Sep 2026 21:48:42 +0800 Subject: [PATCH 2/3] chore(task): archive 09-20-transport-lifecycle --- .../2026-09}/09-20-transport-lifecycle/check.jsonl | 0 .../{ => archive/2026-09}/09-20-transport-lifecycle/design.md | 0 .../2026-09}/09-20-transport-lifecycle/implement.jsonl | 0 .../2026-09}/09-20-transport-lifecycle/implement.md | 0 .../{ => archive/2026-09}/09-20-transport-lifecycle/prd.md | 0 .../{ => archive/2026-09}/09-20-transport-lifecycle/task.json | 4 ++-- 6 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-09}/09-20-transport-lifecycle/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-20-transport-lifecycle/design.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-20-transport-lifecycle/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-20-transport-lifecycle/implement.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-20-transport-lifecycle/prd.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-20-transport-lifecycle/task.json (94%) diff --git a/.trellis/tasks/09-20-transport-lifecycle/check.jsonl b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/check.jsonl similarity index 100% rename from .trellis/tasks/09-20-transport-lifecycle/check.jsonl rename to .trellis/tasks/archive/2026-09/09-20-transport-lifecycle/check.jsonl diff --git a/.trellis/tasks/09-20-transport-lifecycle/design.md b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/design.md similarity index 100% rename from .trellis/tasks/09-20-transport-lifecycle/design.md rename to .trellis/tasks/archive/2026-09/09-20-transport-lifecycle/design.md diff --git a/.trellis/tasks/09-20-transport-lifecycle/implement.jsonl b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/implement.jsonl similarity index 100% rename from .trellis/tasks/09-20-transport-lifecycle/implement.jsonl rename to .trellis/tasks/archive/2026-09/09-20-transport-lifecycle/implement.jsonl diff --git a/.trellis/tasks/09-20-transport-lifecycle/implement.md b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/implement.md similarity index 100% rename from .trellis/tasks/09-20-transport-lifecycle/implement.md rename to .trellis/tasks/archive/2026-09/09-20-transport-lifecycle/implement.md diff --git a/.trellis/tasks/09-20-transport-lifecycle/prd.md b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/prd.md similarity index 100% rename from .trellis/tasks/09-20-transport-lifecycle/prd.md rename to .trellis/tasks/archive/2026-09/09-20-transport-lifecycle/prd.md diff --git a/.trellis/tasks/09-20-transport-lifecycle/task.json b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/task.json similarity index 94% rename from .trellis/tasks/09-20-transport-lifecycle/task.json rename to .trellis/tasks/archive/2026-09/09-20-transport-lifecycle/task.json index 23e7974..0224eab 100644 --- a/.trellis/tasks/09-20-transport-lifecycle/task.json +++ b/.trellis/tasks/archive/2026-09/09-20-transport-lifecycle/task.json @@ -3,7 +3,7 @@ "name": "transport-lifecycle", "title": "Transport lifecycle hardening: explicit quiescence boundary and session state", "description": "Harden TCP relay / UDP session / SOCKS5 transport teardown: give each coordinator one explicit quiescence boundary (track and await every background task on dispose), model UDP session lifecycle as an explicit state machine with a teardown-reason, and consolidate pool/executor ownership into composition (remove _ownsX flags). Findings sourced from the 2026-09-20 transport-lifecycle review (see prd.md).", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "qmazon", "assignee": "qmazon", "createdAt": "2026-09-20", - "completedAt": null, + "completedAt": "2026-09-20", "branch": "feat/transport-lifecycle", "base_branch": "master", "worktree_path": null, From 865228f5f23ff267977b5cb891809b609fb6ec76 Mon Sep 17 00:00:00 2001 From: Snowy117 Date: Sun, 20 Sep 2026 21:48:43 +0800 Subject: [PATCH 3/3] chore: record journal --- .trellis/workspace/qmazon/index.md | 5 +++-- .trellis/workspace/qmazon/journal-1.md | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/qmazon/index.md b/.trellis/workspace/qmazon/index.md index c8e8e81..56a9f65 100644 --- a/.trellis/workspace/qmazon/index.md +++ b/.trellis/workspace/qmazon/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 27 +- **Total Sessions**: 28 - **Last Active**: 2026-09-20 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~735 | Active | +| `journal-1.md` | ~757 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 28 | 2026-09-20 | Transport lifecycle hardening: ownership, quiescence, session state | `4a2007e` | `feat/transport-lifecycle` | | 27 | 2026-09-20 | Adopt the JetBrains inspectcode gate and zero its report | `7b199df`, `25db2f5`, `1d612ea`, `baec0a2`, `b19a779`, `40df6e5`, `fb083cb` | `master` | | 26 | 2026-09-20 | Analyzer diagnostics cleanup: 1369 → 0 (dotnet format --severity info) | `fe21bd3`, `a6c15e0`, `b8484a1`, `1fafb33`, `aa7261d`, `d185cf1` | `master` | | 25 | 2026-09-19 | Design-deepening refactors (R1-R12) + TCP clock seam follow-up | `d434d00`, `804c790`, `7a8df52`, `ac7b957`, `a634ae7`, `53ddcb4`, `cb70b0d`, `cfad0d6`, `3a9ccfc`, `a4322d3`, `31191ad`, `18d2243`, `10e5798`, `d9ca244`, `e6215ba` | `master` | diff --git a/.trellis/workspace/qmazon/journal-1.md b/.trellis/workspace/qmazon/journal-1.md index 1897b30..aa6044c 100644 --- a/.trellis/workspace/qmazon/journal-1.md +++ b/.trellis/workspace/qmazon/journal-1.md @@ -733,3 +733,25 @@ Introduced the jb inspectcode gate (pin 2026.1.3) and drove the baseline report ### Status [OK] **Completed** + + +## Session 28: Transport lifecycle hardening: ownership, quiescence, session state + + +**Date**: 2026-09-20 +**Task**: Transport lifecycle hardening: ownership, quiescence, session state +**Branch**: `feat/transport-lifecycle` + +### Summary + +Planned and implemented 09-20-transport-lifecycle across Phases A-E. Ownership consolidated into DurableCaptureBundle (coordinators take pools + shared SetupExecutor as required ctor deps, never dispose them); TCP quiescence made explicit (relay DisposeAsync awaits Completion, acceptor owns terminal observation + session lifetime, store defers lifetime CTS disposal, attach failure tears the session down, RegisterSession releases on partial failure, setup cooldown written inside the inflight section); UDP got a per-session lifetime CTS, UdpSessionState/UdpTeardownReason enums, and SendSpanAsync returning bool with a counted rate-limited fail-closed drop instead of throwing. Gates: format clean, Release build 0 warnings, 744/744 tests, jb inspectcode 0 issues. Specs updated: tcp-local-redirect, udp-relay, error-handling, quality-guidelines. Deferred follow-up (not tasked): structured concurrency (TaskScope) + lifetime analyzers. + +### Git Commits + +| Hash | Message | +|------|---------| +| `4a2007e` | feat(runtime): explicit transport quiescence boundary and session state | + +### Status + +[OK] **Completed**