Restrict plugin lookups and reuse cached chat reads (Refs #98)

This commit is contained in:
2026-10-17 10:31:13 -04:00
parent b9bc897d6e
commit f3b9699317
8 changed files with 311 additions and 10 deletions
+228
View File
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { TailResult } from '../../src/lib/server/fleet/logs.js';
const SERVER = 't5_zombie_theater_1';
const OTHER_SERVER = 't6_zombie_town_1';
let fixture: Fixture;
let baseline: number;
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
baseline = Date.now() - 600_000;
fixture = await newFixture([SERVER, OTHER_SERVER]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { db } = await import('../../src/lib/server/db/index.js');
await seedServerRegistry();
db()
.prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'")
.run(new Date(baseline - 120_000).toISOString());
});
afterEach(async () => {
vi.restoreAllMocks();
vi.useRealTimers();
await fixture.cleanup();
});
function tail(message: string): TailResult {
const line = `1:00 say;abc;0;Player;${message}`;
return {
lines: [line],
size: Buffer.byteLength(line) + 1,
mtime: new Date(baseline + 60_000).toISOString(),
truncated: false
};
}
function messages(body: { entries: ContextualChatEntry[] }): string[] {
return body.entries.flatMap((entry) =>
entry.type === 'message' ? [entry.message.message] : []
);
}
function advance(milliseconds: number) {
vi.setSystemTime(Date.now() + milliseconds);
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function setup() {
const logs = await import('../../src/lib/server/fleet/logs.js');
const read = vi.spyOn(logs, 'readLogChunk').mockResolvedValue(tail('first'));
const { GET } = await import('../../src/routes/api/servers/[name]/chat/+server.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
return {
read,
db: db(),
collectFleetChat,
get: (server = SERVER) => GET({ params: { name: server } })
};
}
async function addInstance() {
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const host = createHost({ name: 'Chat host' });
db()
.prepare(
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES('chat-instance',?,?,'library','image',28960,?)`
)
.run(SERVER, host.id, new Date(baseline).toISOString());
}
describe('server chat refresh sharing', () => {
it('serves worker state to repeated viewers and still collects after players leave', async () => {
const s = await setup();
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
expect(messages(await (await s.get()).json())).toEqual(['first']);
const responses = await Promise.all(Array.from({ length: 5 }, () => s.get()));
for (const response of responses)
expect(messages(await response.json())).toEqual(['first']);
expect(s.read).toHaveBeenCalledTimes(1);
s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
s.read.mockResolvedValue(tail('last message'));
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
expect(messages(await (await s.get()).json())).toEqual(['last message']);
expect(s.read).toHaveBeenCalledTimes(2);
});
it('refreshes exactly when the completed read becomes fifteen seconds old', async () => {
const s = await setup();
expect(messages(await (await s.get()).json())).toEqual(['first']);
s.read.mockResolvedValue(tail('second message'));
advance(14_999);
expect(messages(await (await s.get()).json())).toEqual(['first']);
expect(s.read).toHaveBeenCalledTimes(1);
advance(1);
const responses = await Promise.all([s.get(), s.get(), s.get()]);
for (const response of responses)
expect(messages(await response.json())).toEqual(['second message']);
expect(s.read).toHaveBeenCalledTimes(2);
});
it('shares slow worker reads and starts the cooldown when they complete', async () => {
const s = await setup();
const slow = deferred<TailResult>();
s.read.mockImplementationOnce(() => slow.promise);
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
const collection = s.collectFleetChat();
const first = s.get();
advance(60_000);
const second = s.get();
expect(s.read).toHaveBeenCalledTimes(1);
slow.resolve(tail('slow message'));
await collection;
for (const response of await Promise.all([first, second]))
expect(messages(await response.json())).toEqual(['slow message']);
advance(14_999);
expect(messages(await (await s.get()).json())).toEqual(['slow message']);
expect(s.read).toHaveBeenCalledTimes(1);
advance(1);
await s.get();
expect(s.read).toHaveBeenCalledTimes(2);
});
it('caches missing logs until the next allowed attempt', async () => {
const s = await setup();
s.read.mockResolvedValueOnce(null);
const missing = { ok: true, entries: [], missing: true };
expect(await (await s.get()).json()).toEqual(missing);
advance(14_999);
for (const response of await Promise.all([s.get(), s.get()]))
expect(await response.json()).toEqual(missing);
expect(s.read).toHaveBeenCalledTimes(1);
advance(1);
expect(messages(await (await s.get()).json())).toEqual(['first']);
expect(s.read).toHaveBeenCalledTimes(2);
});
it('backs off failed reads and retries without leaking the original error', async () => {
const s = await setup();
s.read.mockRejectedValueOnce(new Error('read failed at /private/server/log'));
vi.spyOn(console, 'error').mockImplementation(() => {});
const first = await s.get();
expect(first.status).toBe(500);
expect(await first.json()).toEqual({ ok: false, reason: 'Could not read server chat' });
advance(14_999);
for (const response of await Promise.all([s.get(), s.get()])) {
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
ok: false,
reason: 'Could not read server chat'
});
}
expect(s.read).toHaveBeenCalledTimes(1);
advance(1);
expect(messages(await (await s.get()).json())).toEqual(['first']);
expect(s.read).toHaveBeenCalledTimes(2);
});
it('keeps each server and every replacement identity on its own cooldown', async () => {
const s = await setup();
await addInstance();
expect(messages(await (await s.get()).json())).toEqual(['first']);
s.read.mockResolvedValue(tail('other server'));
expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']);
expect(messages(await (await s.get()).json())).toEqual(['first']);
expect(s.read).toHaveBeenCalledTimes(2);
s.db
.prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?')
.run(SERVER);
s.read.mockResolvedValue(tail('new generation'));
expect(messages(await (await s.get()).json())).toEqual(['new generation']);
s.db
.prepare("UPDATE native_instances SET id='replacement-instance' WHERE server=?")
.run(SERVER);
s.read.mockResolvedValue(tail('new instance'));
expect(messages(await (await s.get()).json())).toEqual(['new instance']);
s.db
.prepare('UPDATE servers SET created_at=? WHERE name=?')
.run(new Date(baseline - 60_000).toISOString(), SERVER);
s.read.mockResolvedValue(tail('new registration'));
expect(messages(await (await s.get()).json())).toEqual(['new registration']);
expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']);
expect(s.read).toHaveBeenCalledTimes(5);
});
it('does not let an old in-flight generation overwrite its replacement cache', async () => {
const s = await setup();
await addInstance();
const oldRead = deferred<TailResult>();
s.read.mockImplementationOnce(() => oldRead.promise);
const oldResponse = s.get();
expect(s.read).toHaveBeenCalledTimes(1);
s.db
.prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?')
.run(SERVER);
s.read.mockResolvedValue(tail('replacement'));
expect(messages(await (await s.get()).json())).toEqual(['replacement']);
advance(10_000);
oldRead.resolve(tail('old generation'));
expect(await (await oldResponse).json()).toEqual({ ok: true, entries: [], missing: true });
expect(messages(await (await s.get()).json())).toEqual(['replacement']);
expect(s.read).toHaveBeenCalledTimes(2);
advance(5_000);
s.read.mockResolvedValue(tail('replacement refreshed'));
expect(messages(await (await s.get()).json())).toEqual(['replacement refreshed']);
expect(s.read).toHaveBeenCalledTimes(3);
});
});