diff --git a/server/__tests__/lane-runtime.test.js b/server/__tests__/lane-runtime.test.js index b17e36c..e5820e7 100644 --- a/server/__tests__/lane-runtime.test.js +++ b/server/__tests__/lane-runtime.test.js @@ -29,7 +29,30 @@ const profileLib = require("../lib/lane-profile"); const runtime = require("../lib/lane-runtime"); const { isListening } = require("../lib/ports"); -after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true })); +/** + * Lanes whose boot hook actually spawned a detached process. `harness_spawn` + * children outlive the hook by design, so a test that throws before its own + * `downLane` — or never calls one — leaves a real server holding a real port + * AFTER the suite exits. The port pool is only ten wide per name, so ten such + * runs exhaust it and every later boot fails with EPORTBUSY: a leak in these + * tests reads as a failure in whichever test runs next. + */ +const bootedLanes = new Set(); + +// One teardown, in this order on purpose: downLane reads the pid files under +// LANES_ROOT, which lives inside SUITE_ROOT — removing the tree first would +// leave nothing to kill the children with. +after(async () => { + for (const id of bootedLanes) { + try { + const lane = lanesLib.getLane(id); + if (lane) await runtime.downLane(lane); + } catch { + /* already stopped, or torn down by the test itself */ + } + } + fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true }); +}); let laneSeq = 0; /** A lane row backed by a real directory, so profile lookup and mkdir work. */ @@ -274,6 +297,7 @@ describe("lifecycle", () => { const lane = makeLane(); serverProfile(lane, 19100); + bootedLanes.add(lane.id); const facts = await runtime.upLane(lanesLib.getLane(lane.id)); assert.equal(facts.available, true); assert.equal(facts.up, true); @@ -301,6 +325,7 @@ describe("lifecycle", () => { assert.equal(afterDown.up, false); assert.deepEqual(afterDown.services, []); + bootedLanes.delete(lane.id); slots.releaseSlot(lane.id); }); @@ -377,12 +402,22 @@ describe("upLane qc option", () => { ].join("\n"), }); + bootedLanes.add(lane.id); await runtime.upLane(lanesLib.getLane(lane.id), { qc: true }); const booted = lanesLib.getLane(lane.id); const markerPath = pathMod.join(booted.cwd, "qc-marker.txt"); const marker = fsMod.readFileSync(markerPath, "utf8"); assert.match(marker, /QC=from-qc/); + // Stop the stack BEFORE releasing the slot: downLane locates the pid file + // through the lane's slot directory, so a released slot orphans a running + // child with no way left to reach it. Asserting the port went quiet is what + // makes a broken teardown fail HERE instead of leaking a live server into + // the next run's port pool. + const bootedPort = booted.ports.web; + await runtime.downLane(lanesLib.getLane(lane.id)); + assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane"); + bootedLanes.delete(lane.id); slots.releaseSlot(lane.id); }); @@ -408,12 +443,17 @@ describe("upLane qc option", () => { ].join("\n"), }); + bootedLanes.add(lane.id); await runtime.upLane(lanesLib.getLane(lane.id)); const booted = lanesLib.getLane(lane.id); const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt"); const marker = fsMod.readFileSync(markerPath, "utf8"); assert.match(marker, /QC=not-set/); + const bootedPort = booted.ports.web; + await runtime.downLane(lanesLib.getLane(lane.id)); + assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane"); + bootedLanes.delete(lane.id); slots.releaseSlot(lane.id); }); });