| 1 | package remote |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "math/rand" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/remote/sshtest" |
| 11 | ) |
| 12 | |
| 13 | func deterministicRand() *rand.Rand { return rand.New(rand.NewSource(1)) } |
| 14 | |
| 15 | // fakeClock is a controllable Clock. After() channels fire when advance() moves |
| 16 | // past their deadline. |
| 17 | type fakeClock struct { |
| 18 | mu sync.Mutex |
| 19 | now time.Time |
| 20 | waiters []fakeWaiter |
| 21 | } |
| 22 | |
| 23 | type fakeWaiter struct { |
| 24 | at time.Time |
| 25 | ch chan time.Time |
| 26 | } |
| 27 | |
| 28 | func newFakeClock() *fakeClock { |
| 29 | return &fakeClock{now: time.Unix(1_700_000_000, 0)} |
| 30 | } |
| 31 | |
| 32 | func (c *fakeClock) Now() time.Time { |
| 33 | c.mu.Lock() |
| 34 | defer c.mu.Unlock() |
| 35 | return c.now |
| 36 | } |
| 37 | |
| 38 | func (c *fakeClock) After(d time.Duration) <-chan time.Time { |
| 39 | c.mu.Lock() |
| 40 | defer c.mu.Unlock() |
| 41 | ch := make(chan time.Time, 1) |
| 42 | if d <= 0 { |
| 43 | ch <- c.now |
| 44 | return ch |
| 45 | } |
| 46 | c.waiters = append(c.waiters, fakeWaiter{at: c.now.Add(d), ch: ch}) |
| 47 | return ch |
| 48 | } |
| 49 | |
| 50 | // advance moves time forward, firing any waiters whose deadline is reached. |
| 51 | func (c *fakeClock) advance(d time.Duration) { |
| 52 | c.mu.Lock() |
| 53 | c.now = c.now.Add(d) |
| 54 | now := c.now |
| 55 | var remaining []fakeWaiter |
| 56 | var fire []chan time.Time |
| 57 | for _, w := range c.waiters { |
| 58 | if !w.at.After(now) { |
| 59 | fire = append(fire, w.ch) |
| 60 | } else { |
| 61 | remaining = append(remaining, w) |
| 62 | } |
| 63 | } |
| 64 | c.waiters = remaining |
| 65 | c.mu.Unlock() |
| 66 | for _, ch := range fire { |
| 67 | ch <- now |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func (c *fakeClock) pendingWaiters() int { |
| 72 | c.mu.Lock() |
| 73 | defer c.mu.Unlock() |
| 74 | return len(c.waiters) |
| 75 | } |
| 76 | |
| 77 | // TestReconnectAfterConnectionDrop verifies the supervisor detects a dropped |
| 78 | // connection and reconnects, emitting Connecting -> Connected -> Reconnecting |
| 79 | // -> Connected. |
| 80 | func TestReconnectAfterConnectionDrop(t *testing.T) { |
| 81 | srv := sshtest.Start(t, sshtest.Options{Password: "x"}) |
| 82 | |
| 83 | var mu sync.Mutex |
| 84 | var states []Status |
| 85 | host, _ := ResolveHost(nil, "test@"+srv.Addr, nil) |
| 86 | c, err := New(Options{ |
| 87 | Host: host, |
| 88 | HostKeys: managedOnlyPolicy(t, true), |
| 89 | Auth: AuthOptions{DisableAgent: true, Password: func() (string, error) { return "x", nil }}, |
| 90 | // Real clock here: we rely on the actual keepalive to notice the drop |
| 91 | // quickly, so keep intervals short. |
| 92 | Keepalive: KeepalivePolicy{Interval: 50 * time.Millisecond, MaxMisses: 1, Timeout: 200 * time.Millisecond}, |
| 93 | Backoff: BackoffPolicy{Initial: 10 * time.Millisecond, Max: 50 * time.Millisecond}, |
| 94 | }) |
| 95 | if err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | reconnected := make(chan struct{}, 1) |
| 99 | connectedCount := 0 |
| 100 | c.Subscribe(func(ev StatusEvent) { |
| 101 | mu.Lock() |
| 102 | states = append(states, ev.Status) |
| 103 | if ev.Status == StatusConnected { |
| 104 | connectedCount++ |
| 105 | if connectedCount == 2 { |
| 106 | select { |
| 107 | case reconnected <- struct{}{}: |
| 108 | default: |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | mu.Unlock() |
| 113 | }) |
| 114 | |
| 115 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 116 | defer cancel() |
| 117 | if err := c.Start(ctx); err != nil { |
| 118 | t.Fatalf("Start: %v", err) |
| 119 | } |
| 120 | defer c.Close() |
| 121 | |
| 122 | // Drop every server-side connection to force a reconnect. |
| 123 | srv.DropConnections() |
| 124 | |
| 125 | select { |
| 126 | case <-reconnected: |
| 127 | case <-time.After(10 * time.Second): |
| 128 | t.Fatalf("never reconnected; states=%v", snapshot(&mu, &states)) |
| 129 | } |
| 130 | |
| 131 | got := snapshot(&mu, &states) |
| 132 | if !containsStatus(got, StatusReconnecting) { |
| 133 | t.Fatalf("no Reconnecting status observed: %v", got) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // TestBackoffUsesClock drives the backoff purely through the fake clock: a |
| 138 | // failed first reconnect must wait on clock.After before retrying. |
| 139 | func TestBackoffSleepHonorsContextCancel(t *testing.T) { |
| 140 | clock := newFakeClock() |
| 141 | c := &Client{ |
| 142 | opts: Options{Backoff: BackoffPolicy{Initial: time.Second, Max: 10 * time.Second}}, |
| 143 | clock: clock, |
| 144 | rng: deterministicRand(), |
| 145 | } |
| 146 | ctx, cancel := context.WithCancel(context.Background()) |
| 147 | done := make(chan bool, 1) |
| 148 | go func() { done <- c.sleepBackoff(ctx, 1) }() |
| 149 | |
| 150 | // Wait until the sleeper registers its waiter, then cancel. |
| 151 | waitForWaiters(t, clock, 1) |
| 152 | cancel() |
| 153 | select { |
| 154 | case ok := <-done: |
| 155 | if ok { |
| 156 | t.Fatal("sleepBackoff returned true after ctx cancel") |
| 157 | } |
| 158 | case <-time.After(2 * time.Second): |
| 159 | t.Fatal("sleepBackoff did not return after ctx cancel") |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestBackoffSleepFiresOnClock(t *testing.T) { |
| 164 | clock := newFakeClock() |
| 165 | c := &Client{ |
| 166 | opts: Options{Backoff: BackoffPolicy{Initial: time.Second, Max: 10 * time.Second}}, |
| 167 | clock: clock, |
| 168 | rng: deterministicRand(), |
| 169 | } |
| 170 | done := make(chan bool, 1) |
| 171 | go func() { done <- c.sleepBackoff(context.Background(), 1) }() |
| 172 | waitForWaiters(t, clock, 1) |
| 173 | clock.advance(2 * time.Second) // past any ceiling in [0, 1s] |
| 174 | select { |
| 175 | case ok := <-done: |
| 176 | if !ok { |
| 177 | t.Fatal("sleepBackoff returned false without cancel") |
| 178 | } |
| 179 | case <-time.After(2 * time.Second): |
| 180 | t.Fatal("sleepBackoff never fired on clock advance") |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func snapshot(mu *sync.Mutex, s *[]Status) []Status { |
| 185 | mu.Lock() |
| 186 | defer mu.Unlock() |
| 187 | out := make([]Status, len(*s)) |
| 188 | copy(out, *s) |
| 189 | return out |
| 190 | } |
| 191 | |
| 192 | func containsStatus(states []Status, want Status) bool { |
| 193 | for _, s := range states { |
| 194 | if s == want { |
| 195 | return true |
| 196 | } |
| 197 | } |
| 198 | return false |
| 199 | } |
| 200 | |
| 201 | func waitForWaiters(t *testing.T, c *fakeClock, n int) { |
| 202 | t.Helper() |
| 203 | for i := 0; i < 200; i++ { |
| 204 | if c.pendingWaiters() >= n { |
| 205 | return |
| 206 | } |
| 207 | time.Sleep(5 * time.Millisecond) |
| 208 | } |
| 209 | t.Fatalf("clock never registered %d waiter(s)", n) |
| 210 | } |
| 211 |