Repository navigation
Expand file tree
/
Copy pathstale_test.go
More file actions
400 lines (365 loc) · 12.8 KB
/
Copy pathstale_test.go
File metadata and controls
400 lines (365 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
package enginehost
// stale_test.go is the half of this package that keeps a rebuild from trapping
// somebody: a host that can be asked which build it is, that goes when it is
// holding nothing, that stays when it is holding work, and that lets itself go
// once the file it was started from has been replaced under it.
//
// These stand a REAL host on a REAL unix socket, which is why they go through
// [shortHome] — a socket path has about a hundred bytes to spend and a Mac's
// TMPDIR eats most of them before the test has said anything, so the home is
// made under /tmp directly and never under whatever TMPDIR happens to be.
import (
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/Agent-Field/codeaf/internal/remote"
)
// liveHost stands a host up on its socket and hands back the workspace it holds.
func liveHost(t *testing.T, workspace string) {
t.Helper()
stopped := make(chan error, 1)
go func() {
stopped <- Run(workspace, Options{
Boot: func(remote.Hello) (*remote.Engine, error) {
return &remote.Engine{Agent: stubAgent{}, Workspace: workspace}, nil
},
})
}()
waitForHostQuietly(t, workspace)
t.Cleanup(func() {
// Whatever the test did, nothing is left listening: a host that
// outlived its test is exactly the thing this file is about.
_ = Retire(workspace, true)
select {
case <-stopped:
case <-time.After(5 * time.Second):
}
})
}
// waitForHostQuietly waits until the host accepts a connection and completes
// the remote whois handshake. A socket pathname and a held lock can coexist
// during startup before the host has replaced a stale socket and begun accepting.
func waitForHostQuietly(t *testing.T, workspace string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := Ask(workspace, remote.WhoIs{}); err == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("no host came up")
}
time.Sleep(10 * time.Millisecond)
}
}
// TestWaitForHostQuietlyRequiresAListeningHost pins the remove-to-listen
// window: a held host lock plus a stale socket file is not readiness.
func TestWaitForHostQuietlyRequiresAListeningHost(t *testing.T) {
shortHome(t)
workspace := "/home/somebody/api"
dir, err := Dir(workspace)
if err != nil {
t.Fatalf("resolve the directory: %v", err)
}
socket := filepath.Join(dir, socketName)
stale, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"})
if err != nil {
t.Fatalf("plant stale socket: %v", err)
}
stale.SetUnlinkOnClose(false)
if err := stale.Close(); err != nil {
t.Fatalf("close stale socket: %v", err)
}
lock, err := takeLock(filepath.Join(dir, lockName))
if err != nil {
t.Fatalf("hold host lock: %v", err)
}
ready := make(chan struct{})
go func() {
waitForHostQuietly(t, workspace)
close(ready)
}()
select {
case <-ready:
t.Fatal("stale socket plus held lock was accepted as ready")
case <-time.After(100 * time.Millisecond):
}
if err := releaseLock(lock); err != nil {
t.Fatalf("release staged lock: %v", err)
}
stopped := make(chan error, 1)
go func() {
stopped <- Run(workspace, Options{
Boot: func(remote.Hello) (*remote.Engine, error) {
return &remote.Engine{Agent: stubAgent{}, Workspace: workspace}, nil
},
})
}()
select {
case <-ready:
case <-time.After(5 * time.Second):
t.Fatal("listening host was not accepted as ready")
}
t.Cleanup(func() {
_ = Retire(workspace, true)
select {
case <-stopped:
case <-time.After(5 * time.Second):
}
})
}
// ── which build is holding this ─────────────────────────────────────────────
func TestAHostSaysWhichBuildItIsOnItsOwnSocket(t *testing.T) {
shortHome(t)
workspace := "/home/somebody/api"
liveHost(t, workspace)
self, err := Ask(workspace, remote.WhoIs{})
if err != nil {
t.Fatalf("ask the host: %v", err)
}
if self.Version != remote.Version {
t.Fatalf("the host said it speaks %d, want %d", self.Version, remote.Version)
}
if self.Workspace != workspace {
t.Fatalf("the host named %q as its workspace", self.Workspace)
}
// THE QUESTION IS NOT THE WORK. A host with nothing else on it must not
// read its own asker as a reason to say it is busy — that would make every
// stale host permanently unretirable.
if self.Busy {
t.Fatal("a host holding nothing said it was busy")
}
}
// ── it goes when it is holding nothing ──────────────────────────────────────
func TestAHostRetiresWhenAskedAndItIsHoldingNothing(t *testing.T) {
shortHome(t)
workspace := "/home/somebody/api"
liveHost(t, workspace)
if err := Retire(workspace, false); err != nil {
t.Fatalf("retire the host: %v", err)
}
if conn, err := Dial(workspace); err == nil {
_ = conn.Close()
t.Fatal("the host was still answering after it agreed to go")
}
// And the lock is free, which is what lets the next connection start a host
// from the binary that is on disk now.
dir, err := Dir(workspace)
if err != nil {
t.Fatalf("resolve the directory: %v", err)
}
held, err := takeLock(filepath.Join(dir, lockName))
if err != nil {
t.Fatalf("the retired host is still holding its lock: %v", err)
}
_ = releaseLock(held)
}
// ── and it stays when somebody is using it ──────────────────────────────────
func TestAHostIsNotRetiredOutFromUnderAnAttachedSurface(t *testing.T) {
shortHome(t)
workspace := "/home/somebody/api"
liveHost(t, workspace)
conn, err := Dial(workspace)
if err != nil {
t.Fatalf("dial the host: %v", err)
}
surface, err := remote.Dial(conn, "test", remote.Hello{Version: remote.Version})
if err != nil {
t.Fatalf("handshake: %v", err)
}
defer surface.Close()
self, err := Ask(workspace, remote.WhoIs{StandDown: true})
if err != nil {
t.Fatalf("ask the host to go: %v", err)
}
if !self.Busy {
t.Fatal("a host with a surface attached said it was holding nothing")
}
if self.Retiring {
t.Fatal("a host with a surface attached agreed to go anyway")
}
if err := Retire(workspace, false); err != ErrHostBusy {
t.Fatalf("retiring a busy host answered %v, want ErrHostBusy", err)
}
// AND IT IS STILL THERE, which is the whole of the promise: nobody's turn
// ended because somebody else's connection wanted a newer build.
if _, err := Ask(workspace, remote.WhoIs{}); err != nil {
t.Fatalf("the host stopped answering after it refused to go: %v", err)
}
}
// A person who has been told what is running and says stop anyway is obeyed.
// That is what `codeaf engine --stop` is, and the turn it catches stops where
// it is and keeps its partial reply — the same thing ctrl+c does locally.
func TestStopEndsAHostEvenWithASurfaceOnIt(t *testing.T) {
shortHome(t)
workspace := "/home/somebody/api"
liveHost(t, workspace)
conn, err := Dial(workspace)
if err != nil {
t.Fatalf("dial the host: %v", err)
}
surface, err := remote.Dial(conn, "test", remote.Hello{Version: remote.Version})
if err != nil {
t.Fatalf("handshake: %v", err)
}
defer surface.Close()
stopped, err := Stop(workspace)
if err != nil {
t.Fatalf("stop the host: %v", err)
}
if !stopped {
t.Fatal("stop found nothing to stop")
}
if conn, err := Dial(workspace); err == nil {
_ = conn.Close()
t.Fatal("the host was still answering after it was stopped")
}
}
// Stopping nothing is not a failure and says nothing happened, because that is
// the ordinary answer on a machine where nobody has connected today.
func TestStopFindsNothingToStopAndSaysSo(t *testing.T) {
shortHome(t)
stopped, err := Stop("/home/somebody/api")
if err != nil {
t.Fatalf("stop with no host: %v", err)
}
if stopped {
t.Fatal("stop claimed to have stopped a host that was never there")
}
}
// ── the binary underneath ───────────────────────────────────────────────────
func TestABinaryRemovedAndRebuiltIsNotTheBinaryThatStarted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "codeaf")
if err := os.WriteFile(path, []byte("first"), 0o755); err != nil {
t.Fatalf("write the binary: %v", err)
}
was, err := os.Stat(path)
if err != nil {
t.Fatalf("stat the binary: %v", err)
}
binary := hostBinary{path: path, was: was}
if binary.replaced() {
t.Fatal("an untouched binary read as replaced")
}
// The install this tree allows and the one it forbids, in that order:
// remove-and-write mints a new inode, and a copy over the top keeps the
// inode and moves the modification time.
if err := os.Remove(path); err != nil {
t.Fatalf("remove the binary: %v", err)
}
if binary.replaced() != true {
t.Fatal("a deleted binary read as unchanged")
}
if err := os.WriteFile(path, []byte("second"), 0o755); err != nil {
t.Fatalf("rebuild the binary: %v", err)
}
if !binary.replaced() {
t.Fatal("a rebuilt binary read as the one that started")
}
// A machine that could not say which file it is never claims to have been
// replaced: the capability is absent rather than guessing.
if (hostBinary{}).replaced() {
t.Fatal("a host with no idea what file it is claimed to be stale")
}
}
func TestAHostWhoseBinaryWasReplacedRetiresOnceItIsHoldingNothing(t *testing.T) {
h := stubHost(t, "/home/somebody/api")
h.binary = replacedBinary(t)
h.quiet = time.Now()
if !h.sweepOnce() {
t.Fatal("a host started from a file that is gone stayed up with nothing to hold")
}
// AND THE DOOR IS SHUT ON THE WAY OUT, so a surface arriving in the moment
// between the decision and the stop is refused rather than handed a
// conversation that is about to end.
if _, err := h.open(remote.Hello{Version: remote.Version}); err == nil {
t.Fatal("a host on its way out opened a new conversation")
}
}
func TestAHostWhoseBinaryWasReplacedStaysWhileItIsHoldingWork(t *testing.T) {
h := stubHost(t, "/home/somebody/api")
h.binary = replacedBinary(t)
h.quiet = time.Now()
// One conversation with a surface on it. It is not idle, and this host is
// not the thing that ends it.
if _, err := h.open(remote.Hello{Version: remote.Version}); err != nil {
t.Fatalf("open a conversation: %v", err)
}
h.mu.Lock()
h.live = 1
h.mu.Unlock()
if h.sweepOnce() {
t.Fatal("a host with a connection on it retired because its binary had moved")
}
if h.retiring {
t.Fatal("a host holding work shut its own door")
}
}
// replacedBinary is a host binary whose file is already gone, which is the
// simplest true version of "this is not the build that is on disk now".
func replacedBinary(t *testing.T) hostBinary {
t.Helper()
path := filepath.Join(t.TempDir(), "codeaf")
if err := os.WriteFile(path, []byte("gone in a moment"), 0o755); err != nil {
t.Fatalf("write the binary: %v", err)
}
was, err := os.Stat(path)
if err != nil {
t.Fatalf("stat the binary: %v", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove the binary: %v", err)
}
return hostBinary{path: path, was: was}
}
// TestDialStartingHostRetriesOnlyWhileTheHostLockIsHeld pins the cost fix: a
// refused connect is retried while a host is coming up (its lock held) but not
// when no host is starting (lock free), which is the ordinary state after a host
// crash that left a stale socket, so a headless launch does not pay the retry
// budget on every start.
func TestDialStartingHostRetriesOnlyWhileTheHostLockIsHeld(t *testing.T) {
shortHome(t)
workspace := "/home/somebody/api"
dir, err := Dir(workspace)
if err != nil {
t.Fatalf("resolve the directory: %v", err)
}
// A stale socket that refuses every connect: a crashed host nobody restarted.
socket := filepath.Join(dir, socketName)
stale, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"})
if err != nil {
t.Fatalf("plant stale socket: %v", err)
}
stale.SetUnlinkOnClose(false)
if err := stale.Close(); err != nil {
t.Fatalf("close stale socket: %v", err)
}
// Lock free: no host is coming up, so the refusal is final and does not spend
// the retry budget.
start := time.Now()
if conn, err := DialStartingHost(workspace); err == nil {
_ = conn.Close()
t.Fatal("a stale socket with a free lock answered as a live host")
}
if spent := time.Since(start); spent >= startingHostWait {
t.Fatalf("a free lock spent the retry budget: %v", spent)
}
// Lock held: a host is coming up in its remove-to-listen window, so the same
// refused connect is retried across the budget rather than answered at once.
lock, err := takeLock(filepath.Join(dir, lockName))
if err != nil {
t.Fatalf("hold host lock: %v", err)
}
defer func() { _ = releaseLock(lock) }()
start = time.Now()
if conn, err := DialStartingHost(workspace); err == nil {
_ = conn.Close()
t.Fatal("no host was listening, yet the dial answered")
}
if spent := time.Since(start); spent < startingHostWait {
t.Fatalf("a held lock did not retry across the budget: %v", spent)
}
}