feat(executors): detect and tear down dead SSH session transports (#3581)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
---
|
||||
created: 2026-09-09
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-001
|
||||
- REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-002
|
||||
system_design:
|
||||
- ../../specs/executors/system-design/ssh-transport-liveness.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation plan: SSH session transport liveness
|
||||
|
||||
## Overview
|
||||
|
||||
A remote runner's SSH tunnel can die silently over a lossy link and stay dead for
|
||||
hours: the local port forward keeps accepting connections, so agentctl's WebSocket
|
||||
hangs waiting on a response that will never arrive instead of erroring. Nothing in
|
||||
`apps/backend/internal/agent/runtime/lifecycle` probed the transport for liveness;
|
||||
the keepalive plumbing had been deliberately removed pre-merge (PR #927) after
|
||||
orphaned goroutines surfaced under e2e fault injection.
|
||||
|
||||
This plan restores a per-session SSH keepalive watchdog, owned and bounded by the
|
||||
session lifecycle so it cannot leak the way the removed version did, and wires its
|
||||
failure into transport teardown so a dead tunnel becomes a fast, non-retryable
|
||||
error instead of an infinite hang.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: a `keepalive@openssh.com` probe loop per SSH session, liveness
|
||||
classification (probe reply, probe error, or transport end), transport teardown on
|
||||
loss (mark, close forward, close client, warn once), disposal-path ownership so a
|
||||
watchdog cannot outlive its session, and the `GetRemoteStatus` reclassification of
|
||||
a transport lost mid-probe.
|
||||
|
||||
Out of scope (see the requirements doc for the full list): reconnect/re-dial, task
|
||||
or agent state transitions, an operator-facing config toggle, the two downstream
|
||||
diagnosability follow-ups (empty-payload-reads-as-success; unbounded agent
|
||||
backoff), restructuring the serial remote-status poll loop, hardening
|
||||
`SSHPortForwarder.Close` itself, shortening remote-command timeouts, other
|
||||
executors, application-level health/ping above the transport, and multi-hop
|
||||
ProxyJump chains.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Add `executor_ssh_keepalive.go`: a watchdog goroutine pair (probe + deadline timer)
|
||||
started under the executor mutex alongside the session record (insert-if-absent),
|
||||
using fixed package-level tuning values (probe interval, unresponsive threshold,
|
||||
disposal timeouts) with no runtime feature flag. A `keepalive@openssh.com` reply of
|
||||
any kind counts as liveness and resets the deadline; a probe error or transport EOF
|
||||
declares the transport lost immediately. Idempotent teardown runs through
|
||||
per-handle once-guards so a stop racing a declaration cannot double-dispose.
|
||||
|
||||
Extend `executor_ssh.go`'s disposal sequence to close the forward and client and
|
||||
mark the session transport-lost exactly once, and extend `GetRemoteStatus` to read
|
||||
the transport marker and client in one critical section so a status read racing a
|
||||
declaration cannot return a stale `agentctl-down` instead of `disconnected`.
|
||||
|
||||
Existing patterns: `executor_ssh_connection.go` for the `golang.org/x/crypto/ssh`
|
||||
client/session lifecycle this watchdog attaches to, and
|
||||
`executor_ssh_stop_resume_test.go` / `ssh_fake_server_test.go` for the fake SSH
|
||||
server harness used to simulate a silent transport.
|
||||
|
||||
## Tests
|
||||
|
||||
All tests live in `apps/backend/internal/agent/runtime/lifecycle`, TDD'd against
|
||||
the fake SSH server harness (no new Playwright E2E: no `apps/web/` change, no
|
||||
schema/migration, no event-bus subscriber).
|
||||
|
||||
| Criteria | Evidence |
|
||||
| --- | --- |
|
||||
| AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.1 through .4, .8 through .11 | `executor_ssh_keepalive_test.go`: watchdog start-under-mutex, probe/reply/error classification, deadline arithmetic, tuning-value production defaults |
|
||||
| AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.5 through .7 | `executor_ssh_transport_teardown_test.go`: mark-then-close-forward-then-close-client sequence, single warning, post-teardown remote-status reclassification |
|
||||
| AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.1 through .15 | `executor_ssh_keepalive_test.go` + `executor_ssh_stop_resume_test.go`: ownership, stop-vs-declare race precedence, once-guard composition, resume-metadata completeness, backstop timeout |
|
||||
| Watchdog leak safety | `goleak_test.go`: no watchdog goroutine survives session disposal |
|
||||
|
||||
## End-to-end evidence
|
||||
|
||||
No new Playwright E2E. Confirmed across five spec re-checks (most recently Spec
|
||||
Review round 3): the changed-file list is backend + specs only, with no frontend,
|
||||
schema, or event-bus surface.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Detect and tear down dead SSH session transports](task-01-ssh-transport-watchdog.md)
|
||||
|
||||
Single work order; implemented sequentially, no subagents.
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation is complete across 5 Build rounds, 5 Testing rounds, and 4 Review
|
||||
rounds (hard cap). Final receipts (branch head prior to this documentation
|
||||
addition): `go build ./...` clean; full SSH/keepalive suite green under `-race`;
|
||||
`golangci-lint` 0 new issues; `gofmt` / spec-lint / architecture-lint clean;
|
||||
package coverage 79.0%. See PR #3581 for the full round-by-round history and CI
|
||||
receipts.
|
||||
|
||||
## Risks
|
||||
|
||||
- The frozen spec accepts three residual internal-consistency gaps (F35/F36/F37)
|
||||
with recorded readings rather than spec amendments; see the requirements doc's
|
||||
cross-references for the exact boundary each reading draws.
|
||||
- `resetTrackedManagedBrokerResume`'s two-critical-section shape is the frozen
|
||||
design's own mandated shape (read+check before preflight, removal after), not a
|
||||
branch-introduced bug; a concurrent stop can still interleave with an in-flight
|
||||
preflight and surface a generic error instead of `ErrSSHTransportLost`. Recorded
|
||||
as carried-forward, non-blocking test-rigor residual, not fixed by this plan.
|
||||
|
||||
## References
|
||||
|
||||
- [Requirements](../../specs/executors/requirements/ssh-transport-liveness.md)
|
||||
- [System design](../../specs/executors/system-design/ssh-transport-liveness.md)
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
id: "01-ssh-transport-watchdog"
|
||||
title: "Detect and tear down dead SSH session transports"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-001
|
||||
- REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-002
|
||||
acceptance_criteria:
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.1
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.2
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.3
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.4
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.5
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.6
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.7
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.8
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.9
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.10
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.11
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.1
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.2
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.3
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.4
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.5
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.6
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.7
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.8
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.9
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.10
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.11
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.12
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.13
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.14
|
||||
- AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.15
|
||||
system_design:
|
||||
- ../../specs/executors/system-design/ssh-transport-liveness.md
|
||||
---
|
||||
|
||||
# Task 01: Detect and tear down dead SSH session transports
|
||||
|
||||
## Summary
|
||||
|
||||
Restore a `keepalive@openssh.com` liveness watchdog on the per-session SSH client,
|
||||
owned and torn down by the session lifecycle so it cannot leak the orphaned
|
||||
goroutines that got the original plumbing removed in PR #927. Wire watchdog
|
||||
failure into transport teardown so a dead tunnel closes the client, which makes
|
||||
`ChannelBackendClient.RequestPayload`'s blocked wait fail fast with a clean,
|
||||
non-retryable error instead of hanging indefinitely.
|
||||
|
||||
## In scope
|
||||
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_keepalive.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_scripts.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/manager_startup.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_docker.go` and
|
||||
`executor_sprites.go` (shared disposal helper adjustments only)
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_keepalive_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_transport_teardown_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_stop_resume_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_lifecycle_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/goleak_test.go`
|
||||
- `apps/backend/internal/agent/runtime/lifecycle/ssh_fake_server_test.go`
|
||||
|
||||
## Out of scope
|
||||
|
||||
Reconnect/re-dial, task or agent state transitions, an operator config/runtime
|
||||
toggle, the two downstream diagnosability follow-ups filed separately
|
||||
(empty-payload-reads-as-success; unbounded agent backoff), restructuring the
|
||||
serial remote-status poll loop, hardening `SSHPortForwarder.Close` itself,
|
||||
shortening remote-command timeouts, other executors, application-level
|
||||
health/ping above the transport, and multi-hop ProxyJump chains.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A session's watchdog starts in the same critical section as its record
|
||||
(insert-if-absent) and probes every 15s; any reply resets the deadline.
|
||||
- 45s of silence, a probe error, or transport end declares the transport lost and
|
||||
runs the six-step disposal sequence: mark, skip/abandon remote commands per the
|
||||
unresponsive condition, close the forward, close the client, warn once, join.
|
||||
- A stop request wins a three-way race against a declaration and a reset; teardown
|
||||
is idempotent under two per-handle once-guards; the record stays insert-if-absent
|
||||
and is not removed by teardown itself.
|
||||
- `GetRemoteStatus` reads the transport marker and client in one critical section
|
||||
so a probe-time loss is reported as `disconnected`, not `agentctl-down`.
|
||||
- A lost create-vs-resume race adopts the winner's full resume metadata, including
|
||||
runtime API tunnel fields.
|
||||
- No watchdog goroutine survives session disposal (`goleak`).
|
||||
|
||||
## Verification
|
||||
|
||||
Run from `apps/backend`:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./internal/agent/runtime/lifecycle/... -run 'TestSSH' -race -count=1
|
||||
golangci-lint run ./internal/agent/runtime/lifecycle/...
|
||||
gofmt -l internal/agent/runtime/lifecycle
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
See "In scope" above.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
The link this watchdog defends against is lossy (12.5% packet loss observed in
|
||||
production); tuning values are fixed, not configurable, per the frozen design.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/executors/requirements/ssh-transport-liveness.md)
|
||||
- [System design](../../specs/executors/system-design/ssh-transport-liveness.md)
|
||||
- [Plan](plan.md)
|
||||
- PR #927 (prior keepalive removal, cited for its e2e fault-injection rationale)
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the watchdog, teardown sequence, and ownership guarantees across 5
|
||||
Build rounds, 5 Testing rounds, and 4 Review rounds (hard cap). Two incidental
|
||||
bugs were fixed during TDD: the prober's replies/error channel split, and an
|
||||
upstream `x/crypto/ssh` v0.52.0 deadlock (bumped to v0.57.0). Production bugs
|
||||
found and fixed during Build/Review: `buildInstanceForLostRace` dropped, then
|
||||
still didn't fully adopt, the resume winner's tunnel metadata (fixed across
|
||||
rounds 3-4, mirroring `buildResumedInstance`); `runLoop`'s stop-beats-both
|
||||
precedence wasn't Go-select-guaranteed (fixed with `stopRequested()`
|
||||
reverification before every declare/handle site); a flaky Testing-round-4 test
|
||||
raced a classification threshold against a fixed sleep (fixed with a
|
||||
lock-guarded poll).
|
||||
|
||||
Verification (branch head prior to this documentation addition):
|
||||
|
||||
- `go build ./...`: clean.
|
||||
- `go test ./internal/agent/runtime/lifecycle/... -race`: full SSH/keepalive
|
||||
suite green; package coverage 79.0%.
|
||||
- `golangci-lint run`: 0 new issues.
|
||||
- `gofmt -l`, spec-lint, architecture-lint: clean.
|
||||
|
||||
See PR #3581 for full round-by-round receipts, the rebase reconciliation with the
|
||||
concurrent SSH Runtime API Tunnel feature, and CI history.
|
||||
@@ -27,6 +27,37 @@ failure and recovery contracts.
|
||||
- Task ownership of worktrees belongs to the [task system](../tasks/README.md).
|
||||
- Desktop process supervision belongs to the [desktop system](../desktop/README.md).
|
||||
|
||||
## Specification map
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
|
||||
- [Executor-Profile Environment Precedence](requirements/executor-profile-env-precedence.md)
|
||||
- [Port collision and backend ownership safety](requirements/port-collision-safety.md)
|
||||
- [SSH Executor](requirements/ssh-executor.md)
|
||||
- [Remote SSH task-directory reclamation](requirements/remote-task-directory-reclamation.md)
|
||||
- [Kubernetes worker presets](requirements/kubernetes-worker-presets.md)
|
||||
- [Kubernetes startup timing](requirements/kubernetes-startup-timing.md)
|
||||
- [Kubernetes retained compute visibility](requirements/kubernetes-retained-compute.md)
|
||||
- [SSH Session Transport Liveness](requirements/ssh-transport-liveness.md)
|
||||
|
||||
### System design
|
||||
|
||||
|
||||
|
||||
- [Executor-Profile Environment Precedence System Design Part 1](system-design/executor-profile-env-precedence-01.md)
|
||||
- [Executor-Profile Environment Precedence System Design Part 2](system-design/executor-profile-env-precedence-02.md)
|
||||
- [Executor-Profile Environment Precedence System Design Part 3](system-design/executor-profile-env-precedence-03.md)
|
||||
- [Executor-Profile Environment Precedence System Design Part 4](system-design/executor-profile-env-precedence-04.md)
|
||||
- [Executor-Profile Environment Precedence System Design Part 5](system-design/executor-profile-env-precedence-05.md)
|
||||
- [SSH Executor](system-design/ssh-executor.md)
|
||||
- [Remote SSH task-directory reclamation](system-design/remote-task-directory-reclamation.md)
|
||||
- [Kubernetes worker presets](system-design/kubernetes-worker-presets.md)
|
||||
- [Kubernetes startup timing](system-design/kubernetes-startup-timing.md)
|
||||
- [Kubernetes retained compute visibility](system-design/kubernetes-retained-compute.md)
|
||||
- [SSH Session Transport Liveness](system-design/ssh-transport-liveness.md)
|
||||
|
||||
## Migration record
|
||||
|
||||
Migration remains in progress while legacy source detail is extracted from the
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
status: active
|
||||
system: executors
|
||||
created: 2026-09-09
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# SSH Session Transport Liveness Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
An SSH executor session owns one SSH connection to its remote host for its whole
|
||||
life. Every agent operation on that session — the HTTP calls to the remote
|
||||
controller and the long-lived agent event WebSocket — travels through a local
|
||||
port forward riding that one connection.
|
||||
|
||||
Nothing checks that the connection is still alive. When the underlying network
|
||||
path dies without a TCP reset (a VPN re-key, a NAT table eviction, a lossy mesh
|
||||
link), the connection becomes a zombie: the local forward keeps accepting and
|
||||
the remote end never answers. Requests do not fail, they hang, and stay hung
|
||||
until something else intervenes.
|
||||
|
||||
Measured 2026-09-09 against a runner over a VPN link with 12.5% packet loss: an
|
||||
SSH-executor session stopped delivering traffic for roughly two hours while sessions on other executors, on the same backend, continued
|
||||
uninterrupted. It recovered only when re-established on a new local forward
|
||||
port. Nothing detected the loss and nothing reported it.
|
||||
|
||||
This capability makes a dead session transport observable in bounded time: it
|
||||
probes the connection and closes it when it stops answering, turning an unbounded
|
||||
hang into an ordinary connection error the layers above already report.
|
||||
|
||||
There is a second contract here: the watchdog must be owned by the session it
|
||||
belongs to and gone once that session is gone. Equivalent plumbing
|
||||
existed once and was removed before it ever shipped, because its background
|
||||
goroutines outlived their connections under fault injection, so the ownership
|
||||
rules are contract, not implementation detail.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Session SSH client:** the single SSH connection an SSH executor session
|
||||
owns from launch or resume until the session is stopped.
|
||||
- **Transport liveness probe:** a request sent on a session SSH client that
|
||||
requires the remote host to answer, used only to learn whether the connection
|
||||
carries traffic.
|
||||
- **Completed probe reply:** an answer received for a probe. An answer that
|
||||
declines the request still proves the connection carries traffic and counts
|
||||
as completed.
|
||||
- **Liveness deadline:** the longest a session SSH client may go without a
|
||||
completed probe reply before its transport is declared lost.
|
||||
- **Silence interval:** for a session SSH client with a running watchdog, the
|
||||
time elapsed since the later of that watchdog's start and its most recent
|
||||
completed probe reply. It is what the liveness deadline is measured against
|
||||
and the value reported in the teardown log. A session with no running watchdog
|
||||
has none.
|
||||
- **Unresponsive transport:** a session SSH client whose silence interval
|
||||
exceeds twice the probe interval, meaning a probe has been outstanding for
|
||||
longer than a further whole interval. A transport is unresponsive for the
|
||||
whole span between twice the probe interval and the liveness deadline, before
|
||||
it is declared lost. The design says why.
|
||||
- **Transport teardown:** closing a session's local port forward and its
|
||||
session SSH client after the transport is declared lost.
|
||||
- **Watchdog:** the background activity that sends probes for one session SSH
|
||||
client and performs transport teardown for it.
|
||||
|
||||
## Prior art
|
||||
|
||||
**Our own prior reasoning (wiki).** Searched: collection `wiki` via the QMD MCP
|
||||
server (qmd 2.8.3), whose `status` resolved a configured vault path with 459
|
||||
documents — a healthy semantic query, not a silent grep fallback. Two queries: a typed
|
||||
`lex`+`vec` pair on keepalive / watchdog / dead-connection terms, and a plain
|
||||
query on liveness probes and background goroutine ownership. **The wiki returned
|
||||
nothing useful** — the top hits do not touch connection liveness, so there is no
|
||||
recorded prior position to follow or depart from.
|
||||
|
||||
**What others shipped (saas-kb).** Searched: `fsm-docs-server` MCP,
|
||||
`search_fsm_docs` with `category: "ai_sdlc"`, on SSH-tunnel keepalive and
|
||||
agent-session heartbeat phrasings, plus a Paperclip-scoped follow-up and one
|
||||
`get_fsm_doc` on its sandbox-providers reference. Coverage is thin (best
|
||||
relevance 0.016); two data points are real:
|
||||
|
||||
- **Kiro** keeps an SSH tunnel up with an out-of-process supervisor (a macOS
|
||||
LaunchAgent that restarts it): supervise from outside, not probe from within.
|
||||
- **Paperclip** (v2026.517.0) emits a keepalive on its streaming execution
|
||||
endpoint **every 15 seconds** so proxies do not idle out, and tunes probe
|
||||
timeouts to 45, 60 and 90 seconds to cover cold starts "without masking real
|
||||
hangs".
|
||||
|
||||
**What we are doing differently.** Both keep a connection *alive*; this
|
||||
capability *detects that one is dead* and closes it. Kiro's shape is wrong here:
|
||||
our tunnel process is fine and the forward is still listening, so only the party
|
||||
holding the connection can tell it has stopped answering.
|
||||
Paperclip's numbers corroborate both constants we fix: 15 seconds is a shipped
|
||||
cadence for a long-lived tunnel, and a deadline near 45 seconds a shipped choice
|
||||
for "long enough not to fire on a slow link, short enough to still detect".
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-001: Session transport liveness detection
|
||||
|
||||
**Intent:** A session whose SSH connection has silently stopped carrying
|
||||
traffic must be detected and closed within a bounded time, so that work on that
|
||||
session fails with an error instead of hanging indefinitely.
|
||||
|
||||
**User story:** As a user running an agent on my own remote machine, I want a
|
||||
session whose connection has died to fail promptly, so that I find out within a
|
||||
minute instead of hours later.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.1:** When an SSH executor session
|
||||
takes ownership of a session SSH client, on either a new launch or a resume
|
||||
of an existing remote controller, the system shall start one watchdog for
|
||||
that client after the session has been recorded in the executor's tracked
|
||||
sessions, in the same critical section in which that record is made, so that
|
||||
no disposal path can run between the record and the start.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.2:** While a watchdog is running,
|
||||
the system shall send transport liveness probes on its session SSH client one
|
||||
at a time, spaced by the probe interval, and shall not start a probe while an
|
||||
earlier one on the same client is still outstanding. Any completed probe reply
|
||||
shall count as evidence of liveness, whether the host accepted or declined
|
||||
it.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.3:** When a session SSH client's
|
||||
silence interval becomes strictly greater than the liveness deadline, the
|
||||
system shall declare that session's transport lost and perform transport
|
||||
teardown. A silence interval exactly equal to the liveness deadline shall not
|
||||
declare transport loss. The comparison shall be made from the recorded time of
|
||||
the most recent completed probe reply, not from the order in which a reply and
|
||||
a deadline expiry became observable; a completed reply not yet accounted for
|
||||
shall be accounted for first.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.4:** While a watchdog is running,
|
||||
when a transport liveness probe returns an error, or the session SSH client's
|
||||
transport terminates on its own, the system shall declare that session's
|
||||
transport lost and tear it down without waiting for the liveness deadline. A
|
||||
probe error observed after that session's watchdog has been stopped shall not
|
||||
declare transport loss.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.5:** Transport teardown shall close
|
||||
the session's local port forward first and the session SSH client second. An
|
||||
error returned by either close shall not prevent the other close, nor prevent
|
||||
the teardown from completing and recording its result. A close that returned
|
||||
an error shall be named in the teardown warning required by
|
||||
`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.9`, so that a teardown which could
|
||||
not fully release its handles is distinguishable in the log from one that did.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.6:** After transport teardown for a
|
||||
session in which the local port forward's close returned no error, a new TCP
|
||||
connection to that session's local forward port shall be refused rather than
|
||||
accepted. When that close returned an error, teardown still completes per
|
||||
`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.5` and the port is left as the failed
|
||||
close produced; the two never both bind.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.7:** After transport teardown for a
|
||||
session, remote status for that session shall report the state
|
||||
`disconnected` with an error message identifying transport loss, and shall not
|
||||
send any command over the closed client.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.8:** When a session SSH client
|
||||
reached its host through a ProxyJump bastion, transport teardown shall also
|
||||
release the bastion connection.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.9:** When transport teardown
|
||||
completes, the system shall record one warning-level log naming the executor
|
||||
instance identifier, the remote host, and the session SSH client's silence
|
||||
interval. That is the same quantity
|
||||
`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.3` compares against the deadline, so
|
||||
it is defined even when no probe reply ever completed.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.10:** The probe interval shall
|
||||
default to 15 seconds and the liveness deadline shall default to 45 seconds.
|
||||
The system shall not expose either as operator configuration and shall not add
|
||||
a runtime feature toggle.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.11:** Transport teardown shall be
|
||||
local to the Kandev backend: no command on the remote host, no stop of the
|
||||
remote controller, and the session's persisted resume metadata left unchanged,
|
||||
so a later resume can re-attach to a controller still running.
|
||||
|
||||
### REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-002: Watchdog ownership and termination
|
||||
|
||||
**Intent:** Liveness detection must not be paid for with background work that
|
||||
outlives the connection it was watching. The watchdog belongs to one session,
|
||||
starts once, and is gone when that session is gone.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.1:** A session shall have at most
|
||||
one watchdog at any time. Recording a session in the executor's tracked
|
||||
sessions shall not replace an existing record for the same executor instance
|
||||
identifier. When a record already exists at that point, the system shall leave
|
||||
the existing record and its watchdog in place, start no additional watchdog,
|
||||
and close the newly obtained port forward and session SSH client that would
|
||||
have been recorded, so neither a connection nor a watchdog is left unowned. The caller that lost shall proceed with the existing session on the
|
||||
same terms as any other reuse, including the refusal required by
|
||||
`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.14` when that session's transport has
|
||||
been declared lost.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.2:** An SSH connection not owned by
|
||||
a session shall not start a watchdog. This covers the connection test used when
|
||||
configuring an executor, remote task-directory reclamation, and the untracked
|
||||
dial used to reset a credential-broker-backed resume.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.3:** When a session has no session
|
||||
SSH client, the system shall not start a watchdog and shall not report an
|
||||
error.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.4:** When a session is stopped, the
|
||||
SSH executor is closed, or a tracked session is discarded while resetting a
|
||||
credential-broker-backed resume, the system shall stop that session's watchdog,
|
||||
and no watchdog activity for it shall remain running once that stop, close, or
|
||||
reset call returns. Those three are the complete set of
|
||||
paths that dispose of a tracked session. Stopping a watchdog that has already
|
||||
stopped, or one that was never started, shall make no further calls and shall
|
||||
report success. This guarantee binds the disposal call that removed the
|
||||
session from the executor's tracked sessions. When two disposal calls run
|
||||
concurrently for the same session, the one that does not remove it finds no
|
||||
tracked session and shall return success immediately, closing no handle and
|
||||
not waiting for the other call's watchdog; the removing call remains bound by
|
||||
the guarantee above.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.5:** Transport teardown for a
|
||||
session shall run at most once. A second teardown for the same session shall
|
||||
close nothing further and shall report success.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.6:** Transport teardown shall not
|
||||
remove the session from the executor's tracked sessions; removal remains the
|
||||
disposal paths' responsibility.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.7:** When transport teardown and a
|
||||
session stop run concurrently for the same session, the local port forward
|
||||
shall be closed exactly once, the session SSH client shall be closed exactly
|
||||
once, and both operations shall complete without error.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.8:** When a disposal path would
|
||||
issue a remote command over a session SSH client whose transport has been
|
||||
declared lost, or whose transport is unresponsive, the system shall skip that
|
||||
command. For a session stop this means skipping both the remote cleanup script
|
||||
and the remote controller stop, and completing the stop without error. The
|
||||
skips shall be decided from a single reading covering both the transport-lost
|
||||
marker and the silence interval, taken before that path issues any remote
|
||||
command and no later than the session's removal from the executor's tracked
|
||||
sessions; that one reading shall govern every remote command on that disposal
|
||||
path, so no path can run one remote operation and skip another. A teardown
|
||||
firing after that reading shall not cause a stop to return an error. Every
|
||||
remote command a disposal path issues shall run under its own remote-cleanup
|
||||
timeout, including one that has none today. When a disposal does issue a remote
|
||||
command and that command has not completed within that timeout, the system
|
||||
shall close the session SSH client without waiting for the command to finish,
|
||||
mark that client transport-lost, treat the command as failed, and continue the
|
||||
disposal. The disposal shall not return until the abandoned command has ended;
|
||||
closing the client bounds that wait, because the close is what releases a
|
||||
command blocked opening its channel. When the session has no running watchdog,
|
||||
neither skip condition holds and the disposal issues every remote command, still
|
||||
bounded by the timeout above.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.9:** When the probe interval or the
|
||||
liveness deadline is not positive, or the deadline is not greater than twice
|
||||
the interval, the system shall not start a watchdog and shall record one
|
||||
debug-level log naming the executor instance identifier.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.10:** When a session is stopped
|
||||
while its transport is still alive, the system shall not declare that
|
||||
session's transport lost and shall not record the transport-loss warning for
|
||||
it.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.11:** When a session is stopped
|
||||
while its transport is unresponsive but its liveness deadline has not yet
|
||||
expired, the stop shall complete without waiting for that deadline, without
|
||||
waiting for any outstanding probe to be answered, and without issuing any
|
||||
remote command over that session's SSH client.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.12:** When transport teardown
|
||||
completes for a session, the system shall send no further transport liveness
|
||||
probes on that session's SSH client, whether or not that session is
|
||||
subsequently stopped.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.13:** When a session whose transport
|
||||
is answering normally is disposed of between two probes, the disposal shall not
|
||||
wait for the watchdog's next probe to be sent, and the time it spends waiting
|
||||
for that session's watchdog activity to finish shall total less than one probe
|
||||
interval. This bounds only the waiting the watchdog imposes; the disposal's own
|
||||
remote commands are not bounded by it. It is observed on a disposal path with
|
||||
no remote commands, where the whole call time is that waiting.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.14:** When the instance-creation
|
||||
path finds an already-tracked session for the requested executor instance
|
||||
identifier whose transport has been declared lost, it shall not return an
|
||||
executor instance for it, and shall report an error identifying transport
|
||||
loss.
|
||||
- **AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.15:** When resetting a
|
||||
credential-broker-backed resume discards a tracked session whose transport is
|
||||
lost or unresponsive, the reset shall skip both its credential-broker
|
||||
reachability preflight and the remote controller stop, leave
|
||||
the session's persisted resume metadata unchanged, and report an error
|
||||
identifying transport loss. It shall report that same error, rather than a
|
||||
generic remote-stop or broker-unreachable failure, when it does issue its
|
||||
preflight or its remote controller stop and that call fails because the session
|
||||
SSH client was closed after the reading required by
|
||||
`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.8`, whether by a teardown or by that
|
||||
criterion's own timeout. The transport-lost mark that criterion requires is what
|
||||
distinguishes those two causes from a host that refused the call.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Each exclusion below is a deliberate contract boundary, not an oversight.
|
||||
|
||||
- **Reconnecting a session whose transport was lost.** Teardown ends the
|
||||
transport and does not re-dial, re-forward, or re-attach. The SSH executor is
|
||||
resumable, so a later resume re-establishes the connection.
|
||||
- **Task, session, and agent state transitions.** This capability does not fail,
|
||||
retry, restart, or re-queue the agent's work. It closes the transport; existing
|
||||
disconnect handling decides what the user sees.
|
||||
- **Operator configuration and feature toggles.** The interval and deadline are
|
||||
fixed internal values (`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.10`): no config
|
||||
key, environment variable, or runtime flag. A link failing the deadline has been
|
||||
unusable for 45 seconds; nothing to tune.
|
||||
- **The two downstream diagnosability defects.** An empty response payload read
|
||||
as success, and the agent's unbounded reconnect backoff, are on their own cards;
|
||||
this capability neither fixes nor depends on them.
|
||||
- **The shape of the remote-status polling loop.** It visits sessions one at a
|
||||
time and can be delayed by one that is not answering. This capability bounds
|
||||
that delay, because a lost transport is closed and short-circuits status
|
||||
(`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.7`), but does not restructure it.
|
||||
- **Hardening `SSHPortForwarder.Close` itself.** It is idempotent for sequential
|
||||
callers and races for concurrent ones. This capability serialises every close
|
||||
of a session's forwarder through that session's once-guard, so no path it
|
||||
introduces reaches the race, and making the forwarder internally safe would
|
||||
change shared code for no behaviour this contract observes. Named because a
|
||||
reader will notice it, not as deferred work.
|
||||
- **Shortening remote-command timeouts.** Disposal skips its remote commands when
|
||||
the transport is lost or unresponsive, and abandons one that outlives its own
|
||||
timeout, both under `AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-002.8`. Neither
|
||||
shortens those timeouts: a transport answering when the reading is taken runs
|
||||
its commands on the same clock as today.
|
||||
- **Other executors.** Local, Docker, Kubernetes and Sprites executors have their
|
||||
own transports and are unchanged.
|
||||
- **Application-level liveness above the transport.** Health, ping, or heartbeat
|
||||
traffic on the controller's endpoints is a separate concern from whether the
|
||||
SSH connection carries bytes.
|
||||
- **Ordered or persisted collections.** This capability introduces no query,
|
||||
list, or stored record, so no sort order or tiebreak applies. Its only
|
||||
orderings are `AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.5`'s teardown sequence
|
||||
and the disposal sequence in the design.
|
||||
- **Multi-hop ProxyJump chains.** One bastion hop is the supported topology.
|
||||
|
||||
## System design
|
||||
|
||||
See [SSH session transport liveness](../system-design/ssh-transport-liveness.md).
|
||||
@@ -0,0 +1,567 @@
|
||||
---
|
||||
status: current
|
||||
system: executors
|
||||
requirements:
|
||||
- REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-001
|
||||
- REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-002
|
||||
---
|
||||
|
||||
# SSH Session Transport Liveness System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The executor system owns the SSH connection a session runs on, so it owns the
|
||||
question of whether it is still alive. Nothing above it can: the local port
|
||||
forward is a listener in this process and keeps accepting whether or not the far
|
||||
end is reachable, so every layer above sees an open socket and waits.
|
||||
|
||||
This design uses, and does not own: the agent runtime's controller client and
|
||||
event stream (they react to a closed connection, not decide one is dead); the
|
||||
lifecycle manager's remote-status poll (it reads status, not drives teardown);
|
||||
host-key trust and target resolution (teardown changes neither).
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-001` | [Probe mechanism](#probe-mechanism), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery) |
|
||||
| `REQ-EXECUTORS-SSH-TRANSPORT-LIVENESS-002` | [Components and responsibilities](#components-and-responsibilities), [Ownership and termination](#ownership-and-termination) |
|
||||
|
||||
Acceptance criteria are cited short: `-001.4` is
|
||||
`AC-EXECUTORS-SSH-TRANSPORT-LIVENESS-001.4`.
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- **Session state** (`sshSessionState`, `apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go`).
|
||||
Gains the watchdog handle, a transport-lost marker, the timestamp of the last
|
||||
completed probe reply, and **two independent once-guards: one for the local
|
||||
port forward and one for the session SSH client**. The executor mutex guards
|
||||
the first three, as it already guards the session map; the once-guards
|
||||
synchronise themselves, because every close through one runs with that mutex
|
||||
released.
|
||||
|
||||
Two guards, not one spanning both handles: disposal paths issue remote commands
|
||||
over the client *between* the two closes, and a combined "close both" guard
|
||||
would either close the client before those commands, stranding them, or defer
|
||||
the forwarder close until after them, inverting an ordering the stop path
|
||||
guarantees. Each handle gets its own guard, closing exactly once whichever of
|
||||
teardown or disposal reaches it first.
|
||||
- **Transport watchdog** (new file, `executor_ssh_keepalive.go`). One per session
|
||||
SSH client. Owns its probe cadence, its deadline, and the decision to declare
|
||||
the transport lost. Exposes start, stop-deciding, and await-probe-exit; the last
|
||||
two are the first and last steps of one disposal, always in that order, with the
|
||||
two handle closes and the path's own remote commands in between.
|
||||
[Stop sites](#stop-sites-and-the-ordering-that-matters) says why a single `stop`
|
||||
cannot work.
|
||||
- **SSH executor** (`SSHExecutor`). Starts a watchdog when it records a session,
|
||||
stops it on every disposal path, and short-circuits remote status for a session
|
||||
whose transport is lost.
|
||||
- **Port forwarder** (`SSHPortForwarder`). Unchanged, but not because its
|
||||
`Close` is safe to call concurrently. That `Close` is
|
||||
`select { case <-f.closed: return nil; default: close(f.closed) }` — idempotent
|
||||
for *sequential* callers, racy for concurrent ones: both can take the `default`
|
||||
branch and the second panics closing an already-closed channel, which is exactly
|
||||
teardown-versus-stop (`-002.7`). So **every** close of a session's forwarder,
|
||||
teardown and all three disposal paths, goes through that session's once-guard,
|
||||
and it is the guard, not the forwarder, that makes the close exactly once.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
### Probe mechanism
|
||||
|
||||
The probe is an SSH global request named `keepalive@openssh.com`, sent on the
|
||||
session client with `wantReply` set and no payload — the OpenSSH convention,
|
||||
chosen over two alternatives:
|
||||
|
||||
- **Not a remote command.** Running something like `kill -0` opens a session
|
||||
channel, and `Client.NewSession` takes no context and blocks until that open is
|
||||
answered — so on a wedged connection the probe would itself hang forever, the
|
||||
exact failure this capability detects. The existing remote-controller liveness
|
||||
probe has this shape, which is why a wedged session can stall the shared
|
||||
remote-status poll.
|
||||
- **Any reply proves liveness.** A peer that does not implement the request
|
||||
answers with a failure rather than ignoring it; bytes crossed both ways, which
|
||||
is the only thing asked. Hence `-001.2` counts a declined reply as completed,
|
||||
and the probe works against hosts that do not recognise the request name.
|
||||
|
||||
A probe error is never transient: `SendRequest` fails only when the frame cannot
|
||||
be written or the multiplexer has torn down, and both mean the connection is
|
||||
finished. Nothing is retryable, which is why `-001.4` tears down on the first
|
||||
error rather than counting failures.
|
||||
|
||||
`Conn.SendRequest` blocks until the reply arrives or the multiplexer tears down,
|
||||
and takes no context. That single fact shapes the rest of this design: the send
|
||||
must live on its own goroutine, and the deadline must be enforced by another.
|
||||
|
||||
### Tuning values
|
||||
|
||||
Package-level variables in the lifecycle package, not constants, so tests can
|
||||
shorten them:
|
||||
|
||||
| Name | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `sshKeepaliveInterval` | 15s | Spacing between probes. |
|
||||
| `sshKeepaliveDeadline` | 45s | Longest gap with no completed reply before the transport is declared lost. |
|
||||
|
||||
Neither is read from configuration, an environment variable, or a runtime flag
|
||||
(`-001.10`). A non-positive value for either, or a deadline not greater than
|
||||
twice the interval, means "do not start a watchdog", logged once at debug level
|
||||
(`-002.9`).
|
||||
|
||||
`-002.8`'s three remote-cleanup timeouts become package-level variables on the
|
||||
same terms, so a backstop test need not wait out 20 or 60 seconds of real time.
|
||||
|
||||
Both are new; nothing sets them today. They exist so the lifecycle package's tests
|
||||
can opt out of the watchdog, and that opt-out is load-bearing: fifteen
|
||||
`CreateInstance` and `ResumeRemoteInstance` calls, in
|
||||
`executor_ssh_lifecycle_test.go` and `executor_ssh_stop_resume_test.go`, would
|
||||
each otherwise acquire a real 15-second watchdog under `goleak.VerifyTestMain`, in
|
||||
tests not about liveness.
|
||||
|
||||
Both are therefore zero for this package's tests by default, so no existing test
|
||||
starts a watchdog and none needs editing; tests that *do* exercise it set
|
||||
millisecond values, where `-002.9` binds them too — such a test's deadline must
|
||||
exceed *twice* its interval or no watchdog starts at all. Retrofitting fifteen
|
||||
unrelated tests with per-test teardown is rejected: it spreads knowledge of this
|
||||
capability across files with no reason to know it, and a later test would silently
|
||||
miss the retrofit. Hence `-002.9` makes an unusable pair a first-class "no
|
||||
watchdog" outcome instead of an error.
|
||||
|
||||
## Control flow
|
||||
|
||||
### Two goroutines, and why not fewer or more
|
||||
|
||||
A watchdog runs exactly two goroutines:
|
||||
|
||||
1. **Prober.** Loops: send one probe, publish the outcome, wait one interval.
|
||||
Publication is a non-blocking send on a capacity-1 channel, so the prober
|
||||
never blocks on a loop that has already returned. **On a probe error it
|
||||
publishes the error first and only then returns, permanently** — publishing
|
||||
before returning is what lets the loop tear down on the error rather than
|
||||
waiting out the deadline (`-001.4`). It also returns, without starting another
|
||||
probe, when the stop signal is set, waiting its interval in a `select` on that
|
||||
signal rather than sleeping, so a signal delivered while idle wakes it at once.
|
||||
Those are its only two exits, both unconditional.
|
||||
2. **Watchdog loop.** Selects on the stop signal, the prober's outcomes, and a
|
||||
deadline timer. A completed reply resets the timer and records the reply time;
|
||||
a probe error or a deadline expiry triggers teardown. After a teardown the
|
||||
loop returns; it never performs a second one.
|
||||
|
||||
The prober's error exit is what makes an internally-triggered teardown terminate:
|
||||
on a deadline expiry the loop tears down and returns, and the client close inside
|
||||
that teardown unblocks the prober's outstanding `SendRequest`. Without it the
|
||||
prober would take that error, wait its interval, and probe a closed client forever
|
||||
— the goroutine outliving its connection that removed the earlier plumbing. So
|
||||
teardown ends probing on its own (`-002.12`).
|
||||
|
||||
Probes never overlap (`-001.2`): the prober is sequential, so on a wedged
|
||||
connection exactly one `SendRequest` is outstanding and the deadline, not a
|
||||
failure count, ends the session. Spacing is end-to-start, so a slow reply delays
|
||||
the next probe rather than stacking behind it — which is also what lets the
|
||||
silence interval stand in for how long a probe has been outstanding in the
|
||||
disposal reading.
|
||||
|
||||
Two precedence rules, because a `select` with several ready cases picks at
|
||||
random.
|
||||
|
||||
**A pending completed reply beats an expired deadline timer.** The loop decides
|
||||
loss from the *recorded time* of the most recent completed reply, not from which
|
||||
channel it observed first: a reply that completed inside the deadline can still be
|
||||
unread when the timer fires, and tearing down there would kill a connection that
|
||||
had answered. When both are ready the loop accounts for the reply first,
|
||||
recomputes the silence interval, and declares loss only if that interval is
|
||||
*strictly greater* than the deadline (`-001.3`) — so the timer firing is never
|
||||
sufficient alone.
|
||||
|
||||
**The stop signal beats both**, so a deliberate stop coinciding with a deadline
|
||||
expiry cannot log a transport-loss warning (`-002.10`): the loop checks it before
|
||||
acting on either of the others.
|
||||
|
||||
Fewer is not possible: with `SendRequest` uncancellable, one goroutine cannot both
|
||||
wait for a reply and enforce a deadline. More is not needed: a `Client.Wait`
|
||||
watcher would report a cleanly-ended connection only marginally sooner than the
|
||||
prober, whose blocked `SendRequest` returns as soon as the multiplexer tears
|
||||
down.
|
||||
|
||||
### Detection bounds
|
||||
|
||||
A connection that ends with an error is detected immediately when a probe is in
|
||||
flight, and within one probe interval (15 s) when the prober is idle. One that
|
||||
stops answering without an error is detected at the liveness deadline (45 s),
|
||||
measured from the last reply.
|
||||
|
||||
### Transport teardown
|
||||
|
||||
Ordered, and the order is contract (`-001.5`):
|
||||
|
||||
1. Under the executor mutex, set the transport-lost marker and read the
|
||||
forwarder and client handles. Release the mutex before closing anything, so
|
||||
one dead session cannot stall another session's operations.
|
||||
2. Close the local port forward. Its listener closes, so a new local connection
|
||||
is refused rather than accepted
|
||||
(`-001.6`).
|
||||
3. Close the session SSH client, tearing down the multiplexer and ending every
|
||||
in-flight forwarded channel — which is what turns a hung request above into an
|
||||
ordinary connection error. When the client was reached through a bastion, the
|
||||
executor's existing bastion-release path fires from the client's own
|
||||
termination (`-001.8`).
|
||||
4. Log one warning (`-001.9`).
|
||||
|
||||
Close runtime listener after client.
|
||||
|
||||
Step 2 runs through the session's forwarder once-guard and step 3 through its
|
||||
client once-guard — the two separate guards described in
|
||||
[Components and responsibilities](#components-and-responsibilities), which the
|
||||
disposal paths share. Each handle is closed exactly once no matter which side
|
||||
gets there first (`-002.5`,
|
||||
`-002.7`).
|
||||
|
||||
Teardown issues no remote command and touches no persisted metadata (`-001.11`),
|
||||
so the remote controller keeps running and its recorded pid, port and session
|
||||
directory stay valid for a later resume.
|
||||
|
||||
Setting the marker in step 1, before any handle is closed, removes the window a
|
||||
concurrent status read would see: a reader holding the mutex across both the
|
||||
marker read and its decision either sees the marker and reports `disconnected`,
|
||||
or sees open handles — never a closing client.
|
||||
|
||||
That requires a change to how remote status locks, and the change is part of this
|
||||
capability rather than an assumption about existing code. `GetRemoteStatus` today
|
||||
takes the executor mutex only long enough to read the session out of the map,
|
||||
releases it, then reads `state.client` and probes unguarded — so a teardown can
|
||||
close the client between the read and the probe. `-001.7`'s short-circuit must
|
||||
read the marker and the client handle in one critical section and decide there,
|
||||
returning `disconnected` without issuing any command. Only the decision needs the
|
||||
mutex; the healthy-session probe runs after it is released, since holding it
|
||||
across the probe would let one wedged session block status for every other.
|
||||
|
||||
Watchdogs share nothing: two sessions on one host hold separate clients and
|
||||
watchdogs, so a loss tears down only the one that stopped answering.
|
||||
|
||||
Teardown does not remove the session from the executor's session map (`-002.6`);
|
||||
removing it would make a later stop find no state and return early, skipping the
|
||||
bookkeeping the stop still owes.
|
||||
|
||||
That has one consequence outside teardown. The instance-creation path reuses an
|
||||
already-tracked session by reading its recorded local forward port — a value that
|
||||
survives the listener being closed — so reusing a torn-down session would return a
|
||||
healthy-looking instance pointing at a port that now refuses connections: the
|
||||
silent dead end this capability exists to remove. The reuse path consults the
|
||||
marker and refuses, naming transport loss (`-002.14`); creation already reports
|
||||
errors when the dial fails, so callers handle that shape.
|
||||
|
||||
## Ownership and termination
|
||||
|
||||
### Start sites
|
||||
|
||||
A watchdog starts at the two places the executor takes ownership of a session SSH
|
||||
client and records it: instance creation, and resume of a live remote controller.
|
||||
Both start it after the session is in the map **and before releasing the executor
|
||||
mutex**, in the one critical section that also writes the entry (`-001.1`).
|
||||
Ordering alone is not enough: both sites today write the entry, release the mutex
|
||||
and return, so a stop landing in that gap would stop a watchdog that did not exist
|
||||
yet, which would then start against a client the stop had already closed and
|
||||
declare loss for a session nobody owns. Starting under the mutex costs two
|
||||
goroutines and no I/O, removing the window rather than narrowing it.
|
||||
|
||||
Starting under the mutex closes the window between record and start, but on its
|
||||
own does not deliver "at most one watchdog per session"
|
||||
(`-002.1`). Both sites check for an existing
|
||||
session, **release the mutex to connect**, and only then re-take it to record, so
|
||||
two callers for one executor instance identifier can both pass the check and both
|
||||
reach the recording step. Today the second overwrites the entry, orphaning the
|
||||
first caller's client and forwarder. With a watchdog attached that orphan stops
|
||||
being merely wasteful: every disposal path finds a session by reading the map, so
|
||||
an overwritten entry's watchdog is unreachable by all three and never exits,
|
||||
probing a connection nobody owns or declaring loss against a live session — the
|
||||
outlived-goroutine failure that removed the earlier plumbing.
|
||||
|
||||
The record is therefore **insert-if-absent**, not assignment. A caller finding an
|
||||
entry present leaves it and its watchdog alone, starts none of its own, closes
|
||||
the port forward and SSH client it had just obtained, and proceeds with the
|
||||
existing session on the same terms as any other reuse, which for a session
|
||||
already marked transport-lost means the refusal of `-002.14` rather than an
|
||||
instance. Otherwise it reports no error: losing this race means another caller
|
||||
already produced what this one was asked for. Re-checking under the mutex at the
|
||||
point of insertion, not only before connecting, is what makes check and record
|
||||
atomic.
|
||||
|
||||
Every other dial in the SSH paths is short-lived, already closed by its own
|
||||
`defer`, and starts no watchdog (`-002.2`): the connection test used when
|
||||
configuring an executor, remote task-directory reclamation, and the untracked dial
|
||||
that resets a credential-broker-backed resume.
|
||||
|
||||
### Stop sites, and the ordering that matters
|
||||
|
||||
There are exactly three paths that dispose of a tracked session (`-002.4`):
|
||||
instance stop, executor close, and the tracked credential-broker resume reset.
|
||||
Each runs the same sequence.
|
||||
|
||||
That sequence has six steps, not three, for one reason: **two of the three paths
|
||||
issue remote commands over the session SSH client between closing the forward and
|
||||
closing the client, and those commands cannot be moved.** The stop runs the
|
||||
cleanup script and the remote controller stop there; the reset the controller
|
||||
stop. The reset carries one further remote call, a credential-broker reachability
|
||||
preflight, which runs ahead of the removal and is gated by the same reading.
|
||||
|
||||
1. **Under the executor mutex, remove the session from the tracked sessions and
|
||||
take the disposal reading.** The reading covers the transport-lost marker and
|
||||
the silence interval together and classifies the transport as *lost*,
|
||||
*unresponsive*, or *answering* (`-002.8`), and that one reading governs every
|
||||
remote command on the path. A session with no running watchdog has no silence
|
||||
interval and classifies as *answering*, leaving every existing stop unchanged
|
||||
when the tuning values are non-positive.
|
||||
|
||||
**The reset takes the reading before its broker-reachability preflight**, which
|
||||
runs ahead of the removal and stays there. A reading taken behind that
|
||||
preflight is worthless: on a wedged transport it blocks in
|
||||
`Client.NewSession`, so the disposal never reads at all and never skips
|
||||
anything. Only the reading moves — the preflight keeps its position, so its
|
||||
own failure path still returns before the session is untracked. `-002.8`
|
||||
therefore anchors the reading no later than the removal rather than at it,
|
||||
which is what makes `-002.15` reachable in the one condition it names. For the
|
||||
reset the reading is therefore its own, earlier critical section: the mutex is
|
||||
taken to read, released for the preflight, re-taken for the removal. The stop
|
||||
and the executor close take one section, reading and removing together.
|
||||
|
||||
**Then release the executor mutex and do not re-take it across steps 2 to 6.**
|
||||
An invariant, not a preference: teardown takes the mutex as its own first act,
|
||||
and step 2 waits for a loop that may be in the middle of one, so a disposal
|
||||
holding it would deadlock against the teardown it waits for. The requirement to
|
||||
*start* a watchdog under the mutex (`-001.1`) invites the symmetric mistake.
|
||||
|
||||
2. **Signal the watchdog loop and wait for the loop — only the loop — to exit.**
|
||||
Bounded: the loop is parked in its `select`, already returned, or performing a
|
||||
teardown, and a teardown is two local handle closes and one log line, so the
|
||||
wait ends without touching the network. It is not bounded by the prober, which
|
||||
is the point of splitting the steps. Once this returns, no teardown for this
|
||||
session is running and none can start.
|
||||
|
||||
3. **Close the local port forward**, through the forwarder once-guard.
|
||||
|
||||
4. **Run this path's own remote commands — unless step 1's reading said *lost*
|
||||
or *unresponsive*, in which case skip them entirely.** For a stop these are
|
||||
the cleanup script and then the remote controller stop, in that order; for the
|
||||
reset, the remote controller stop; the executor close has none, so this step is
|
||||
empty for it. The reset's preflight runs ahead of step 1, gated by the same
|
||||
reading.
|
||||
|
||||
Step 1's reading is a snapshot, and a transport can wedge *after* it, so each
|
||||
command runs under its own timeout and one still outstanding when that expires
|
||||
is abandoned (`-002.8`). Without it a wedge landing between the reading and the
|
||||
command hangs the disposal for ever: step 2 has already retired the only
|
||||
component that would have closed the client. The three timeouts are
|
||||
`sshCleanupTimeout` (60 s) for the cleanup script, `sshAgentctlCleanupTimeout`
|
||||
(20 s) for the controller stop, and a new one for the reset's preflight, which
|
||||
runs on the raw caller context today and is therefore unbounded.
|
||||
|
||||
Abandoning needs a second goroutine, because the disposal's own is inside
|
||||
`Client.NewSession`. The disposal runs the command on its own goroutine and
|
||||
waits on that goroutine and a timer. On the timer it closes the client
|
||||
**through the client once-guard**, so step 5 finds the guard spent rather than
|
||||
double-closing, marks the client transport-lost for `-002.15`, and only then
|
||||
joins the command goroutine. That join is bounded by the close, not by the
|
||||
command, and it keeps the abandoned command inside `-002.4`'s no-leak guarantee
|
||||
rather than beside it.
|
||||
|
||||
Skipping is what makes `-002.11` achievable; the next section gives the two
|
||||
conditions and why each is needed.
|
||||
|
||||
5. **Close the session SSH client**, through the client once-guard.
|
||||
|
||||
6. **Wait for the prober to exit.** If it was mid-probe, step 5's close unblocks
|
||||
its `SendRequest` and it returns through its error exit; if it was idle, step
|
||||
2's signal already woke it out of its interval wait. Both are prompt, so this
|
||||
never costs a probe interval, keeping a deliberate stop fast rather than merely
|
||||
finite. `-002.13` catches an implementation that waits the interval out;
|
||||
`-002.11` one that never returns.
|
||||
|
||||
Only after step 6 does the disposal call return, which is what `-002.4` requires:
|
||||
"the call" is the stop, close, or reset call, not an internal helper.
|
||||
|
||||
Four collapses of this sequence look simpler and each breaks a named criterion:
|
||||
|
||||
- **Stop before closing** (one `stop` that signals and joins both goroutines,
|
||||
called before the client is closed) deadlocks on exactly the connection this
|
||||
capability exists for: the prober is blocked in an uncancellable `SendRequest`,
|
||||
the loop returns on the signal and never tears down, and nothing else closes the
|
||||
client because that was scheduled for after `stop` returns. Returning early from
|
||||
`stop` leaves the prober running past the call — the leak `-002.4` forbids.
|
||||
- **Close before stopping.** The prober's in-flight `SendRequest` errors while the
|
||||
loop is still selecting, and a probe error declares the transport lost
|
||||
(`-001.4`), so a deliberate stop would log a transport-loss warning, which
|
||||
`-002.10` forbids.
|
||||
- **Close the client early, before the remote commands.** It bounds step 4 by
|
||||
making every command fail fast on *every* disposal, including the vast majority
|
||||
where the transport is healthy. Those commands remove the task directory and the
|
||||
remote controller process, so running them over a just-closed client abandons
|
||||
that process on the host every time a session stops. It also inverts an ordering
|
||||
the stop path guarantees and `executor_ssh_scripts_test.go` asserts directly:
|
||||
cleanup before the remote stop and before the client close.
|
||||
- **Bound the remote commands with a context timeout instead of skipping.** What
|
||||
hangs is not bounded by their context: each already runs under a cleanup
|
||||
timeout, and it governs execution, not the channel-open preceding it. Step 4's
|
||||
backstop is a different mechanism — closing the client, the only thing that
|
||||
unblocks a `Client.NewSession` — and it complements the skip rather than
|
||||
replacing it: the skip keeps an already-unresponsive disposal from paying that
|
||||
timeout at all (`-002.11`).
|
||||
|
||||
The sequence needs no "was I asked to stop?" flag inside teardown. Step 2
|
||||
removes the only component that can declare loss or log, so by
|
||||
step 5 nobody is left to misread the resulting error — which is also why `-001.4`
|
||||
is scoped to a *running* watchdog.
|
||||
|
||||
Every step is idempotent. A disposal of a session that never started a watchdog,
|
||||
or whose watchdog already stopped, does nothing in steps 2 and 6 and succeeds. One
|
||||
whose watchdog already tore the transport down needs no close of its own (the
|
||||
once-guards make steps 3 and 5 no-ops) and its prober has exited, so step 6
|
||||
returns at once.
|
||||
|
||||
**Two disposals racing each other.** All three paths remove the session in step 1,
|
||||
so of two concurrent disposals exactly one gets the record. The other finds
|
||||
nothing and returns success immediately, closing no handle and waiting for nothing
|
||||
(`-002.4`). The no-remaining-activity guarantee therefore binds the caller that
|
||||
removed the session, not both: a shared completion handle would let a backend
|
||||
shutdown block on an unrelated stop's work.
|
||||
|
||||
Termination is provable: the loop exits on its signal, and the prober on the error
|
||||
produced by a close that step 5 guarantees. The package's `goleak` check over
|
||||
`TestMain` is the standing regression guard.
|
||||
|
||||
### Disposing of a session whose transport is lost or unresponsive
|
||||
|
||||
Step 1's reading classifies the transport once, and every remote command on that
|
||||
disposal path obeys it (`-002.8`). Re-reading per decision would let a teardown
|
||||
land between two of them and produce a stop that ran the cleanup script but
|
||||
skipped the controller stop.
|
||||
|
||||
*Lost* and *unresponsive* reach the same decision for different reasons, and both
|
||||
are needed. **Lost** means teardown has run and the client is closed, so a remote
|
||||
command fails immediately and skipping saves only a pointless error.
|
||||
**Unresponsive** means the client is still open and has stopped answering, so a
|
||||
remote command does *not* fail — it blocks in `Client.NewSession`, which takes no
|
||||
context, and never returns. That makes the skip load-bearing, and it is invisible
|
||||
to the marker alone: a transport is unresponsive for the whole span between twice
|
||||
the probe interval and the liveness deadline, while the watchdog has correctly not
|
||||
yet declared it lost. Consulting only the marker would satisfy `-002.8` and still
|
||||
hang on `-002.11`.
|
||||
|
||||
Deriving *unresponsive* from the silence interval costs no new signalling between
|
||||
the goroutines. Probes are sent one at a time spaced by the interval, so silence
|
||||
past one interval means a probe is outstanding — but on a healthy link that is
|
||||
true from every interval boundary until the reply lands, so a one-interval
|
||||
threshold would call each healthy session unresponsive for one round trip per
|
||||
cycle and skip the remote commands of a stop with no reason to skip them. The
|
||||
threshold is therefore *twice* the interval, which `-002.9` keeps inside the
|
||||
deadline: a probe still unanswered a whole further interval later is not a slow
|
||||
reply. The timestamp it is computed from is already on the session state for the
|
||||
teardown log (`-001.9`) and is read under the same mutex, in the same critical
|
||||
section, as the marker.
|
||||
|
||||
The reading is not a guarantee that the transport survives the disposal, and need
|
||||
not be: a teardown firing just after it closes the client underneath a command
|
||||
still in flight from before step 2, which then fails promptly and is logged like
|
||||
any other. A command on a *closed* client fails immediately; only the
|
||||
*wedged-but-open* client hangs. The single reading buys a coherent, bounded
|
||||
decision, not an uninterruptible one.
|
||||
|
||||
**The stop and the reset differ in what they report.** A stop treats a skipped or
|
||||
failed cleanup as non-fatal and completes without error (`-002.8`); its job is to
|
||||
release local resources and it has done so. The reset cannot: its remote
|
||||
controller stop is the *point* of the call, killing a stale broker-backed
|
||||
controller before the resume metadata is cleared so a fresh create can run. If
|
||||
that stop was skipped, or failed because the client was closed under it — by a
|
||||
teardown, or by step 4's own backstop — the controller is still running on the
|
||||
host. The same holds for the preflight, whose helper otherwise reports every
|
||||
failure as broker-unreachable. The reset therefore leaves the resume metadata
|
||||
unchanged and reports an error identifying transport loss (`-002.15`), and tells
|
||||
that from a host that refused the call by the transport-lost mark both closers
|
||||
set.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
A session whose transport is torn down does not recover in place. In-flight
|
||||
requests fail with connection errors, the agent's event stream disconnects through
|
||||
the path it already uses for a dropped stream, and the session's work ends with an
|
||||
error instead of hanging. Recovery is a resume: re-dial, re-verify the fingerprint,
|
||||
open a fresh forward, re-attach to the still-running controller.
|
||||
|
||||
## Persistence
|
||||
|
||||
None. The watchdog holds no durable state. The transport-lost marker lives on
|
||||
in-memory session state for the life of the executor's session entry; persisted
|
||||
resume metadata is untouched by teardown.
|
||||
|
||||
## Security
|
||||
|
||||
Probes carry no payload and no credential material, and are sent only on
|
||||
connections whose host key already matched the pinned fingerprint. Teardown
|
||||
neither re-pins nor clears trust; the next dial runs the existing check
|
||||
unchanged.
|
||||
|
||||
## Observability
|
||||
|
||||
- One warning log at teardown naming SSH session transport loss, the executor
|
||||
instance identifier, the remote host, and the silence interval
|
||||
(`-001.9`). A forwarder or client close
|
||||
that returned an error is named in the same line (`-001.5`).
|
||||
- One debug log with the executor instance identifier when a watchdog is not
|
||||
started, because a tuning value is not positive or the deadline is not greater
|
||||
than twice the interval.
|
||||
- Remote status reports `disconnected` with an error naming transport loss, which
|
||||
the executor status badge renders (`-001.7`). It short-circuits on the marker
|
||||
and issues no command, so an already-dead session cannot delay the shared
|
||||
remote-status poll.
|
||||
- No new `expvar` counter; the structured logs carry the instance identifier.
|
||||
|
||||
## Testing
|
||||
|
||||
The fake SSH server answers global requests through `ssh.DiscardRequests`, which
|
||||
replies. This capability needs the opposite: a server that accepts a global
|
||||
request, and a channel open, and answers neither — so `SendRequest` and
|
||||
`Client.NewSession` both block as on a wedged link. The fake server gains a switchable mode for that, so a test can run a session healthy,
|
||||
flip the server silent, and assert teardown within a shortened deadline — bounded
|
||||
in milliseconds once the tuning values are lowered, with `goleak` over `TestMain`
|
||||
covering termination. That silent mode makes the hardest criteria testable:
|
||||
|
||||
- **A stop that races a silent transport must return** — flip the server silent,
|
||||
stop the session well before the deadline, assert the stop completes
|
||||
(`-002.11`). A stop-then-close implementation hangs here rather than failing an
|
||||
assertion, so this test needs its own bound. It also proves the step 4 skip: an
|
||||
implementation issuing either remote command over the still-open wedged client
|
||||
blocks in `Client.NewSession` and never reaches the assertion. The reset needs
|
||||
the same shape, its preflight being the earlier of its two remote calls.
|
||||
- **A command that wedges after the reading must not hang the disposal** — read
|
||||
*answering*, then flip the server silent: the stop must still return and report
|
||||
the command failed (`-002.8`). Millisecond-bounded once the cleanup timeouts are
|
||||
lowered; without the backstop it never returns, so it needs its own bound.
|
||||
- **A deliberate stop must not report transport loss** — the same setup on a
|
||||
*healthy* server, asserting no transport-loss warning is recorded (`-002.10`),
|
||||
which is what fails if the steps are reordered to close before signalling.
|
||||
- **An ordinary disposal between two probes must not wait out the interval** — a
|
||||
healthy session with a long probe interval and a longer deadline; let one probe
|
||||
complete, then dispose of it and assert the call returns in well under one
|
||||
interval (`-002.13`). Use the executor-close path, whose lack of remote commands
|
||||
makes the whole call time the watchdog wait the criterion bounds. Without it, a
|
||||
disposal that waits for the prober's interval timer passes every other criterion
|
||||
while making every ordinary stop take up to fifteen seconds.
|
||||
|
||||
`goleak` covers the prober's error exit (`-002.12`) only if some test reaches an
|
||||
internally-triggered teardown and then ends without stopping the session, so the
|
||||
deadline-expiry test should do exactly that.
|
||||
|
||||
Because both tuning values default to zero for this package's tests (see
|
||||
[Tuning values](#tuning-values)), every test above sets them explicitly and no
|
||||
other test in the package starts a watchdog. Those variables are package-level and
|
||||
mutable, which carries one constraint invisible in the code: **a test that sets
|
||||
any of them must not call `t.Parallel()`**, and neither may any test that creates
|
||||
or resumes an SSH session while such a test could run. No test in the package uses
|
||||
`t.Parallel()` today; it is written down because the instinct on a wait-based
|
||||
liveness test is to parallelise it, and the result is a data race surfacing under
|
||||
`-race` as a flake in an unrelated change.
|
||||
|
||||
## Related decisions
|
||||
|
||||
No architecture decision record is required: this capability restores a transport
|
||||
property inside an existing owned boundary and adds no public contract,
|
||||
configuration key, or dependency.
|
||||
Reference in New Issue
Block a user