Recreate the zooid container in teardown and let its network churn settle

This commit is contained in:
Coracle-Bot 2026-09-15 17:29:33 +00:00
parent 7aba172a9c
commit f30081a2f7
3 changed files with 61 additions and 3 deletions

View file

@ -389,6 +389,18 @@ and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 ca
every test starts against the same relays with nothing in them. The container is a worker fixture,
so the docker start-up cost is paid once per worker rather than once per test.
It is recreated in the teardown of the test that finishes, not the setup of the one that starts.
Creating a container tears down a veth pair and builds another, and the bridge behind it loses
carrier with them; chromium watches the host's interfaces and aborts everything it has in flight
when they move, which the app meets as a route chunk that failed to import and, with `ssr = false`,
a 500 page.
Recreating from teardown puts the next test's seeding — an authenticated socket per identity, and
every fixture written over it — between the churn and the first page that test opens. That is not
on its own enough: every netlink event lands before `compose up --wait` returns, but chromium
coalesces interface changes for up to two seconds before acting on one. So a recreate also stamps
the moment it settles, and a page waits out whatever is left of that window when it opens — which
for most tests is nothing, the teardown and the seeding having spent it already.
Seeding then writes over a real authenticated socket, one per identity per relay, held open for the
rest of the test. A fixture must therefore be signed by an identity `harness/keys.ts` holds, since
zooid authenticates every write.

View file

@ -172,8 +172,8 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
"them. Anything the app fetches belongs in a mock installed by `as(user, path)`.",
)
},
// One container per worker, torn down when the worker ends. It is reset between tests rather
// than recreated, so the docker start-up cost is paid once.
// One container per worker, torn down when the worker ends. It is recreated between tests, in
// the teardown of the test that finishes rather than the setup of the one that starts.
zooid: [
// playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern
// even when there are none.
@ -192,7 +192,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
testInfo.skip(Boolean(dockerProblem), dockerProblem)
await zooid.start()
await zooid.reset()
await zooid.ensure()
const contexts: BrowserContext[] = []
const faults = watchFaults()
@ -207,6 +207,8 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
const open = async (path: string, options: PageOptions, user?: TestUser) => {
const {urls, indexerUrls, cache} = requireScenario()
await zooid.settle()
// The project's own `use` first, so a viewport or device descriptor set in
// playwright.config.ts reaches the context rather than being dropped.
//
@ -291,6 +293,11 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
await context.close()
}
// Before the assertions below, which throw: a test that fails still owes the next one a
// container, and with every page already closed there is nothing left for the recreate's
// network churn to interrupt.
await zooid.reset()
if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) {
// A path rather than a body: the list reporter truncates an inline attachment, and the
// nightly run on the box keeps test-results and nothing else.

View file

@ -22,6 +22,12 @@ import type {ZooidConnection} from "./transport"
const image = "gitea.coracle.social/coracle/zooid:latest"
// How long after a recreate chromium can still abort what a page has in flight. Every netlink
// event a create-and-destroy produces lands before `compose up --wait` returns — measured at 26 of
// them, all of them within a second, none after — but chromium coalesces interface changes for up
// to two seconds before acting on one, so the abort arrives well after the harness has moved on.
const SETTLE = 2500
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
const configSource = fileURLToPath(new URL("docker/config", import.meta.url))
@ -167,6 +173,12 @@ export class Zooid {
private started = false
// Whether a container is up. `reset` is what creates one, so this stays false until it has run.
private running = false
// When the last recreate's interface churn stops being able to abort a request.
private settledAt = 0
// Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are
// free, so the fixture can call this per test.
start = async () => {
@ -226,10 +238,36 @@ export class Zooid {
}
}
// Leaves a fresh container for whoever runs next. Recreating one churns the host's network
// interfaces — a veth pair torn down and rebuilt, the project's bridge losing carrier with it —
// and chromium aborts every request it has in flight when it acts on that, which reaches the app
// as a route chunk that failed to import and, with `ssr = false`, a 500 page. So this is called
// from teardown rather than setup, and everything after it counts towards `settle` below.
reset = async () => {
this.closeSessions()
await this.up()
this.running = true
this.settledAt = Date.now() + SETTLE
}
// The container the test about to run will use. Only the first test of a worker finds none.
ensure = async () => {
if (!this.running) {
await this.reset()
}
}
// Waited out before a page is opened, which is as late as it can be left: teardown, the next
// test's fixtures and its seeding are all inside the window already, so most tests pay nothing
// here.
settle = async () => {
const remaining = this.settledAt - Date.now()
if (remaining > 0) {
await sleep(remaining)
}
}
stop = async () => {
@ -239,6 +277,7 @@ export class Zooid {
await compose("down")
this.started = false
this.running = false
}
}