VM 복귀 후 예약턴 잠금 복구와 Windows 부팅 식별자 확인을 추가한다

This commit is contained in:
Hide_D committed 2026-11-12 09:16:04 +09:00
1 parent fd8145d6eb
commit f36bc2c8d7
24 files changed
+1048 -28

No files matched your search

@@ -0,0 +1,134 @@
import { createHmac, randomBytes } from 'node:crypto';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createHostBootMonitor, loadHostBootMonitor } from '../src/turn/hostBootMonitor.js';
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
});
const fixture = async () => {
const key = randomBytes(32);
let bootId = 'a'.repeat(64);
let replay: string | null = null;
let mode: 'valid' | 'replay' | 'forged' | 'oversized' | 'offline' | 'hang' = 'valid';
const requests: string[] = [];
const server = createServer((request, response) => {
const nonce = String(request.headers['x-sammo-challenge']);
requests.push(nonce);
expect(request.headers['x-sammo-signature']).toBe(
createHmac('sha256', key).update(`sammo-host-boot-request-v1\n${nonce}`).digest('hex')
);
if (mode === 'hang') return;
if (mode === 'offline') {
response.writeHead(503).end();
return;
}
if (mode === 'oversized') {
response.end('x'.repeat(4096));
return;
}
if (mode === 'replay') {
response.end(replay);
return;
}
const body = JSON.stringify({
version: 1,
challenge: nonce,
bootId,
signature:
mode === 'forged'
? '0'.repeat(64)
: createHmac('sha256', key)
.update(`sammo-host-boot-response-v1\n${nonce}\n${bootId}`)
.digest('hex'),
});
replay ??= body;
response.end(body);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
cleanups.push(
() =>
new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
})
);
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Missing fixture listener');
return {
key,
requests,
url: `http://127.0.0.1:${address.port}/v1/boot-id`,
setBoot: (value: string) => {
bootId = value;
},
setMode: (value: typeof mode) => {
mode = value;
},
};
};
describe('Windows boot observation', () => {
it('uses a stable baseline, survives outages, and latches a signed boot change', async () => {
const f = await fixture();
const monitor = createHostBootMonitor({ ...f, pollMs: 0 });
expect(await monitor.hasChanged()).toBe(false);
f.setMode('offline');
expect(await monitor.hasChanged()).toBe(false);
f.setBoot('b'.repeat(64));
f.setMode('valid');
expect(await monitor.hasChanged()).toBe(true);
f.setMode('offline');
expect(await monitor.hasChanged()).toBe(true);
});
it.each(['replay', 'forged', 'oversized', 'hang'] as const)(
'rejects %s without changing the baseline',
async (mode) => {
const f = await fixture();
const onUnavailable = vi.fn();
const monitor = createHostBootMonitor({ ...f, pollMs: 0, timeoutMs: 100, onUnavailable });
expect(await monitor.hasChanged()).toBe(false);
f.setBoot('b'.repeat(64));
f.setMode(mode);
expect(await monitor.hasChanged()).toBe(false);
expect(onUnavailable).toHaveBeenCalledOnce();
f.setMode('valid');
expect(await monitor.hasChanged()).toBe(true);
}
);
it('samples immediately after a wall-clock jump despite a frozen monotonic cache and coalesces requests', async () => {
const f = await fixture();
let wall = 10_000;
const monitor = createHostBootMonitor({ ...f, now: () => 0, wallNow: () => wall });
expect(await monitor.hasChanged()).toBe(false);
expect(await monitor.hasChanged()).toBe(false);
expect(f.requests).toHaveLength(1);
wall += 300_000;
f.setBoot('b'.repeat(64));
expect(await Promise.all([monitor.hasChanged(), monitor.hasChanged()])).toEqual([true, true]);
expect(f.requests).toHaveLength(2);
expect(new Set(f.requests).size).toBe(2);
});
it('does not block game startup when optional configuration or its key file is absent', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(await loadHostBootMonitor({})).toBeUndefined();
expect(await loadHostBootMonitor({ TURN_HOST_BOOT_URL: 'http://host/v1/boot-id' })).toBeUndefined();
expect(
await loadHostBootMonitor({
TURN_HOST_BOOT_URL: 'http://host/v1/boot-id',
TURN_HOST_BOOT_KEY_FILE: '/nonexistent-host-boot-key',
})
).toBeUndefined();
expect(() =>
createHostBootMonitor({ url: 'http://user:password@host/v1/boot-id', key: randomBytes(32) })
).toThrow('without credentials');
warning.mockRestore();
});
});
@@ -4,7 +4,9 @@ import { createGamePostgresConnector } from '@sammo-ts/infra';
import {
InMemoryReservedTurnStore,
ReservedTurnLeaseConflictError,
createReservedTurnStore,
} from '../src/turn/reservedTurnStore.js';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL;
const describeIntegration = databaseUrl ? describe : describe.skip;
@@ -132,4 +134,98 @@ describeIntegration('reserved turn daemon/API lease integration', () => {
});
expect(after.map((turn) => turn.actionCode)).toEqual(before.map((turn) => turn.actionCode));
});
it('cleans persisted ownership after rollback loses the journal, preserves other writers, and reloads once', async () => {
if (!connector || !databaseUrl) throw new Error('integration connector is unavailable');
await connector.prisma.generalTurnRevision.update({
where: { generalId: GENERAL_ID },
data: { leaseOwner: null, leaseExpiresAt: null },
});
const before = await connector.prisma.generalTurn.findMany({
where: { generalId: GENERAL_ID },
orderBy: { turnIdx: 'asc' },
});
const revision = await connector.prisma.generalTurnRevision.findUniqueOrThrow({
where: { generalId: GENERAL_ID },
});
const old = await createReservedTurnStore({ databaseUrl, leaseOwner: 'interrupted-daemon' });
const savepoint = old.store.captureTransactionState();
await old.store.prepareTurnsForExecution(GENERAL_ID);
old.store.shiftGeneralTurns(GENERAL_ID, -1);
old.store.restoreState(savepoint);
expect(old.store.getQueueCounts().leasedGeneralQueues).toBe(0);
const next = await createReservedTurnStore({ databaseUrl, leaseOwner: 'new-daemon' });
try {
await expect(next.store.prepareTurnsForExecution(GENERAL_ID)).rejects.toBeInstanceOf(
ReservedTurnLeaseConflictError
);
await old.close();
const released = await connector.prisma.generalTurnRevision.findUniqueOrThrow({
where: { generalId: GENERAL_ID },
});
expect(released).toMatchObject({ leaseOwner: null, leaseExpiresAt: null, revision: revision.revision });
await next.store.prepareTurnsForExecution(GENERAL_ID);
// A delayed close of an old owner cannot clear the new owner's lease.
const delayedCleaner = new InMemoryReservedTurnStore(connector.prisma, {
maxGeneralTurns: 2,
maxNationTurns: 1,
leaseOwner: 'interrupted-daemon',
});
await delayedCleaner.releaseOwnedLeases();
expect(
await connector.prisma.generalTurnRevision.findUniqueOrThrow({ where: { generalId: GENERAL_ID } })
).toMatchObject({ leaseOwner: 'new-daemon', revision: revision.revision });
await next.store.releaseOwnedLeases();
const actual = await connector.prisma.generalTurn.findMany({
where: { generalId: GENERAL_ID },
orderBy: { turnIdx: 'asc' },
});
expect(actual.map((row) => row.actionCode)).toEqual(before.map((row) => row.actionCode));
} finally {
await next.close();
}
});
it('waits out an orphaned lease without deleting it and reloads an intervening API edit', async () => {
if (!connector) throw new Error('integration connector is unavailable');
await connector.prisma.generalTurnRevision.update({
where: { generalId: GENERAL_ID },
data: { leaseOwner: 'killed-daemon', leaseExpiresAt: new Date(Date.now() + 300_000) },
});
const store = new InMemoryReservedTurnStore(connector.prisma, {
maxGeneralTurns: 2,
maxNationTurns: 1,
leaseOwner: 'recovery-daemon',
});
await store.loadAll();
const retry = createReservedTurnLeaseRetry({
sleep: async () => {
// Simulate the recorded expiry, then an authorized API edit before the next claim.
expect(
await connector.prisma.generalTurnRevision.findUniqueOrThrow({ where: { generalId: GENERAL_ID } })
).toMatchObject({ leaseOwner: 'killed-daemon' });
await connector.prisma.generalTurnRevision.update({
where: { generalId: GENERAL_ID },
data: { leaseExpiresAt: new Date(0) },
});
await connector.prisma.generalTurn.updateMany({
where: { generalId: GENERAL_ID, turnIdx: 0 },
data: { actionCode: 'che_수련' },
});
},
});
try {
let failure: unknown;
try {
await store.prepareTurnsForExecution(GENERAL_ID);
} catch (error) {
failure = error;
}
expect(await retry.wait(failure)).toBe(true);
await store.prepareTurnsForExecution(GENERAL_ID);
expect(store.getGeneralTurns(GENERAL_ID)[0]?.action).toBe('che_수련');
} finally {
await store.releaseOwnedLeases();
}
});
});
@@ -169,6 +169,18 @@ const buildHarness = (initialRevision: RevisionRow | null = null) => {
};
describe('reserved turn daemon lease', () => {
it('cleans both persisted lease kinds even after their journal is rolled back', async () => {
const harness = buildHarness();
const snapshot = harness.store.captureTransactionState();
await harness.store.prepareTurnsForExecution(7, { nationId: 3, officerLevel: 12 });
harness.store.restoreState(snapshot);
expect(harness.store.getQueueCounts()).toMatchObject({ leasedGeneralQueues: 0, leasedNationQueues: 0 });
await harness.store.releaseOwnedLeases();
expect(harness.getRevision()).toMatchObject({ leaseOwner: null, leaseExpiresAt: null, revision: 0 });
expect(harness.getNationRevision()).toMatchObject({ leaseOwner: null, leaseExpiresAt: null, revision: 0 });
expect(harness.generalDeleteMany).not.toHaveBeenCalled();
});
it('prunes deleted general and nation queues together with their journal state', () => {
const harness = buildHarness();
harness.store.ensureGeneralTurns(7);
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
import { ReservedTurnLeaseBusyError, ReservedTurnLeaseConflictError } from '../src/turn/reservedTurnStore.js';
describe('reserved turn acquisition retry', () => {
it('bounds the entire contention episode across different queues and resets after progress', async () => {
let now = 0;
const onWait = vi.fn();
const retry = createReservedTurnLeaseRetry({
now: () => now,
sleep: async (ms) => {
now += ms;
},
maxWaitMs: 2_500,
onWait,
});
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:1'))).toBe(true);
expect(await retry.wait(new ReservedTurnLeaseBusyError('nation:2:12'))).toBe(true);
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:3'))).toBe(true);
expect(now).toBe(2_500);
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:1'))).toBe(false);
expect(onWait).toHaveBeenCalledTimes(1);
retry.reset();
expect(await retry.wait(new ReservedTurnLeaseBusyError('general:1'))).toBe(true);
});
it('never retries flush ownership loss, calculation errors, or failed savepoint restoration', async () => {
const sleep = vi.fn();
const retry = createReservedTurnLeaseRetry({ sleep });
for (const error of [
new ReservedTurnLeaseConflictError('general:1'),
new Error('calculation failed'),
new AggregateError([new ReservedTurnLeaseBusyError('general:1')], 'restore failed'),
])
expect(await retry.wait(error)).toBe(false);
expect(sleep).not.toHaveBeenCalled();
});
});
+25 -1
View File
@@ -3,12 +3,36 @@ import type { GameClockPhase } from '@sammo-ts/common';
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
describe('runtime pause clock boundary', () => {
it('awaits a host authority check before reading profile state or changing the clock', async () => {
const calls: string[] = [];
const gate = createRuntimePauseGate({
assertLease: async () => {
await Promise.resolve();
calls.push('host');
throw new Error('host changed');
},
shouldPause: async () => {
calls.push('profile');
return false;
},
isExplicitlyPaused: () => false,
getPhase: () => 'RUNNING',
prepareRecovery: async () => {
calls.push('recovery');
},
synchronize: async () => {},
});
await expect(gate()).rejects.toThrow('host changed');
expect(calls).toEqual(['host']);
});
it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => {
let phase: GameClockPhase = 'RUNNING';
let paused = true;
const calls: string[] = [];
const gate = createRuntimePauseGate({
assertLease: () => calls.push('lease'),
assertLease: () => {
calls.push('lease');
},
shouldPause: async () => paused,
isExplicitlyPaused: () => paused,
getPhase: () => phase,
@@ -207,6 +207,39 @@ integration('database turn daemon lease and fencing', () => {
expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b');
});
it('fences a host boot change inside a transaction before mutation, without expiring the DB lease', async () => {
const profile = `${profilePrefix}host-boot`;
let changed = false;
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, {
profile,
ownerId: 'saved-vm-owner',
heartbeat: false,
leaseDurationMs: 60_000,
hostBootMonitor: { hasChanged: async () => changed },
});
leases.push(lease);
await lease.acquire();
await lease.markClockReady();
changed = true;
let committed = false;
await expect(
db.$transaction(async (transaction) => {
await lease.assertActive(transaction);
committed = true;
})
).rejects.toBeInstanceOf(TurnDaemonLeaseLostError);
expect(committed).toBe(false);
expect(lease.isLost()).toBe(true);
expect(await lease.renew()).toBe(false);
// The monitor does not force-clear ownership or bypass the successor's fencing.
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).ownerId).toBe('saved-vm-owner');
const next = await createLease(profile, 'fresh-vm-owner');
expect(await next.acquire()).toBeNull();
await lease.release();
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(false);
expect((await next.acquire())?.fencingEpoch).toBe(2n);
});
it('persists and evaluates lease wall time as UTC under a non-UTC database session', async () => {
const profile = `${profilePrefix}utc-wall`;
const zonedUrl = new URL(databaseUrl!);
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common';
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
import { ReservedTurnLeaseBusyError } from '../src/turn/reservedTurnStore.js';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
import {
InMemoryControlQueue,
@@ -15,6 +17,68 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it('rolls back a busy queue, checks the operator gate, and retries an explicit run exactly once', async () => {
const now = new Date('2026-11-12T00:00:00Z');
const queue = new InMemoryControlQueue();
queue.enqueue({ type: 'run', reason: 'manual', targetTime: now.toISOString() });
let resource = 10;
let runs = 0;
const stateManager = new EngineStateManager();
stateManager.register('resource', {
capture: () => resource,
restore: (value) => {
resource = value;
},
});
const onRunError = vi.fn();
const flushChanges = vi.fn();
const gate = vi.fn(async () => false);
const retry = createReservedTurnLeaseRetry({
sleep: async () => {
expect(resource).toBe(10);
},
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: queue,
stateManager,
runErrorRetry: retry,
getNextTickTime: (value) => addMinutes(value, 5),
pauseGate: gate,
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
},
processor: {
run: async () => {
resource -= 2;
if (++runs === 1) throw new ReservedTurnLeaseBusyError('general:7');
queue.enqueue({ type: 'shutdown' });
return {
lastTurnTime: now.toISOString(),
processedGenerals: 1,
processedTurns: 0,
durationMs: 0,
partial: false,
};
},
},
hooks: { onRunError, flushChanges },
},
{ profile: 'che', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } }
);
await lifecycle.start();
expect(runs).toBe(2);
expect(resource).toBe(8);
expect(gate.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(flushChanges).toHaveBeenCalledOnce();
expect(onRunError).not.toHaveBeenCalled();
});
it.each([false, true])('reports a fatal lease gate and exits even if reporting fails (%s)', async (reportFails) => {
const now = new Date('2026-09-09T17:30:00Z');
const error = new TurnDaemonLeaseLostError('che:default');