Files
James Tucker f5fdb18b2e ssh/tailssh: give delegated action fetches one timeout owner
Let fetchSSHAction own the per-fetch deadline and pass the connection cancellation context directly from clientAuth. Remove the duplicate timeout and its deferred cancellation inside the action loop. Derive the longer transport watchdog from the fetch timeout, keeping both durations unchanged.

Use virtual time to test the fetch deadline across retries, earlier parent deadlines, and parent cancellation. Verify that a successful fetch releases its request context.

Updates tailscale/corp#50380

Change-Id: I185958ca19566f04cbd68f33e4b1dad3d18aebb7
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-26 15:11:58 -07:00

98 lines
2.9 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
package tailssh
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"testing/synctest"
"time"
)
type delegateBackend struct {
ipnLocalBackend
request func(*http.Request) (*http.Response, error)
}
func (b *delegateBackend) DoNoiseRequest(req *http.Request) (*http.Response, error) {
return b.request(req)
}
func TestFetchSSHActionTimeout(t *testing.T) {
for _, mode := range []string{"timeout", "retry-timeout", "parent-deadline", "parent-cancel"} {
t.Run(mode, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
wantDeadline := start.Add(delegateFetchTimeout)
wantElapsed := delegateFetchTimeout
wantErr := context.DeadlineExceeded
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
switch mode {
case "parent-deadline":
var stop context.CancelFunc
ctx, stop = context.WithTimeout(ctx, time.Minute)
defer stop()
wantElapsed = time.Minute
wantDeadline = start.Add(wantElapsed)
case "parent-cancel":
time.AfterFunc(time.Minute, cancel)
wantElapsed = time.Minute
wantErr = context.Canceled
}
calls := 0
b := &delegateBackend{request: func(req *http.Request) (*http.Response, error) {
calls++
if got, ok := req.Context().Deadline(); !ok || !got.Equal(wantDeadline) {
t.Errorf("request deadline = %v, %v; want %v", got, ok, wantDeadline)
}
if mode == "retry-timeout" {
return nil, errors.New("temporarily unavailable")
}
<-req.Context().Done()
return nil, req.Context().Err()
}}
c := &conn{srv: &server{lb: b, logf: t.Logf}}
if _, err := c.fetchSSHAction(ctx, "https://unused/action"); !errors.Is(err, wantErr) {
t.Errorf("fetchSSHAction error = %v, want %v", err, wantErr)
}
if got := time.Since(start); got != wantElapsed {
t.Errorf("fetch duration = %v, want %v", got, wantElapsed)
}
if calls == 0 || mode == "retry-timeout" && calls < 2 {
t.Errorf("request calls = %d", calls)
}
})
})
}
}
func TestFetchSSHActionCancelsRequestOnSuccess(t *testing.T) {
var requestContext context.Context
b := &delegateBackend{request: func(req *http.Request) (*http.Response, error) {
requestContext = req.Context()
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"Accept":true}`)),
}, nil
}}
c := &conn{srv: &server{lb: b, logf: t.Logf}}
action, err := c.fetchSSHAction(context.Background(), "https://unused/action")
if err != nil {
t.Fatal(err)
}
if !action.Accept {
t.Fatal("delegated action did not accept")
}
if err := requestContext.Err(); err != context.Canceled {
t.Errorf("request context error = %v, want context.Canceled", err)
}
}