8000 fix: 소멸한 브라우저의 종료 대기로 중계가 멈추는 문제 수정 by hmmhmmhm · Pull Request #269 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며

소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다.

소유 그룹과 전용 Crashpad의 소멸이 확인되면 Playwright의 정상 프로필 정리를 공유 종료 기한까지 기다립니다. 종료 Promise가 거절되거나 기한까지 끝나지 않아도 확인된 소멸을 실패로 바꾸지 않으며, guard의 마커·전용 Crashpad 디렉터리 정리는 계속 완료해야 합니다. 기한을 넘긴 경우 임시 프로필 정리가 끝났다는 보장은 없습니다. 살아 있는 프로세스·불확실한 소유권·파일 정리 실패는 계속 재시작을 차단합니다. 이 회귀는 모의 종료 Promise로 재현했으며, 2026-10-13~14 운영 장애의 정확한 종료 오류는 미확정입니다.

### 전체 호출량과 인증

모든 중계 호출은 전체 분당 300회, UTC 하루 300,000회로 제한합니다. MCP·REST 모두 같은 중계를 거치므로 호출 경로로 우회할 수 없습니다. 원장을 `OY_RELAY_STATE_DIR`에 원자적으로 저장하며 저장 실패·손상은 조회 거절로 처리합니다. 소비자별 신규 원본 작업은 분당 240회로 제한하며 소비자 동시 요청은 2개입니다. 본문 업로드 중인 요청도 최대 8개 슬롯에 포함합니다. 실패한 원본 요청도 이미 소비한 예산을 돌려주지 않습니다.
Expand Down
4 changes: 2 additions & 2 deletions scripts/relay/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ export async function closeOwnedGroup(
): Promise<void> {
const remainingMs = () => Math.max(0, deadline - Date.now());
const inspect = () => bounded(snapshot(), remainingMs());
// 프로세스 소멸을 확인한 뒤에만 Playwright의 종료 거절을 허용합니다.
// 소멸 확인 뒤에도 정상 프로필 정리는 기다리지만 종료 거절·기한 초과는 허용합니다.
const finishGone = (closing: Promise<unknown>) =>
bounded(
closing.catch(() => undefined),
remainingMs(),
);
).catch(() => undefined);
const initial = await inspect();
const group = members(root, initial);
if (!group.length) {
Expand Down
39 changes: 34 additions & 5 deletions tests/relay/guard-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { EventEmitter } from 'node:events';
import { mkdtemp, readFile, access, rm } from 'node:fs/promises';
import { mkdtemp, readFile, access, rm, rename, mkdir } from 'node:fs/promises';
import { afterEach, expect, it, vi } from 'vitest';
import { runGuard } from '../../scripts/relay/guard-runtime.js';
const dirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
vi.useRealTimers();
for (const dir of dirs) await rm(dir, { recursive: true, force: true });
});
async function fixture() {
Expand Down Expand Up @@ -100,20 +102,47 @@ it('IPC 공유 기한 안에 느린 close와 소멸 확인을 마치고 마커
await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0));
await expect(access(f.marker)).rejects.toThrow();
});
it('최악 경로에서 프로필 종료가 공유 기한을 넘으면 확인 실패와 마커를 보존한다', async () => {
it('소멸 확인 후 멈춘 프로필 정리는 기한 뒤 마커를 해제하고 정상 종료한다', async () => {
const f = await fixture();
await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot);
vi.useFakeTimers();
const started = Date.now();
f.server.close.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 8900)));
f.server.close.mockImplementation(() => new Promise(() => {}));
f.snapshot.mockImplementation(async () => (Date.now() - started < 5900 ? [f.root] : []));
const kill = vi.spyOn(process, 'kill').mockReturnValue(true);
f.channel.emit('message', { type: 'close', deadline: started + 8000 });
await vi.advanceTimersByTimeAsync(7600);
expect(f.channel.exit).toHaveBeenCalledWith(1);
expect(f.channel.send).not.toHaveBeenCalledWith({ type: 'closed' });
expect(kill).toHaveBeenCalledExactlyOnceWith(-123, 'SIGKILL');
kill.mockRestore();
vi.useRealTimers();
await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0));
expect(f.channel.send).toHaveBeenCalledWith({ type: 'closed' });
await expect(access(f.marker)).rejects.toThrow();
});
it('종료가 멈추고 소유 그룹도 남으면 마커를 보존하고 실패 종료한다', async () => {
const f = await fixture();
await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot);
vi.useFakeTimers();
f.server.close.mockImplementation(() => new Promise(() => {}));
const kill = vi.spyOn(process, 'kill').mockReturnValue(true);
f.channel.emit('message', { type: 'close', deadline: Date.now() + 8000 });
await vi.advanceTimersByTimeAsync(7600);
expect(f.channel.exit).toHaveBeenCalledWith(1);
expect(f.channel.send).not.toHaveBeenCalledWith({ type: 'closed' });
expect(kill).toHaveBeenCalledExactlyOnceWith(-123, 'SIGKILL');
await access(f.marker);
});
it('소멸이 확인되어도 마커 파일 해제 실패는 실패 종료로 남긴다', async () => {
const f = await fixture();
await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot);
const preserved = f.marker + '/preserved.json';
const original = await readFile(f.marker, 'utf8');
await rename(f.marker, f.marker + '.backup');
await mkdir(f.marker);
await rename(f.marker + '.backup', preserved);
f.channel.emit('message', 'close');
await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(1));
expect(f.channel.send).not.toHaveBeenCalledWith({ type: 'closed' });
expect(await readFile(preserved, 'utf8')).toBe(original);
await access(f.marker);
});
54 changes: 50 additions & 4 deletions tests/relay/ownership.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, it, vi } from 'vitest';
import { afterEach, expect, it, vi } from 'vitest';
import { parseProcesses, ownedGroup, closeOwnedGroup } from '../../scripts/relay/ownership.js';
afterEach(() => vi.useRealTimers());
const root = { pid: 100, ppid: 1, pgid: 100, rss: 200, birth: 'Mon Sep 15 09:00:00 2026' };
const child = { ...root, pid: 101, ppid: 100, rss: 300 };
it('프로세스 그룹의 전체RSS를 조회하고 PID 재사용을 거절한다', () => {
Expand Down Expand Up @@ -195,19 +196,64 @@ it('느린 close와 강제 종료 확인 및 프로필 정리도 한 예산 안
vi.useRealTimers();
});

it('공유 종료 기한을 넘긴 프로필 정리는 실패로 남긴다', async () => {
it('프로세스 소멸 후 프로필 정리가 공유 기한을 넘겨도 회수를 완료한다', async () => {
vi.useFakeTimers();
const started = Date.now();
const snapshot = vi
.fn()
.mockImplementation(async () => (Date.now() - started < 5900 ? [root] : []));
const closing = new Promise<void>((resolve) => setTimeout(resolve, 6900));
const pending = closeOwnedGroup(root, () => closing, snapshot, vi.fn(), started + 6500);
const rejected = expect(pending).rejects.toThrow('watchdog');
const completed = expect(pending).resolves.toBeUndefined();
void completed.catch(() => undefined);
await vi.advanceTimersByTimeAsync(7000);
await rejected;
await completed;
vi.useRealTimers();
});
it('이미 사라진 그룹의 종료가 멈춰도 공유 기한 후 완료한다', async () => {
vi.useFakeTimers();
const started = Date.now();
const kill = vi.fn();
let done = false;
const pending = closeOwnedGroup(
root,
() => new Promise(() => {}),
async () => [],
kill,
started + 500,
).then(() => { done = true; });
const completed = expect(pending).resolves.toBeUndefined();
void completed.catch(() => undefined);
await vi.advanceTimersByTimeAsync(499);
expect(done).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await completed;
expect(done).toBe(true);
expect(kill).not.toHaveBeenCalled();
});
it('소멸 확인 후 공유 기한이 지난 종료의 늦은 거절도 처리한다', async () => {
vi.useFakeTimers();
let rejectClose!: (error: Error) => void;
const closing = new Promise<void>((_, reject) => { rejectClose = reject; });
const pending = closeOwnedGroup(root, () => closing, async () => [], vi.fn(), Date.now() + 500);
const completed = expect(pending).resolves.toBeUndefined();
void completed.catch(() => undefined);
await vi.advanceTimersByTimeAsync(500);
await completed;
rejectClose(new Error('late profile cleanup failure'));
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
});
it('공유 기한까지 소멸 스냅샷을 확인하지 못하면 실패를 유지한다', async () => {
vi.useFakeTimers();
const snapshot = vi.fn().mockResolvedValueOnce([root]).mockImplementation(() => new Promise(() => {}));
const kill = vi.fn();
const pending = closeOwnedGroup(root, () => new Promise(() => {}), snapshot, kill, Date.now() + 500);
const rejected = expect(pending).rejects.toThrow('watchdog');
await vi.advanceTimersByTimeAsync(501);
await rejected;
expect(kill).not.toHaveBeenCalled();
});
it('정상 종료 또는 강제 종료 후 소멸이 확인되어도 close 거절을 허용한다', async () => {
for (const snapshots of [
[[root], []],
Expand Down
Loading
0