Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .trellis/spec/backend/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>`; `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.
14 changes: 14 additions & 0 deletions .trellis/spec/backend/quality-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
111 changes: 111 additions & 0 deletions .trellis/spec/backend/tcp-local-redirect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TcpRedirectSession?>`.
- `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();
```
95 changes: 95 additions & 0 deletions .trellis/spec/backend/udp-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte> payload, CancellationToken)`
-> `ValueTask<bool>` (`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; }
```
Original file line number Diff line number Diff line change
@@ -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."}
Loading
Loading