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