merge: 배포 중 화면 유지와 자동 재연결 반영

This commit is contained in:
2026-08-27 02:09:24 +00:00
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', 'npcPossession.spec.ts',
'joinLayout.spec.ts', 'joinLayout.spec.ts',
'deploymentVersionNotice.spec.ts', 'deploymentVersionNotice.spec.ts',
'connectionRecovery.spec.ts',
], ],
fullyParallel: false, fullyParallel: false,
workers: 1, 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('장 수 선 택'); await expect(page.locator('.page-title')).toContainText('장 수 선 택');
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_valid'); expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_valid');
expect(statusRequests).toBe(1); expect(statusRequests).toBe(1);
expect(lobbyRequests).toBe(1); expect(lobbyRequests).toBeGreaterThanOrEqual(1);
expect(gatewayRequests).toBe(0); expect(gatewayRequests).toBe(0);
}); });
+2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue'; import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice'; import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
@@ -8,6 +9,7 @@ useDeploymentVersionNotice();
<template> <template>
<RouterView /> <RouterView />
<GameServerConnectionNotice />
<GameFeedbackLayer /> <GameFeedbackLayer />
</template> </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 { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant'; import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { markGameServerContact } from '../utils/gameServerActivity'; import { markGameServerContact } from '../utils/gameServerActivity';
import { GAME_SERVER_RECONNECTED_EVENT } from '../utils/gameServerConnection';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig'; import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -1261,12 +1262,19 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator()); void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
}; };
const handleGameServerReconnected = () => {
if (!realtimeActive.value || document.visibilityState === 'hidden') return;
realtimeRefreshQueue.beginCooldown();
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
};
const startRealtime = () => { const startRealtime = () => {
if (typeof window === 'undefined' || realtimeActive.value) return; if (typeof window === 'undefined' || realtimeActive.value) return;
realtimeActive.value = true; realtimeActive.value = true;
realtimeRefreshQueue.beginCooldown(); realtimeRefreshQueue.beginCooldown();
if (!visibilityListenerInstalled) { if (!visibilityListenerInstalled) {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener(GAME_SERVER_RECONNECTED_EVENT, handleGameServerReconnected);
visibilityListenerInstalled = true; visibilityListenerInstalled = true;
} }
reconcileRealtimeCoordinator(); reconcileRealtimeCoordinator();
@@ -1279,6 +1287,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
closeRealtimeCoordinator(); closeRealtimeCoordinator();
if (visibilityListenerInstalled) { if (visibilityListenerInstalled) {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener(GAME_SERVER_RECONNECTED_EVENT, handleGameServerReconnected);
visibilityListenerInstalled = false; visibilityListenerInstalled = false;
} }
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; 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 { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant'; import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
import { markGameServerContact } from './gameServerActivity'; import { markGameServerContact } from './gameServerActivity';
import {
canConfirmGameServerRecovery,
gameServerConnection,
isAbortedGameServerRequest,
isGameServerRecoveryRequest,
isRetryableGameServerStatus,
markGameServerConnectionFailure,
markGameServerConnectionReady,
} from './gameServerConnection';
const getGameToken = (): string | null => { const getGameToken = (): string | null => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -20,9 +29,24 @@ export const trpc = createTRPCProxyClient<AppRouter>({
url: gameFrontendRuntimeConfig.gameApiUrl, url: gameFrontendRuntimeConfig.gameApiUrl,
...trpcJsonBodyHttpClientOptions, ...trpcJsonBodyHttpClientOptions,
async fetch(input, init) { async fetch(input, init) {
const result = await globalThis.fetch(input, init); try {
markGameServerContact(); const result = await globalThis.fetch(input, init);
return result; 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 }) { headers({ opList }) {
const token = getGameToken(); 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 this.appendOperationLog(operationId, 'switch', '기존 profile process를 정지합니다.');
await assertLease(); await assertLease();
await this.stopProfile(profile, assertLease); await this.stopProfile(profile, assertLease, { preserveStaticFrontend: true });
oldRuntimeStopped = true; oldRuntimeStopped = true;
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile); const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
await this.appendOperationLog(operationId, 'migration', '선택 버전의 game migration을 적용합니다.'); await this.appendOperationLog(operationId, 'migration', '선택 버전의 game migration을 적용합니다.');
@@ -2821,7 +2821,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return false; 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 frontendName = buildProcessName(profile.profileName, 'frontend');
const apiName = buildProcessName(profile.profileName, 'api'); const apiName = buildProcessName(profile.profileName, 'api');
const daemonName = buildProcessName(profile.profileName, 'daemon'); const daemonName = buildProcessName(profile.profileName, 'daemon');
@@ -2829,7 +2833,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament'); const tournamentName = buildProcessName(profile.profileName, 'tournament');
await assertLease?.(); 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 this.artifactManager.deactivate(profile.profile);
} }
await assertLease?.(); await assertLease?.();
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js'; import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import type { BuildCommand } from '../src/orchestrator/buildRunner.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 { ProcessManager } from '../src/orchestrator/processManager.js';
import type { import type {
GatewayClaimedProfileUpdate, GatewayClaimedProfileUpdate,
@@ -62,6 +63,17 @@ afterEach(async () => {
describe('profile DEPLOY operation', () => { describe('profile DEPLOY operation', () => {
it('migrates and atomically switches a static frontend without executing the reset seed path', async () => { it('migrates and atomically switches a static frontend without executing the reset seed path', async () => {
const workspace = await createReleaseWorkspace(); 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 = { const profile: GatewayProfileRecord = {
profileName: 'che:1010', profileName: 'che:1010',
profile: 'che', profile: 'che',
@@ -149,13 +161,16 @@ describe('profile DEPLOY operation', () => {
const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend')); const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend'));
const running = new Set(processNames); const running = new Set(processNames);
const startedDefinitions: Array<Parameters<ProcessManager['start']>[0]> = []; const startedDefinitions: Array<Parameters<ProcessManager['start']>[0]> = [];
const frontendDuringStop: string[] = [];
const processManager: ProcessManager = { const processManager: ProcessManager = {
list: async () => [...running].map((name) => ({ name, status: 'online' })), list: async () => [...running].map((name) => ({ name, status: 'online' })),
start: async (definition) => { start: async (definition) => {
startedDefinitions.push(definition); startedDefinitions.push(definition);
running.add(definition.name); running.add(definition.name);
}, },
stop: async () => {}, stop: async () => {
frontendDuringStop.push(await fs.readFile(path.join(artifactRoot, 'che', 'current', 'index.html'), 'utf8'));
},
delete: async (name) => { delete: async (name) => {
running.delete(name); running.delete(name);
}, },
@@ -190,7 +205,7 @@ describe('profile DEPLOY operation', () => {
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:15001', gatewayInternalApiUrl: 'http://127.0.0.1:15001',
frontendServeMode: 'static', frontendServeMode: 'static',
frontendArtifactRoot: path.join(workspace, 'artifact-volume'), frontendArtifactRoot: artifactRoot,
frontendReadinessOrigin: 'http://caddy', frontendReadinessOrigin: 'http://caddy',
baseEnv: { baseEnv: {
GATEWAY_DATABASE_URL: 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')).not.toContain('pass@integration.invalid');
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]'); expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]');
expect([...running].sort()).toEqual([...backendProcessNames].sort()); expect([...running].sort()).toEqual([...backendProcessNames].sort());
expect(frontendDuringStop).toHaveLength(processNames.length);
expect(frontendDuringStop.every((html) => html.includes('old static profile'))).toBe(true);
expect( 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/'); ).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를 빌드한 뒤, 1. 대상 commit의 game API target과 그 transitive engine/worker artifact를 빌드한 뒤,
profile frontend typecheck와 bundle을 공유 Turbo cache에서 복원하거나 생성합니다. 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`를 실행합니다. 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을 게시합니다. 5. HTTP와 모든 PM2 role의 readiness가 확인된 뒤 build commit을 게시합니다.
이 모드는 현재 scenario, status와 인게임 DB를 유지합니다. Migration이 이 모드는 현재 scenario, status와 인게임 DB를 유지합니다. Migration이
@@ -220,6 +223,14 @@ Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습
표시합니다. 정상 응답을 한 번 받은 profile은 실패한 지도·인증 재확인 중에도 표시합니다. 정상 응답을 한 번 받은 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로 현 시즌 데이터를 교체할 때 시나리오 초기화는 새 시즌이나 새 scenario로 현 시즌 데이터를 교체할 때