diff --git a/docs/dtryx-relay.md b/docs/dtryx-relay.md index cc86a65b..c16bc140 100644 --- a/docs/dtryx-relay.md +++ b/docs/dtryx-relay.md @@ -53,3 +53,8 @@ node --max-old-space-size=128 --env-file=/absolute/path/dtryx.env \ 먼저 로컬 인증 health와 세 API를 확인한 뒤 Access를 통한 같은 요청, 운영 REST/MCP를 확인한다. 올리브영의 health·실제 조회·탭 수를 함께 확인한다. 강제 종료 후 LaunchAgent 재시작 및 사용량 원장 보존을 확인한다. 중계를 중단할 때는 Worker의 디트릭스 설정을 제거하면 직접 요청으로 돌아가지만, 원본 연결 장애가 재현될 수 있다. 토큰을 Worker/GitHub/로컬에서 함께 교체한다. 올리브영 토큰이나 상태 파일을 덮어쓰지 않는다. + + +## 일본 상품 상세의 외부 호출 제한 + +일본 공식 상품 JSON이429를 반환하면 같은 Mac 중계의 모든 일본 상품 상세 요청이 대기 시간을 공유합니다. Retry-After를 읽고, 안내가 없으면60초 대기하며 최소1초·최대24시간으로 제한합니다. 인증된 캐시 우회도 원본 제한 대기는 우회하지 않습니다. 기존 성공 응답 캐시는 기존 정책대로 유지합니다. 이 대기는 정상 품절이나 자체 일일/분당 한도를 뜻하지 않습니다. 실패는502로 유지하며 x-relay-upstream-status:429를 Worker 공개 진단의 upstreamStatus에 전달합니다. 이 처리는 원본429를 없애거나 검사를 성공으로 바꾸지 않고 중복 원본 호출을 줄이는 조치입니다. diff --git a/docs/oliveyoung-http-relay.md b/docs/oliveyoung-http-relay.md index c4f2a73d..9e41624e 100644 --- a/docs/oliveyoung-http-relay.md +++ b/docs/oliveyoung-http-relay.md @@ -53,3 +53,16 @@ HTTP 모드의 health에 `lastFailure: { status, at }`가 추가됩니다. `stat 이 확인은 기존 종료 기한 안에서 한 번만 공유합니다. 확인이 끝나면 다음 요청에서 새 브라우저를 준비할 수 있지만, 처음 실패한 원본 요청을 성공으로 바꾸거나 자동으로 무한 재시도하지 않습니다. 인증 자료와 누적 호출 원장은 수정하지 않으며, 원본의 간헐적 차단·지연을 해소한다는 의미도 아닙니다. 이미 실패 상태로 남은 구버전 프로세스는 먼저 유휴 상태를 확인해 정상 종료합니다. 남은 마커의 감시자 PID·브라우저 PID·원래 그룹·전용 crashpad 프로세스가 모두 없는지 확인한 뒤 마커를 보존 이동하고 LaunchAgent를 다시 시작합니다. 소유권이 불명확하면 다른 브라우저를 종료하거나 마커를 삭제하지 않습니다. 복구 후 캐시 우회 실제 조회와 CLI/MCP, 정기 경로 스냅샷을 확인합니다. + + +## 2026-10-25 반복 종료 장애와 상시 복구 + +일반 HTTP 중계는 현재도 공식 상품 검색에서 HTTP403을 반환했습니다. 별도 격리 Chrome의 headless 실행도 공식 메인 페이지와 상품 API에서403이 확인됐습니다. 창 없는 실행이나 HTTP 전환이 검증되기 전에는 정상인 browser 모드를 유지합니다. Chrome 창을 닫으면 해당 세션의 조회에 영향을 줄 수 있습니다. 개인 Chrome 프로필이나 쿠키는 사용하지 않습니다. + +브라우저 교체 시 종료 확인이 제한 시간을 넘기면 원래 요청은 실패로 남깁니다. 이후 감시자의 정상 종료가 확인됐거나, 숫자형 비정상 종료 후 기존 PID·그룹·전용 crashpad 및 마커의 소멸/동일성 검증을 다시 통과한 경우에만 다음 작업에서 세션을 재생성합니다. 시그널 종료·살아 있는 감시자·불확실한 소유권은 계속 차단합니다. 원래 종료 기한을 늘리거나 재고0으로 대체하지 않습니다. + +Mac 중계는 기존60초 heartbeat에서 로컬 수명 상태를 확인합니다. failed/idle이고 작업 중이 아닐 때만 준비를 시도하며 동시 시도를 공유합니다. 실패하면1분,2분,4분,8분,최대15분 간격으로 대기합니다. 이 감시는 상품·재고 API를 호출하지 않으며, Worker의5분 경로 선택 및 GitHub 원본 조회 검사를 대체하지 않습니다. 실제 복구 뒤 운영 fresh 조회와 CLI/MCP 검증은 별도로 필요합니다. 로그는 recovery-started/recovery-ready/recovery-failed/cleanup-recovered 같은 고정된 사유만 남기고 인증/오류 원문을 저장하지 않습니다. + +일반 사용자 요청과 자동 준비도 같은 수명 계층의 실패 대기(retryAt)를 적용합니다. 요청을 반복해도 대기를 연장하거나 우회하지 않습니다. 부모 프로세스가 재시작되어 메모리 기록이 없는 경우에는 private 마커의 정수형PID·부모PID·그룹·생성시각·전용crashpad 형식과 파일 소유자·inode/device를 확인하고, 현재 관련 프로세스가 모두 없을 때만 원본 기록을 보존 이동합니다. 시작 중 기록처럼 브라우저 정체가 없는 마커는 계속 차단합니다. 이 복구에서는 프로세스를 추정해 종료하지 않습니다. + +동시 재시작·종료 복구는 마커 경로의 SHA-256으로 정한 loopback TCP 포트(49152~65535)의 OS lease로 직렬화합니다. 시작 검증부터 감시자의 새 마커 획득까지와 후속 소멸 재검증/보존 이동에 같은 lease를 사용합니다. 임시 리스너는 외부 인터페이스에 노출하지 않고 연결을 즉시 닫습니다. 충돌·사용 중이면 소유권을 추정하지 않고 실패로 유지합니다. lease는 작업 완료 또는 부모 프로세스 종료 때 OS가 해제하므로 별도 stale lock 파일은 남기지 않습니다. diff --git a/scripts/relay/daiso-jp.ts b/scripts/relay/daiso-jp.ts index 61585a43..f4669f07 100644 --- a/scripts/relay/daiso-jp.ts +++ b/scripts/relay/daiso-jp.ts @@ -1,22 +1,52 @@ /** 고정 일본 다이소 상품 JSON만 제공하며 임의 URL을 받지 않습니다. */ import { parseProductDetail } from '../../src/services/daisoJp/parser.js'; -import { createHttpRelay, RelayError, abortable, readJson, type HttpRelayOptions } from './http-relay.js'; -export function createJapanProductRelay(token: string, options: Pick) { - return createHttpRelay(token, { - ...options, prefix: '/v1/dtryx/', operations: ['daiso-jp-product'], cacheTtl: () => 1800000, - validate(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400); - const p = value as Record; - if (Object.keys(p).length !== 1 || typeof p.id !== 'string' || !/^\d{13}$/.test(p.id)) throw new RelayError(400); - return { id: p.id }; - }, - async upstream(_operation, value, signal, fetcher) { - const { id } = value as { id: string }; - const response = await abortable(fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { redirect: 'manual', signal }), signal); - if (response.status !== 200) { void response.body?.cancel().catch(() => undefined); throw new RelayError(502, undefined, undefined, response.status); } - const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); - parseProductDetail(data, id); - return data; - }, - }); +import { parseRetryAfterDelayMs } from '../../src/utils/http.js'; +import { + createHttpRelay, + RelayError, + abortable, + readJson, + type HttpRelayOptions, +} from './http-relay.js'; +export function createJapanProductRelay( + token: string, + options: Pick, +) { + let blockedUntil = 0; + return createHttpRelay(token, { + ...options, + prefix: '/v1/dtryx/', + operations: ['daiso-jp-product'], + cacheTtl: () => 1800000, + validate(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400); + const p = value as Record; + if (Object.keys(p).length !== 1 || typeof p.id !== 'string' || !/^\d{13}$/.test(p.id)) + throw new RelayError(400); + return { id: p.id }; + }, + async upstream(_operation, value, signal, fetcher) { + // 원본 제한은 품절이나 자체 할당량으로 바꾸지 않고 모든 상품에 대기를 공유합니다. + if (Date.now() < blockedUntil) throw new RelayError(502, undefined, undefined, 429); + const { id } = value as { id: string }; + const response = await abortable( + fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { + redirect: 'manual', + signal, + }), + signal, + ); + if (response.status === 429) + blockedUntil = + Date.now() + + Math.max(1000, Math.min(parseRetryAfterDelayMs(response) ?? 60000, 86400000)); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError(502, undefined, undefined, response.status); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + parseProductDetail(data, id); + return data; + }, + }); } diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index f637b8b0..a16b338d 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -6,6 +6,7 @@ export interface BrowserOwner { browser: Pick; pid: number; close(deadline?: number): Promise; + recover?(deadline: number): Promise; rss(): Promise; } export async function bounded(work: Promise, ms: number): Promise { @@ -39,6 +40,7 @@ export function createBrowserLifecycle( let closed = false; let failed: unknown; let cleanup: Promise = Promise.resolve(); + let retired: BrowserOwner | undefined; let born = 0; let calls = 0; let totalCalls = 0; @@ -48,6 +50,16 @@ export function createBrowserLifecycle( let timer: ReturnType | undefined; let initializing: Promise | undefined; let replacementPending = false; + let failures = 0; + let retryAt = 0; + let lastFailure: unknown; + const backoffError = new Error('Browser recovery backoff'); + const defer = (error: unknown) => { + if (closed || error === backoffError || error === lastFailure) return; + lastFailure = error; + retryAt = Date.now() + Math.min(900000, 60000 * 2 ** Math.min(failures++, 4)); + emit('recovery-backoff'); + }; const retire = (why: string) => { reason = why; emit(why); @@ -64,6 +76,7 @@ export function createBrowserLifecycle( const deadline = Date.now() + 10000; cleanup = bounded(previous.close(deadline), Math.max(0, deadline - Date.now())).catch( (error) => { + retired = previous; failed = error; throw error; }, @@ -76,7 +89,22 @@ export function createBrowserLifecycle( void retire(why).catch(() => undefined); }; const ensureSession = async () => { - await cleanup; + if (closed) throw new Error('Browser lifecycle unavailable'); + if (!owner && Date.now() < retryAt) throw backoffError; + // 같은 실패의 ensure→run 전파만 공유하고 새 복구 시도는 별도로 계산합니다. + lastFailure = undefined; + try { + await cleanup; + } catch (error) { + if (closed || !retired?.recover) throw error; + const deadline = Date.now() + 5000; + if (!(await bounded(retired.recover(deadline), Math.max(0, deadline - Date.now())))) + throw error; + retired = undefined; + failed = undefined; + cleanup = Promise.resolve(); + emit('cleanup-recovered'); + } if (closed) throw new Error('Browser lifecycle unavailable'); if (owner && Date.now() - born >= 30 * 60 * 1000) { await retire('rotation'); @@ -142,9 +170,14 @@ export function createBrowserLifecycle( } }; const ensure = () => { - initializing ??= ensureSession().finally(() => { - initializing = undefined; - }); + initializing ??= ensureSession() + .catch((error) => { + defer(error); + throw error; + }) + .finally(() => { + initializing = undefined; + }); return initializing; }; // 교체당 한 번만 미리 준비하고 실패 시 다음 명시적 요청을 기다립니다. @@ -163,6 +196,9 @@ export function createBrowserLifecycle( try { await ensure(); const result = await bounded(createBrowserRunner(page!)(path, body), 18000); + failures = 0; + retryAt = 0; + lastFailure = undefined; calls++; totalCalls++; if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) { @@ -172,6 +208,7 @@ export function createBrowserLifecycle( } return result; } catch (error) { + defer(error); await retire('request-failed'); throw error; } finally { @@ -205,6 +242,7 @@ export function createBrowserLifecycle( rssBytes, pages: context?.pages().length || 0, reason, + retryAt, }), }; } diff --git a/scripts/relay/marker-lease.ts b/scripts/relay/marker-lease.ts new file mode 100644 index 00000000..95c01e11 --- /dev/null +++ b/scripts/relay/marker-lease.ts @@ -0,0 +1,23 @@ +/** 파일 잠금 대신 OS가 종료 시 해제하는 로컬 포트로 마커 변경을 직렬화합니다. */ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:net'; +import { realpath } from 'node:fs/promises'; +import { dirname, basename, join } from 'node:path'; +export function markerLeasePort(markerPath: string): number { + return 49152 + (createHash('sha256').update(markerPath).digest().readUInt16BE(0) % 16384); +} +export async function acquireMarkerLease( + markerPath: string, +): Promise<(() => Promise) & { markerPath: string }> { + markerPath = join(await realpath(dirname(markerPath)), basename(markerPath)); + const server = createServer((socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(markerLeasePort(markerPath), '127.0.0.1', resolve); + }); + let closing: Promise | undefined; + return Object.assign( + () => (closing ??= new Promise((resolve) => server.close(() => resolve()))), + { markerPath }, + ); +} diff --git a/scripts/relay/recover-abandoned-guard.ts b/scripts/relay/recover-abandoned-guard.ts new file mode 100644 index 00000000..58e94ab0 --- /dev/null +++ b/scripts/relay/recover-abandoned-guard.ts @@ -0,0 +1,51 @@ +/** 재시작 전 마커도 파일 정체와 소유 프로세스의 완전 소멸을 확인합니다. */ +import { constants } from 'node:fs'; +import { open } from 'node:fs/promises'; +import { bounded } from './lifecycle.js'; +import { processSnapshot, type ProcessIdentity } from './ownership.js'; +import { recoverExitedGuard } from './recover-exited-guard.js'; +export async function recoverAbandonedGuard( + markerPath: string, + deadline: number, + snapshot = processSnapshot, +): Promise { + const remaining = () => Math.max(0, deadline - Date.now()); + try { + const handle = await open( + markerPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const stat = await bounded(handle.stat(), remaining()); + if ( + !stat.isFile() || + stat.size > 16384 || + stat.uid !== process.getuid!() || + stat.mode & 0o077 + ) + return false; + const marker = JSON.parse(await bounded(handle.readFile('utf8'), remaining())); + const root = marker?.browser as ProcessIdentity | undefined; + if ( + !root || + !Number.isSafeInteger(marker.guardPid) || + marker.guardPid <= 1 || + !Number.isSafeInteger(root.pid) || + root.pid <= 1 || + root.pgid !== root.pid || + root.ppid !== marker.guardPid || + typeof root.birth !== 'string' || + !root.birth || + typeof root.crashDir !== 'string' || + !root.crashDir.startsWith(markerPath + '.') || + !root.crashDir.endsWith('.crashpad') + ) + return false; + return await recoverExitedGuard(markerPath, marker.guardPid, root, deadline, snapshot, stat); + } finally { + await handle.close(); + } + } catch { + return false; + } +} diff --git a/scripts/relay/recover-exited-guard.ts b/scripts/relay/recover-exited-guard.ts index b0095080..52018381 100644 --- a/scripts/relay/recover-exited-guard.ts +++ b/scripts/relay/recover-exited-guard.ts @@ -10,6 +10,7 @@ export async function recoverExitedGuard( root: ProcessIdentity, deadline: number, snapshot = processSnapshot, + expected?: { dev: number; ino: number }, ): Promise { const remaining = () => Math.max(0, deadline - Date.now()); try { @@ -20,6 +21,7 @@ export async function recoverExitedGuard( ); try { const stat = await bounded(handle.stat(), remaining()); + if (expected && (expected.dev !== stat.dev || expected.ino !== stat.ino)) return false; if (!stat.isFile() || stat.size > 16384 || stat.uid !== process.getuid!()) return false; const marker = JSON.parse(await bounded(handle.readFile('utf8'), remaining())); if (marker?.guardPid !== guardPid) return false; @@ -31,7 +33,8 @@ export async function recoverExitedGuard( const current = await bounded(lstat(markerPath), remaining()); if (current.dev !== stat.dev || current.ino !== stat.ino) return false; if (!remaining()) return false; - await bounded(rename(markerPath, `${markerPath}.exited-${randomUUID()}`), remaining()); + // 완료 전 잠금을 해제하지 않도록 커밋 중인 파일 이동 자체는 기다립니다. + await rename(markerPath, `${markerPath}.exited-${randomUUID()}`); return true; } finally { await handle.close(); diff --git a/scripts/relay/start.ts b/scripts/relay/start.ts index 3eaf08fc..a7297c6f 100644 --- a/scripts/relay/start.ts +++ b/scripts/relay/start.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { createTransportLifecycle } from './transport-lifecycle.js'; +import { createRelayWatchdog } from './watchdog.js'; import { createFileQuota } from './quota.js'; import { createOliveyoungRelay } from './oliveyoung.js'; @@ -25,11 +26,6 @@ async function main() { log.append({ stage: 'process', outcome: 'starting' }); const takeQuota = await createFileQuota(join(stateDir, 'quota.json')); log.append({ stage: 'quota', outcome: 'snapshot', ...takeQuota.status() }); - const heartbeat = setInterval( - () => log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() }), - 60000, - ); - heartbeat.unref(); const lifecycle = await createTransportLifecycle( process.env.OY_RELAY_TRANSPORT, stateDir, @@ -40,6 +36,14 @@ async function main() { outcome, }), ); + const recover = createRelayWatchdog(lifecycle, (outcome) => + log.append({ stage: 'lifecycle', outcome }), + ); + const heartbeat = setInterval(() => { + log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() }); + void recover(); + }, 60000); + heartbeat.unref(); try { await lifecycle.start(); const handler = observeRelay( diff --git a/scripts/relay/supervisor.ts b/scripts/relay/supervisor.ts index 86dde954..49464e3e 100644 --- a/scripts/relay/supervisor.ts +++ b/scripts/relay/supervisor.ts @@ -1,19 +1,68 @@ /** 독립 감시 프로세스와 연결하고 소유 브라우저의 종료 확인을 기다립니다. */ import { recoverExitedGuard } from './recover-exited-guard.js'; +import { recoverAbandonedGuard } from './recover-abandoned-guard.js'; +import { existsSync } from 'node:fs'; +import { acquireMarkerLease } from './marker-lease.js'; import { fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { bounded, type BrowserOwner } from './lifecycle.js'; import { ownedGroup, processSnapshot, type ProcessIdentity } from './ownership.js'; -export async function launchGuardedBrowser(markerPath: string): Promise { +export async function launchGuardedBrowser( + markerPath: string, + onEvent?: (reason: string) => void | Promise, +): Promise { + const release = await acquireMarkerLease(markerPath); + markerPath = release.markerPath; + let starting = true; + const serial = async (action: () => Promise) => { + if (starting) return action(); + const unlock = await acquireMarkerLease(markerPath); + try { + return await action(); + } finally { + await unlock(); + } + }; + try { + return await launchWhileLocked(markerPath, serial, onEvent); + } finally { + starting = false; + await release(); + } +} +async function launchWhileLocked( + markerPath: string, + serial: (action: () => Promise) => Promise, + onEvent?: (reason: string) => void | Promise, +): Promise { + const emit = (reason: string) => { + try { + void Promise.resolve(onEvent?.(reason)).catch(() => undefined); + } catch { + /* 관측 실패를 격리합니다. */ + } + }; + if (existsSync(markerPath)) { + if (!(await recoverAbandonedGuard(markerPath, Date.now() + 5000))) { + emit('abandoned-marker-blocked'); + throw new Error('Existing browser ownership unconfirmed'); + } + emit('abandoned-marker-recovered'); + } const guard = fork(fileURLToPath(new URL('./guard.ts', import.meta.url)), [markerPath], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], }); let exited = false; + let exitCode: number | null | undefined; const exit = new Promise((resolve) => guard.once('exit', (code) => { exited = true; + exitCode = code; + emit( + code === 0 ? 'guard-exit-normal' : code === null ? 'guard-exit-signal' : 'guard-exit-error', + ); resolve(code); }), ); @@ -28,7 +77,7 @@ export async function launchGuardedBrowser(markerPath: string): Promise recoverExitedGuard(markerPath, guard.pid!, readyRoot!, deadline))) ) return; throw new Error('Browser guard cleanup unconfirmed'); @@ -53,6 +102,12 @@ export async function launchGuardedBrowser(markerPath: string): Promise recoverExitedGuard(markerPath, guard.pid!, ready.root, deadline)); + }, rss: async () => ownedGroup(ready.root, await processSnapshot()).reduce((sum, row) => sum + row.rss, 0), }; diff --git a/scripts/relay/transport-lifecycle.ts b/scripts/relay/transport-lifecycle.ts index a6cb7746..a21de3a9 100644 --- a/scripts/relay/transport-lifecycle.ts +++ b/scripts/relay/transport-lifecycle.ts @@ -14,7 +14,7 @@ export async function createTransportLifecycle( const { createBrowserLifecycle } = await import('./lifecycle.js'); const { launchGuardedBrowser } = await import('./supervisor.js'); return createBrowserLifecycle( - () => launchGuardedBrowser(join(stateDir, 'browser-owner.json')), + () => launchGuardedBrowser(join(stateDir, 'browser-owner.json'), onEvent), onEvent, ); } diff --git a/scripts/relay/watchdog.ts b/scripts/relay/watchdog.ts new file mode 100644 index 00000000..c2803c43 --- /dev/null +++ b/scripts/relay/watchdog.ts @@ -0,0 +1,48 @@ +/** 원본 조회 없이 중계 수명을 감시하고 복구 실패 간격을 늘립니다. */ +interface RecoverableLifecycle { + start(): Promise; + status(): { state: string; active: boolean }; +} +export function createRelayWatchdog( + lifecycle: RecoverableLifecycle, + onEvent?: (event: string) => void, + now = Date.now, +) { + let pending: Promise | undefined; + let failures = 0; + let nextAttempt = 0; + const emit = (event: string) => { + try { + onEvent?.(event); + } catch { + /* 관측 실패는 수명 관리에 영향을 주지 않습니다. */ + } + }; + return async () => { + if (pending) return pending; + const { state, active } = lifecycle.status(); + if (state === 'ready') { + failures = 0; + nextAttempt = 0; + return; + } + if (active || (state !== 'failed' && state !== 'idle') || now() < nextAttempt) return; + pending = (async () => { + emit('recovery-started'); + try { + await lifecycle.start(); + failures = 0; + nextAttempt = now() + 60000; + emit('recovery-ready'); + } catch { + nextAttempt = now() + Math.min(900000, 60000 * 2 ** Math.min(failures++, 4)); + emit('recovery-failed'); + } + })(); + try { + await pending; + } finally { + pending = undefined; + } + }; +} diff --git a/src/services/daisoJp/productRelay.ts b/src/services/daisoJp/productRelay.ts index a41a7d15..c4634171 100644 --- a/src/services/daisoJp/productRelay.ts +++ b/src/services/daisoJp/productRelay.ts @@ -2,53 +2,114 @@ import { ServiceError } from '../../core/errors.js'; import { diagnosticHeaders } from '../../utils/diagnostics.js'; import { parseRelayQuota } from '../../utils/relayQuota.js'; -import { dtryxTransportFromBindings, isValidDtryxRelayUrl, type DtryxTransportOptions } from '../dtryx/transport.js'; +import { + dtryxTransportFromBindings, + isValidDtryxRelayUrl, + type DtryxTransportOptions, +} from '../dtryx/transport.js'; import { parseProductDetail } from './parser.js'; import type { JapanProduct } from './types.js'; -export interface JapanProductRelayOptions extends DtryxTransportOptions { bypassCache?: boolean } -export interface JapanTransportOptions { productRelay?: JapanProductRelayOptions } -export function japanProductTransportFromBindings(bindings?: { - DAISO_JP_PRODUCT_RELAY_ENABLED?: string; - DTRYX_RELAY_URL?: string; DTRYX_RELAY_TOKEN?: string; - DTRYX_ACCESS_CLIENT_ID?: string; DTRYX_ACCESS_CLIENT_SECRET?: string; -}, bypassCache = false): JapanTransportOptions { - return bindings?.DAISO_JP_PRODUCT_RELAY_ENABLED === 'true' - ? { productRelay: { ...dtryxTransportFromBindings(bindings), bypassCache } } : {}; +export interface JapanProductRelayOptions extends DtryxTransportOptions { + bypassCache?: boolean; } -export async function requestJapanProductRelay(id: string, options: JapanProductRelayOptions): Promise { - const { relayUrl, relayToken, accessClientId, accessClientSecret } = options; - if (!isValidDtryxRelayUrl(relayUrl) || !relayToken?.trim() || - ((accessClientId !== undefined || accessClientSecret !== undefined) && (!accessClientId?.trim() || !accessClientSecret?.trim()))) { - throw new ServiceError('DAISO_JP_RELAY_CONFIG_ERROR', '일본 다이소 중계 설정을 확인하세요.', 503, false); - } - const headers: Record = { ...diagnosticHeaders(), Authorization: `Bearer ${relayToken}`, 'Content-Type': 'application/json' }; - if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; - if (accessClientId && accessClientSecret) { - headers['CF-Access-Client-Id'] = accessClientId; - headers['CF-Access-Client-Secret'] = accessClientSecret; - } - const controller = new AbortController(); - let timer: ReturnType; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { controller.abort(); reject(new ServiceError('DAISO_JP_RELAY_TIMEOUT', '일본 다이소 중계 요청 시간이 초과되었습니다.', 504, true)); }, 15000); - }); - try { - const work = (async () => { - const response = await fetch(`${relayUrl!.replace(/\/$/, '')}/v1/dtryx/daiso-jp-product`, { - method: 'POST', redirect: 'manual', headers, body: JSON.stringify({ id }), signal: controller.signal, - }); - if (response.status !== 200) { - void response.body?.cancel().catch(() => undefined); - const status = response.status; - throw new ServiceError('DAISO_JP_RELAY_FAILED', '일본 다이소 중계 요청에 실패했습니다.', - status === 429 || status === 503 || status === 504 ? status : 502, - status === 429 || status >= 500, status, parseRelayQuota(status, response.headers)); - } - return parseProductDetail(await response.json(), id); - })(); - return await Promise.race([work, timeout]); - } catch (error) { - if (error instanceof ServiceError) throw error; - throw new ServiceError('DAISO_JP_RELAY_FAILED', '일본 다이소 중계 응답을 확인할 수 없습니다.', 502, true); - } finally { clearTimeout(timer!); } +export interface JapanTransportOptions { + productRelay?: JapanProductRelayOptions; +} +export function japanProductTransportFromBindings( + bindings?: { + DAISO_JP_PRODUCT_RELAY_ENABLED?: string; + DTRYX_RELAY_URL?: string; + DTRYX_RELAY_TOKEN?: string; + DTRYX_ACCESS_CLIENT_ID?: string; + DTRYX_ACCESS_CLIENT_SECRET?: string; + }, + bypassCache = false, +): JapanTransportOptions { + return bindings?.DAISO_JP_PRODUCT_RELAY_ENABLED === 'true' + ? { productRelay: { ...dtryxTransportFromBindings(bindings), bypassCache } } + : {}; +} +export async function requestJapanProductRelay( + id: string, + options: JapanProductRelayOptions, +): Promise { + const { relayUrl, relayToken, accessClientId, accessClientSecret } = options; + if ( + !isValidDtryxRelayUrl(relayUrl) || + !relayToken?.trim() || + ((accessClientId !== undefined || accessClientSecret !== undefined) && + (!accessClientId?.trim() || !accessClientSecret?.trim())) + ) { + throw new ServiceError( + 'DAISO_JP_RELAY_CONFIG_ERROR', + '일본 다이소 중계 설정을 확인하세요.', + 503, + false, + ); + } + const headers: Record = { + ...diagnosticHeaders(), + Authorization: `Bearer ${relayToken}`, + 'Content-Type': 'application/json', + }; + if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; + if (accessClientId && accessClientSecret) { + headers['CF-Access-Client-Id'] = accessClientId; + headers['CF-Access-Client-Secret'] = accessClientSecret; + } + const controller = new AbortController(); + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject( + new ServiceError( + 'DAISO_JP_RELAY_TIMEOUT', + '일본 다이소 중계 요청 시간이 초과되었습니다.', + 504, + true, + ), + ); + }, 15000); + }); + try { + const work = (async () => { + const response = await fetch(`${relayUrl!.replace(/\/$/, '')}/v1/dtryx/daiso-jp-product`, { + method: 'POST', + redirect: 'manual', + headers, + body: JSON.stringify({ id }), + signal: controller.signal, + }); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + const status = response.status; + const upstreamStatus = /^[1-5]\d{2}$/.test( + response.headers.get('x-relay-upstream-status') ?? '', + ) + ? Number(response.headers.get('x-relay-upstream-status')) + : status; + throw new ServiceError( + 'DAISO_JP_RELAY_FAILED', + '일본 다이소 중계 요청에 실패했습니다.', + status === 429 || status === 503 || status === 504 ? status : 502, + status === 429 || status >= 500, + upstreamStatus, + parseRelayQuota(status, response.headers), + ); + } + return parseProductDetail(await response.json(), id); + })(); + return await Promise.race([work, timeout]); + } catch (error) { + if (error instanceof ServiceError) throw error; + throw new ServiceError( + 'DAISO_JP_RELAY_FAILED', + '일본 다이소 중계 응답을 확인할 수 없습니다.', + 502, + true, + ); + } finally { + clearTimeout(timer!); + } } diff --git a/tests/relay/daiso-jp.test.ts b/tests/relay/daiso-jp.test.ts index 2bc803a1..5f0a8f6e 100644 --- a/tests/relay/daiso-jp.test.ts +++ b/tests/relay/daiso-jp.test.ts @@ -1,27 +1,98 @@ import { expect, it, vi } from 'vitest'; import { createJapanProductRelay } from '../../scripts/relay/daiso-jp.js'; const raw = { handle: '4550746075917', title: 'ボウル', price: 22000, available: true }; -const req = (body: unknown, token = 'test-token') => new Request('http://localhost/v1/dtryx/daiso-jp-product', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(body) }); +const req = (body: unknown, token = 'test-token') => + new Request('http://localhost/v1/dtryx/daiso-jp-product', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); it('인증된 고정 상품 JSON만 읽고 실제 상품 코드와 대조한다', async () => { - const fetcher = vi.fn(async () => Response.json(raw)); - const takeQuota = vi.fn(async () => true); - const handler = createJapanProductRelay('test-token', { fetcher, takeQuota }); - expect((await handler(req({ id: raw.handle }, 'wrong'))).status).toBe(401); - for (const body of [null, [], {}, { id: 1 }, { id: '../x' }, { id: raw.handle, url: 'https://other' }]) expect((await handler(req(body))).status).toBe(400); - const response = await handler(req({ id: raw.handle })); - expect(response.status).toBe(200); expect(await response.json()).toEqual(raw); - expect(fetcher.mock.calls[0][0].toString()).toBe('https://jp.daisonet.com/products/4550746075917.js'); - expect(takeQuota).toHaveBeenCalledTimes(1); + const fetcher = vi.fn(async () => Response.json(raw)); + const takeQuota = vi.fn(async () => true); + const handler = createJapanProductRelay('test-token', { fetcher, takeQuota }); + expect((await handler(req({ id: raw.handle }, 'wrong'))).status).toBe(401); + for (const body of [ + null, + [], + {}, + { id: 1 }, + { id: '../x' }, + { id: raw.handle, url: 'https://other' }, + ]) + expect((await handler(req(body))).status).toBe(400); + const response = await handler(req({ id: raw.handle })); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(raw); + expect(fetcher.mock.calls[0][0].toString()).toBe( + 'https://jp.daisonet.com/products/4550746075917.js', + ); + expect(takeQuota).toHaveBeenCalledTimes(1); }); it('원본 차단과 잘못된 상품을 실패로 유지한다', async () => { - for (const response of [new Response('denied', { status: 403 }), new Response(null, { status: 403 }), Response.json({ ...raw, handle: '4550746075918' })]) { - const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher: vi.fn(async () => response) }); - expect((await handler(req({ id: raw.handle }))).status).toBe(502); - } + for (const response of [ + new Response('denied', { status: 403 }), + new Response(null, { status: 403 }), + Response.json({ ...raw, handle: '4550746075918' }), + ]) { + const handler = createJapanProductRelay('test-token', { + takeQuota: async () => true, + fetcher: vi.fn(async () => response), + }); + expect((await handler(req({ id: raw.handle }))).status).toBe(502); + } }); it('원본 실패 본문의 회수 오류를 처리한다', async () => { - const fetcher = vi.fn(async () => ({ status: 403, body: { cancel: async () => { throw Error('cancel failed'); } } }) as unknown as Response); - const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); - expect((await handler(req({ id: raw.handle }))).status).toBe(502); + const fetcher = vi.fn( + async () => + ({ + status: 403, + body: { + cancel: async () => { + throw Error('cancel failed'); + }, + }, + }) as unknown as Response, + ); + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); + expect((await handler(req({ id: raw.handle }))).status).toBe(502); +}); + +it('원본429 뒤 Retry-After 동안 다른 상품과 캐시 우회도 원본을 반복하지 않는다', async () => { + vi.useFakeTimers(); + try { + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 429, headers: { 'Retry-After': '120' } })) + .mockResolvedValue(Response.json(raw)); + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); + expect((await handler(req({ id: raw.handle }))).headers.get('x-relay-upstream-status')).toBe( + '429', + ); + const bypass = req({ id: '4550746075918' }); + bypass.headers.set('x-relay-cache-bypass', 'true'); + expect((await handler(bypass)).status).toBe(502); + expect(fetcher).toHaveBeenCalledTimes(1); + vi.setSystemTime(Date.now() + 120001); + expect((await handler(req({ id: raw.handle }))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } +}); +it('원본429에 대기 안내가 없으면1분 후 원본을 재검증한다', async () => { + vi.useFakeTimers(); + try { + const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 429 })); + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); + await handler(req({ id: raw.handle })); + await handler(req({ id: raw.handle })); + expect(fetcher).toHaveBeenCalledTimes(1); + vi.setSystemTime(Date.now() + 60001); + await handler(req({ id: raw.handle })); + expect(fetcher).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } }); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts index 0ecc493a..4de46f0f 100644 --- a/tests/relay/lifecycle.test.ts +++ b/tests/relay/lifecycle.test.ts @@ -134,10 +134,12 @@ it('시작 중 종료는 늦게 도착한 소유 브라우저도 회수한다', expect(f.owner.close).toHaveBeenCalledTimes(1); }); it('초기 페이지 접속 실패와 페이지 닫힘을 회수한다', async () => { + vi.useFakeTimers(); const f = fixture(); f.page.goto.mockRejectedValueOnce(new Error('navigation')); const life = createBrowserLifecycle(f.launch); await expect(life.start()).rejects.toThrow('navigation'); + await vi.advanceTimersByTimeAsync(60001); expect(f.owner.close).toHaveBeenCalledTimes(1); await life.start(); f.page.emit('close'); @@ -400,10 +402,98 @@ it('문서가 준비됐으면 느린 부가 리소스의 load 이벤트 없이 }); }); const life = createBrowserLifecycle(f.launch); - const ready = life.start().then(() => true, () => false); + const ready = life.start().then( + () => true, + () => false, + ); await vi.advanceTimersByTimeAsync(30000); expect(await ready).toBe(true); expect(life.status()).toMatchObject({ state: 'ready', pages: 1 }); await expect(life.run('/p', {})).resolves.toEqual({ status: 'SUCCESS' }); await life.close(); }); + +it('종료 실패 뒤 소멸이 확인되면 다음 요청에서만 새 브라우저를 시작한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const recover = vi.fn().mockResolvedValue(false); + Object.assign(f.owner, { recover }); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.page.evaluate.mockRejectedValueOnce(new Error('upstream failed')); + f.owner.close.mockRejectedValueOnce(new Error('cleanup uncertain')); + await expect(life.run('/p', {})).rejects.toThrow('cleanup uncertain'); + await vi.advanceTimersByTimeAsync(60001); + await expect(life.run('/p', {})).rejects.toThrow('cleanup uncertain'); + expect(f.launch).toHaveBeenCalledTimes(1); + recover.mockResolvedValue(true); + await vi.advanceTimersByTimeAsync(120001); + await expect(life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(f.launch).toHaveBeenCalledTimes(2); + expect(life.status().state).toBe('ready'); + await life.close(); +}); + +it('watchdog와 실제 요청은 같은 시작 실패 대기를 지킨다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + f.launch.mockRejectedValueOnce(new Error('launch failed')); + await expect(life.start()).rejects.toThrow('launch failed'); + for (let i = 0; i < 3; i++) await expect(life.run('/p', {})).rejects.toThrow('backoff'); + expect(f.launch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60001); + await life.run('/p', {}); + expect(f.launch).toHaveBeenCalledTimes(2); + await life.close(); +}); + +it('소멸 재검증 중 종료하면 확인이 끝나도 새 브라우저를 만들지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + let finish!: (value: boolean) => void; + const recover = vi.fn( + () => + new Promise((r) => { + finish = r; + }), + ); + Object.assign(f.owner, { recover }); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.owner.close.mockRejectedValueOnce(new Error('uncertain')); + f.page.emit('crash'); + await vi.advanceTimersByTimeAsync(0); + const starting = life.start(); + const failed = expect(starting).rejects.toThrow('unavailable'); + await vi.advanceTimersByTimeAsync(0); + const closing = life.close(); + finish(true); + await failed; + await closing; + expect(f.launch).toHaveBeenCalledTimes(1); +}); + +it('같은 종료 오류가 반복되어도 새로운 복구 시도마다 대기를 늘린다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const recover = vi.fn().mockResolvedValue(false); + Object.assign(f.owner, { recover }); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.owner.close.mockRejectedValue(new Error('same cleanup error')); + f.page.emit('crash'); + await vi.advanceTimersByTimeAsync(0); + await expect(life.start()).rejects.toThrow('same cleanup error'); + expect(recover).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60001); + await expect(life.start()).rejects.toThrow('same cleanup error'); + expect(recover).toHaveBeenCalledTimes(2); + for (let i = 0; i < 3; i++) + await expect(life.run('/p', {})).rejects.toThrow('same cleanup error'); + expect(recover).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(120001); + await expect(life.start()).rejects.toThrow('same cleanup error'); + expect(recover).toHaveBeenCalledTimes(3); + await expect(life.close()).rejects.toThrow('same cleanup error'); +}); diff --git a/tests/relay/marker-lease.test.ts b/tests/relay/marker-lease.test.ts new file mode 100644 index 00000000..dc7237ef --- /dev/null +++ b/tests/relay/marker-lease.test.ts @@ -0,0 +1,77 @@ +import { createConnection } from 'node:net'; +import { expect, it } from 'vitest'; +import { acquireMarkerLease, markerLeasePort } from '../../scripts/relay/marker-lease.js'; +it('같은 마커의 동시 변경을 차단하고 OS 잠금 해제 뒤에만 새 시작을 허용한다', async () => { + const path = '/private/tmp/oy-lease-test-' + process.pid; + const release = await acquireMarkerLease(path); + try { + await expect(acquireMarkerLease(path)).rejects.toMatchObject({ code: 'EADDRINUSE' }); + await new Promise((resolve, reject) => { + const socket = createConnection(markerLeasePort(path), '127.0.0.1'); + socket.on('error', reject); + socket.on('close', () => resolve()); + }); + } finally { + await Promise.all([release(), release()]); + } + const next = await acquireMarkerLease(path); + await next(); + expect(markerLeasePort(path)).toBe(markerLeasePort(path)); +}); + +it('부모가 강제 종료되어도 파일 잠금 없이 OS가 포트를 해제한다', async () => { + const { fork } = await import('node:child_process'); + const { writeFile, rm, mkdtemp } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join, resolve } = await import('node:path'); + const dir = await mkdtemp(join(tmpdir(), 'oy-lease-process-')); + const p = join(dir, 'child.mjs'); + const marker = join(dir, 'owner.json'); + const modulePath = new URL('file://' + resolve('scripts/relay/marker-lease.ts')).href; + await writeFile( + p, + `import {acquireMarkerLease} from ${JSON.stringify(modulePath)};await acquireMarkerLease(${JSON.stringify(marker)});process.send('ready');`, + ); + const child = fork(p, [], { + execArgv: ['--import', resolve('node_modules/tsx/dist/loader.mjs')], + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + try { + await new Promise((resolve, reject) => { + child.once('message', () => resolve()); + child.once('error', reject); + child.once('exit', () => reject(Error('child exited before ready'))); + }); + await expect(acquireMarkerLease(marker)).rejects.toMatchObject({ code: 'EADDRINUSE' }); + const exited = new Promise((resolve) => child.once('exit', () => resolve())); + child.kill('SIGKILL'); + await exited; + const release = await acquireMarkerLease(marker); + await release(); + } finally { + child.kill('SIGKILL'); + await rm(dir, { recursive: true, force: true }); + } +}); + +it('같은 디렉터리의 별칭 경로도 동일한 OS 잠금을 사용한다', async () => { + const { mkdtemp, symlink, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = await mkdtemp(join(tmpdir(), 'oy-lease-alias-')); + const alias = dir + '-alias'; + try { + await symlink(dir, alias); + const release = await acquireMarkerLease(join(dir, 'owner.json')); + try { + await expect(acquireMarkerLease(join(alias, 'owner.json'))).rejects.toMatchObject({ + code: 'EADDRINUSE', + }); + } finally { + await release(); + } + } finally { + await rm(alias); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/relay/recover-abandoned-guard.test.ts b/tests/relay/recover-abandoned-guard.test.ts new file mode 100644 index 00000000..0c656c55 --- /dev/null +++ b/tests/relay/recover-abandoned-guard.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, writeFile, rm, readFile, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { recoverAbandonedGuard } from '../../scripts/relay/recover-abandoned-guard.js'; +it('재시작 뒤에도 소유 기록과 전체 프로세스 소멸을 검증하여 보존 이동한다', async () => { + const dir = await mkdtemp(join(tmpdir(), 'oy-startup-')); + const p = join(dir, 'owner.json'); + const root = { + pid: 123, + pgid: 123, + ppid: 122, + rss: 0, + birth: 'birth', + crashDir: p + '.unique.crashpad', + }; + const marker = { guardPid: 122, browser: root }; + try { + await writeFile(p, JSON.stringify(marker), { mode: 0o600 }); + const snapshot = vi + .fn() + .mockResolvedValueOnce([{ ...root, pid: 122 }]) + .mockResolvedValue([]); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + expect(JSON.parse(await readFile(p, 'utf8'))).toEqual(marker); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(true); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + for (const value of [ + null, + {}, + { guardPid: 0, browser: root }, + { guardPid: 122, browser: { ...root, pgid: 1 } }, + { guardPid: 122, browser: { ...root, ppid: 1 } }, + { guardPid: 122, browser: { ...root, birth: '' } }, + { guardPid: 122, browser: { ...root, crashDir: 'other' } }, + ]) { + await writeFile(p, JSON.stringify(value), { mode: 0o600 }); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + } + await rm(p); + await symlink(join(dir, 'target'), p); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +it('공개 쓰기 가능하거나 크기가 큰 기록은 프로세스 검사 전에 차단한다', async () => { + const { chmod, mkdir } = await import('node:fs/promises'); + const dir = await mkdtemp(join(tmpdir(), 'oy-unsafe-startup-')); + const p = join(dir, 'owner.json'); + const snapshot = vi.fn(); + try { + await mkdir(p); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + await rm(p, { recursive: true }); + await writeFile(p, ' '.repeat(16385), { mode: 0o600 }); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + await writeFile(p, '{}'); + await chmod(p, 0o666); + expect(await recoverAbandonedGuard(p, Date.now() + 5000, snapshot)).toBe(false); + expect(snapshot).not.toHaveBeenCalled(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/relay/recover-exited-guard.test.ts b/tests/relay/recover-exited-guard.test.ts index f14b6d28..feea8426 100644 --- a/tests/relay/recover-exited-guard.test.ts +++ b/tests/relay/recover-exited-guard.test.ts @@ -159,3 +159,10 @@ it('FIFO 마커도 파일 열기에서 멈추지 않고 거절한다', async () execFileSync('/usr/bin/mkfifo', [path]); expect(await recoverExitedGuard(path, 122, root, Date.now() + 1000, snapshot)).toBe(false); }); + +it('재시작 전 읽은 파일 정체와 달라지면 같은 내용이어도 이동하지 않는다', async () => { + expect( + await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot, { dev: -1, ino: -1 }), + ).toBe(false); + expect(await readdir(dir)).toEqual(['owner.json']); +}); diff --git a/tests/relay/startup-lease.test.ts b/tests/relay/startup-lease.test.ts new file mode 100644 index 00000000..d49c3a64 --- /dev/null +++ b/tests/relay/startup-lease.test.ts @@ -0,0 +1,56 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, writeFile, readFile, rm, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ fork: vi.fn(), snapshot: vi.fn() })); +vi.mock('node:child_process', () => ({ fork: mocks.fork })); +vi.mock('playwright', () => ({ chromium: { connect: async () => ({ newContext: vi.fn() }) } })); +vi.mock('../../scripts/relay/ownership.js', async (original) => ({ + ...(await original()), + processSnapshot: mocks.snapshot, +})); +import { launchGuardedBrowser } from '../../scripts/relay/supervisor.js'; +it('동시 시작은 보존 이동과 새 마커 획득 사이에도 직렬화된다', async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), 'oy-concurrent-start-'))); + const p = join(dir, 'owner.json'); + const root = { + pid: 123, + ppid: 122, + pgid: 123, + rss: 0, + birth: 'old', + crashDir: p + '.old.crashpad', + }; + const current = { + ...root, + pid: 457, + ppid: 456, + pgid: 457, + birth: 'new', + crashDir: p + '.new.crashpad', + }; + const child = Object.assign(new EventEmitter(), { + pid: 456, + connected: true, + send: vi.fn(() => queueMicrotask(() => child.emit('exit', 0))), + }); + mocks.fork.mockReturnValue(child); + mocks.snapshot.mockResolvedValue([]); + try { + await writeFile(p, JSON.stringify({ guardPid: 122, browser: root }), { mode: 0o600 }); + const first = launchGuardedBrowser(p); + await vi.waitFor(() => expect(mocks.fork).toHaveBeenCalledTimes(1)); + await writeFile(p, JSON.stringify({ guardPid: 456, browser: current }), { mode: 0o600 }); + await expect(launchGuardedBrowser(p)).rejects.toMatchObject({ code: 'EADDRINUSE' }); + expect(JSON.parse(await readFile(p, 'utf8')).browser).toEqual(current); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root: current }); + const owner = await first; + mocks.snapshot.mockResolvedValue([current]); + await expect(launchGuardedBrowser(p)).rejects.toThrow('ownership unconfirmed'); + expect(mocks.fork).toHaveBeenCalledTimes(1); + await owner.close(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/relay/supervisor.test.ts b/tests/relay/supervisor.test.ts index ea211a78..e2fa7466 100644 --- a/tests/relay/supervisor.test.ts +++ b/tests/relay/supervisor.test.ts @@ -15,12 +15,20 @@ vi.mock('../../scripts/relay/ownership.js', async (importOriginal) => ({ vi.mock('../../scripts/relay/recover-exited-guard.js', () => ({ recoverExitedGuard: mocks.recover, })); +vi.mock('../../scripts/relay/marker-lease.js', () => ({ + acquireMarkerLease: async (markerPath: string) => Object.assign(async () => {}, { markerPath }), +})); import { launchGuardedBrowser } from '../../scripts/relay/supervisor.js'; afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); const root = { pid: 123, ppid: 1, pgid: 123, rss: 10, birth: 'start' }; +async function ready(child: EventEmitter) { + while (!child.listenerCount('message')) await Promise.resolve(); + child.emit('message', { type: 'other' }); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); +} function fixture() { const child = Object.assign(new EventEmitter(), { pid: 122, connected: true, send: vi.fn() }); mocks.fork.mockReturnValue(child); @@ -33,7 +41,7 @@ it('준비된 소유 브라우저에 연결하고 그룹 RSS 및 정상 종료 const child = fixture(); const pending = launchGuardedBrowser('/tmp/test-owner'); child.emit('message', { type: 'other' }); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); const owner = await pending; expect(await owner.rss()).toBe(30); expect(owner.pid).toBe(123); @@ -46,6 +54,7 @@ it('준비 전 감시 프로세스 종료는 후속 실행을 허용하지 않 const child = fixture(); const pending = launchGuardedBrowser('/tmp/test-owner'); const rejected = expect(pending).rejects.toThrow('cleanup unconfirmed'); + while (!child.listenerCount('exit')) await Promise.resolve(); child.emit('exit', 1); await rejected; expect(mocks.connect).not.toHaveBeenCalled(); @@ -54,7 +63,7 @@ it('브라우저 연결 실패도 소유 감시 프로세스를 종료한다', a const child = fixture(); mocks.connect.mockRejectedValue(new Error('connect failed')); const pending = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); await expect(pending).rejects.toThrow('connect failed'); expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'close' })); }); @@ -63,7 +72,7 @@ it('감시 프로세스 종료가 멈추면 확인 실패로 종료한다', asyn const child = fixture(); child.send.mockImplementation(() => {}); const pending = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); const owner = await pending; child.connected = false; const closing = owner.close(); @@ -77,7 +86,7 @@ it('감시 프로세스 종료가 멈추면 확인 실패로 종료한다', asyn it('수명 계층의 종료 기한을 IPC에 그대로 전달한다', async () => { const child = fixture(); const pending = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); const owner = await pending; const deadline = Date.now() + 6000; await owner.close(deadline); @@ -88,7 +97,7 @@ it('준비된 감시자의 비정상 종료는 소멸 검증 후 다음 실행 const child = fixture(); mocks.recover.mockResolvedValue(true); const pending = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); const owner = await pending; child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); await Promise.all([owner.close(), owner.close()]); @@ -100,7 +109,7 @@ it.each([null, 1])('불확실한 종료(%s)는 검증되지 않으면 계속 실 const child = fixture(); mocks.recover.mockResolvedValue(false); const pending = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); const owner = await pending; child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', code))); await expect(owner.close()).rejects.toThrow('cleanup unconfirmed'); @@ -111,13 +120,14 @@ it('감시자 PID가 없으면 종료 소멸을 추측하지 않는다', async ( const child = fixture(); Object.assign(child, { pid: undefined }); const task = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); const owner = await task; child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); await expect(owner.close()).rejects.toThrow('cleanup unconfirmed'); expect(mocks.recover).not.toHaveBeenCalled(); }); it('실패한 원본 요청은 유지하고 소멸 확인 뒤 다음 요청에서 세션을 재생성한다', async () => { + vi.useFakeTimers(); const { createBrowserLifecycle } = await import('../../scripts/relay/lifecycle.js'); mocks.recover.mockResolvedValue(true); const launch = vi.fn(async () => { @@ -136,7 +146,7 @@ it('실패한 원본 요청은 유지하고 소멸 확인 뒤 다음 요청에 }); mocks.connect.mockResolvedValue({ newContext: vi.fn().mockResolvedValue(context) }); const pending = launchGuardedBrowser('/tmp/test-owner'); - child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await ready(child); child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); return pending; }); @@ -145,9 +155,85 @@ it('실패한 원본 요청은 유지하고 소멸 확인 뒤 다음 요청에 await life.start(); await expect(life.run('/test', {})).rejects.toThrow('upstream failed'); expect(life.status().state).toBe('idle'); + await vi.advanceTimersByTimeAsync(60001); await expect(life.run('/test', {})).resolves.toMatchObject({ status: 'SUCCESS' }); expect(launch).toHaveBeenCalledTimes(2); } finally { await life.close(); } }); + +it('종료 기한 뒤 정상 종료된 감시자는 새 요청에서만 복구를 확인한다', async () => { + vi.useFakeTimers(); + const child = fixture(); + child.send.mockImplementation(() => {}); + const pending = launchGuardedBrowser('/tmp/test-owner'); + await ready(child); + const owner = await pending; + const rejected = expect(owner.close()).rejects.toThrow('watchdog'); + await vi.advanceTimersByTimeAsync(9000); + await rejected; + expect(await owner.recover!(Date.now() + 5000)).toBe(false); + child.emit('exit', 0); + expect(await owner.recover!(Date.now() + 5000)).toBe(true); + expect(mocks.recover).not.toHaveBeenCalled(); +}); +it('늦게 소멸한 비정상 감시자는 새 기한의 소유권 확인 뒤에만 복구한다', async () => { + const child = fixture(); + const pending = launchGuardedBrowser('/tmp/test-owner'); + await ready(child); + const owner = await pending; + child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); + mocks.recover.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + await expect(owner.close()).rejects.toThrow('cleanup unconfirmed'); + expect(await owner.recover!(Date.now() + 5000)).toBe(true); + expect(mocks.recover).toHaveBeenCalledTimes(2); +}); + +it('기존 마커의 소멸을 재검증한 다음에만 새 감시자를 실행한다', async () => { + const { mkdtemp, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = await mkdtemp(join(tmpdir(), 'oy-supervisor-startup-')); + const p = join(dir, 'owner.json'); + const old = { ...root, ppid: 122, crashDir: p + '.unique.crashpad' }; + try { + await writeFile(p, JSON.stringify({ guardPid: 122, browser: old }), { mode: 0o600 }); + mocks.recover.mockResolvedValue(false); + await expect(launchGuardedBrowser(p)).rejects.toThrow('ownership unconfirmed'); + expect(mocks.fork).not.toHaveBeenCalled(); + mocks.recover.mockResolvedValue(true); + const child = fixture(); + const pending = launchGuardedBrowser(p); + await vi.waitFor(() => expect(mocks.fork).toHaveBeenCalledTimes(1)); + await ready(child); + const owner = await pending; + await owner.close(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +it.each(['throw', 'reject'])('감시 종료 관측기의 %s 실패를 회수에서 격리한다', async (mode) => { + const child = fixture(); + const event = vi.fn(() => { + if (mode === 'throw') throw Error('private'); + return Promise.reject(Error('private')); + }); + const pending = launchGuardedBrowser('/tmp/test-owner', event); + await ready(child); + const owner = await pending; + await owner.close(); + expect(event).toHaveBeenCalledWith('guard-exit-normal'); +}); + +it('시작 중 연결 실패도 같은 lease 안에서 비정상 감시자를 확인하고 해제한다', async () => { + const child = fixture(); + mocks.connect.mockRejectedValueOnce(new Error('connect failed')); + mocks.recover.mockResolvedValue(true); + child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); + const pending = launchGuardedBrowser('/tmp/test-owner'); + await ready(child); + await expect(pending).rejects.toThrow('connect failed'); + expect(mocks.recover).toHaveBeenCalledTimes(1); +}); diff --git a/tests/relay/transport-lifecycle.test.ts b/tests/relay/transport-lifecycle.test.ts index 91ae3129..1cff66da 100644 --- a/tests/relay/transport-lifecycle.test.ts +++ b/tests/relay/transport-lifecycle.test.ts @@ -13,7 +13,7 @@ it('defaults to the existing browser lifecycle and forwards observer/ownership p expect(await createTransportLifecycle(undefined, '/state', observer)).toBe('browser'); const [launch, observed] = mocks.browser.mock.calls[0] as unknown as [() => unknown, unknown]; launch(); - expect(mocks.launch).toHaveBeenCalledWith('/state/browser-owner.json'); + expect(mocks.launch).toHaveBeenCalledWith('/state/browser-owner.json', observer); expect(observed).toBe(observer); }); it('selects HTTP without importing or launching browser modules', async () => { diff --git a/tests/relay/watchdog.test.ts b/tests/relay/watchdog.test.ts new file mode 100644 index 00000000..4af6b269 --- /dev/null +++ b/tests/relay/watchdog.test.ts @@ -0,0 +1,61 @@ +import { expect, it, vi } from 'vitest'; +import { createRelayWatchdog } from '../../scripts/relay/watchdog.js'; +it('정상·작업 중·종료 상태에서는 복구하거나 원본을 조회하지 않는다', async () => { + const start = vi.fn(); + let state = 'ready'; + let active = false; + const tick = createRelayWatchdog({ start, status: () => ({ state, active }) }); + for (state of ['ready', 'starting', 'closed']) await tick(); + state = 'failed'; + active = true; + await tick(); + expect(start).not.toHaveBeenCalled(); +}); +it('실패한 중계의 복구를 공유하고 실패 시 최대15분까지 대기한다', async () => { + let now = 0; + let finish!: () => void; + const start = vi.fn().mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const event = vi.fn(); + const tick = createRelayWatchdog( + { start, status: () => ({ state: 'failed', active: false }) }, + event, + () => now, + ); + const first = tick(); + const same = tick(); + expect(start).toHaveBeenCalledTimes(1); + finish(); + await Promise.all([first, same]); + start.mockRejectedValue(new Error('secret must not be logged')); + for (const wait of [60000, 60000, 120000, 240000, 480000, 900000]) { + now += wait; + await tick(); + const count = start.mock.calls.length; + await tick(); + expect(start).toHaveBeenCalledTimes(count); + } + expect(event.mock.calls.flat().join(' ')).not.toContain('secret'); + expect(event).toHaveBeenCalledWith('recovery-failed'); +}); +it('상태가 정상으로 돌아오면 실패 대기를 해제한다', async () => { + let state = 'idle'; + let now = 0; + const start = vi.fn().mockRejectedValueOnce(new Error('failed')).mockResolvedValue(undefined); + const tick = createRelayWatchdog( + { start, status: () => ({ state, active: false }) }, + undefined, + () => now, + ); + await tick(); + state = 'ready'; + await tick(); + state = 'idle'; + now = 1; + await tick(); + expect(start).toHaveBeenCalledTimes(2); +}); diff --git a/tests/services/daisoJp/product-relay.test.ts b/tests/services/daisoJp/product-relay.test.ts index b175a3af..a91cd55b 100644 --- a/tests/services/daisoJp/product-relay.test.ts +++ b/tests/services/daisoJp/product-relay.test.ts @@ -1,48 +1,145 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { requestJapanProductRelay, japanProductTransportFromBindings } from '../../../src/services/daisoJp/productRelay.js'; +import { + requestJapanProductRelay, + japanProductTransportFromBindings, +} from '../../../src/services/daisoJp/productRelay.js'; const raw = { handle: '4550746075917', title: 'ボウル', price: 22000, available: true }; const options = { relayUrl: 'https://relay.example', relayToken: 'test-token' }; -afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); it('명시한 중계에서 같은 상품만 인증된 고정 경로로 읽는다', async () => { expect(japanProductTransportFromBindings()).toEqual({}); - expect(japanProductTransportFromBindings({ DAISO_JP_PRODUCT_RELAY_ENABLED: 'true', DTRYX_RELAY_URL: options.relayUrl, DTRYX_RELAY_TOKEN: options.relayToken })).toEqual({ productRelay: { ...options, accessClientId: undefined, accessClientSecret: undefined, bypassCache: false } }); - const fetcher = vi.fn(async () => Response.json(raw)); vi.stubGlobal('fetch', fetcher); - expect(await requestJapanProductRelay(raw.handle, { ...options, bypassCache: true, accessClientId: 'test-id', accessClientSecret: 'test-secret' })).toMatchObject({ id: raw.handle, onlineAvailable: true, price: 220 }); + expect( + japanProductTransportFromBindings({ + DAISO_JP_PRODUCT_RELAY_ENABLED: 'true', + DTRYX_RELAY_URL: options.relayUrl, + DTRYX_RELAY_TOKEN: options.relayToken, + }), + ).toEqual({ + productRelay: { + ...options, + accessClientId: undefined, + accessClientSecret: undefined, + bypassCache: false, + }, + }); + const fetcher = vi.fn(async () => Response.json(raw)); + vi.stubGlobal('fetch', fetcher); + expect( + await requestJapanProductRelay(raw.handle, { + ...options, + bypassCache: true, + accessClientId: 'test-id', + accessClientSecret: 'test-secret', + }), + ).toMatchObject({ id: raw.handle, onlineAvailable: true, price: 220 }); const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe('https://relay.example/v1/dtryx/daiso-jp-product'); expect(init.redirect).toBe('manual'); - expect(init.headers).toMatchObject({ Authorization: 'Bearer test-token', 'x-relay-cache-bypass': 'true', 'CF-Access-Client-Id': 'test-id' }); + expect(init.headers).toMatchObject({ + Authorization: 'Bearer test-token', + 'x-relay-cache-bypass': 'true', + 'CF-Access-Client-Id': 'test-id', + }); }); -it.each([{}, { relayUrl: options.relayUrl }, { ...options, relayToken: ' ' }, { ...options, accessClientId: ' ', accessClientSecret: 'x' }, { ...options, accessClientId: 'x', accessClientSecret: ' ' }, { relayUrl: 'http://remote.example', relayToken: 'x' }, { ...options, accessClientId: 'x' }, { ...options, accessClientSecret: 'x' }])('불완전한 중계 설정은 원본 요청 전에 거절한다', async (p) => { +it.each([ + {}, + { relayUrl: options.relayUrl }, + { ...options, relayToken: ' ' }, + { ...options, accessClientId: ' ', accessClientSecret: 'x' }, + { ...options, accessClientId: 'x', accessClientSecret: ' ' }, + { relayUrl: 'http://remote.example', relayToken: 'x' }, + { ...options, accessClientId: 'x' }, + { ...options, accessClientSecret: 'x' }, +])('불완전한 중계 설정은 원본 요청 전에 거절한다', async (p) => { vi.stubGlobal('fetch', vi.fn()); - await expect(requestJapanProductRelay(raw.handle, p)).rejects.toMatchObject({ code: 'DAISO_JP_RELAY_CONFIG_ERROR' }); + await expect(requestJapanProductRelay(raw.handle, p)).rejects.toMatchObject({ + code: 'DAISO_JP_RELAY_CONFIG_ERROR', + }); expect(fetch).not.toHaveBeenCalled(); }); it.each([302, 401, 429, 503, 504])('중계 HTTP%i를 성공으로 바꾸지 않는다', async (status) => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('private body', { status }))); - await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ upstreamStatus: status }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('private body', { status })), + ); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ + upstreamStatus: status, + }); }); it('본문 없는 중계 실패도 오류로 유지한다', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 401 }))); - await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ status: 502 }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 401 })), + ); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ + status: 502, + }); }); it('잘못된 JSON·다른 상품·네트워크 실패를 거절한다', async () => { - for (const response of [new Response('html'), Response.json({ ...raw, handle: '4550746075918' })]) { - vi.stubGlobal('fetch', vi.fn(async () => response)); - await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ status: 502 }); + for (const response of [ + new Response('html'), + Response.json({ ...raw, handle: '4550746075918' }), + ]) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => response), + ); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ + status: 502, + }); } - vi.stubGlobal('fetch', vi.fn(async () => { throw Error('private'); })); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw Error('private'); + }), + ); await expect(requestJapanProductRelay(raw.handle, options)).rejects.toThrow('일본 다이소'); }); it('응답 본문이 멈춰도 전체 기한을 지킨다', async () => { vi.useFakeTimers(); - vi.stubGlobal('fetch', vi.fn(async () => ({ status: 200, json: () => new Promise(() => {}) }))); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ status: 200, json: () => new Promise(() => {}) })), + ); const pending = requestJapanProductRelay(raw.handle, options); const assertion = expect(pending).rejects.toMatchObject({ status: 504 }); - await vi.advanceTimersByTimeAsync(15000); await assertion; + await vi.advanceTimersByTimeAsync(15000); + await assertion; }); it('실패 응답 회수 오류는 원래 중계 오류를 바꾸지 않는다', async () => { - vi.stubGlobal('fetch', vi.fn(async () => ({ status: 403, headers: new Headers(), body: { cancel: async () => { throw Error('cancel failed'); } } }))); - await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ upstreamStatus: 403 }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + status: 403, + headers: new Headers(), + body: { + cancel: async () => { + throw Error('cancel failed'); + }, + }, + })), + ); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ + upstreamStatus: 403, + }); +}); + +it.each(['429', '403', 'bad'])('원본 상태 %s를 자체 할당량과 구분해 전달한다', async (upstream) => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(null, { status: 502, headers: { 'x-relay-upstream-status': upstream } }), + ), + ); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ + status: 502, + upstreamStatus: upstream === 'bad' ? 502 : Number(upstream), + quotaReason: undefined, + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 1ca16223..91afa94d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,35 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['scripts/ops/mcp-smoke-validation.ts', 'scripts/relay/daiso-jp.ts', 'scripts/ops/health-check-retry.ts', 'src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/gs25-session.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts'], + include: [ + 'scripts/relay/watchdog.ts', + 'scripts/relay/marker-lease.ts', + 'scripts/relay/recover-abandoned-guard.ts', + 'scripts/relay/recover-exited-guard.ts', + 'scripts/ops/mcp-smoke-validation.ts', + 'scripts/relay/daiso-jp.ts', + 'scripts/ops/health-check-retry.ts', + 'src/**/*.ts', + 'scripts/relay/cache.ts', + 'scripts/relay/logging.ts', + 'scripts/relay/observed.ts', + 'scripts/relay/oliveyoung.ts', + 'scripts/relay/dtryx.ts', + 'scripts/relay/http-relay.ts', + 'scripts/relay/convenience.ts', + 'scripts/relay/gs25-session.ts', + 'scripts/relay/browser.ts', + 'scripts/relay/lifecycle.ts', + 'scripts/relay/ownership.ts', + 'scripts/relay/guard-controller.ts', + 'scripts/relay/guard-runtime.ts', + 'scripts/relay/supervisor.ts', + 'scripts/relay/quota.ts', + 'scripts/relay/consumer.ts', + 'scripts/relay/browser-environment.ts', + 'scripts/relay/http-lifecycle.ts', + 'scripts/relay/transport-lifecycle.ts', + ], // 커버리지 제외 파일 exclude: [