2 Commits
Author SHA1 Message Date
clawbot 5111697885 Drop the work log from TODO.md; keep only open work (closes #255)
check / check (push) Successful in 1m12s
TODO.md held a dated log of finished steps, merge history and accounts
of earlier live-feed runs. It now holds the Status line, the open Next
Step and an empty Future Steps. The Workflow section deletes the
finished Next Step instead of moving it to Completed Steps. README
"TODO" no longer says the file lists the steps done.

Model: opus-5-5
2026-11-11 20:25:40 +01:00
clawbot caed69249c Make two tests independent of host load (closes #256)
check / check (push) Canceled after 0s
The home-card refresh test runs keepHomeCardsFresh in a synctest bubble,
so its waits are exact on a fake clock instead of 15-second polls.

The geo fallback subtest in which geojs never answers used a 50 ms time
limit and counted requests at the fake server, which a slow host could
miss. It now uses a transport that counts each request as it is sent and
holds it until the time limit ends it.

Model: opus-5-5
2026-11-11 19:40:39 +01:00
3 changed files with 69 additions and 37 deletions

No files matched your search

+13 -6
View File
@@ -2,7 +2,7 @@
- branch (from `main`) - branch (from `main`)
- do the work in Next Step - do the work in Next Step
- move Next Step to the top of Completed Steps - delete Next Step from `TODO.md`
- move the top item of Future Steps into Next Step - move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work) - commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR - merge to `main` if the branch is not protected, otherwise open a PR
@@ -16,10 +16,14 @@ https://git.eeqj.de/sneak/routewatch/issues/86.
# Next Step # Next Step
Post the outcome of the run of `e58c663` on the live feed on Post the outcome of the run of `e58c663` on the live feed on
https://git.eeqj.de/sneak/routewatch/issues/232. The run began at 09:19 UTC on https://git.eeqj.de/sneak/routewatch/issues/232. Work it out from the run's
2026-11-10 and lasts at least 24 hours. If the feed has not stalled or data:
reconnected by then, the run goes on until it does, up to 72 hours in all. The
outcome says: - the log of the stopped container `routewatch-run-e58c663`, read with
`docker logs`
- the database in the Docker volume `routewatch-run-e58c663`
Keep the container until the outcome is posted. The outcome says:
- whether the feed stalled or reconnected - whether the feed stalled or reconnected
- whether any handler dropped an update, and the most messages the prefix - whether any handler dropped an update, and the most messages the prefix
@@ -28,6 +32,9 @@ outcome says:
- the size of the `origin_changes` table on disk - the size of the `origin_changes` table on disk
- the process's peak memory against 5 GiB - the process's peak memory against 5 GiB
File an issue for each problem the run finds. The run's data does not show the most messages the prefix handler held back at
once, or the process's peak memory.
File an issue for each problem the run found.
# Future Steps # Future Steps
+24 -16
View File
@@ -90,14 +90,12 @@ func parseAddresses(t *testing.T, addresses ...string) []netip.Addr {
return addrs return addrs
} }
// fakeGeoJS is a geojs server. It sends body, or status when that is set, or // fakeGeoJS is a geojs server. It sends body, or status when that is set. It
// waits for the request to end when hang is true. It keeps each request's // keeps each request's address list.
// address list.
type fakeGeoJS struct { type fakeGeoJS struct {
server *httptest.Server server *httptest.Server
body string body string
status int status int
hang bool
mu sync.Mutex mu sync.Mutex
requests []string requests []string
@@ -119,12 +117,6 @@ func (f *fakeGeoJS) serve(w http.ResponseWriter, r *http.Request) {
f.requests = append(f.requests, r.URL.Query().Get("ip")) f.requests = append(f.requests, r.URL.Query().Get("ip"))
f.mu.Unlock() f.mu.Unlock()
if f.hang {
<-r.Context().Done()
return
}
if f.status != 0 { if f.status != 0 {
w.WriteHeader(f.status) w.WriteHeader(f.status)
@@ -142,6 +134,20 @@ func (f *fakeGeoJS) requestsSeen() []string {
return append([]string{}, f.requests...) return append([]string{}, f.requests...)
} }
// hangingTransport never answers: each request waits until its context ends.
// It counts the requests as they are sent, so the count does not depend on
// whether one reaches a server before its time limit runs out.
type hangingTransport struct {
requests atomic.Int32
}
func (h *hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
h.requests.Add(1)
<-r.Context().Done()
return nil, r.Context().Err()
}
// openCacheDB opens a new cache.db in a temporary directory. // openCacheDB opens a new cache.db in a temporary directory.
func openCacheDB(t *testing.T, dir string) *sql.DB { func openCacheDB(t *testing.T, dir string) *sql.DB {
t.Helper() t.Helper()
@@ -385,13 +391,15 @@ func TestLookupFallsBackToTheDatabase(t *testing.T) {
fake := newFakeGeoJS(t, tt.body) fake := newFakeGeoJS(t, tt.body)
fake.status = tt.status fake.status = tt.status
fake.hang = tt.hang
dir := t.TempDir() dir := t.TempDir()
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir) locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
locator.timeLimit = shortTimeLimit requestCount := func() int { return len(fake.requestsSeen()) }
if !tt.hang { if tt.hang {
locator.timeLimit = testTimeLimit hanging := &hangingTransport{}
locator.client = &http.Client{Transport: hanging}
locator.timeLimit = shortTimeLimit
requestCount = func() int { return int(hanging.requests.Load()) }
} }
giveDatabase(t, locator) giveDatabase(t, locator)
@@ -413,8 +421,8 @@ func TestLookupFallsBackToTheDatabase(t *testing.T) {
// asks geojs again. // asks geojs again.
lookupOne(t, locator, testAddress) lookupOne(t, locator, testAddress)
if requests := fake.requestsSeen(); len(requests) != 2 { if requests := requestCount(); requests != 2 {
t.Errorf("%d requests, want 2", len(requests)) t.Errorf("%d requests, want 2", requests)
} }
}) })
} }
+32 -15
View File
@@ -275,22 +275,32 @@ func TestPagesServeTheCopyOfTheCards(t *testing.T) {
} }
} }
// TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh with // TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh. It
// a short interval. It works the cards out at once, again after the table // works the cards out at once, again an interval after the table changes, and
// changes, and stops when its context ends. // stops when its context ends. It runs in a synctest bubble, whose clock is
// fake, so a stall on the host cannot fail it.
func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) { func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
t.Parallel() t.Parallel()
const ( const interval = time.Minute
interval = 10 * time.Millisecond
maxWait = 15 * time.Second
)
table := emptyTable() table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0)) announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
s := newServerWithTable(t, table) s := newServerWithTable(t, table)
// newestRouteOrigin returns the origin AS of the newest route on the
// cards, or 0 when there is none.
newestRouteOrigin := func() int {
cards := s.cards.Load()
if cards == nil || cards.NewestRoute == nil {
return 0
}
return cards.NewestRoute.OriginASN
}
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
stopped := make(chan struct{}) stopped := make(chan struct{})
@@ -299,25 +309,32 @@ func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
close(stopped) close(stopped)
}() }()
newestRouteASNIs := func(asn int) bool { synctest.Wait()
cards := s.cards.Load()
return cards != nil && cards.NewestRoute != nil && if got := newestRouteOrigin(); got != twoPrefixASN {
cards.NewestRoute.OriginASN == asn t.Fatalf("at the start, the newest route's AS is %d, want %d",
got, twoPrefixASN)
} }
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(twoPrefixASN) }) announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2),
testRouteTime(1))
time.Sleep(interval)
synctest.Wait()
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1)) if got := newestRouteOrigin(); got != newestRouteASN {
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(newestRouteASN) }) t.Fatalf("an interval after the table changed, the newest "+
"route's AS is %d, want %d", got, newestRouteASN)
}
cancel() cancel()
synctest.Wait()
select { select {
case <-stopped: case <-stopped:
case <-time.After(maxWait): default:
t.Fatal("keepHomeCardsFresh did not stop when its context ended") t.Fatal("keepHomeCardsFresh did not stop when its context ended")
} }
})
} }
// TestStartedServerWorksOutTheCards starts a server and waits until its home // TestStartedServerWorksOutTheCards starts a server and waits until its home