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

+3
View File
@@ -91,6 +91,9 @@ DAEMON_REQUEST_TIMEOUT_MS=5000
# realtime은 벽시계 경과를 game tick으로 투영합니다. manual은 daemon이 # realtime은 벽시계 경과를 game tick으로 투영합니다. manual은 daemon이
# 다음 월 snapshot을 대기 없이 실행하며 테스트/장기 시뮬레이션에 사용합니다. # 다음 월 snapshot을 대기 없이 실행하며 테스트/장기 시뮬레이션에 사용합니다.
GAME_CLOCK_MODE=realtime GAME_CLOCK_MODE=realtime
# Optional Windows host boot identity. Key is a private file, never a VITE_* value.
# TURN_HOST_BOOT_URL=http://windows-private-address:17781/v1/boot-id
# TURN_HOST_BOOT_KEY_FILE=/run/secrets/host_boot_key
TRPC_PATH=/trpc TRPC_PATH=/trpc
# Frontend public URLs # Frontend public URLs
+3
View File
@@ -16,6 +16,9 @@ GAME_API_HOST=0.0.0.0
GAME_API_PORT=14000 GAME_API_PORT=14000
PROFILE=hwe PROFILE=hwe
SCENARIO=default SCENARIO=default
# Optional Windows observation; unavailable or unconfigured service never blocks gameplay.
# TURN_HOST_BOOT_URL=http://windows-private-address:17781/v1/boot-id
# TURN_HOST_BOOT_KEY_FILE=/run/sammo-host-recovery/host_boot.key
KAKAO_REST_KEY=your-kakao-rest-key KAKAO_REST_KEY=your-kakao-rest-key
KAKAO_ADMIN_KEY=your-kakao-admin-key KAKAO_ADMIN_KEY=your-kakao-admin-key
KAKAO_REDIRECT_URI=https://example.com/oauth/kakao/callback KAKAO_REDIRECT_URI=https://example.com/oauth/kakao/callback
+1
View File
@@ -89,6 +89,7 @@ web_modules/
.env.local .env.local
.env.ci .env.ci
.env.ops .env.ops
tools/host-recovery/*.key
tools/legacy-db-migration/migration-plan.json tools/legacy-db-migration/migration-plan.json
tools/legacy-db-migration/secrets/ tools/legacy-db-migration/secrets/
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { HostBootMonitor } from '../turn/hostBootMonitor.js';
export interface TurnDaemonLeaseToken { export interface TurnDaemonLeaseToken {
profile: string; profile: string;
@@ -14,6 +15,7 @@ export interface DatabaseTurnDaemonLeaseOptions {
ownerId?: string; ownerId?: string;
leaseDurationMs?: number; leaseDurationMs?: number;
heartbeat?: boolean; heartbeat?: boolean;
hostBootMonitor?: HostBootMonitor;
} }
export class TurnDaemonLeaseUnavailableError extends Error { export class TurnDaemonLeaseUnavailableError extends Error {
@@ -45,6 +47,7 @@ export class DatabaseTurnDaemonLease {
private readonly ownerId: string; private readonly ownerId: string;
private readonly leaseDurationMs: number; private readonly leaseDurationMs: number;
private readonly heartbeatEnabled: boolean; private readonly heartbeatEnabled: boolean;
private readonly hostBootMonitor?: HostBootMonitor;
private token: TurnDaemonLeaseToken | null = null; private token: TurnDaemonLeaseToken | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null; private heartbeatTimer: NodeJS.Timeout | null = null;
private expiryTimer: NodeJS.Timeout | null = null; private expiryTimer: NodeJS.Timeout | null = null;
@@ -63,6 +66,7 @@ export class DatabaseTurnDaemonLease {
this.ownerId = options.ownerId ?? randomUUID(); this.ownerId = options.ownerId ?? randomUUID();
this.leaseDurationMs = normalizeLeaseDuration(options.leaseDurationMs); this.leaseDurationMs = normalizeLeaseDuration(options.leaseDurationMs);
this.heartbeatEnabled = options.heartbeat ?? true; this.heartbeatEnabled = options.heartbeat ?? true;
this.hostBootMonitor = options.hostBootMonitor;
} }
static async connect( static async connect(
@@ -75,6 +79,8 @@ export class DatabaseTurnDaemonLease {
} }
async acquire(): Promise<TurnDaemonLeaseToken | null> { async acquire(): Promise<TurnDaemonLeaseToken | null> {
await this.checkHostBoot();
if (this.lost) throw this.getLossError();
const requestStartedAt = performance.now(); const requestStartedAt = performance.now();
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql` const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
INSERT INTO "turn_daemon_lease" ( INSERT INTO "turn_daemon_lease" (
@@ -145,6 +151,12 @@ export class DatabaseTurnDaemonLease {
return new TurnDaemonLeaseLostError(this.profile, this.lossReason); return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
} }
async checkHostBoot(): Promise<void> {
if (!this.lost && (await this.hostBootMonitor?.hasChanged())) {
this.markLost('Host boot identity changed; restart with a fresh database snapshot.');
}
}
async renew(): Promise<boolean> { async renew(): Promise<boolean> {
const token = this.token; const token = this.token;
if (!token || this.lost || this.renewalInFlight) { if (!token || this.lost || this.renewalInFlight) {
@@ -153,6 +165,8 @@ export class DatabaseTurnDaemonLease {
const requestStartedAt = performance.now(); const requestStartedAt = performance.now();
this.renewalInFlight = true; this.renewalInFlight = true;
try { try {
await this.checkHostBoot();
if (this.lost) return false;
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql` const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
UPDATE "turn_daemon_lease" UPDATE "turn_daemon_lease"
SET SET
@@ -180,6 +194,7 @@ export class DatabaseTurnDaemonLease {
} }
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> { async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
await this.checkHostBoot();
const token = this.token; const token = this.token;
if (!token || this.lost) { if (!token || this.lost) {
throw this.getLossError(); throw this.getLossError();
@@ -207,12 +222,13 @@ export class DatabaseTurnDaemonLease {
this.stopExpiryWatchdog(); this.stopExpiryWatchdog();
const token = this.token; const token = this.token;
this.token = null; this.token = null;
if (!token || this.lost) { if (!token) {
return; return;
} }
await this.db.$executeRaw(GamePrisma.sql` await this.db.$executeRaw(GamePrisma.sql`
UPDATE "turn_daemon_lease" UPDATE "turn_daemon_lease"
SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC', SET "clock_ready" = FALSE,
"lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC' "heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
WHERE WHERE
"profile" = ${token.profile} "profile" = ${token.profile}
@@ -45,6 +45,7 @@ export interface TurnDaemonLifecycleDeps {
commandResponder?: TurnDaemonCommandResponder; commandResponder?: TurnDaemonCommandResponder;
pauseGate?: () => Promise<boolean>; pauseGate?: () => Promise<boolean>;
stateManager?: Pick<EngineStateManager, 'transaction'>; stateManager?: Pick<EngineStateManager, 'transaction'>;
runErrorRetry?: { wait(error: unknown): Promise<boolean>; reset(): void };
} }
export class TurnDaemonLifecycle { export class TurnDaemonLifecycle {
@@ -59,6 +60,7 @@ export class TurnDaemonLifecycle {
private readonly commandResponder?: TurnDaemonCommandResponder; private readonly commandResponder?: TurnDaemonCommandResponder;
private readonly pauseGate?: () => Promise<boolean>; private readonly pauseGate?: () => Promise<boolean>;
private readonly stateManager?: Pick<EngineStateManager, 'transaction'>; private readonly stateManager?: Pick<EngineStateManager, 'transaction'>;
private readonly runErrorRetry?: TurnDaemonLifecycleDeps['runErrorRetry'];
private readonly options: TurnDaemonLifecycleOptions; private readonly options: TurnDaemonLifecycleOptions;
private status: TurnDaemonStatus; private status: TurnDaemonStatus;
@@ -79,6 +81,7 @@ export class TurnDaemonLifecycle {
this.commandResponder = deps.commandResponder; this.commandResponder = deps.commandResponder;
this.pauseGate = deps.pauseGate; this.pauseGate = deps.pauseGate;
this.stateManager = deps.stateManager; this.stateManager = deps.stateManager;
this.runErrorRetry = deps.runErrorRetry;
this.options = options; this.options = options;
this.status = { this.status = {
state: 'idle', state: 'idle',
@@ -192,8 +195,9 @@ export class TurnDaemonLifecycle {
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다. // 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다. // 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) { if (this.pendingRun) {
await this.runOnce(this.pendingRun); const pending = this.pendingRun;
this.pendingRun = null; this.pendingRun = null;
await this.runOnce(pending);
continue; continue;
} }
@@ -447,6 +451,15 @@ export class TurnDaemonLifecycle {
}; };
result = this.stateManager ? await this.stateManager.transaction(runAndFlush) : await runAndFlush(); result = this.stateManager ? await this.stateManager.transaction(runAndFlush) : await runAndFlush();
} catch (error) { } catch (error) {
// savepoint 복원이 성공한 획득 충돌만 대기한다. 복원 실패/AggregateError나
// 저장 중 소유권 상실을 자동 재실행하면 중복 효과를 숨길 수 있다.
if (this.stateManager && (await this.runErrorRetry?.wait(error))) {
this.status.running = false;
this.status.state = 'idle';
this.pendingRun = pending;
return;
}
this.runErrorRetry?.reset();
this.status.running = false; this.status.running = false;
this.status.state = 'paused'; this.status.state = 'paused';
this.status.paused = true; this.status.paused = true;
@@ -459,6 +472,7 @@ export class TurnDaemonLifecycle {
} }
await this.applyRunResult(result, startMs); await this.applyRunResult(result, startMs);
this.runErrorRetry?.reset();
this.status.state = 'idle'; this.status.state = 'idle';
try { try {
await this.hooks?.publishEvents?.(result); await this.hooks?.publishEvents?.(result);
+3
View File
@@ -8,6 +8,7 @@ import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js'; import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { retryTurnDaemonLeaseStartup } from './leaseStartupRetry.js'; import { retryTurnDaemonLeaseStartup } from './leaseStartupRetry.js';
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
import { loadHostBootMonitor } from './hostBootMonitor.js';
export interface TurnDaemonCliOptions { export interface TurnDaemonCliOptions {
profile?: string; profile?: string;
@@ -80,6 +81,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`); throw new Error(`GAME_CLOCK_MODE must be realtime or manual: ${rawGameClockMode}`);
} }
const gameClockMode = rawGameClockMode as GameClockMode | undefined; const gameClockMode = rawGameClockMode as GameClockMode | undefined;
const hostBootMonitor = await loadHostBootMonitor(env);
const runtime = await retryTurnDaemonLeaseStartup(() => const runtime = await retryTurnDaemonLeaseStartup(() =>
createTurnDaemonRuntime({ createTurnDaemonRuntime({
@@ -95,6 +97,7 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
pauseGateIntervalMs, pauseGateIntervalMs,
adminActionIntervalMs, adminActionIntervalMs,
gameClockMode, gameClockMode,
hostBootMonitor,
}) })
).catch(async (error: unknown) => { ).catch(async (error: unknown) => {
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는 // 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
+142
View File
@@ -0,0 +1,142 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import { isRecord } from '@sammo-ts/common';
export interface HostBootMonitor {
hasChanged(): Promise<boolean>;
}
const mac = (key: Buffer, value: string): string => createHmac('sha256', key).update(value).digest('hex');
/** 부팅 세대는 첫 인증 응답에 고정한다. 통신 실패는 기존 lease 복구를 막지 않는다. */
export const createHostBootMonitor = (options: {
url: string;
key: Buffer;
pollMs?: number;
timeoutMs?: number;
now?: () => number;
wallNow?: () => number;
onUnavailable?: () => void;
onVerified?: () => void;
}): HostBootMonitor => {
const url = new URL(options.url);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== '/v1/boot-id'
) {
throw new Error('Host boot URL must use HTTP(S) and /v1/boot-id without credentials or query.');
}
if (options.key.length !== 32) throw new Error('Host boot key must contain exactly 32 bytes.');
const now = options.now ?? (() => performance.now());
const wallNow = options.wallNow ?? (() => Date.now());
let baseline: string | null = null;
let changed = false;
let lastAttempt = -Infinity;
let lastWall = 0;
let lastWarning = -Infinity;
let inFlight: Promise<boolean> | null = null;
const sample = async (): Promise<boolean> => {
const nonce = randomBytes(32).toString('hex');
const response = await fetch(url, {
method: 'GET',
redirect: 'error',
signal: AbortSignal.timeout(options.timeoutMs ?? 1_000),
headers: {
'x-sammo-challenge': nonce,
'x-sammo-signature': mac(options.key, `sammo-host-boot-request-v1\n${nonce}`),
},
});
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error('Host boot response unavailable.');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > 2_048) throw new Error('Host boot response exceeds its limit.');
chunks.push(value);
}
} finally {
await reader.cancel();
}
const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (
!isRecord(body) ||
body.version !== 1 ||
body.challenge !== nonce ||
typeof body.bootId !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.bootId) ||
typeof body.signature !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.signature)
) {
throw new Error('Host boot response is invalid.');
}
const expected = mac(options.key, `sammo-host-boot-response-v1\n${nonce}\n${body.bootId}`);
if (!timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(body.signature, 'hex'))) {
throw new Error('Host boot response signature is invalid.');
}
if (baseline === null) {
baseline = body.bootId;
options.onVerified?.();
}
changed ||= body.bootId !== baseline;
return changed;
};
return {
hasChanged(): Promise<boolean> {
if (changed) return Promise.resolve(true);
if (inFlight) return inFlight;
const current = now();
const wall = wallNow();
const discontinuity = Math.abs(wall - lastWall - (current - lastAttempt)) > 1_000;
if (current - lastAttempt < (options.pollMs ?? 2_000) && !discontinuity) return Promise.resolve(false);
lastAttempt = current;
lastWall = wall;
inFlight = sample()
.catch(() => {
if (current - lastWarning >= 60_000) {
options.onUnavailable?.();
lastWarning = current;
}
return false;
})
.finally(() => {
inFlight = null;
});
return inFlight;
},
};
};
export const loadHostBootMonitor = async (env: NodeJS.ProcessEnv): Promise<HostBootMonitor | undefined> => {
const url = env.TURN_HOST_BOOT_URL?.trim();
const keyFile = env.TURN_HOST_BOOT_KEY_FILE?.trim();
if (!url && !keyFile) return undefined;
try {
if (!url || !keyFile) throw new Error('Incomplete optional configuration.');
const encoded = (await readFile(keyFile, 'utf8')).trim();
if (!/^[a-f0-9]{64}$/.test(encoded)) throw new Error('Invalid optional key file.');
return createHostBootMonitor({
url,
key: Buffer.from(encoded, 'hex'),
onVerified: () => console.info('[turn-daemon] host boot identity verified.'),
onUnavailable: () => {
console.warn('[turn-daemon] host boot identity unavailable; ordinary lease recovery remains active.');
},
});
} catch {
// 보조 관측기의 설정/파일 부재도 게임 기동의 필수 의존성이 되지 않는다.
console.warn('[turn-daemon] host boot observation disabled; check optional configuration and key file.');
return undefined;
}
};
@@ -0,0 +1,38 @@
import { performance } from 'node:perf_hooks';
import { setTimeout } from 'node:timers/promises';
import { ReservedTurnLeaseBusyError } from './reservedTurnStore.js';
/** rollback 완료 뒤에만 대기한다. 다음 loop에서 pause/lease gate와 최신 큐를 다시 읽는다. */
export const createReservedTurnLeaseRetry = (
options: {
now?: () => number;
sleep?: (ms: number) => Promise<void>;
maxWaitMs?: number;
onWait?: (queueKey: string) => void;
} = {}
) => {
const now = options.now ?? (() => performance.now());
const sleep = options.sleep ?? ((ms: number) => setTimeout(ms));
const maxWaitMs = options.maxWaitMs ?? 5 * 60_000 + 1_000;
let startedAt: number | null = null;
let lastReportedAt = -Infinity;
return {
reset(): void {
startedAt = null;
lastReportedAt = -Infinity;
},
async wait(error: unknown): Promise<boolean> {
if (!(error instanceof ReservedTurnLeaseBusyError)) return false;
const current = now();
startedAt ??= current;
const remaining = maxWaitMs - (current - startedAt);
if (remaining <= 0) return false;
if (current - lastReportedAt >= 30_000) {
options.onWait?.(error.queueKey);
lastReportedAt = current;
}
await sleep(Math.min(1_000, remaining));
return true;
},
};
};
+36 -8
View File
@@ -129,6 +129,14 @@ export class ReservedTurnLeaseConflictError extends Error {
} }
} }
/** 실행 전 획득 충돌만 재시도한다. flush/finalize의 소유권 상실과 구분한다. */
export class ReservedTurnLeaseBusyError extends ReservedTurnLeaseConflictError {
constructor(queueKey: string) {
super(queueKey);
this.name = 'ReservedTurnLeaseBusyError';
}
}
export class InMemoryReservedTurnStore { export class InMemoryReservedTurnStore {
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>(); private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
private readonly nationTurns = new Map<string, ReservedTurnEntry[]>(); private readonly nationTurns = new Map<string, ReservedTurnEntry[]>();
@@ -249,11 +257,7 @@ export class InMemoryReservedTurnStore {
} }
}; };
pruneNationKeys(Array.from(this.nationTurns.keys()), (key) => this.nationTurns.delete(key)); pruneNationKeys(Array.from(this.nationTurns.keys()), (key) => this.nationTurns.delete(key));
for (const keys of [ for (const keys of [this.dirtyNationKeys, this.pendingNationInitializationKeys, this.leasedNationKeys]) {
this.dirtyNationKeys,
this.pendingNationInitializationKeys,
this.leasedNationKeys,
]) {
for (const key of Array.from(keys)) { for (const key of Array.from(keys)) {
const nationId = Number(key.split(':', 1)[0]); const nationId = Number(key.split(':', 1)[0]);
if (deletedNations.has(nationId)) { if (deletedNations.has(nationId)) {
@@ -376,7 +380,7 @@ export class InMemoryReservedTurnStore {
} }
} }
if (claimed.count !== 1) { if (claimed.count !== 1) {
throw new ReservedTurnLeaseConflictError(`general:${generalId}`); throw new ReservedTurnLeaseBusyError(`general:${generalId}`);
} }
this.leasedGeneralIds.add(generalId); this.leasedGeneralIds.add(generalId);
return !retainedExistingLease; return !retainedExistingLease;
@@ -427,7 +431,7 @@ export class InMemoryReservedTurnStore {
} }
} }
if (claimed.count !== 1) { if (claimed.count !== 1) {
throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`); throw new ReservedTurnLeaseBusyError(`nation:${nationId}:${officerLevel}`);
} }
this.leasedNationKeys.add(buildNationKey(nationId, officerLevel)); this.leasedNationKeys.add(buildNationKey(nationId, officerLevel));
return !retainedExistingLease; return !retainedExistingLease;
@@ -904,6 +908,24 @@ export class InMemoryReservedTurnStore {
await this.persistChanges(this.prisma, changes); await this.persistChanges(this.prisma, changes);
this.acknowledgeDirtyState(changes); this.acknowledgeDirtyState(changes);
} }
/** 실행 루프 종료 뒤 호출한다. rollback으로 journal에서 빠진 DB 임대도 해제한다. */
async releaseOwnedLeases(): Promise<void> {
const results = await Promise.allSettled([
this.prisma.generalTurnRevision?.updateMany({
where: { leaseOwner: this.leaseOwner },
data: { leaseOwner: null, leaseExpiresAt: null },
}),
this.prisma.nationTurnRevision?.updateMany({
where: { leaseOwner: this.leaseOwner },
data: { leaseOwner: null, leaseExpiresAt: null },
}),
]);
const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
if (errors.length) throw new AggregateError(errors, 'Reserved turn lease cleanup failed.');
this.leasedGeneralIds.clear();
this.leasedNationKeys.clear();
}
} }
export const createReservedTurnStore = async (options: ReservedTurnStoreOptions): Promise<ReservedTurnStoreHandle> => { export const createReservedTurnStore = async (options: ReservedTurnStoreOptions): Promise<ReservedTurnStoreHandle> => {
@@ -918,6 +940,12 @@ export const createReservedTurnStore = async (options: ReservedTurnStoreOptions)
await store.loadAll(); await store.loadAll();
return { return {
store, store,
close: () => connector.disconnect(), close: async () => {
try {
await store.releaseOwnedLeases();
} finally {
await connector.disconnect();
}
},
}; };
}; };
+2 -2
View File
@@ -2,7 +2,7 @@ import type { GameClockPhase } from '@sammo-ts/common';
/** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */ /** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */
export const createRuntimePauseGate = (options: { export const createRuntimePauseGate = (options: {
assertLease(): void; assertLease(): void | Promise<void>;
shouldPause(): Promise<boolean>; shouldPause(): Promise<boolean>;
getPhase(): GameClockPhase; getPhase(): GameClockPhase;
isExplicitlyPaused(): boolean; isExplicitlyPaused(): boolean;
@@ -11,7 +11,7 @@ export const createRuntimePauseGate = (options: {
}): (() => Promise<boolean>) => { }): (() => Promise<boolean>) => {
let lastPaused: boolean | null = null; let lastPaused: boolean | null = null;
return async () => { return async () => {
options.assertLease(); await options.assertLease();
const paused = await options.shouldPause(); const paused = await options.shouldPause();
const phase = options.getPhase(); const phase = options.getPhase();
// 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은 // 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은
+34 -14
View File
@@ -108,6 +108,8 @@ import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lif
import { EngineStateManager } from './engineStateManager.js'; import { EngineStateManager } from './engineStateManager.js';
import { applyRuntimeClockShift } from './runtimeClockShift.js'; import { applyRuntimeClockShift } from './runtimeClockShift.js';
import { applyRuntimeGameSettings } from './runtimeGameSettings.js'; import { applyRuntimeGameSettings } from './runtimeGameSettings.js';
import { createReservedTurnLeaseRetry } from './reservedTurnLeaseRetry.js';
import type { HostBootMonitor } from './hostBootMonitor.js';
export interface TurnDaemonRuntimeOptions { export interface TurnDaemonRuntimeOptions {
profile: string; profile: string;
@@ -134,6 +136,7 @@ export interface TurnDaemonRuntimeOptions {
leaseDurationMs?: number; leaseDurationMs?: number;
leaseOwnerId?: string; leaseOwnerId?: string;
enableLeaseHeartbeat?: boolean; enableLeaseHeartbeat?: boolean;
hostBootMonitor?: HostBootMonitor;
/** /**
* Isolated, single-process fixture acceleration only. Reserved turns are * Isolated, single-process fixture acceleration only. Reserved turns are
* loaded once and no concurrent API writer may touch this database. * loaded once and no concurrent API writer may touch this database.
@@ -985,6 +988,7 @@ const createTurnDaemonRuntimeWithLease = async (
prepareClockRecovery = dbHooks.prepareRealtimeRecovery; prepareClockRecovery = dbHooks.prepareRealtimeRecovery;
prunePreviousAudit = dbHooks.prunePreviousAudit; prunePreviousAudit = dbHooks.prunePreviousAudit;
close = async () => { close = async () => {
await adminActionConsumer?.stop();
if (auctionBidder) { if (auctionBidder) {
await auctionBidder.close(); await auctionBidder.close();
} }
@@ -995,12 +999,15 @@ const createTurnDaemonRuntimeWithLease = async (
await tournamentRewardFinalizer.close(); await tournamentRewardFinalizer.close();
} }
await dbHooks.close(); await dbHooks.close();
if (reservedTurnStoreHandle) { try {
await reservedTurnStoreHandle.close(); await reservedTurnStoreHandle?.close();
} finally {
try {
await gatewayGate?.close();
} finally {
await turnDaemonLease?.close();
}
} }
await gatewayGate?.close();
await adminActionConsumer?.stop();
await turnDaemonLease?.close();
}; };
} else if (reservedTurnStoreHandle) { } else if (reservedTurnStoreHandle) {
hooks = { hooks = {
@@ -1009,9 +1016,12 @@ const createTurnDaemonRuntimeWithLease = async (
}, },
}; };
close = async () => { close = async () => {
await reservedTurnStoreHandle.close();
await gatewayGate?.close();
await adminActionConsumer?.stop(); await adminActionConsumer?.stop();
try {
await reservedTurnStoreHandle.close();
} finally {
await gatewayGate?.close();
}
}; };
} else if (gatewayGate) { } else if (gatewayGate) {
hooks = { hooks = {
@@ -1070,12 +1080,17 @@ const createTurnDaemonRuntimeWithLease = async (
const baseClose = close; const baseClose = close;
close = async () => { close = async () => {
stopClockProjectionWorker(); stopClockProjectionWorker();
await baseClose(); const baseResults = await Promise.allSettled([baseClose()]);
await neutralAuctionRegistrar.close(); // queue 임대 정리 실패도 다른 연결/worker의 종료를 막지 않게 한다.
if (redisConnector) { const results = await Promise.allSettled([
await redisConnector.disconnect(); neutralAuctionRegistrar.close(),
} redisConnector?.disconnect(),
await commandConnector?.disconnect(); commandConnector?.disconnect(),
]);
const failures = [...baseResults, ...results].flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
);
if (failures.length) throw new AggregateError(failures, 'Turn daemon connection cleanup failed.');
}; };
const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue; const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
@@ -1109,7 +1124,8 @@ const createTurnDaemonRuntimeWithLease = async (
processor, processor,
hooks, hooks,
pauseGate: createRuntimePauseGate({ pauseGate: createRuntimePauseGate({
assertLease: () => { assertLease: async () => {
await turnDaemonLease?.checkHostBoot();
if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError(); if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError();
}, },
shouldPause: async () => (await pauseGate?.()) ?? false, shouldPause: async () => (await pauseGate?.()) ?? false,
@@ -1127,6 +1143,9 @@ const createTurnDaemonRuntimeWithLease = async (
// accumulated world before every due tick in that isolated mode; // accumulated world before every due tick in that isolated mode;
// production and gateway-managed runtimes keep rollback savepoints. // production and gateway-managed runtimes keep rollback savepoints.
stateManager: options.exclusiveFastForward ? undefined : stateManager, stateManager: options.exclusiveFastForward ? undefined : stateManager,
runErrorRetry: createReservedTurnLeaseRetry({
onWait: (queueKey) => console.info(`[turn-daemon] waiting for reserved turn lease: ${queueKey}`),
}),
}, },
{ profile: options.profile, defaultBudget } { profile: options.profile, defaultBudget }
); );
@@ -1179,6 +1198,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
ownerId: options.leaseOwnerId, ownerId: options.leaseOwnerId,
leaseDurationMs: options.leaseDurationMs, leaseDurationMs: options.leaseDurationMs,
heartbeat: options.enableLeaseHeartbeat, heartbeat: options.enableLeaseHeartbeat,
hostBootMonitor: options.hostBootMonitor,
}) })
: null; : null;
if (turnDaemonLease && !(await turnDaemonLease.acquire())) { if (turnDaemonLease && !(await turnDaemonLease.acquire())) {
@@ -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 { import {
InMemoryReservedTurnStore, InMemoryReservedTurnStore,
ReservedTurnLeaseConflictError, ReservedTurnLeaseConflictError,
createReservedTurnStore,
} from '../src/turn/reservedTurnStore.js'; } from '../src/turn/reservedTurnStore.js';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL; const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL;
const describeIntegration = databaseUrl ? describe : describe.skip; 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)); 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', () => { 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', () => { it('prunes deleted general and nation queues together with their journal state', () => {
const harness = buildHarness(); const harness = buildHarness();
harness.store.ensureGeneralTurns(7); 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'; import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
describe('runtime pause clock boundary', () => { 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 () => { it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => {
let phase: GameClockPhase = 'RUNNING'; let phase: GameClockPhase = 'RUNNING';
let paused = true; let paused = true;
const calls: string[] = []; const calls: string[] = [];
const gate = createRuntimePauseGate({ const gate = createRuntimePauseGate({
assertLease: () => calls.push('lease'), assertLease: () => {
calls.push('lease');
},
shouldPause: async () => paused, shouldPause: async () => paused,
isExplicitlyPaused: () => paused, isExplicitlyPaused: () => paused,
getPhase: () => phase, getPhase: () => phase,
@@ -207,6 +207,39 @@ integration('database turn daemon lease and fencing', () => {
expect(row.ownerId).toBe(firstToken ? 'owner-a' : 'owner-b'); 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 () => { it('persists and evaluates lease wall time as UTC under a non-UTC database session', async () => {
const profile = `${profilePrefix}utc-wall`; const profile = `${profilePrefix}utc-wall`;
const zonedUrl = new URL(databaseUrl!); const zonedUrl = new URL(databaseUrl!);
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ManualClock } from '@sammo-ts/common'; import { ManualClock } from '@sammo-ts/common';
import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js'; import { TurnDaemonLeaseLostError } from '../src/lifecycle/databaseTurnDaemonLease.js';
import { ReservedTurnLeaseBusyError } from '../src/turn/reservedTurnStore.js';
import { createReservedTurnLeaseRetry } from '../src/turn/reservedTurnLeaseRetry.js';
import { import {
InMemoryControlQueue, InMemoryControlQueue,
@@ -15,6 +17,68 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => { 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) => { 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 now = new Date('2026-09-09T17:30:00Z');
const error = new TurnDaemonLeaseLostError('che:default'); const error = new TurnDaemonLeaseLostError('che:default');
+149
View File
@@ -0,0 +1,149 @@
# Hyper-V 저장·복원 이후 게임 복구
Windows 재부팅 때 Linux VM이 저장·복원되면 게스트의 shutdown/resume hook이
실행되지 않을 수 있다. 게임은 종료 이벤트 없이도 기존 lease 만료와 DB snapshot
재적재로 복구한다. Windows 식별자 확인은 이 경로에 추가하는 선택 기능이다.
## 예약턴 임대 복구
- 실행 루프가 종료되면 DB에서 자기 owner의 장수·국가 예약턴 임대를 해제한다.
In-memory rollback으로 leased set에서 빠진 row도 정리한다. 큐 내용과 revision,
다른 실행자/API의 임대는 변경하지 않는다.
- 계산 전 임대 획득 충돌은 전체 메모리 savepoint를 복원한 뒤 1초 단위로 재시도한다.
한 충돌 구간의 상한은 301초다(기본 queue lease 5분 + 여유 1초). 그동안 다음
loop마다 운영자 pause/stop과 daemon 권위를 확인한다. 성공한 batch에서 상한을
초기화하며 큐가 바뀌어도 같은 실패 구간의 상한을 연장하지 않는다.
- 저장·finalize 단계의 소유권 상실, 메모리 복원 실패와 다른 게임 오류는 자동
재시도하지 않는다. 상한을 넘은 획득 충돌도 기존 PAUSED/장애 이력 경로로 간다.
- daemon의 30초 lease와 fencing epoch, 기존 10분 이하 catch-up 및 장시간 복구
정책은 유지한다. 운영자가 정지한 profile을 자동 START하지 않는다.
## Windows Node.js 에이전트
필요 파일은 `windows-boot-agent.mjs` 하나이며 외부 npm package가 필요 없다.
Node.js 24.22.1 이상을 사용한다. 설정 예시는
`tools/host-recovery/windows-boot-agent.example.json`이다.
Node.js가 Windows 내장 PowerShell의 CIM 조회로 `Win32_OperatingSystem.LastBootUpTime`
UTC ticks를 읽고 hostname과 함께 hash한다. 별도의 PowerShell 스크립트 설치나
Linux에서 Windows 관리자 명령 실행은 필요 없다. Node 에이전트를 재시작해도
같은 Windows boot의 식별자는 같고, Windows를 재부팅하면 달라진다.
실제 Windows에서 CIM 조회가 실패하면 가짜 식별자를 만들지 않고 시작을 거부한다.
1. 파일들을 `C:\ProgramData\Sammo` 같은 고정 경로에 복사한다.
2. 디렉터리를 만든 뒤 Windows 터미널에서 private key를 생성한다. 기존 파일을
덮어쓰지 않으며 key 내용은 출력하지 않는다.
```text
node C:\ProgramData\Sammo\windows-boot-agent.mjs --create-key C:\ProgramData\Sammo\host-boot.key
```
3. 설정 예시를 `C:\ProgramData\Sammo\host-boot.json`으로 복사한다. `keyFile`은
위 경로, `bindAddress`는 **Linux VM에서 접근할 수 있는 Windows의 사설 주소**로
설정한다. 예시의 `127.0.0.1`은 안전한 기본값이며 원격 Linux에서 접근할 수 없다.
`port` 기본값은 17781이다. Windows 방화벽은 해당 사설 listener에 Linux VM
주소에서 오는 TCP 연결만 허용하도록 설정한다. 기존 공개 Caddy는 변경하지 않는다.
4. key 파일은 실행 계정만 읽도록 NTFS 권한을 제한하고, 같은 내용을 Linux의
`/run/secrets/host_boot_key` 같은 private file로 비공개 전달한다. Git·메신저·
CLI 인자에 key 내용을 넣지 않는다. 에이전트는 key를 시작할 때 읽으므로 key
교체 시 양쪽 프로세스를 함께 재시작한다.
5. 실행한다.
```text
node C:\ProgramData\Sammo\windows-boot-agent.mjs --config C:\ProgramData\Sammo\host-boot.json
```
6. Windows 작업 스케줄러에 **시스템 시작 시** task를 등록한다. 프로그램은 Node의
절대 `node.exe` 경로, 인자는 위 `.mjs --config ...` 부분을 사용한다. 로그인 없이
실행되고 실패 시 재시작하도록 구성한다. 설정 파일을 사용하므로 터미널 세션의
임시 환경변수에 의존하지 않는다. 실제 Windows 설치·방화벽 변경·task 등록은
대상 host에서 별도 적용하고 확인해야 한다.
원한다면 설정 파일 대신 `HOST_BOOT_KEY_FILE`, `HOST_BOOT_BIND_ADDRESS`,
`HOST_BOOT_PORT` 환경변수로도 실행할 수 있다.
## Linux 연결
기존 game runtime/PM2가 상속하는 서버 환경에 아래 두 값을 함께 설정한다.
설정이 없으면 기능을 사용하지 않는다. 설정이 불완전하거나 key 파일이 없거나 잘못돼도
고정 경고와 함께 보조 관측만 비활성화하며 게임 기동은 계속한다.
```dotenv
TURN_HOST_BOOT_URL=http://windows-private-address:17781/v1/boot-id
TURN_HOST_BOOT_KEY_FILE=/run/secrets/host_boot_key
```
Docker에서는 private key가 들어 있는 전용 디렉터리를 runtime에 read-only mount하고 두 환경변수를
**runtime container environment**에 전달해야 한다. 호스트의 `.env`에만 추가하면
자동 전달되지 않는다. 기존 Compose와 host overlay를 함께 적용하는 전용 wrapper를
사용한다. 실제 secret 파일의 host 경로와 Windows 사설 주소를 확인하기 전에는
운영 stack 설정을 추측해 바꾸지 않는다.
예를 들어 host overlay의 runtime 설정에는 다음 형태로 연결할 수 있다. host 경로는
placeholder이며 실제 운영 경로로 교체해야 한다.
```yaml
services:
runtime:
environment:
TURN_HOST_BOOT_URL: ${TURN_HOST_BOOT_URL:-}
TURN_HOST_BOOT_KEY_FILE: /run/sammo-host-recovery/host_boot.key
volumes:
- type: bind
source: /absolute/private/path/host-recovery
target: /run/sammo-host-recovery
read_only: true
bind:
create_host_path: true
```
위 디렉터리는 보조 기능 전용이므로 없으면 빈 디렉터리를 만들 수 있다. 파일 부재는
관측만 비활성화한다. DB·backup 같은 필수 저장소에는 이 fallback을 적용하지 않는다.
운영에는 이 전용 디렉터리의 `host_boot.key` 파일을 전달한다.
처음 인증에 성공한 식별자를 프로세스의 기준으로 삼고 고정된 성공 로그를 남긴다. 이후 다른 식별자를
확인하면 `TurnDaemonLeaseLostError`로 기존 실행자를 중단하고 DB를 다시 읽는다.
종료 시 owner/epoch가 여전히 자기 것인 경우에만 DB lease와 `clock_ready`를
해제하므로 다른 실행자를 중지시키지 않는다. 재시작은 기존 profile 상태를
보존하며 PAUSED/STOPPED를 임의로 RUNNING으로 바꾸지 않는다.
보통 최대 2초 간격으로 확인한다. 벽시계와 monotonic 경과가 1초 이상 어긋나면
cache를 무시하고 다시 조회한다. 조회 timeout은 1초다. 네트워크·인증·응답 오류는
분당 한 번의 고정 경고만 남기고 기존 DB lease 복구를 유지한다. 처음 인증받기 전
발생한 reboot는 이 신호만으로 확정할 수 없으며 기존 복구 경로가 담당한다.
## 인증 프로토콜과 검증
key 파일은 32바이트 난수를 lowercase hex 64자로 저장한다. 매 요청마다 새 32바이트
challenge를 만들며, key 자체를 네트워크에 보내지 않는다.
- GET `/v1/boot-id`, query string 없음.
- `x-sammo-challenge`: lowercase hex 64자.
- `x-sammo-signature`: `HMAC-SHA256(key, "sammo-host-boot-request-v1\n" + challenge)`.
- JSON: `version=1`, `challenge`, `bootId`(hex 64자), `signature`.
- 응답 signature:
`HMAC-SHA256(key, "sammo-host-boot-response-v1\n" + challenge + "\n" + bootId)`.
위 `\n`은 실제 LF다. Linux는 challenge와 응답 signature를 검증하고, redirect와
2048바이트 초과 응답을 거부한다. 서명된 옛 응답도 새 challenge에는 유효하지 않다.
시각 정확도나 요청 시각 만료에 의존하지 않는다. 이 프로토콜은 인증과 무결성을
제공하며 암호화는 하지 않는다. 사설 망에서만 사용하며 공개 무인증 재개 API를
만들지 않는다.
```sh
node --test tools/host-recovery/windows-boot-agent.test.mjs
pnpm --filter @sammo-ts/game-engine test hostBootMonitor.test.ts runtimePauseGate.test.ts reservedTurnLeaseRetry.test.ts
```
CI에서는 Windows boot를 모사한 HTTP 응답, 위조·재사용·timeout, 실제 PostgreSQL
lease fencing과 queue cleanup을 검증한다. 실제 Windows CIM·Task Scheduler와
Hyper-V 재부팅 검증을 대체하지 않는다. 배포 후 기존 장애 진단 화면의 lease/
epoch와 public lobby `turnEngineRunning`, 실제 tick/queue revision 진행을 확인한다.
기능을 해제하려면 Linux의 두 환경변수를 함께 제거하고 해당 runtime을 정상
재기동한다. Windows task도 중지할 수 있다. DB migration이나 게임 초기화는 없다.
근거: [Microsoft LastBootUpTime](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-operatingsystem),
[Node.js HMAC와 timingSafeEqual](https://nodejs.org/api/crypto.html),
[게임 시계 복구 계약](../architecture/game-clock-reconciliation.md),
[릴리스 운영](../release-operations.md).
+12
View File
@@ -363,6 +363,18 @@ Hyper-V의 VM 정지/재개, 체크포인트 또는 host 시간 보정은 게스
NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나 NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나
시간 동기화를 임의로 끄는 것으로 해결하지 않습니다. 시간 동기화를 임의로 끄는 것으로 해결하지 않습니다.
예약턴의 실행 전 임대 충돌은 메모리 rollback 뒤 1초 간격, 한 구간 최대 301초 동안
해제·만료를 기다립니다. flush/finalize의 ownership 상실은 재시도하지 않습니다.
종료 시에는 rollback으로 journal에서 사라진 자기 owner의 queue 임대도 DB에서
해제합니다. 다른 owner와 queue 내용/revision은 보존합니다. 대기 중에도 매 loop의
운영자 pause/stop과 daemon gate를 통과하며 상한을 넘으면 기존 오류 정지를 유지합니다.
Windows의 저장·복원으로 guest 이벤트가 없을 때를 위한 선택적 boot identity 확인은
[Windows Node.js 에이전트 설치와 Linux 연결](./developer/host-boot-recovery.md)을 따릅니다.
`TURN_HOST_BOOT_URL`과 private `TURN_HOST_BOOT_KEY_FILE`을 함께 설정하면 인증된
식별자 변경을 기존 lease 상실/재기동으로 처리합니다. 통신 실패는 기존 복구를
막지 않으며 운영자의 PAUSED/STOPPED를 자동 해제하지 않습니다.
Turn daemon의 DB persistence interactive transaction은 연결 획득 대기(`maxWait`) Turn daemon의 DB persistence interactive transaction은 연결 획득 대기(`maxWait`)
10초와 callback 실행(`timeout`) 30초를 각각 제한합니다. Prisma 기본 연결 대기 10초와 callback 실행(`timeout`) 30초를 각각 제한합니다. Prisma 기본 연결 대기
2초는 일시적인 DB 지연에도 callback 시작 전 `P2028`로 서버를 PAUSED로 만들 수 2초는 일시적인 DB 지연에도 callback 시작 전 `P2028`로 서버를 PAUSED로 만들 수
+12
View File
@@ -0,0 +1,12 @@
# Windows host boot identity agent
`windows-boot-agent.mjs`는 외부 npm package 없이 실행하는 Node.js 에이전트다.
Windows OS의 부팅 시각 기반 식별자를 인증된 Linux 게임 데몬에 제공한다.
설치, key 생성, 자동 시작과 선택 기능 장애 시 동작은
[Hyper-V 복구 운영 안내](../../docs/developer/host-boot-recovery.md)를 따른다.
설정 예시는 `windows-boot-agent.example.json`이며 실제 key와 설정은 Git에 넣지 않는다.
```sh
node --test tools/host-recovery/windows-boot-agent.test.mjs
```
@@ -0,0 +1,5 @@
{
"keyFile": "C:\\ProgramData\\Sammo\\host-boot.key",
"bindAddress": "127.0.0.1",
"port": 17781
}
+131
View File
@@ -0,0 +1,131 @@
import { execFile } from 'node:child_process';
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { hostname } from 'node:os';
import { win32, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const mac = (key, value) => createHmac('sha256', key).update(value).digest('hex');
export const bootIdentity = (host, ticks) => {
if (!/^\d{17,20}$/.test(ticks)) throw new Error('Windows returned an invalid boot time.');
return createHash('sha256').update(`${host.toLowerCase()}\n${ticks}`).digest('hex');
};
export const readWindowsBootIdentity = async () => {
if (process.platform !== 'win32') throw new Error('Run the host boot agent on Windows.');
const executable = win32.join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
);
// Node.js가 OS 내장 CIM 조회를 실행한다. 별도 PowerShell 스크립트/모듈은 필요 없다.
const { stdout } = await promisify(execFile)(
executable,
[
'-NoProfile',
'-NonInteractive',
'-Command',
'(Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToUniversalTime().Ticks.ToString()',
],
{ windowsHide: true, timeout: 10_000, maxBuffer: 2_048 }
);
return bootIdentity(hostname(), stdout.trim());
};
export const createBootIdentityServer = ({ key, getBootId }) => {
if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('A 32-byte host boot key is required.');
const server = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store');
if (request.method !== 'GET' || request.url !== '/v1/boot-id') {
response.writeHead(404).end();
return;
}
const challenge = request.headers['x-sammo-challenge'];
const signature = request.headers['x-sammo-signature'];
if (
typeof challenge !== 'string' ||
!/^[a-f0-9]{64}$/.test(challenge) ||
typeof signature !== 'string' ||
!/^[a-f0-9]{64}$/.test(signature) ||
!timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(mac(key, `sammo-host-boot-request-v1\n${challenge}`), 'hex')
)
) {
response.writeHead(401).end();
return;
}
const bootId = getBootId();
if (typeof bootId !== 'string' || !/^[a-f0-9]{64}$/.test(bootId)) {
response.writeHead(503).end();
return;
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
version: 1,
challenge,
bootId,
signature: mac(key, `sammo-host-boot-response-v1\n${challenge}\n${bootId}`),
})
);
});
server.headersTimeout = 5_000;
server.requestTimeout = 5_000;
server.keepAliveTimeout = 1_000;
server.maxHeadersCount = 20;
return server;
};
export const main = async (args = process.argv.slice(2), env = process.env) => {
if (args.length === 2 && args[0] === '--create-key') {
await writeFile(args[1], `${randomBytes(32).toString('hex')}\n`, { flag: 'wx', mode: 0o600 });
console.info('Host boot key created. Copy it privately to the Linux secret file.');
return;
}
if (args.length === 2 && args[0] === '--config') {
const config = JSON.parse(await readFile(args[1], 'utf8'));
if (
!config ||
typeof config.keyFile !== 'string' ||
typeof config.bindAddress !== 'string' ||
!Number.isInteger(config.port)
) {
throw new Error('Host boot agent configuration is invalid.');
}
env = {
...env,
HOST_BOOT_KEY_FILE: config.keyFile,
HOST_BOOT_BIND_ADDRESS: config.bindAddress,
HOST_BOOT_PORT: String(config.port),
};
} else if (args.length) throw new Error('Usage: node windows-boot-agent.mjs [--create-key path | --config path]');
if (!env.HOST_BOOT_KEY_FILE) throw new Error('HOST_BOOT_KEY_FILE is required.');
const encoded = (await readFile(env.HOST_BOOT_KEY_FILE, 'utf8')).trim();
if (!/^[a-f0-9]{64}$/.test(encoded)) throw new Error('Host boot key file is invalid.');
const port = Number(env.HOST_BOOT_PORT ?? '17781');
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('HOST_BOOT_PORT is invalid.');
// OS 부팅 시각은 agent 재시작에도 같으며, 시각 차이 계산으로 재부팅을 추정하지 않는다.
const bootId = await readWindowsBootIdentity();
const server = createBootIdentityServer({ key: Buffer.from(encoded, 'hex'), getBootId: () => bootId });
server.on('error', () => {
console.error('Host boot agent listener failed.');
process.exitCode = 1;
});
server.listen(port, env.HOST_BOOT_BIND_ADDRESS ?? '127.0.0.1', () => {
console.info('Host boot identity agent is listening.');
});
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
};
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(() => {
console.error('Host boot agent startup failed; check OS, private key file, and listener configuration.');
process.exitCode = 1;
});
}
@@ -0,0 +1,42 @@
import { after, test } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { once } from 'node:events';
import { bootIdentity, createBootIdentityServer } from './windows-boot-agent.mjs';
test('agent restart preserves OS boot identity; a new OS boot changes it', () => {
assert.equal(bootIdentity('HOST', '638985120000000000'), bootIdentity('host', '638985120000000000'));
assert.notEqual(bootIdentity('HOST', '638985120000000000'), bootIdentity('HOST', '638985120000000001'));
assert.throws(() => bootIdentity('HOST', 'invalid'));
});
test('HTTP transport rejects anonymous requests and signs the current challenge and boot identity', async () => {
const key = randomBytes(32);
let bootId = 'a'.repeat(64);
const server = createBootIdentityServer({ key, getBootId: () => bootId });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const url = `http://127.0.0.1:${server.address().port}/v1/boot-id`;
after(
() =>
new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
})
);
assert.equal((await fetch(url)).status, 401);
assert.equal((await fetch(`${url}?bypass=1`)).status, 404);
const challenge = randomBytes(32).toString('hex');
const signature = createHmac('sha256', key).update(`sammo-host-boot-request-v1\n${challenge}`).digest('hex');
const headers = { 'x-sammo-challenge': challenge, 'x-sammo-signature': signature };
const response = await fetch(url, { headers });
assert.equal(response.status, 200);
assert.equal(response.headers.get('cache-control'), 'no-store');
const body = await response.json();
assert.equal(body.challenge, challenge);
const expected = createHmac('sha256', key).update(`sammo-host-boot-response-v1\n${challenge}\n${bootId}`).digest();
assert.ok(timingSafeEqual(expected, Buffer.from(body.signature, 'hex')));
bootId = 'b'.repeat(64);
assert.equal((await (await fetch(url, { headers })).json()).bootId, bootId);
assert.equal((await fetch(url, { headers: { ...headers, 'x-sammo-signature': '0'.repeat(64) } })).status, 401);
});