carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "context"
5 "errors"
6 "io"
7 "net"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "syscall"
17 "testing"
18 "time"
19)
20
21// fakeGens is a GenSource a test drives by hand: switchTo is plan 3's
22// switch as the edge sees it, sleep is a stop.
23type fakeGens struct {
24 mu sync.Mutex
25 on bool
26 cur map[string]GenInfo // host -> current generation; absent = no current generation
27 last map[string]uint64 // host -> CacheGen
28 asks int // AppSocket calls
29 retired []func(host, socket string)
30 // onServing, when set, runs after every ServingGen answer, with no lock
31 // held: a test's way to land a switch between the pool choice and the
32 // dial.
33 onServing func()
34}
35
36func newFakeGens(on bool) *fakeGens {
37 return &fakeGens{on: on, cur: map[string]GenInfo{}, last: map[string]uint64{}}
38}
39
40func (f *fakeGens) GenerationsOn() bool { return f.on }
41
42func (f *fakeGens) AppSocket(host string) (string, bool) {
43 f.mu.Lock()
44 defer f.mu.Unlock()
45 f.asks++
46 g, ok := f.cur[host]
47 return g.Socket, ok
48}
49
50func (f *fakeGens) ServingGen(host string) (GenInfo, bool) {
51 f.mu.Lock()
52 g, ok := f.cur[host]
53 hook := f.onServing
54 f.mu.Unlock()
55 if hook != nil {
56 hook()
57 }
58 return g, ok
59}
60
61func (f *fakeGens) CacheGen(host string) (uint64, bool) {
62 f.mu.Lock()
63 defer f.mu.Unlock()
64 n, ok := f.last[host]
65 return n, ok
66}
67
68func (f *fakeGens) OnGenRetired(fn func(host, socket string)) {
69 f.mu.Lock()
70 defer f.mu.Unlock()
71 f.retired = append(f.retired, fn)
72}
73
74// switchTo makes g host's current generation, then tells the retire hooks
75// about the one it replaced.
76func (f *fakeGens) switchTo(host string, g GenInfo) {
77 f.mu.Lock()
78 old, had := f.cur[host]
79 f.cur[host], f.last[host] = g, g.N
80 hooks := append([]func(string, string){}, f.retired...)
81 f.mu.Unlock()
82 if had {
83 for _, fn := range hooks {
84 fn(host, old.Socket)
85 }
86 }
87}
88
89// sleep takes host's current generation away and keeps its number for the
90// cache, as the activator does at a stop.
91func (f *fakeGens) sleep(host string) {
92 f.mu.Lock()
93 old, had := f.cur[host]
94 delete(f.cur, host)
95 hooks := append([]func(string, string){}, f.retired...)
96 f.mu.Unlock()
97 if had {
98 for _, fn := range hooks {
99 fn(host, old.Socket)
100 }
101 }
102}
103
104func (f *fakeGens) askCount() int {
105 f.mu.Lock()
106 defer f.mu.Unlock()
107 return f.asks
108}
109
110// genSockDir is a short directory for generation sockets. Run with
111// TMPDIR=/tmp/k: a socket path must stay under sockaddr_un's 108 bytes.
112func genSockDir(t *testing.T) string {
113 t.Helper()
114 dir, err := os.MkdirTemp("", "e")
115 if err != nil {
116 t.Fatal(err)
117 }
118 t.Cleanup(func() { os.RemoveAll(dir) })
119 return dir
120}
121
122// genApp is one generation's process: an HTTP server on a unix socket that
123// counts the connections it opened and closed.
124type genApp struct {
125 sock string
126 ln net.Listener
127 opened, closed atomic.Int64
128 srv *http.Server
129}
130
131func startGenApp(t *testing.T, sock string, h http.Handler) *genApp {
132 t.Helper()
133 ln, err := net.Listen("unix", sock)
134 if err != nil {
135 if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
136 t.Skipf("unix-socket listeners not permitted in this environment: %v", err)
137 }
138 t.Fatalf("listen %s: %v", sock, err)
139 }
140 a := &genApp{sock: sock, ln: ln}
141 a.srv = &http.Server{Handler: h, ConnState: func(_ net.Conn, st http.ConnState) {
142 switch st {
143 case http.StateNew:
144 a.opened.Add(1)
145 case http.StateClosed, http.StateHijacked:
146 a.closed.Add(1)
147 }
148 }}
149 go a.srv.Serve(ln)
150 t.Cleanup(func() { a.srv.Close() })
151 return a
152}
153
154// stop is the generation's process exiting: the server and its listener close now,
155// so the socket file is unlinked before stop returns (srv.Close alone misses a
156// listener Serve has not registered yet, and Serve's late close would unlink the
157// path by name, possibly a replacement's socket).
158func (a *genApp) stop() { a.srv.Close(); a.ln.Close() }
159
160// body answers b with cache-control cc ("" for none).
161func body(b, cc string) http.Handler {
162 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
163 if cc != "" {
164 w.Header().Set("Cache-Control", cc)
165 }
166 io.WriteString(w, b)
167 })
168}
169
170func serve(t *testing.T, h http.Handler, url string) *httptest.ResponseRecorder {
171 t.Helper()
172 rec := httptest.NewRecorder()
173 h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
174 return rec
175}
176
177// Each retry asks the source again: a dial that races a switch reaches the
178// generation that is current by the time it retries.
179func TestDialUnixRetryingAsksAgainOnEveryRetry(t *testing.T) {
180 dir := genSockDir(t)
181 live := startGenApp(t, filepath.Join(dir, "2.sock"), body("B", ""))
182 var asks atomic.Int64
183 resolve := func() (string, bool) {
184 switch asks.Add(1) {
185 case 1:
186 return "", false // between generations: no current one yet
187 case 2:
188 return filepath.Join(dir, "1.sock"), true // a retired generation: its file is gone
189 default:
190 return live.sock, true
191 }
192 }
193 conn, err := dialUnixRetrying(context.Background(), &net.Dialer{}, resolve)
194 if err != nil {
195 t.Fatalf("dial: %v", err)
196 }
197 conn.Close()
198 if got := asks.Load(); got != 3 {
199 t.Fatalf("resolve asked %d times, want 3 (once per attempt)", got)
200 }
201}
202
203// A connection to generation n is never reused for n+1, and n's idle
204// connections close when it stops being current. The version header names
205// the build of the generation that served each request.
206func TestAppSocketProxyFollowsTheCurrentGeneration(t *testing.T) {
207 dir := genSockDir(t)
208 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
209 b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", ""))
210 src := newFakeGens(true)
211 pools := NewGenPools()
212 src.OnGenRetired(pools.Retire)
213 const host = "inst.example.com"
214 src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"})
215 p := appSocketProxy(host, src, pools)
216
217 for i := 0; i < 2; i++ {
218 rec := serve(t, p, "http://inst.example.com/")
219 if rec.Body.String() != "A" || rec.Header().Get("X-Carlos-Version") != "v1" {
220 t.Fatalf("request %d: %q version %q, want A from v1", i, rec.Body.String(), rec.Header().Get("X-Carlos-Version"))
221 }
222 }
223 if got := a.opened.Load(); got != 1 {
224 t.Fatalf("generation 1 saw %d connections, want 1 (keep-alive reused)", got)
225 }
226
227 src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"})
228 rec := serve(t, p, "http://inst.example.com/")
229 if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" {
230 t.Fatalf("after the switch: %q version %q, want B from v2", rec.Body.String(), rec.Header().Get("X-Carlos-Version"))
231 }
232 if got := a.opened.Load(); got != 1 {
233 t.Fatalf("generation 1 saw %d connections after the switch, want still 1", got)
234 }
235 deadline := time.Now().Add(2 * time.Second)
236 for a.closed.Load() != 1 && time.Now().Before(deadline) {
237 time.Sleep(10 * time.Millisecond)
238 }
239 if got := a.closed.Load(); got != 1 {
240 t.Fatalf("generation 1's idle connection was not closed at retirement (closed=%d)", got)
241 }
242 if got := pools.Len(); got != 1 {
243 t.Fatalf("%d pools live, want 1 (the retired one forgotten)", got)
244 }
245}
246
247// Spec §7.2: the version header and the cache's generation name the
248// generation whose connection carried the request. Here the switch lands
249// between the pool choice and the dial. With a pool whose dial re-resolves
250// AppSocket, the request reached B while it was stamped and tagged as A.
251func TestAppSocketProxyAttributesTheConnectionsGeneration(t *testing.T) {
252 const host = "inst.example.com"
253 // Rev 17: a switch that lands before the dial completes sends the request
254 // to the new current generation, even though the retiring one still
255 // accepts. Its response is attributed to the generation that served it.
256 t.Run("a switch before the dial completes", func(t *testing.T) {
257 dir := genSockDir(t)
258 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
259 b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", ""))
260 src := newFakeGens(true)
261 src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"})
262 var once sync.Once
263 src.onServing = func() {
264 once.Do(func() { src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"}) })
265 }
266 p := appSocketProxy(host, src, NewGenPools())
267 r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
268 rec := httptest.NewRecorder()
269 p.ServeHTTP(rec, r)
270 _, n, _ := servedGen(r)
271 if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 {
272 t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (retried on the current generation)",
273 rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n)
274 }
275 if got := b.opened.Load(); got < 1 {
276 t.Fatal("generation 2 was never reached")
277 }
278 _ = a
279 })
280 // A proxied POST streams its body: no GetBody, unknown length. A switch
281 // before the dial completes must not turn it into a 502. The generation
282 // is settled before a byte of the body is read, so the body goes, whole,
283 // to the current generation. Retrying only replayable requests failed it.
284 t.Run("a streaming POST across a switch", func(t *testing.T) {
285 dir := genSockDir(t)
286 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
287 echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
288 b, _ := io.ReadAll(r.Body)
289 io.WriteString(w, "B:"+string(b))
290 })
291 b := startGenApp(t, filepath.Join(dir, "b.sock"), echo)
292 src := newFakeGens(true)
293 src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"})
294 var once sync.Once
295 src.onServing = func() {
296 once.Do(func() {
297 a.stop() // generation 1 has gone: its socket file with it
298 src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"})
299 })
300 }
301 pr, pw := io.Pipe()
302 go func() {
303 io.WriteString(pw, "hello, ")
304 io.WriteString(pw, "world")
305 pw.Close()
306 }()
307 req := httptest.NewRequest("POST", "http://inst.example.com/form", pr)
308 req.ContentLength = -1
309 if req.GetBody != nil {
310 t.Fatal("fixture: the request must have no GetBody")
311 }
312 r, _ := withOutcome(req)
313 rec := httptest.NewRecorder()
314 appSocketProxy(host, src, NewGenPools()).ServeHTTP(rec, r)
315 if rec.Code != http.StatusOK || rec.Body.String() != "B:hello, world" || rec.Header().Get("X-Carlos-Version") != "v2" {
316 t.Fatalf("status %d, body %q, version %q: want 200, the whole body at generation 2, v2",
317 rec.Code, rec.Body.String(), rec.Header().Get("X-Carlos-Version"))
318 }
319 _ = b
320 })
321 t.Run("the sampled generation is gone", func(t *testing.T) {
322 dir := genSockDir(t)
323 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
324 b := startGenApp(t, filepath.Join(dir, "b.sock"), body("B", ""))
325 src := newFakeGens(true)
326 src.switchTo(host, GenInfo{Socket: a.sock, N: 1, Build: "v1"})
327 var once sync.Once
328 src.onServing = func() {
329 once.Do(func() {
330 a.stop() // A has exited: its socket file is gone
331 src.switchTo(host, GenInfo{Socket: b.sock, N: 2, Build: "v2"})
332 })
333 }
334 p := appSocketProxy(host, src, NewGenPools())
335 r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
336 rec := httptest.NewRecorder()
337 p.ServeHTTP(rec, r)
338 _, n, _ := servedGen(r)
339 if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 {
340 t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (sent again on the current pool)",
341 rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n)
342 }
343 })
344 // A foreign row's generations share one path. Generation 1's pool, chosen
345 // an instant before the hold, dials that path after generation 2 has
346 // bound it: the connection reaches 2, and a dial that trusts the path
347 // stamps 2's response as 1 and keeps the connection in 1's pool.
348 t.Run("a foreign row's path now belongs to the replacement", func(t *testing.T) {
349 dir := genSockDir(t)
350 path := filepath.Join(dir, "canonical.sock")
351 a := startGenApp(t, path, body("A", ""))
352 src := newFakeGens(true)
353 src.switchTo(host, GenInfo{Socket: path, N: 1, Build: "v1"})
354 var once sync.Once
355 var b *genApp
356 src.onServing = func() {
357 once.Do(func() {
358 a.stop() // the hold stops 1; its socket file goes
359 b = startGenApp(t, path, body("B", ""))
360 src.switchTo(host, GenInfo{Socket: path, N: 2, Build: "v2"})
361 })
362 }
363 p := appSocketProxy(host, src, NewGenPools())
364 r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
365 rec := httptest.NewRecorder()
366 p.ServeHTTP(rec, r)
367 _, n, _ := servedGen(r)
368 if rec.Body.String() != "B" || rec.Header().Get("X-Carlos-Version") != "v2" || n != 2 {
369 t.Fatalf("served %q, version %q, generation %d: want B, v2, 2 (never attributed to the old generation)",
370 rec.Body.String(), rec.Header().Get("X-Carlos-Version"), n)
371 }
372 if b == nil || b.opened.Load() < 1 {
373 t.Fatal("the replacement was never reached")
374 }
375 })
376}
377
378// The generation that carried a request is recorded on its outcome, for the
379// cache to tag with.
380func TestAppSocketProxyNotesTheServedGeneration(t *testing.T) {
381 dir := genSockDir(t)
382 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
383 src := newFakeGens(true)
384 src.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 7, Build: "v1"})
385 p := appSocketProxy("inst.example.com", src, NewGenPools())
386 r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
387 p.ServeHTTP(httptest.NewRecorder(), r)
388 if host, n, ok := servedGen(r); !ok || host != "inst.example.com" || n != 7 {
389 t.Fatalf("servedGen = %q %d %v, want inst.example.com 7", host, n, ok)
390 }
391}
392
393// With no current generation the request waits inside the dial's window for
394// one, then goes the normal way: through that generation's pool, stamped
395// with its build and tagged with its number (spec §7.2). An unbound one-shot
396// transport returned the response unattributed, with no platform version.
397func TestAppSocketProxyWaitsForAGeneration(t *testing.T) {
398 dir := genSockDir(t)
399 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
400 src := newFakeGens(true)
401 pools := NewGenPools()
402 p := appSocketProxy("inst.example.com", src, pools)
403 go func() {
404 time.Sleep(300 * time.Millisecond)
405 src.switchTo("inst.example.com", GenInfo{Socket: a.sock, N: 3, Build: "v1"})
406 }()
407 r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
408 rec := httptest.NewRecorder()
409 p.ServeHTTP(rec, r)
410 if rec.Body.String() != "A" || rec.Header().Get("X-Carlos-Version") != "v1" {
411 t.Fatalf("body %q, version %q: want A from v1 once the generation appeared", rec.Body.String(), rec.Header().Get("X-Carlos-Version"))
412 }
413 if host, n, ok := servedGen(r); !ok || host != "inst.example.com" || n != 3 {
414 t.Fatalf("servedGen = %q %d %v, want generation 3", host, n, ok)
415 }
416 if pools.Len() != 1 {
417 t.Fatalf("%d pools, want generation 3's", pools.Len())
418 }
419}
420
421// A request that sampled generation n before a switch must not leave n's
422// pool behind when it resumes after Retire removed it: the pool is only
423// published for a generation that is still current. The switch lands at the
424// request's own sampling call, the instant between choosing n and asking for
425// its pool, and repeats over many generations; the set stays at most one pool
426// and every request is served by the generation current when it finished.
427func TestGenPoolsRetiredGenerationIsNotRecreated(t *testing.T) {
428 const (
429 host = "inst.example.com"
430 swaps = 20
431 )
432 dir := genSockDir(t)
433 apps := make([]*genApp, swaps+2)
434 for n := 1; n < len(apps); n++ {
435 apps[n] = startGenApp(t, filepath.Join(dir, strconv.Itoa(n)+".sock"), body("G"+strconv.Itoa(n), ""))
436 }
437 info := func(n int) GenInfo {
438 return GenInfo{Socket: apps[n].sock, N: uint64(n), Build: "v" + strconv.Itoa(n)}
439 }
440 src := newFakeGens(true)
441 pools := NewGenPools()
442 src.OnGenRetired(pools.Retire)
443 src.switchTo(host, info(1))
444 p := appSocketProxy(host, src, pools)
445
446 for n := 1; n <= swaps; n++ {
447 var once sync.Once
448 next := n + 1
449 src.onServing = func() { once.Do(func() { src.switchTo(host, info(next)) }) }
450 r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
451 rec := httptest.NewRecorder()
452 p.ServeHTTP(rec, r)
453 want := "G" + strconv.Itoa(next)
454 if rec.Body.String() != want || rec.Header().Get("X-Carlos-Version") != "v"+strconv.Itoa(next) {
455 t.Fatalf("switch %d: served %q version %q, want %s from v%d",
456 n, rec.Body.String(), rec.Header().Get("X-Carlos-Version"), want, next)
457 }
458 if _, got, _ := servedGen(r); got != uint64(next) {
459 t.Fatalf("switch %d: attributed to generation %d, want %d", n, got, next)
460 }
461 if got := pools.Len(); got > 1 {
462 t.Fatalf("switch %d: %d pools live, want at most 1 (a retired generation's pool was recreated)", n, got)
463 }
464 }
465}
466
467// flappingGens answers a different generation on every ServingGen call, so no
468// sampled generation is ever still current when its pool is asked for.
469type flappingGens struct {
470 *fakeGens
471 sock string
472 n atomic.Uint64
473}
474
475func (f *flappingGens) ServingGen(string) (GenInfo, bool) {
476 return GenInfo{Socket: f.sock, N: f.n.Add(1), Build: "v"}, true
477}
478
479type closeCounter struct {
480 io.Reader
481 closed atomic.Int64
482}
483
484func (c *closeCounter) Close() error { c.closed.Add(1); return nil }
485
486// A request whose sampled generation is refused again and again does not spin
487// and does not leave a pool behind: after a few refusals it gives up with the
488// generation-moved error, as exhausted retries do, and its body is released.
489func TestGenRoundTripperGivesUpWhenEveryGenerationIsStale(t *testing.T) {
490 dir := genSockDir(t)
491 a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
492 src := &flappingGens{fakeGens: newFakeGens(true), sock: a.sock}
493 pools := NewGenPools()
494 rt := &genRoundTripper{host: "inst.example.com", src: src, pools: pools}
495 b := &closeCounter{Reader: strings.NewReader("x")}
496 req := httptest.NewRequest("POST", "http://inst.example.com/", b)
497 _, err := rt.RoundTrip(req)
498 var moved *genMovedError
499 if !errors.As(err, &moved) {
500 t.Fatalf("RoundTrip error = %v, want a *genMovedError", err)
501 }
502 if got := pools.Len(); got != 0 {
503 t.Fatalf("%d pools live, want none for a generation that was never current", got)
504 }
505 if got := b.closed.Load(); got != 1 {
506 t.Fatalf("body closed %d times, want 1", got)
507 }
508}
509