fix: 배포 중 화면 유지와 자동 재연결 적용

정상 DEPLOY 중에는 기존 정적 프론트엔드를 유지하고 새 artifact 준비 후 전환합니다. 게임 화면은 일시적인 gateway 오류를 별도 상태로 표시하고 읽기 전용 probe로 제한적으로 복구합니다.
This commit is contained in:
2026-08-27 02:08:44 +00:00
parent 9e01d24b5d
commit c1d5a80f79
13 changed files with 395 additions and 12 deletions
@@ -0,0 +1,97 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installRecoveryFixture = async (page: Page) => {
let unavailable = true;
let lobbyRequests = 0;
await page.addInitScript(
({ token, profile }) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', profile);
window.addEventListener('sammo:game-server-reconnected', () => {
const state = window as typeof window & { __gameServerReconnects?: number };
state.__gameServerReconnects = (state.__gameServerReconnects ?? 0) + 1;
});
},
{ token: 'ga_recovery', profile: gameProfile }
);
await page.route(gameTrpcRoute, async (route) => {
const operations = operationNames(route);
if (operations.includes('lobby.info')) {
lobbyRequests += 1;
if (unavailable) {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'profile switch in progress' }),
});
return;
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
operations.map((operation) => {
if (operation === 'auth.status') return response({ userId: 'recovery-user' });
if (operation === 'lobby.info') return response({ myGeneral: null });
if (operation === 'join.getConfig') return response({});
return response(null);
})
),
});
});
return {
recover: () => {
unavailable = false;
},
lobbyRequests: () => lobbyRequests,
};
};
for (const viewport of [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'mobile', width: 390, height: 844 },
]) {
test(`keeps the current screen and reconnects after a transient profile switch on ${viewport.name}`, async ({
page,
}) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const fixture = await installRecoveryFixture(page);
await page.goto('select-general');
const heading = page.locator('.page-title');
await expect(heading).toContainText('장 수 선 택');
const notice = page.getByTestId('game-server-connection-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('화면을 유지한 채 자동으로 다시 연결합니다');
await expect(notice).toHaveCSS('position', 'fixed');
const noticeBox = await notice.boundingBox();
expect(noticeBox).not.toBeNull();
expect(noticeBox!.x).toBeGreaterThanOrEqual(0);
expect(noticeBox!.x + noticeBox!.width).toBeLessThanOrEqual(viewport.width);
const documentWidthDuringReconnect = await page.evaluate(() => document.documentElement.scrollWidth);
await page.evaluate(() => {
Object.assign(window, { __connectionRecoveryPageMarker: 'kept' });
});
fixture.recover();
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect(notice).toHaveCount(0);
await expect(heading).toContainText('장 수 선 택');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(documentWidthDuringReconnect);
expect(await page.evaluate(() => Reflect.get(window, '__connectionRecoveryPageMarker'))).toBe('kept');
expect(await page.evaluate(() => Reflect.get(window, '__gameServerReconnects'))).toBe(1);
expect(fixture.lobbyRequests()).toBeGreaterThanOrEqual(2);
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_recovery');
});
}
@@ -43,6 +43,7 @@ export default defineConfig({
'npcPossession.spec.ts',
'joinLayout.spec.ts',
'deploymentVersionNotice.spec.ts',
'connectionRecovery.spec.ts',
],
fullyParallel: false,
workers: 1,
+1 -1
View File
@@ -159,6 +159,6 @@ test('keeps a valid ga_ token when only lobby.info is unavailable', async ({ pag
await expect(page.locator('.page-title')).toContainText('장 수 선 택');
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_valid');
expect(statusRequests).toBe(1);
expect(lobbyRequests).toBe(1);
expect(lobbyRequests).toBeGreaterThanOrEqual(1);
expect(gatewayRequests).toBe(0);
});
+2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
@@ -8,6 +9,7 @@ useDeploymentVersionNotice();
<template>
<RouterView />
<GameServerConnectionNotice />
<GameFeedbackLayer />
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useGameServerConnectionRecovery } from '../../composables/useGameServerConnectionRecovery';
const { reconnecting } = useGameServerConnectionRecovery();
</script>
<template>
<div
v-if="reconnecting"
class="game-server-connection-notice"
data-testid="game-server-connection-notice"
role="status"
aria-live="polite"
>
서버 연결이 잠시 끊겼습니다. 화면을 유지한 자동으로 다시 연결합니다.
</div>
</template>
<style scoped>
.game-server-connection-notice {
position: fixed;
z-index: 1100;
top: 0;
left: 50%;
box-sizing: border-box;
width: min(100%, 520px);
padding: 7px 12px;
transform: translateX(-50%);
border: 1px solid #a67c00;
border-top: 0;
background: #fff3bf;
color: #3d3100;
font: 13px/1.45 var(--sammo-font-sans);
text-align: center;
box-shadow: 0 2px 6px rgb(0 0 0 / 25%);
}
</style>
@@ -0,0 +1,75 @@
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { trpc } from '../utils/trpc';
import {
GAME_SERVER_RECONNECTED_EVENT,
gameServerConnection,
retryDelayForFailure,
} from '../utils/gameServerConnection';
export const useGameServerConnectionRecovery = () => {
const reconnecting = computed(() => gameServerConnection.status.value === 'reconnecting');
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let failureCount = 0;
let mounted = false;
let wasReconnecting = false;
const clearRetry = (): void => {
if (retryTimer) clearTimeout(retryTimer);
retryTimer = null;
};
const scheduleRetry = (): void => {
if (!mounted || !reconnecting.value || retryTimer) return;
failureCount += 1;
retryTimer = setTimeout(() => {
retryTimer = null;
void trpc.lobby.info
.query()
.catch(() => undefined)
.finally(() => scheduleRetry());
}, retryDelayForFailure(failureCount));
};
const retryNow = (): void => {
if (!reconnecting.value) return;
clearRetry();
failureCount = 0;
void trpc.lobby.info
.query()
.catch(() => undefined)
.finally(() => scheduleRetry());
};
const stopWatching = watch(
reconnecting,
(current) => {
if (current) {
wasReconnecting = true;
scheduleRetry();
return;
}
clearRetry();
failureCount = 0;
if (wasReconnecting && mounted) {
window.dispatchEvent(new Event(GAME_SERVER_RECONNECTED_EVENT));
}
wasReconnecting = false;
},
{ immediate: true }
);
onMounted(() => {
mounted = true;
window.addEventListener('online', retryNow);
scheduleRetry();
});
onBeforeUnmount(() => {
mounted = false;
clearRetry();
stopWatching();
window.removeEventListener('online', retryNow);
});
return { reconnecting };
};
@@ -22,6 +22,7 @@ import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { markGameServerContact } from '../utils/gameServerActivity';
import { GAME_SERVER_RECONNECTED_EVENT } from '../utils/gameServerConnection';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -1261,12 +1262,19 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
};
const handleGameServerReconnected = () => {
if (!realtimeActive.value || document.visibilityState === 'hidden') return;
realtimeRefreshQueue.beginCooldown();
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
};
const startRealtime = () => {
if (typeof window === 'undefined' || realtimeActive.value) return;
realtimeActive.value = true;
realtimeRefreshQueue.beginCooldown();
if (!visibilityListenerInstalled) {
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener(GAME_SERVER_RECONNECTED_EVENT, handleGameServerReconnected);
visibilityListenerInstalled = true;
}
reconcileRealtimeCoordinator();
@@ -1279,6 +1287,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
closeRealtimeCoordinator();
if (visibilityListenerInstalled) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener(GAME_SERVER_RECONNECTED_EVENT, handleGameServerReconnected);
visibilityListenerInstalled = false;
}
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
@@ -0,0 +1,54 @@
import { readonly, ref, type Ref } from 'vue';
export const GAME_SERVER_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000] as const;
export const GAME_SERVER_RECONNECTED_EVENT = 'sammo:game-server-reconnected';
export type GameServerConnectionStatus = 'connected' | 'reconnecting';
export type GameServerConnectionTracker = {
status: Readonly<Ref<GameServerConnectionStatus>>;
markFailure: () => void;
markConnected: () => void;
};
export const isRetryableGameServerStatus = (status: number): boolean =>
status === 502 || status === 503 || status === 504;
export const canConfirmGameServerRecovery = (status: number): boolean => status < 500;
export const isGameServerRecoveryRequest = (input: RequestInfo | URL): boolean => {
const rawUrl = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
try {
const operationList = decodeURIComponent(new URL(rawUrl, 'http://game.local').pathname).split('/').at(-1);
return operationList?.split(',').includes('lobby.info') ?? false;
} catch {
return false;
}
};
export const isAbortedGameServerRequest = (error: unknown): boolean =>
error instanceof DOMException && error.name === 'AbortError';
export const retryDelayForFailure = (failureCount: number): number =>
GAME_SERVER_RETRY_DELAYS_MS[
Math.min(Math.max(0, Math.trunc(failureCount) - 1), GAME_SERVER_RETRY_DELAYS_MS.length - 1)
];
export const createGameServerConnectionTracker = (): GameServerConnectionTracker => {
const status = ref<GameServerConnectionStatus>('connected');
return {
status: readonly(status),
markFailure() {
status.value = 'reconnecting';
},
markConnected() {
status.value = 'connected';
},
};
};
export const gameServerConnection = createGameServerConnectionTracker();
export const markGameServerConnectionFailure = (): void => gameServerConnection.markFailure();
export const markGameServerConnectionReady = (): void => gameServerConnection.markConnected();
+27 -3
View File
@@ -5,6 +5,15 @@ import type { AppRouter } from '@sammo-ts/game-api';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
import { markGameServerContact } from './gameServerActivity';
import {
canConfirmGameServerRecovery,
gameServerConnection,
isAbortedGameServerRequest,
isGameServerRecoveryRequest,
isRetryableGameServerStatus,
markGameServerConnectionFailure,
markGameServerConnectionReady,
} from './gameServerConnection';
const getGameToken = (): string | null => {
if (typeof window === 'undefined') {
@@ -20,9 +29,24 @@ export const trpc = createTRPCProxyClient<AppRouter>({
url: gameFrontendRuntimeConfig.gameApiUrl,
...trpcJsonBodyHttpClientOptions,
async fetch(input, init) {
const result = await globalThis.fetch(input, init);
markGameServerContact();
return result;
try {
const result = await globalThis.fetch(input, init);
if (isRetryableGameServerStatus(result.status)) {
markGameServerConnectionFailure();
} else {
markGameServerContact();
if (
gameServerConnection.status.value === 'connected' ||
(isGameServerRecoveryRequest(input) && canConfirmGameServerRecovery(result.status))
) {
markGameServerConnectionReady();
}
}
return result;
} catch (error) {
if (!isAbortedGameServerRequest(error)) markGameServerConnectionFailure();
throw error;
}
},
headers({ opList }) {
const token = getGameToken();
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
canConfirmGameServerRecovery,
createGameServerConnectionTracker,
isGameServerRecoveryRequest,
isRetryableGameServerStatus,
retryDelayForFailure,
} from '../src/utils/gameServerConnection.ts';
void test('classifies only transient deployment gateway responses as reconnectable', () => {
assert.equal(isRetryableGameServerStatus(502), true);
assert.equal(isRetryableGameServerStatus(503), true);
assert.equal(isRetryableGameServerStatus(504), true);
assert.equal(isRetryableGameServerStatus(401), false);
assert.equal(isRetryableGameServerStatus(403), false);
assert.equal(isRetryableGameServerStatus(500), false);
});
void test('does not treat a server error from the recovery probe as restored service', () => {
assert.equal(canConfirmGameServerRecovery(200), true);
assert.equal(canConfirmGameServerRecovery(401), true);
assert.equal(canConfirmGameServerRecovery(403), true);
assert.equal(canConfirmGameServerRecovery(500), false);
assert.equal(canConfirmGameServerRecovery(503), false);
});
void test('uses bounded reconnect delays', () => {
assert.equal(retryDelayForFailure(1), 500);
assert.equal(retryDelayForFailure(2), 1_000);
assert.equal(retryDelayForFailure(3), 2_000);
assert.equal(retryDelayForFailure(4), 4_000);
assert.equal(retryDelayForFailure(20), 4_000);
});
void test('recognizes only the read-only lobby probe as connection recovery evidence', () => {
assert.equal(isGameServerRecoveryRequest('/che/api/trpc/lobby.info'), true);
assert.equal(isGameServerRecoveryRequest('/che/api/trpc/auth.status,lobby.info?batch=1'), true);
assert.equal(isGameServerRecoveryRequest('/che/api/trpc/join.getConfig'), false);
});
void test('retains reconnecting state until the server responds again', () => {
const tracker = createGameServerConnectionTracker();
tracker.markFailure();
tracker.markFailure();
assert.equal(tracker.status.value, 'reconnecting');
tracker.markConnected();
assert.equal(tracker.status.value, 'connected');
});
@@ -1857,7 +1857,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.appendOperationLog(operationId, 'switch', '기존 profile process를 정지합니다.');
await assertLease();
await this.stopProfile(profile, assertLease);
await this.stopProfile(profile, assertLease, { preserveStaticFrontend: true });
oldRuntimeStopped = true;
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
await this.appendOperationLog(operationId, 'migration', '선택 버전의 game migration을 적용합니다.');
@@ -2821,7 +2821,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return false;
}
private async stopProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<void> {
private async stopProfile(
profile: GatewayProfileRecord,
assertLease?: () => Promise<void>,
options: { preserveStaticFrontend?: boolean } = {}
): Promise<void> {
const frontendName = buildProcessName(profile.profileName, 'frontend');
const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon');
@@ -2829,7 +2833,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
await assertLease?.();
if (this.frontendServeMode === 'static') {
// A normal DEPLOY keeps the last immutable page available while its API is replaced.
// STOP/RESET still remove the pointer because those operations intentionally close the profile.
if (this.frontendServeMode === 'static' && !options.preserveStaticFrontend) {
await this.artifactManager.deactivate(profile.profile);
}
await assertLease?.();
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import type { BuildCommand } from '../src/orchestrator/buildRunner.js';
import { FrontendArtifactManager } from '../src/orchestrator/frontendArtifactManager.js';
import type { ProcessManager } from '../src/orchestrator/processManager.js';
import type {
GatewayClaimedProfileUpdate,
@@ -62,6 +63,17 @@ afterEach(async () => {
describe('profile DEPLOY operation', () => {
it('migrates and atomically switches a static frontend without executing the reset seed path', async () => {
const workspace = await createReleaseWorkspace();
const artifactRoot = path.join(workspace, 'artifact-volume');
const oldFrontendRoot = path.join(workspace, 'old-static-frontend');
await fs.mkdir(oldFrontendRoot, { recursive: true });
await fs.writeFile(path.join(oldFrontendRoot, 'index.html'), '<!doctype html><title>old static profile</title>');
const artifactManager = new FrontendArtifactManager(artifactRoot);
const oldArtifact = await artifactManager.stage({
frontendKey: 'che',
sourceRoot: oldFrontendRoot,
commitSha: '2222222222222222222222222222222222222222',
});
await artifactManager.activate('che', oldArtifact.releaseId);
const profile: GatewayProfileRecord = {
profileName: 'che:1010',
profile: 'che',
@@ -149,13 +161,16 @@ describe('profile DEPLOY operation', () => {
const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend'));
const running = new Set(processNames);
const startedDefinitions: Array<Parameters<ProcessManager['start']>[0]> = [];
const frontendDuringStop: string[] = [];
const processManager: ProcessManager = {
list: async () => [...running].map((name) => ({ name, status: 'online' })),
start: async (definition) => {
startedDefinitions.push(definition);
running.add(definition.name);
},
stop: async () => {},
stop: async () => {
frontendDuringStop.push(await fs.readFile(path.join(artifactRoot, 'che', 'current', 'index.html'), 'utf8'));
},
delete: async (name) => {
running.delete(name);
},
@@ -190,7 +205,7 @@ describe('profile DEPLOY operation', () => {
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
frontendServeMode: 'static',
frontendArtifactRoot: path.join(workspace, 'artifact-volume'),
frontendArtifactRoot: artifactRoot,
frontendReadinessOrigin: 'http://caddy',
baseEnv: {
GATEWAY_DATABASE_URL:
@@ -271,8 +286,10 @@ describe('profile DEPLOY operation', () => {
expect(logs.map((entry) => entry.message).join('\n')).not.toContain('pass@integration.invalid');
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]');
expect([...running].sort()).toEqual([...backendProcessNames].sort());
expect(frontendDuringStop).toHaveLength(processNames.length);
expect(frontendDuringStop.every((html) => html.includes('old static profile'))).toBe(true);
expect(
await fs.readFile(path.join(workspace, 'artifact-volume', 'che', 'current', 'index.html'), 'utf8')
await fs.readFile(path.join(artifactRoot, 'che', 'current', 'index.html'), 'utf8')
).toContain('/gateway/profile-assets/');
});
});
+13 -2
View File
@@ -185,9 +185,12 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
1. 대상 commit의 game API target과 그 transitive engine/worker artifact를 빌드한 뒤,
profile frontend typecheck와 bundle을 공유 Turbo cache에서 복원하거나 생성합니다.
2. 기존 profile PM2 process를 정지합니다.
2. 정적 운영 모드에서는 현재 frontend artifact를 계속 게시한 채 기존 profile PM2
process를 정지합니다. Preview 개발 모드와 명시적 STOP/RESET의 frontend 종료 계약은
바꾸지 않습니다.
3. profile game schema에 `prisma migrate deploy`를 실행합니다.
4. Scenario seed를 실행하지 않고 frontend, API, daemon과 worker를 시작합니다.
4. Scenario seed를 실행하지 않고 API, daemon과 worker를 시작하고 새 정적 frontend
artifact를 원자적으로 활성화합니다.
5. HTTP와 모든 PM2 role의 readiness가 확인된 뒤 build commit을 게시합니다.
이 모드는 현재 scenario, status와 인게임 DB를 유지합니다. Migration이
@@ -220,6 +223,14 @@ Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습
표시합니다. 정상 응답을 한 번 받은 profile은 실패한 지도·인증 재확인 중에도
마지막 상세를 유지합니다.
열린 game frontend도 502/503/504 또는 network failure를 인증 실패와 분리하여 현재
화면을 유지하고 상단에 연결 복구 안내를 표시합니다. 0.5·1·2·4초 뒤 읽기 전용
`lobby.info`만 재시도하며 이후에도 4초 상한을 유지합니다. 다른 조회의 우연한 성공은
복구 완료로 간주하지 않고 이 probe가 성공해야 안내를 닫습니다. Mutation은 자동
재전송하지 않으며, main dashboard가 열린 경우 복구 직후 기존 bounded refresh queue로
전체 projection을 한 번 다시 읽습니다. 401/403과 일반 500 제품 오류는 이 배포 복구
상태로 분류하지 않습니다.
### 시나리오 초기화
시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때