| index 334f83b63..bf7501a20 100644 |
| --- a/internal/console/apidb_test.go |
| +++ b/internal/console/apidb_test.go |
| @@ -7,10 +7,13 @@ import ( |
| "errors" |
| "fmt" |
| "io" |
| + "net" |
| "net/http" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "sync/atomic" |
| "testing" |
| "time" |
| @@ -762,3 +765,98 @@ func TestAPIDBStalledClientIsCut(t *testing.T) { |
| <-sr.opened |
| fx.waitIdle(t, 15*time.Second) |
| } |
| + |
| +// recConn records, in order, every write and every write-deadline change on |
| +// a server-side connection. |
| +type recConn struct { |
| + net.Conn |
| + mu *sync.Mutex |
| + events *[]string |
| +} |
| + |
| +func (c recConn) Write(p []byte) (int, error) { |
| + c.mu.Lock() |
| + *c.events = append(*c.events, "write") |
| + c.mu.Unlock() |
| + return c.Conn.Write(p) |
| +} |
| + |
| +func (c recConn) SetWriteDeadline(t time.Time) error { |
| + c.mu.Lock() |
| + if t.IsZero() { |
| + *c.events = append(*c.events, "clear") |
| + } else { |
| + *c.events = append(*c.events, "deadline") |
| + } |
| + c.mu.Unlock() |
| + return c.Conn.SetWriteDeadline(t) |
| +} |
| + |
| +type recListener struct { |
| + net.Listener |
| + mu sync.Mutex |
| + events []string |
| +} |
| + |
| +func (l *recListener) Accept() (net.Conn, error) { |
| + c, err := l.Listener.Accept() |
| + if err != nil { |
| + return nil, err |
| + } |
| + return recConn{Conn: c, mu: &l.mu, events: &l.events}, nil |
| +} |
| + |
| +// net/http flushes what is still buffered (the last bytes, the chunked |
| +// terminator) in finishRequest, AFTER the handler returns, and clears the |
| +// write deadline itself only after that. A handler that clears it on the |
| +// way out leaves that final flush with no deadline, so a client that stops |
| +// reading near the end holds the connection forever, outside the stream |
| +// bound. No byte may ever be written with the deadline cleared. |
| +func TestAPIDBNeverWritesWithoutADeadline(t *testing.T) { |
| + fx := dbFixture(t) |
| + fx.replicate(t, dbTestHost, 1) |
| + f := listFiles(t, lsfile.NewReplicaClient(filepath.Join(fx.root, "instances", dbTestHost)), 0)[0] |
| + ts := httptest.NewUnstartedServer(fx.srv) |
| + rl := &recListener{Listener: ts.Listener} |
| + ts.Listener = rl |
| + ts.Start() |
| + defer ts.Close() |
| + req, _ := http.NewRequest(http.MethodGet, ts.URL+dbdoor.GetPath("bab", "titogo", dbTestHost, 0, uint64(f.MinTXID), uint64(f.MaxTXID)), nil) |
| + req.Header.Set("Authorization", "Bearer "+fx.member) |
| + req.Close = true |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + io.Copy(io.Discard, res.Body) |
| + res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("get = %d", res.StatusCode) |
| + } |
| + deadline := time.Now().Add(5 * time.Second) |
| + for { |
| + rl.mu.Lock() |
| + events := append([]string(nil), rl.events...) |
| + rl.mu.Unlock() |
| + if len(events) > 0 && events[len(events)-1] == "clear" || time.Now().After(deadline) { |
| + armed := false |
| + for i, e := range events { |
| + switch e { |
| + case "deadline": |
| + armed = true |
| + case "clear": |
| + armed = false |
| + case "write": |
| + if !armed { |
| + t.Fatalf("write %d happened with no write deadline: %v", i, events) |
| + } |
| + } |
| + } |
| + if !armed && events[len(events)-1] != "clear" { |
| + t.Fatalf("events = %v", events) |
| + } |
| + return |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| +} |