74 lines
3.3 KiB
TypeScript
74 lines
3.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
// Regression for the reconnect ping-pong: two live daemons sharing a traposId used to
|
|
// thrash forever — each newest-wins eviction (close 1000) made the displaced client
|
|
// reconnect, which evicted the other, every `reconnectDelay`. The fix closes the evicted
|
|
// socket with code 4000 ("displaced"); the Lua client treats that as terminal and yields.
|
|
//
|
|
// registry.count() can't see the churn (newest-wins pins it at 1), so we count server-side
|
|
// "gateway connection opened" log lines. With the fix the loser yields, so opens stay at the
|
|
// two initial connects; pre-fix this number climbed once per reconnectDelay.
|
|
test("displaced daemon yields instead of reconnect-looping", async () => {
|
|
const server = await startServer({ captureLogs: true });
|
|
const TRAPOS_ID = "9:dup";
|
|
// Aggressive reconnect so the pre-fix loop would be obvious within the observation window.
|
|
const RECONNECT_DELAY = "1";
|
|
|
|
const first = startCraftos("gateway-client.lua", {
|
|
computerId: 9,
|
|
computerLabel: "dup",
|
|
shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
|
|
});
|
|
|
|
let second: ReturnType<typeof startCraftos> | undefined;
|
|
try {
|
|
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
|
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
|
|
|
const secondHandle = startCraftos("gateway-client.lua", {
|
|
computerId: 9,
|
|
computerLabel: "dup",
|
|
shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
|
|
});
|
|
second = secondHandle;
|
|
await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
|
|
|
|
const openedAfterBoth = server.countLog("gateway connection opened");
|
|
|
|
// Let several reconnect intervals elapse. If the displaced daemon reconnected, we would
|
|
// see a fresh "gateway connection opened" every ~1s here.
|
|
await sleep(5_000);
|
|
|
|
const openedAfterWait = server.countLog("gateway connection opened");
|
|
assert.equal(
|
|
openedAfterWait,
|
|
openedAfterBoth,
|
|
`no new connections after both daemons settled (was ${openedAfterBoth}, now ${openedAfterWait})`,
|
|
);
|
|
// The two initial connects, and nothing more.
|
|
assert.ok(openedAfterWait <= 3, `bounded connection count, got ${openedAfterWait}`);
|
|
assert.equal(server.registry.count(), 1, "exactly one registration survives");
|
|
|
|
// The surviving (newest) daemon still answers a gateway->computer ping.
|
|
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
|
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
|
|
} catch (error) {
|
|
throw new Error(
|
|
formatFailure(
|
|
error instanceof Error ? error.message : String(error),
|
|
`first:\n${first.snapshot()}\nsecond:\n${second?.snapshot() ?? "(not started)"}`,
|
|
),
|
|
{ cause: error },
|
|
);
|
|
} finally {
|
|
first.abort();
|
|
second?.abort();
|
|
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
|
|
await server.close();
|
|
}
|
|
});
|