diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c5494..4a30dea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,18 +12,15 @@ concurrency: jobs: unit-tests: - name: Unit tests (server, node ${{ matrix.node-version }}) + name: Unit tests (server) runs-on: ubuntu-latest - strategy: - matrix: - # README's stated minimum, and the version the rest of CI runs on. - node-version: ['20', '22'] + timeout-minutes: 30 steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: - node-version: ${{ matrix.node-version }} + node-version: '22' cache: npm - name: Install dependencies @@ -32,7 +29,7 @@ jobs: - name: Syntax-check all server sources run: | find server \( -name '*.js' -o -name '*.cjs' \) -not -path '*/node_modules/*' -print0 \ - | xargs -0 -n1 node --check + | xargs -0 -P4 -n1 node --check - name: Validate sandbox.config.example.json run: node -e "JSON.parse(require('fs').readFileSync('server/sandbox.config.example.json', 'utf-8'))" @@ -43,6 +40,7 @@ jobs: client-build: name: Client build runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v5 @@ -60,6 +58,7 @@ jobs: e2e: name: E2E tests (Playwright) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v5 @@ -71,6 +70,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Install Playwright browsers run: npx playwright install --with-deps chromium diff --git a/.gitignore b/.gitignore index 18dc566..c3d4faf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ playwright-report/ .saved-notifications.json server/sandbox.config.json /tmp/ +.claude/ diff --git a/README.md b/README.md index de10050..80faadf 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ VS Code のようにフォルダを選択し、ブラウザ内のターミナル ## 必要な環境 -- Node.js >= 20 / npm >= 9 +- Node.js >= 22 / npm >= 9 - C++ コンパイラ(node-pty のビルドに必要。Arch: `base-devel`、Ubuntu: `build-essential`) - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) — このプロジェクトの主対象。既定で起動するエージェント。 - [opencode](https://opencode.ai/) — 任意。入っていれば起動時にアプリとして選べます (下記「起動」参照)。入れずに opencode を選んだ場合、ターミナルに `execvp(3) failed` 等のエラーが表示され起動に失敗します。 diff --git a/server/ws/sessionManager.js b/server/ws/sessionManager.js index 57a94db..3cfc3e0 100644 --- a/server/ws/sessionManager.js +++ b/server/ws/sessionManager.js @@ -321,6 +321,7 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox startedClaudeSessionId: claudeSessionId || null, scheduleId: null, // key into the module-level `schedules` map, if any pendingInjection: null, // { text, at } — scheduled prompt awaiting a freshly-resumed session + pendingInjectionTimer: null, // RESUME_INJECT_FALLBACK_MS safety net; cleared on teardown // Lightweight virtual screen (see screenModel.js): fed every output // chunk, exposing the current visible screen and a change counter so // read_output can tell "spinner still drawing" from "static screen". @@ -375,6 +376,10 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox if (session.pendingInjection) { const inj = session.pendingInjection; session.pendingInjection = null; + if (session.pendingInjectionTimer) { + clearTimeout(session.pendingInjectionTimer); + session.pendingInjectionTimer = null; + } const delivered = injectIntoLiveSession(session, inj.text); notifyFired(session, { at: inj.at, text: inj.text }, delivered); } @@ -478,7 +483,7 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox })); } - if (!session.socket) { + if (!session.socket && sessions.has(session.id)) { startTimeout(session, SESSION_EXITED_TIMEOUT_MS); } }); @@ -794,10 +799,13 @@ async function fireSchedule(scheduleId) { session.pendingInjection = { text: entry.text, at: entry.at }; // Safety net: deliver even if the session never emits an idle gap (e.g. a // plain shell). The idle path normally fires first for Claude sessions. - setTimeout(() => { + // Tracked on the session so a destroyed one doesn't keep a dead timer + // armed (it no-ops, but holds the event loop in tests and lingers in prod). + session.pendingInjectionTimer = setTimeout(() => { if (session.exited || !session.pendingInjection) return; const inj = session.pendingInjection; session.pendingInjection = null; + session.pendingInjectionTimer = null; const delivered = injectIntoLiveSession(session, inj.text); notifyFired(session, inj, delivered); }, RESUME_INJECT_FALLBACK_MS); @@ -977,6 +985,11 @@ export function destroySession(id, { keepSchedule = true } = {}) { session.idleTimer = null; } + if (session.pendingInjectionTimer) { + clearTimeout(session.pendingInjectionTimer); + session.pendingInjectionTimer = null; + } + // By default the scheduled prompt outlives the session (disconnect / idle // timeout / shutdown) and auto-resumes at fire time. Only an explicit // user-initiated teardown cancels it. @@ -994,6 +1007,16 @@ export function destroySession(id, { keepSchedule = true } = {}) { } } + // Force-close the pty master read stream. kill() alone only signals the + // child; if a grandchild still holds the slave fd (or the child lingers), + // the master never sees EOF and the read stream keeps the event loop + // alive indefinitely (hanging test runners and lingering handles in prod). + try { + session.ptyProcess.destroy(); + } catch { + // already torn down + } + // Remove the sandbox's unique rootlesskit state dir. The --unshare-pid tree is // torn down by the kill above (kernel reaps dockerd with the namespace); this // just clears the leftover socket dir under /run. Best effort — the dir is diff --git a/server/ws/sessionManager.test.js b/server/ws/sessionManager.test.js index 17bcf80..0bcf74e 100644 --- a/server/ws/sessionManager.test.js +++ b/server/ws/sessionManager.test.js @@ -65,6 +65,10 @@ before(async () => { after(() => { try { rmSync(runtimeDir, { recursive: true, force: true }); } catch { /* ignore */ } + // Release the exited-session retention timers (30s cleanup) and any + // pending schedule/fallback timers the tests armed, so the runner process + // exits promptly instead of waiting out the production retention period. + sessionManager.destroyAllSessions(); }); test('savedSessionPublic preserves group membership (restart filter keeps working)', () => {