diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6e82c90..e19a9569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,4 +83,4 @@ jobs: - name: Install dependencies run: npm ci - name: Native helper regression tests - run: npx vitest run tests/scripts/hide-readiness.test.ts tests/scripts/macos-ops.test.ts --maxWorkers=1 + run: npm test -- tests/scripts/hide-readiness.test.ts tests/scripts/macos-ops.test.ts --maxWorkers=1 diff --git a/AGENTS.md b/AGENTS.md index f551c259..f72c4b25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,13 @@ # 에이전트 개발 규칙 +## 로컬 메모리 및 브라우저 보호 + +- 테스트는 `npm test`, `npm run test:coverage`, `npm run mcp:smoke`, `npm run cli:smoke`의 자원 보호 실행기를 사용합니다. 직접 Vitest나 여러 검사 실행기로 공용 잠금을 우회하지 않습니다. Mac 기본 워커는 1개이며 환경 변수로 상한을 올리지 않습니다. +- 시작 전·진행 중 메모리 여유, CPU, Chrome 본체 수를 관측합니다. 보호 실행기의 자원 거절은 실패이며 검사를 통과한 것으로 처리하지 않습니다. 압박 상태에서 즉시 재시작하지 말고 원인을 확인합니다. +- 이 Mac의 전체 회귀·커버리지·self-hosted CI·fresh 운영 조회·브라우저 실험을 겹치지 않습니다. 기존 CI 결과를 활용하고 필요한 최소 검증을 순서대로 실행합니다. +- 브라우저 실험은 소유 세션 1개만 사용하며 성공·실패·중단 모두 finally에서 탭과 세션을 회수합니다. OY 운영 브라우저 외에 추가 본체를 계속 생성하지 않습니다. 종료 후 세션 목록과 프로세스 수를 확인합니다. +- 공용 Codex `cua_node`나 사용자 브라우저를 임의 종료하지 않습니다. 공유 보조 프로세스가 누적되면 신규 브라우저 작업을 멈추고 상태를 보고합니다. 단일 RSS나 swap 값만으로 누수로 단정하지 않습니다. + ## 최우선: 공개 MCP 도구는 10개 고정 공개 `tools/list`의 도구는 정확히 **10개**이며 **절대 늘리지 않습니다**. 새 서비스와 기능은 기존 도구의 `service`/`operation`/`requestType`에 통합합니다. 기존 이름은 deprecated 호출 별칭으로만 보존하며 공개 목록에는 추가하지 않습니다. 일반 MCP와 PlayMCP에 동일하게 적용합니다. [공개 도구 계약](docs/mcp-tools.md)과 `tests/core/compact`, `tests/app/compact-contract.test.ts` 검사를 반드시 유지합니다. diff --git a/docs/superpowers/plans/2026-10-27-resource-guard.md b/docs/superpowers/plans/2026-10-27-resource-guard.md new file mode 100644 index 00000000..fb4598a2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-resource-guard.md @@ -0,0 +1,12 @@ +# 로컬 자원 보호 및 중계 복귀 계획 + +사용자 승인 범위: 메모리와 브라우저 과다 실행 방지, 로컬 CI 및 OY 감시 복귀. + +1. 테스트·커버리지·CLI/MCP smoke를 공용 파일 잠금으로 직렬 실행한다. Mac 기본 워커는 1개, 다른 환경은 2개로 제한한다. +2. 시작 전과 실행 중 메모리 여유, CPU 합계, Chrome 본체 수를 관측한다. 시작 시 메모리 20% 미만, CPU 85% 초과, Chrome 본체 2개 초과면 실행을 거절한다. 실행 중 메모리 12% 미만 또는 Chrome 본체 2개 초과는 3회, CPU 90% 초과는 15회 연속 관측 시 소유 테스트 그룹만 종료한다. 2초 간격으로 관측하며 실패를 정상으로 처리하지 않는다. +3. 상태 수집 실패도 검사 실패로 처리한다. 사용자 브라우저, 운영 중계, 공용 Codex 프로세스는 종료하지 않는다. 새 종속성은 추가하지 않는다. +4. 저부하 Python 회귀로 잠금 충돌, 관측 실패, 임계값, 소유 자식 종료를 먼저 검증한다. 기존 Vitest 전체 회귀는 1워커로 한 번만 실행한다. +5. OY의 기존 단일 소유권·추가 페이지 종료·1GB RSS/30분/200요청 교체를 유지한다. CI 실행기, OY 중계, ready 확인, 감시·숨김 순으로 복귀한다. 안전한 상태와 실제 fresh 재고를 순차 검증한다. +6. AGENTS.md와 운영 문서에 브라우저 실험 1세션, finally 회수, 부하 중 작업 중단, 운영 검증과 테스트 직렬화를 명시한다. + +플랫폼 공용 cua_node 누적은 프로젝트에서 임의 회수하지 않는다. 이 변경은 그 누수를 해결했다고 주장하지 않는다. 기존 미커밋 웹 실험은 별도로 보존한다. diff --git a/package.json b/package.json index 09aeaf92..bd1698a7 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "build": "tsc && npx tsx scripts/ops/generate-openapi.ts", "build:openapi": "npx tsx scripts/ops/generate-openapi.ts", "build:discovery": "npx tsx scripts/ops/generate-discovery.ts", - "cli:smoke": "npm run build && npx tsx scripts/ops/cli-smoke.ts", - "mcp:smoke": "npx tsx scripts/ops/mcp-smoke.ts", + "cli:smoke": "python3 scripts/ops/resource-guard.py -- sh -c 'npm run build && node --import tsx scripts/ops/cli-smoke.ts'", + "mcp:smoke": "python3 scripts/ops/resource-guard.py -- node --import tsx scripts/ops/mcp-smoke.ts", + "ops:resources": "python3 scripts/ops/resource-guard.py --status", "update:workers-chart": "npx tsx scripts/ops/update-workers-invocations-chart.ts", "prepack": "npm run build", "prepublishOnly": "npm run check && npm run build", @@ -38,9 +39,9 @@ "format": "prettier .github/**/*.md .github/**/*.yml README.md SECURITY.md AGENTS.md CONTRIBUTING.md package.json tsconfig.json .prettierrc.json .mcp.json eslint.config.ts --write", "format:check": "prettier .github/**/*.md .github/**/*.yml README.md SECURITY.md AGENTS.md CONTRIBUTING.md package.json tsconfig.json .prettierrc.json .mcp.json eslint.config.ts --check", "cf-typegen": "wrangler types", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test": "python3 scripts/ops/resource-guard.py -- node node_modules/vitest/vitest.mjs run", + "test:watch": "python3 scripts/ops/resource-guard.py -- node node_modules/vitest/vitest.mjs", + "test:coverage": "python3 scripts/ops/resource-guard.py -- node node_modules/vitest/vitest.mjs run --coverage", "check": "npm run format:check && npm run lint && npm run lint:biome && npm run typecheck && npm run check:source-lines && npm test", "lint:biome": "biome lint" }, diff --git a/scripts/ops/README.md b/scripts/ops/README.md index b0abb866..11a55167 100644 --- a/scripts/ops/README.md +++ b/scripts/ops/README.md @@ -10,6 +10,14 @@ 운영 스크립트는 `npm run` 명령에서 참조되며, 입력과 실패 메시지는 자동화 로그만으로 원인 추적이 가능해야 합니다. +## 로컬 자원 보호 + +`npm run ops:resources`로 메모리 여유·전체 CPU 비율·Chrome 본체 수를 확인합니다. 테스트·커버리지·CLI/MCP smoke는 Python 3 기반 `resource-guard.py`로 같은 사용자 계정에서 한 번에 하나만 실행합니다. Mac 워커 상한은 1개, 그 외 Unix 환경은 2개입니다. Python 3와 Unix 프로세스 그룹·파일 잠금이 필요합니다. + +시작 시 메모리 여유 20% 미만, CPU 85% 초과 또는 Chrome 본체 2개 초과면 exit 75로 거절합니다. 실행 중 2초 간격으로 확인하며 메모리 12% 미만·브라우저 2개 초과가 3회 연속이거나 CPU 90% 초과가 15회 연속이면 소유 검사 그룹만 종료합니다. 관측 실패도 exit 75입니다. 로그에는 수치와 사유만 남깁니다. 기존 검사 결과나 품질 기준을 생략하지 않습니다. + +OY는 기존 단일 소유권 잠금, 페이지 1개 재사용, 팝업 회수, 1GB RSS·30분·200요청 세션 교체를 유지합니다. 브라우저 실험은 별도 세션 1개만 허용하고 finally에서 종료합니다. 공용 Codex 보조 프로세스 누적은 이 실행기가 해결하거나 강제 종료하지 않습니다. 메모리 압박 중 검사와 운영 조회를 중첩하거나 재시작하지 않습니다. + ## Workers 호출량 차트 새 데이터를 가져와 `scripts/ops/README.md`와 `assets/analytics/` 산출물을 갱신하려면 다음 환경 변수가 필요합니다. diff --git a/scripts/ops/resource-guard.py b/scripts/ops/resource-guard.py new file mode 100644 index 00000000..09d69f93 --- /dev/null +++ b/scripts/ops/resource-guard.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""검사 중복과 자원 고갈을 막고 자신이 시작한 프로세스 그룹만 회수합니다.""" +from contextlib import contextmanager +import fcntl +import json +import math +import os +from pathlib import Path +import re +import signal +import stat +import subprocess +import sys +import time + + +def parse_memory(text): + match = re.search(r'System-wide memory free percentage:\s*(\d+)%', text) + if not match or not 0 <= int(match[1]) <= 100: + raise ValueError('Memory observation unavailable') + return int(match[1]) + + +def parse_processes(text, cores): + cpu, browsers, count = 0.0, {}, 0 + for line in text.splitlines(): + row = line.strip().split(None, 3) + if len(row) != 4: + raise ValueError('Process observation unavailable') + value = float(row[2]) + if not math.isfinite(value) or value < 0: + raise ValueError('CPU observation unavailable') + cpu += value + # 헬퍼·렌더러 수를 Chrome 본체 수로 잘못 계산하지 않습니다. + name = row[3].rsplit('/', 1)[-1] + if name in ['Google Chrome', 'Google Chrome for Testing', 'Google Chrome Canary', + 'Chromium', 'chrome', 'headless_shell', 'chrome-headless-shell']: + browsers[int(row[0])] = int(row[1]) + count += 1 + if not count: + raise ValueError('Process observation unavailable') + # Linux 자식 렌더러도 같은 실행파일 이름을 쓰므로 브라우저 부모가 있는 자식은 제외합니다. + return round(cpu / max(1, cores), 1), sum(parent not in browsers for parent in browsers.values()) + + +def snapshot(): + if sys.platform == 'darwin': + free = parse_memory(subprocess.check_output(['memory_pressure', '-Q'], text=True, timeout=3)) + else: + values = dict(re.findall(r'^(MemTotal|MemAvailable):\s+(\d+)', Path('/proc/meminfo').read_text(), re.M)) + free = round(int(values['MemAvailable']) / int(values['MemTotal']) * 100, 1) + cpu, browsers = parse_processes(subprocess.check_output( + ['ps', '-axo', 'pid=,ppid=,%cpu=,comm='], text=True, timeout=3), os.cpu_count() or 1) + return {'freePercent': free, 'cpuPercent': cpu, 'browsers': browsers} + + +def conditions(value, starting=False): + return {'memory': value['freePercent'] < (20 if starting else 12), + 'browsers': value['browsers'] > 2, + 'cpu': value['cpuPercent'] > (85 if starting else 90)} + + +def reason(value, starting=False): + return next((key for key, failed in conditions(value, starting).items() if failed), None) + + +def emit(event, **fields): + print(json.dumps({'resourceGuard': event, **fields}), file=sys.stderr, flush=True) + + +@contextmanager +def lease(path): + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + info = os.fstat(fd) + if info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077: + raise ValueError('Unsafe lock file') + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + finally: + os.close(fd) + + +def stop(child): + # start_new_session으로 생성한 그룹만 대상으로 하며 공용 브라우저는 건드리지 않습니다. + if child.poll() is None: + try: + if os.getpgid(child.pid) != child.pid: + raise ValueError('Owned process group mismatch') + except ProcessLookupError: + pass + def send(sig): + try: + os.killpg(child.pid, sig) + except ProcessLookupError: + pass + except PermissionError: + # macOS는 좀비만 남은 그룹에도 EPERM을 반환할 수 있습니다. + rows = subprocess.check_output(['ps', '-axo', 'pgid=,stat='], text=True, timeout=3) + if any(row.split()[0] == str(child.pid) and not row.split()[1].startswith('Z') + for row in rows.splitlines() if len(row.split()) == 2): + raise + send(signal.SIGTERM) + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + send(signal.SIGKILL) + child.wait(timeout=3) + + +def watch(child, probe=snapshot, interval=2): + streaks, ticks = {'memory': 0, 'browsers': 0, 'cpu': 0}, 0 + try: + while child.poll() is None: + try: + value = probe() + except Exception: + emit('observation-failed') + stop(child) + return 75 + for key, failed in conditions(value).items(): + streaks[key] = streaks[key] + 1 if failed else 0 + failure = next((key for key, streak in streaks.items() + if streak >= (15 if key == 'cpu' else 3)), None) + if ticks % 15 == 0: + emit('sample', **value) + if failure: + emit('stopped', reason=failure, **value) + stop(child) + return 75 + ticks += 1 + time.sleep(interval) + code = child.returncode if child.returncode >= 0 else 128 - child.returncode + stop(child) + return code + except BaseException: + stop(child) + raise + + +def main(args): + if args == ['--status']: + print(json.dumps(snapshot())) + return 0 + if not args or args[0] != '--' or len(args) < 2: + emit('usage', usage='resource-guard.py --status | -- command [args]') + return 64 + # Actions와 GUI 셸의 TMPDIR이 달라도 같은 사용자 잠금을 공유합니다. + lock = Path('/tmp') / ('daiso-resource-guard-%s.lock' % os.getuid()) + try: + with lease(lock): + value = snapshot() + failure = reason(value, True) + if failure: + emit('refused', reason=failure, **value) + return 75 + # 환경 지정으로 병렬 수가 상한을 넘지 않도록 제한합니다. + env = os.environ.copy() + cap = 1 if sys.platform == 'darwin' else 2 + requested = int(env.get('VITEST_MAX_WORKERS', cap)) + env['VITEST_MAX_WORKERS'] = str(max(1, min(requested, cap))) + emit('started', workers=int(env['VITEST_MAX_WORKERS']), **value) + child = subprocess.Popen(args[1:], env=env, start_new_session=True) + return watch(child) + except BlockingIOError: + emit('refused', reason='another-check-running') + return 75 + except (ValueError, OSError, subprocess.SubprocessError, KeyError): + emit('observation-or-launch-failed') + return 75 + + +if __name__ == '__main__': + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + try: + sys.exit(main(sys.argv[1:])) + except KeyboardInterrupt: + sys.exit(130) diff --git a/tests/scripts/fixtures/resource_guard_tests.py b/tests/scripts/fixtures/resource_guard_tests.py new file mode 100644 index 00000000..ace485ef --- /dev/null +++ b/tests/scripts/fixtures/resource_guard_tests.py @@ -0,0 +1,121 @@ +"""자원 보호의 한도·잠금·소유 프로세스 종료 회귀.""" +import importlib.util +from pathlib import Path +from contextlib import nullcontext +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location('resource_guard', ROOT / 'scripts/ops/resource-guard.py') +guard = importlib.util.module_from_spec(SPEC) + + +class GuardTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + SPEC.loader.exec_module(guard) + + def setUp(self): + silent = patch.object(guard, 'emit') + silent.start() + self.addCleanup(silent.stop) + + def test_start_and_runtime_thresholds(self): + self.assertIsNone(guard.reason({'freePercent': 40, 'cpuPercent': 10, 'browsers': 1}, True)) + self.assertEqual(guard.reason({'freePercent': 19, 'cpuPercent': 10, 'browsers': 1}, True), 'memory') + self.assertEqual(guard.reason({'freePercent': 11, 'cpuPercent': 10, 'browsers': 1}), 'memory') + self.assertIsNone(guard.reason({'freePercent': 19, 'cpuPercent': 10, 'browsers': 1})) + self.assertEqual(guard.reason({'freePercent': 40, 'cpuPercent': 91, 'browsers': 1}), 'cpu') + self.assertEqual(guard.reason({'freePercent': 40, 'cpuPercent': 10, 'browsers': 3}), 'browsers') + + def test_chrome_helpers_do_not_count_as_browsers(self): + rows = ' 10 1 50 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome\n 2 10 50 /Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper\n 3 1 0 /tmp/chrome-headless-shell\n 4 1 0 /tmp/Google Chrome for Testing\n 5 4 0 /tmp/chrome\n 6 5 0 /tmp/chrome\n' + self.assertEqual(guard.parse_processes(rows, 4), (25, 3)) + + def test_missing_or_malformed_measurement_fails_closed(self): + with self.assertRaises(ValueError): + guard.parse_processes('broken', 4) + with self.assertRaises(ValueError): + guard.parse_memory('not a measurement') + self.assertEqual(guard.parse_memory('System-wide memory free percentage: 42%'), 42) + + def test_only_one_guard_can_hold_lock(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / 'lock' + with guard.lease(path): + child = subprocess.run([sys.executable, '-c', + "import runpy,sys; g=runpy.run_path(sys.argv[1]); c=g['lease'](sys.argv[2]); c.__enter__()", + str(ROOT / 'scripts/ops/resource-guard.py'), str(path)], capture_output=True) + self.assertNotEqual(child.returncode, 0) + self.assertIn(b'BlockingIOError', child.stderr) + with guard.lease(path): + pass + + def test_observation_failure_and_pressure_stop_owned_child(self): + for snapshot in [lambda: (_ for _ in ()).throw(ValueError('probe failed')), + lambda: {'freePercent': 5, 'cpuPercent': 0, 'browsers': 0}]: + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], start_new_session=True) + self.assertEqual(guard.watch(child, snapshot, interval=0.01), 75) + self.assertIsNotNone(child.poll()) + + def test_success_keeps_exit_code(self): + child = subprocess.Popen([sys.executable, '-c', 'raise SystemExit(7)'], start_new_session=True) + self.assertEqual(guard.watch(child, lambda: {'freePercent': 50, 'cpuPercent': 0, 'browsers': 0}, interval=0.01), 7) + + def test_refusal_does_not_start_command(self): + with patch.object(guard, 'snapshot', return_value={'freePercent': 5, 'cpuPercent': 0, 'browsers': 0}), \ + patch.object(guard, 'lease', return_value=nullcontext()), \ + patch.object(guard.subprocess, 'Popen') as spawn: + self.assertEqual(guard.main(['--', 'never-run']), 75) + spawn.assert_not_called() + + def test_browser_limit_and_sustained_cpu_stop_child(self): + for value, expected in [({'freePercent': 50, 'cpuPercent': 0, 'browsers': 3}, 3), + ({'freePercent': 50, 'cpuPercent': 95, 'browsers': 0}, 15)]: + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], start_new_session=True) + calls = [] + def probe(): + calls.append(True) + return value + self.assertEqual(guard.watch(child, probe, interval=0.001), 75) + self.assertEqual(len(calls), expected) + + def test_symlink_lock_is_refused(self): + with tempfile.TemporaryDirectory() as d: + target = Path(d) / 'target' + target.write_text('preserve') + link = Path(d) / 'link' + link.symlink_to(target) + with self.assertRaises(OSError): + with guard.lease(link): + self.fail('symlink accepted') + self.assertEqual(target.read_text(), 'preserve') + + def test_changing_memory_reason_does_not_reset_browser_streak(self): + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], start_new_session=True) + values = iter([{'freePercent': free, 'cpuPercent': 0, 'browsers': 3} for free in [11, 13, 11]]) + self.assertEqual(guard.watch(child, lambda: next(values), interval=0.001), 75) + guard.emit.assert_any_call('stopped', reason='browsers', freePercent=11, cpuPercent=0, browsers=3) + + def test_interruption_reaps_child(self): + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'], start_new_session=True) + with self.assertRaises(KeyboardInterrupt): + guard.watch(child, lambda: (_ for _ in ()).throw(KeyboardInterrupt()), interval=0.001) + self.assertIsNotNone(child.poll()) + + def test_normal_exit_reaps_remaining_owned_descendant(self): + with tempfile.TemporaryDirectory() as d: + pidfile = Path(d) / 'child.pid' + script = "import subprocess,sys; p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(30)']); open(sys.argv[1],'w').write(str(p.pid))" + child = subprocess.Popen([sys.executable, '-c', script, str(pidfile)], start_new_session=True) + self.assertEqual(guard.watch(child, lambda: {'freePercent': 50, 'cpuPercent': 0, 'browsers': 0}, interval=0.01), 0) + pid = int(pidfile.read_text()) + result = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], capture_output=True, text=True) + self.assertTrue(result.returncode != 0 or result.stdout.strip().startswith('Z')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/scripts/resource-guard.test.ts b/tests/scripts/resource-guard.test.ts new file mode 100644 index 00000000..17ccc04b --- /dev/null +++ b/tests/scripts/resource-guard.test.ts @@ -0,0 +1,15 @@ +/** 가벼운 Python 회귀로 소유 프로세스 회수와 자원 판정을 확인합니다. */ +import { execFileSync } from 'node:child_process'; +import { expect, it } from 'vitest'; + +it.skipIf(process.platform === 'win32')( + '자원 보호는 중복 검사를 거절하고 소유한 자식만 종료한다', + () => { + const output = execFileSync( + process.platform === 'darwin' ? '/usr/bin/python3' : 'python3', + ['tests/scripts/fixtures/resource_guard_tests.py'], + { encoding: 'utf8' }, + ); + expect(output).toBe(''); + }, +); diff --git a/vitest.config.ts b/vitest.config.ts index 1f6bfdc8..cf96160a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // 운영 중계와 메모리를 공유하는 Mac은 한 워커만 사용합니다. + maxWorkers: process.platform === 'darwin' ? 1 : 2, // 테스트 환경 environment: 'node',