From c99e6c2a94871d8e86e71e67d5b09a6eaac66cd9 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 24 Oct 2026 09:13:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=83=88=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EB=90=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 36 ++++++++++++------------- package.json | 2 +- tests/scripts/repository-config.test.ts | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9d4976f2..e0d2ecbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2600,18 +2600,18 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -3290,9 +3290,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -4573,9 +4573,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -5107,9 +5107,9 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -5515,9 +5515,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f12ff496..09aeaf92 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ }, "miniflare": { "sharp": "0.35.5", - "undici": "7.29.1" + "undici": "7.30.1" } }, "dependencies": { diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index de5afe78..ce93d750 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -62,7 +62,7 @@ describe('repository maintenance configuration', () => { overrides?: Record>; }; - expect(pkg.overrides?.miniflare).toEqual({ sharp: '0.35.5', undici: '7.29.1' }); + expect(pkg.overrides?.miniflare).toEqual({ sharp: '0.35.5', undici: '7.30.1' }); }); it('brace-expansion은 취약점이 수정된 5.0.8 이상을 사용한다', () => { From 6705ed9868adcea6e7d68c89aea24896d597ba10 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 24 Oct 2026 09:13:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=A2=85=EB=A3=8C=EB=90=9C=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EA=B0=90=EC=8B=9C?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EC=9E=94=EC=97=AC=20=EB=A7=88=EC=BB=A4=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/oliveyoung-http-relay.md | 8 ++ scripts/relay/ownership.ts | 3 + scripts/relay/recover-exited-guard.ts | 43 ++++++ scripts/relay/supervisor.ts | 23 +++- tests/relay/recover-exited-guard.test.ts | 161 +++++++++++++++++++++++ tests/relay/supervisor.test.ts | 82 +++++++++++- 6 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 scripts/relay/recover-exited-guard.ts create mode 100644 tests/relay/recover-exited-guard.test.ts diff --git a/docs/oliveyoung-http-relay.md b/docs/oliveyoung-http-relay.md index 029513aa..c4f2a73d 100644 --- a/docs/oliveyoung-http-relay.md +++ b/docs/oliveyoung-http-relay.md @@ -45,3 +45,11 @@ HTTP 모드의 health에 `lastFailure: { status, at }`가 추가됩니다. `stat 기존 소유 브라우저를 임시 독립 세션으로 실행한 정상 페이지 이동 후 네 공식 조회(매장9개·상품 검색·상품 식별자·매장 재고)는 모두 `SUCCESS`를 반환했고 감시 프로세스의 종료도 확인했습니다. HTTP 실패를 헤더 변형·쿠키 복사·보안 검증 자동화로 회피하지 않습니다. browser 모드 복구는 명시적 설정 변경으로만 수행하며, 브라우저 없는 실행이 필요하면 현재 HTTP403 차단이 해소되거나 제공자가 지원하는 다른 접근 경로가 필요합니다. 이 검증 자체는 운영 설정이나 원장을 수정하지 않았습니다. 인증된 운영 점검의 `x-health-check-force-fresh: true`는 상품·매장 fallback과 상품 식별자 캐시를 우회하고, 재고 보강 오류를 그대로 보고합니다. 릴레이에 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청도 재사용하지 않습니다. 이때도 인증·호출 한도·동시 작업 상한을 적용합니다. 인증되지 않은 공개 요청은 이 우회를 사용할 수 없습니다. + +## 브라우저 감시자 비정상 종료 후 복구 + +브라우저 모드에서 감시자가 비정상 종료하면 새 브라우저를 즉시 실행하지 않습니다. 준비 완료 때 확보한 감시자 PID, 브라우저 PID·프로세스 그룹·생성 시각·전용 crashpad 경로와 마커를 대조합니다. 감시자가 숫자형 비정상 종료 코드로 끝났고 해당 PID·그룹·crashpad 프로세스가 모두 사라진 경우에만, 현재 사용자 소유의 일반 마커 파일을 `.exited-...` 이름으로 보존 이동합니다. 파일 교체·심볼릭 링크·큰 본문·소유권 불일치·조회 기한 초과가 있으면 기존 실패를 유지합니다. 시작 전 실패나 종료되지 않은 감시자, 시그널 종료도 자동 복구하지 않습니다. + +이 확인은 기존 종료 기한 안에서 한 번만 공유합니다. 확인이 끝나면 다음 요청에서 새 브라우저를 준비할 수 있지만, 처음 실패한 원본 요청을 성공으로 바꾸거나 자동으로 무한 재시도하지 않습니다. 인증 자료와 누적 호출 원장은 수정하지 않으며, 원본의 간헐적 차단·지연을 해소한다는 의미도 아닙니다. + +이미 실패 상태로 남은 구버전 프로세스는 먼저 유휴 상태를 확인해 정상 종료합니다. 남은 마커의 감시자 PID·브라우저 PID·원래 그룹·전용 crashpad 프로세스가 모두 없는지 확인한 뒤 마커를 보존 이동하고 LaunchAgent를 다시 시작합니다. 소유권이 불명확하면 다른 브라우저를 종료하거나 마커를 삭제하지 않습니다. 복구 후 캐시 우회 실제 조회와 CLI/MCP, 정기 경로 스냅샷을 확인합니다. diff --git a/scripts/relay/ownership.ts b/scripts/relay/ownership.ts index 10542eb1..6dbc6a3c 100644 --- a/scripts/relay/ownership.ts +++ b/scripts/relay/ownership.ts @@ -47,6 +47,9 @@ const crashHelper = (root: ProcessIdentity, row: ProcessIdentity) => { }; const members = (root: ProcessIdentity, rows: ProcessIdentity[]) => rows.filter((row) => row.pgid === root.pgid || crashHelper(root, row)); +export function hasOwnedProcesses(root: ProcessIdentity, rows: ProcessIdentity[]): boolean { + return members(root, rows).length > 0; +} export function ownedGroup(root: ProcessIdentity, rows: ProcessIdentity[]): ProcessIdentity[] { if (root.pid !== root.pgid || !rows.some((row) => same(root, row))) throw new Error('Browser process identity uncertain'); diff --git a/scripts/relay/recover-exited-guard.ts b/scripts/relay/recover-exited-guard.ts new file mode 100644 index 00000000..b0095080 --- /dev/null +++ b/scripts/relay/recover-exited-guard.ts @@ -0,0 +1,43 @@ +/** 종료한 감시자의 소유 프로세스가 모두 사라진 경우에만 실패 마커를 보존 이동합니다. */ +import { constants } from 'node:fs'; +import { open, lstat, rename } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { bounded } from './lifecycle.js'; +import { hasOwnedProcesses, processSnapshot, type ProcessIdentity } from './ownership.js'; +export async function recoverExitedGuard( + markerPath: string, + guardPid: number, + root: ProcessIdentity, + deadline: number, + snapshot = processSnapshot, +): Promise { + const remaining = () => Math.max(0, deadline - Date.now()); + try { + if (!root.crashDir || !root.crashDir.startsWith(markerPath + '.')) return false; + 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!()) return false; + const marker = JSON.parse(await bounded(handle.readFile('utf8'), remaining())); + if (marker?.guardPid !== guardPid) return false; + for (const key of ['pid', 'pgid', 'birth', 'crashDir'] as const) + if (marker.browser?.[key] !== root[key]) return false; + const rows = await bounded(snapshot(), remaining()); + if (rows.some((row) => row.pid === guardPid || row.pid === root.pid)) return false; + if (hasOwnedProcesses(root, rows)) return false; + 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()); + return true; + } finally { + await handle.close(); + } + } catch { + // 소유권·파일·조회 기한 중 하나라도 확인할 수 없으면 기존 실패 상태를 유지합니다. + return false; + } +} diff --git a/scripts/relay/supervisor.ts b/scripts/relay/supervisor.ts index cea2e1df..86dde954 100644 --- a/scripts/relay/supervisor.ts +++ b/scripts/relay/supervisor.ts @@ -1,4 +1,5 @@ /** 독립 감시 프로세스와 연결하고 소유 브라우저의 종료 확인을 기다립니다. */ +import { recoverExitedGuard } from './recover-exited-guard.js'; import { fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; @@ -16,10 +17,23 @@ export async function launchGuardedBrowser(markerPath: string): Promise { - if (!exited && guard.connected) guard.send({ type: 'close', deadline }); - const code = await bounded(exit, Math.max(0, deadline - Date.now())); - if (code !== 0) throw new Error('Browser guard cleanup unconfirmed'); + let readyRoot: ProcessIdentity | undefined; + let closing: Promise | undefined; + const closed = (deadline = Date.now() + 9000): Promise => { + closing ??= (async () => { + if (!exited && guard.connected) guard.send({ type: 'close', deadline }); + const code = await bounded(exit, Math.max(0, deadline - Date.now())); + if (code === 0) return; + if ( + typeof code === 'number' && + readyRoot && + guard.pid && + (await recoverExitedGuard(markerPath, guard.pid, readyRoot, deadline)) + ) + return; + throw new Error('Browser guard cleanup unconfirmed'); + })(); + return closing; }; try { const ready = await bounded( @@ -33,6 +47,7 @@ export async function launchGuardedBrowser(markerPath: string): Promise ({ mode: 'normal' })); +vi.mock('node:fs/promises', async (original) => { + const real = await original(); + return { + ...real, + lstat: async (...args: Parameters) => { + const value = await real.lstat(...args); + if (changes.mode === 'device') Object.assign(value, { dev: -1 }); + if (changes.mode === 'expire') vi.setSystemTime(Date.now() + 6000); + return value; + }, + rename: async (...args: Parameters) => { + if (changes.mode === 'rename') throw new Error('archive failed'); + return real.rename(...args); + }, + }; +}); +let dir: string; +let path: string; +let root: { pid: number; ppid: number; pgid: number; rss: number; birth: string; crashDir: string }; +const snapshot = vi.fn(); +beforeEach(async () => { + changes.mode = 'normal'; + dir = await mkdtemp(join(tmpdir(), 'oy-guard-recovery-')); + path = join(dir, 'owner.json'); + root = { + pid: 123, + ppid: 122, + pgid: 123, + rss: 10, + birth: 'original', + crashDir: path + '.unique.crashpad', + }; + await writeFile(path, JSON.stringify({ guardPid: 122, browser: root }), { mode: 0o600 }); + snapshot.mockReset().mockResolvedValue([]); +}); +afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + await rm(dir, { recursive: true, force: true }); +}); +it('감시자와 소유 그룹이 모두 사라진 경우에만 마커를 보존 이동한다', async () => { + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(true); + const names = await readdir(dir); + expect(names).toHaveLength(1); + expect(names[0]).toMatch(/^owner.json\.exited-/); + expect(JSON.parse(await readFile(join(dir, names[0]), 'utf8')).browser).toEqual(root); +}); +it.each([ + { pid: 122, pgid: 999 }, + { pid: 123, pgid: 999 }, + { pid: 999, pgid: 123 }, +])('PID 재사용이나 잔여 프로세스가 있으면 이동하지 않는다: %j', async (row) => { + snapshot.mockResolvedValue([{ ...root, ...row, birth: 'reused' }]); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); + expect(await readdir(dir)).toEqual(['owner.json']); +}); +it('다른 프로세스 그룹의 전용 crashpad도 남아 있으면 차단한다', async () => { + snapshot.mockResolvedValue([ + { + ...root, + pid: 999, + pgid: 999, + command: 'chrome_crashpad_handler --database=' + root.crashDir, + }, + ]); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); +}); +it('snapshot 실패는 종료 증거가 아니다', async () => { + snapshot.mockRejectedValue(new Error('ps failed')); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); +}); +it('기한 이후 도착한 snapshot은 나중에도 마커를 이동하지 않는다', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + snapshot.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 100))); + const task = recoverExitedGuard(path, 122, root, Date.now() + 50, snapshot); + while (!snapshot.mock.calls.length) await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(200); + expect(await task).toBe(false); + expect(await readdir(dir)).toEqual(['owner.json']); +}); +it.each(['guardPid', 'pid', 'pgid', 'birth', 'crashDir'])( + '마커의 %s 식별자가 다르면 차단한다', + async (key) => { + const marker = { guardPid: 122, browser: { ...root } }; + if (key === 'guardPid') marker.guardPid = 999; + else Object.assign(marker.browser, { [key]: 'different' }); + await writeFile(path, JSON.stringify(marker)); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); + }, +); +it.each(['', '{', ' '.repeat(16385), 'null'])('불완전하거나 큰 마커를 거절한다', async (body) => { + await writeFile(path, body); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); +}); +it('심볼릭 링크는 따라가지 않는다', async () => { + const link = path + '.link'; + const linkedRoot = { ...root, crashDir: link + '.unique.crashpad' }; + await writeFile(path, JSON.stringify({ guardPid: 122, browser: linkedRoot })); + await symlink(path, link); + expect(await recoverExitedGuard(link, 122, linkedRoot, Date.now() + 5000, snapshot)).toBe(false); + expect(JSON.parse(await readFile(path, 'utf8')).guardPid).toBe(122); +}); +it('snapshot 중 마커가 교체되면 새 마커를 이동하지 않는다', async () => { + snapshot.mockImplementation(async () => { + await rm(path); + await writeFile(path, JSON.stringify({ guardPid: 999 })); + return []; + }); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); + expect(JSON.parse(await readFile(path, 'utf8')).guardPid).toBe(999); +}); + +it.each(['device', 'rename', 'expire'])( + '장치 변경·이동 실패·기한 만료를 성공으로 바꾸지 않는다: %s', + async (mode) => { + if (mode === 'expire') vi.useFakeTimers({ toFake: ['Date'] }); + changes.mode = mode; + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); + expect(await readdir(dir)).toEqual(['owner.json']); + }, +); +it('다른 UID의 마커를 이동하지 않는다', async () => { + const uid = process.getuid!(); + vi.spyOn(process, 'getuid').mockReturnValue(uid + 1); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); +}); +it('디렉터리를 마커로 사용하지 않는다', async () => { + await rm(path); + await mkdir(path); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); +}); +it.each([undefined, '/unrelated.crashpad'])( + '고유 crashDir 증거가 없으면 거절한다', + async (crashDir) => { + expect( + await recoverExitedGuard(path, 122, { ...root, crashDir }, Date.now() + 5000, snapshot), + ).toBe(false); + }, +); +it('브라우저 정보가 없으면 소유권을 추측하지 않는다', async () => { + await writeFile(path, JSON.stringify({ guardPid: 122 })); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 5000, snapshot)).toBe(false); +}); +it('기본 운영 프로세스 스냅샷으로도 완전 소멸을 확인한다', async () => { + const missingRoot = { ...root, pid: 2147483000, pgid: 2147483000 }; + await writeFile(path, JSON.stringify({ guardPid: 2147483001, browser: missingRoot })); + expect(await recoverExitedGuard(path, 2147483001, missingRoot, Date.now() + 5000)).toBe(true); +}); + +it('FIFO 마커도 파일 열기에서 멈추지 않고 거절한다', async () => { + await rm(path); + execFileSync('/usr/bin/mkfifo', [path]); + expect(await recoverExitedGuard(path, 122, root, Date.now() + 1000, snapshot)).toBe(false); +}); diff --git a/tests/relay/supervisor.test.ts b/tests/relay/supervisor.test.ts index 4def1067..ea211a78 100644 --- a/tests/relay/supervisor.test.ts +++ b/tests/relay/supervisor.test.ts @@ -1,12 +1,20 @@ import { EventEmitter } from 'node:events'; import { afterEach, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ fork: vi.fn(), connect: vi.fn(), snapshot: vi.fn() })); +const mocks = vi.hoisted(() => ({ + fork: vi.fn(), + connect: vi.fn(), + snapshot: vi.fn(), + recover: vi.fn(), +})); vi.mock('node:child_process', () => ({ fork: mocks.fork })); vi.mock('playwright', () => ({ chromium: { connect: mocks.connect } })); vi.mock('../../scripts/relay/ownership.js', async (importOriginal) => ({ ...(await importOriginal()), processSnapshot: mocks.snapshot, })); +vi.mock('../../scripts/relay/recover-exited-guard.js', () => ({ + recoverExitedGuard: mocks.recover, +})); import { launchGuardedBrowser } from '../../scripts/relay/supervisor.js'; afterEach(() => { vi.useRealTimers(); @@ -14,7 +22,7 @@ afterEach(() => { }); const root = { pid: 123, ppid: 1, pgid: 123, rss: 10, birth: 'start' }; function fixture() { - const child = Object.assign(new EventEmitter(), { connected: true, send: vi.fn() }); + const child = Object.assign(new EventEmitter(), { pid: 122, connected: true, send: vi.fn() }); mocks.fork.mockReturnValue(child); mocks.connect.mockResolvedValue({ newContext: vi.fn() }); mocks.snapshot.mockResolvedValue([root, { ...root, pid: 124, rss: 20 }]); @@ -63,6 +71,8 @@ it('감시 프로세스 종료가 멈추면 확인 실패로 종료한다', asyn await vi.advanceTimersByTimeAsync(9000); await rejected; expect(child.send).not.toHaveBeenCalled(); + child.emit('exit', 1); + expect(mocks.recover).not.toHaveBeenCalled(); }); it('수명 계층의 종료 기한을 IPC에 그대로 전달한다', async () => { const child = fixture(); @@ -73,3 +83,71 @@ it('수명 계층의 종료 기한을 IPC에 그대로 전달한다', async () = await owner.close(deadline); expect(child.send).toHaveBeenCalledWith({ type: 'close', deadline }); }); + +it('준비된 감시자의 비정상 종료는 소멸 검증 후 다음 실행을 허용한다', async () => { + const child = fixture(); + mocks.recover.mockResolvedValue(true); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + const owner = await pending; + child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); + await Promise.all([owner.close(), owner.close()]); + await owner.close(); + expect(mocks.recover).toHaveBeenCalledTimes(1); + expect(mocks.recover).toHaveBeenCalledWith('/tmp/test-owner', 122, root, expect.any(Number)); +}); +it.each([null, 1])('불확실한 종료(%s)는 검증되지 않으면 계속 실패한다', async (code) => { + const child = fixture(); + mocks.recover.mockResolvedValue(false); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + const owner = await pending; + child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', code))); + await expect(owner.close()).rejects.toThrow('cleanup unconfirmed'); + expect(mocks.recover).toHaveBeenCalledTimes(code === null ? 0 : 1); +}); + +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 }); + 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 () => { + const { createBrowserLifecycle } = await import('../../scripts/relay/lifecycle.js'); + mocks.recover.mockResolvedValue(true); + const launch = vi.fn(async () => { + const child = fixture(); + const page = Object.assign(new EventEmitter(), { + goto: vi.fn().mockResolvedValue(undefined), + waitForURL: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(async () => { + if (launch.mock.calls.length === 1) throw new Error('upstream failed'); + return { status: 200, body: { status: 'SUCCESS' } }; + }), + }); + const context = Object.assign(new EventEmitter(), { + newPage: vi.fn().mockResolvedValue(page), + pages: () => [page], + }); + mocks.connect.mockResolvedValue({ newContext: vi.fn().mockResolvedValue(context) }); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 1))); + return pending; + }); + const life = createBrowserLifecycle(launch); + try { + await life.start(); + await expect(life.run('/test', {})).rejects.toThrow('upstream failed'); + expect(life.status().state).toBe('idle'); + await expect(life.run('/test', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(launch).toHaveBeenCalledTimes(2); + } finally { + await life.close(); + } +});