Fix stale match liveness and stop handling (Refs #136)
CI / checks (push) Successful in 5m15s

This commit is contained in:
2026-10-10 19:30:51 -04:00
parent e3e740cea3
commit 03e2d08a38
10 changed files with 211 additions and 24 deletions
+33
View File
@@ -369,6 +369,39 @@ it('refreshes stats during a round without adding rounds or completing the match
expect((await matches()).map((match) => match.status)).toEqual(['completed', 'active']);
});
it('refreshes the last report for identical heartbeats without changing participation or completed results', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
await report('game_start');
await report('heartbeat', 5, [player(undefined, { kills: 100 })]);
const participants = await records();
received += 120;
await report('heartbeat', 5, [player(undefined, { kills: 100 })]);
expect(await matches()).toHaveLength(1);
expect(await records()).toEqual(participants);
expect(serverMatchHistory(server).matches[0]).toMatchObject({
status: 'active',
lastReportedAt: '2026-09-18T20:02:02.000Z'
});
await report('game_end', 5);
const completed = serverMatchHistory(server).matches[0];
await report('game_end', 5);
expect(serverMatchHistory(server).matches[0]).toEqual(completed);
});
it('starts a new match when an identical heartbeat follows an interrupted match', async () => {
await report('heartbeat', 5);
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare("UPDATE leaderboard_matches SET status='interrupted', ended_at=last_received_at")
.run();
const interrupted = (await matches())[0];
await report('heartbeat', 5);
expect(await matches()).toEqual([
interrupted,
expect.objectContaining({ status: 'active', highest_round: 5, start_observed: 0 })
]);
});
it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => {
await report('game_start');
await report('round_change', 1);
+1
View File
@@ -91,6 +91,7 @@ it('keeps unknown durations last in either direction and retains historical and
expect(data.matches[0].server_deleted_at).toBeTruthy();
expect(data.matches[1]).toMatchObject({
status: 'active',
lastReportedAt: '2026-10-04T20:02:00.000Z',
observedRound: 3,
finalRound: null,
durationMs: null
+66
View File
@@ -4000,6 +4000,72 @@ describe('additional concurrency admission', () => {
);
});
describe('match history on server stop', () => {
async function setupStop() {
const job = await queue();
await advance();
await acknowledge(job.deploymentId);
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const insert = handle.prepare(`INSERT INTO leaderboard_matches
(server, game, map, map_key, map_name, started_at, last_received_at, status,
ended_at, server_deleted_at, highest_round, last_event, last_fingerprint)
VALUES (?, 't5', 'zombie_theater', 't5:zombie_theater', 'Kino Der Toten',
'2026-10-01T00:00:00Z', '2026-10-01T01:00:00Z', ?, ?, ?, 20, 'heartbeat', 'test')`);
insert.run(job.name, 'active', null, null);
insert.run(job.name, 'completed', '2026-10-01T01:00:00Z', null);
insert.run(job.name, 'interrupted', '2026-10-01T01:00:00Z', null);
insert.run(job.name, 'active', null, '2026-10-01T01:00:00Z');
insert.run('another-server', 'active', null, null);
const matches = () => handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
const before = matches();
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const stopped = requestLifecycle(job.name, 'stop');
if (!stopped.ok) throw new Error(stopped.error);
await advance();
expect(matches()).toEqual(before);
return { stopped, matches, before };
}
it.each(['stopped', 'exited', 'created', 'dead'])(
'interrupts only the current open match after verifying the container is %s',
async (state) => {
const { stopped, matches, before } = await setupStop();
// A successful command receipt alone does not prove the container stopped.
await acknowledge(stopped.deploymentId);
expect(matches()).toEqual(before);
remote.state = state;
const verifiedAfter = Date.now();
await advance();
expect((await detail(stopped.deploymentId)).state).toBe('done');
const after = matches();
expect(after[0]).toEqual({
...(before[0] as object),
status: 'interrupted',
ended_at: expect.any(String)
});
const endedAt = Date.parse((after[0] as { ended_at: string }).ended_at);
expect(endedAt).toBeGreaterThanOrEqual(verifiedAfter);
expect(endedAt).toBeLessThanOrEqual(Date.now());
expect(after.slice(1)).toEqual(before.slice(1));
await advance();
expect(matches()).toEqual(after);
}
);
it.each(['failed', 'unknown'] as const)(
'keeps the match open when the stop command is %s',
async (state) => {
const { stopped, matches, before } = await setupStop();
const operation = (await operationFor(stopped.deploymentId))!;
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state, error: 'Stop not confirmed' });
await advance();
expect(matches()).toEqual(before);
}
);
});
describe('game key pool launch lifecycle', () => {
it('reserves across maps atomically, rejects duplicate credentials, and reuses a cancelled launch key', async () => {
await prepareLibrary();
+1
View File
@@ -19,6 +19,7 @@ const match: MatchDetail = {
status: 'completed',
startObserved: true,
startedAt: '2026-10-07T00:00:00Z',
lastReportedAt: '2026-10-07T00:01:00Z',
endedAt: '2026-10-07T00:01:00Z',
durationMs: 60_000,
startPlayers: 1,
+49
View File
@@ -17,6 +17,7 @@ const match: ServerMatch = {
finalRound: 20,
status: 'completed',
startedAt: '2026-10-06T17:26:00Z',
lastReportedAt: '2026-10-06T19:59:59Z',
endedAt: '2026-10-06T18:04:00Z',
durationMs: 2280000,
startPlayers: 2,
@@ -153,6 +154,54 @@ it('labels unfinished rounds and missing durations without inventing final resul
expect(body).toContain('>—</span>');
expect(body).toContain('>0</td>');
});
it.each([
'2026-10-06T19:58:29Z',
'2026-10-05T18:00:00Z',
'2026-10-06 19:58:29',
'2026-10-06T20:00:02Z',
null,
'invalid'
])('shows a non-live match with its last report when %s is not current', (lastReportedAt) => {
const data = report();
data.matches = [{ ...match, status: 'active', lastReportedAt, durationMs: null }];
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
/<!--.*?-->/g,
''
);
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/Not reporting/g)).toHaveLength(2);
expect(rows.match(/Last report /g)).toHaveLength(2);
expect(rows).not.toContain('In progress');
expect(rows).not.toContain('View live');
expect(rows).not.toContain('<a');
expect(rows).not.toContain('2h 34m');
expect(rows).toContain('>—</span>');
if (lastReportedAt && lastReportedAt !== 'invalid') {
expect(rows).toMatch(/Last report <time [^>]*datetime=/);
} else {
expect(rows).toContain('Last report <span>Not recorded</span>');
}
});
it('expires live links and clocks at the shared freshness boundary and restores them on a new report', () => {
const data = report();
data.matches = [
{ ...match, status: 'active', lastReportedAt: '2026-10-06T19:58:30Z', durationMs: null }
];
const rows = () => {
const body = render(MatchHistory, { props: { initial: data } }).body;
return body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
};
expect(rows()).toContain('View live');
expect(rows()).toContain('2h 34m');
vi.advanceTimersByTime(1000);
expect(rows()).toContain('Not reporting');
expect(rows()).not.toContain('View live');
expect(rows()).not.toContain('2h 34m');
data.matches[0].lastReportedAt = '2026-10-06T20:00:01Z';
expect(rows()).toContain('View live');
expect(rows()).toContain('2h 34m');
expect(rows()).not.toContain('Not reporting');
});
it('offers filter reset for an empty selection and retains the scoped instance view', () => {
scope('?matchQ=missing');
const data = report();