From 511b94133aa15d5cbab73063d1bf50d0d9a731bd Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:15:31 +0300 Subject: [PATCH 1/2] fix: harden SessionEnd cleanup within Codex deadline --- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 8 + README.md | 14 +- hooks/hooks.json | 2 +- hooks/session-lifecycle-hook.mjs | 283 +++++++++++-- package-lock.json | 4 +- package.json | 2 +- scripts/lib/git.mjs | 20 +- scripts/lib/process.mjs | 8 +- scripts/lib/render.mjs | 72 +++- scripts/lib/session-cleanup.mjs | 8 + scripts/lib/state.mjs | 118 +++++- scripts/lib/workspace.mjs | 52 ++- stryker.shard.config.mjs | 15 +- tests/hooks.test.mjs | 695 ++++++++++++++++++++++++++++++- tests/mutation-config.test.mjs | 15 +- tests/process.test.mjs | 16 +- tests/render.test.mjs | 76 +++- tests/state.test.mjs | 95 +++++ tests/workspace.test.mjs | 68 +++ 20 files changed, 1439 insertions(+), 134 deletions(-) create mode 100644 scripts/lib/session-cleanup.mjs create mode 100644 tests/workspace.test.mjs diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 92d2853..310f66e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.5.2", + "version": "1.5.3", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, tracked tasks, and transcript transfers from inside Codex.", "author": { "name": "CBEPX", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a38b7f..79beafa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## v1.5.3 + +### Fixed + +- Keep Codex SessionEnd cleanup within its three-second ceiling by bounding workspace detection and sharing one monotonic deadline across state-lock retries and bounded PID identity/termination commands, capped 500 ms before the hook ceiling. +- Record ended sessions before taking per-job locks, persist unresolved PID/identity handles in a non-terminal `session_cleanup_pending` phase before termination, surface automatic and identity-aware manual cleanup, and let the next top-level `SessionStart` recover unmarked or pending jobs without touching unrelated and nested sessions. +- Keep deadline-bound Windows lock publication independent of a cold CIM startup; target-process verification remains fail-closed and leaves an explicit pending/manual-cleanup state when PowerShell cannot finish inside the hook budget. + ## v1.5.2 ### Added diff --git a/README.md b/README.md index 99c7823..b36cc32 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex Install the fork release from the CBEPX marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.2 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.3 codex plugin add cc@cbepx ``` @@ -59,8 +59,8 @@ The optional `npx` helper can install this fork release and enable the required ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.2 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.2/cc-plugin-codex-1.5.2.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.3 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.3/cc-plugin-codex-1.5.3.tgz install ``` On Windows, prefer the marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. @@ -334,7 +334,7 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C Install from the fork's marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.2 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.3 codex plugin add cc@cbepx ``` @@ -355,8 +355,8 @@ This fork does not install from the upstream Sendbird marketplace. Use the CBEPX ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.2 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.2/cc-plugin-codex-1.5.2.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.5.3 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.5.3/cc-plugin-codex-1.5.3.tgz install ``` After install, run: @@ -386,7 +386,7 @@ $cc:setup Re-run the fork marketplace install flow, pinned to the release you want: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.2 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.5.3 codex plugin add cc@cbepx ``` diff --git a/hooks/hooks.json b/hooks/hooks.json index 6121289..e1ada7b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -19,7 +19,7 @@ { "type": "command", "command": "node \"$PLUGIN_ROOT/hooks/session-lifecycle-hook.mjs\" SessionEnd", - "timeout": 45, + "timeout": 3, "statusMessage": "Cleaning up Claude Code bridge jobs" } ] diff --git a/hooks/session-lifecycle-hook.mjs b/hooks/session-lifecycle-hook.mjs index badd540..d3e533f 100644 --- a/hooks/session-lifecycle-hook.mjs +++ b/hooks/session-lifecycle-hook.mjs @@ -17,6 +17,7 @@ import fs from "node:fs"; import path from "node:path"; +import { performance } from "node:perf_hooks"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -26,11 +27,18 @@ import { terminateProcessTreeIfIdentityMatches } from "../scripts/lib/process.mj import { ACTIVE_JOB_STATUSES, clearCurrentSession, + clearSessionCleanupPending, getCurrentSession, + listPendingSessionCleanups, listStoredJobs, + markSessionCleanupPending, setCurrentSession, transitionJob, } from "../scripts/lib/state.mjs"; +import { + SESSION_CLEANUP_PENDING_MESSAGE, + SESSION_CLEANUP_PENDING_PHASE, +} from "../scripts/lib/session-cleanup.mjs"; import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs"; import { TRANSCRIPT_PATH_ENV } from "../scripts/lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs"; @@ -39,7 +47,8 @@ export { SESSION_ID_ENV }; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS"; const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const SESSION_CLEANUP_SOFT_BUDGET_MS = 20_000; +const SESSION_CLEANUP_BUDGET_MS = 1_500; +const SESSION_HOOK_CLEANUP_DEADLINE_MS = 2_500; function shellEscape(value) { return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; @@ -69,43 +78,154 @@ function isNestedCodexSession(inputSessionId) { // Session cleanup // --------------------------------------------------------------------------- -function cleanupSessionJobs(cwd, sessionId) { - if (!cwd || !sessionId) { - return; - } +function createCleanupDeadlineAt() { + return Math.min( + SESSION_HOOK_CLEANUP_DEADLINE_MS, + performance.now() + SESSION_CLEANUP_BUDGET_MS + ); +} - const workspaceRoot = resolveWorkspaceRoot(cwd); - const jobs = listStoredJobs(workspaceRoot); - const sessionJobs = jobs.filter((job) => job.sessionId === sessionId); - if (sessionJobs.length === 0) { - return; +function remainingCleanupMs(cleanupDeadlineAt) { + return Math.max( + 0, + cleanupDeadlineAt - performance.now() + ); +} + +function resolveLifecycleWorkspaceRoot(cwd) { + return resolveWorkspaceRoot(cwd, { + filesystemFallbackOnTimeout: true, + gitTimeout: 200, + processLocalHandoff: true, + }); +} + +function reportLifecycleFailure(eventName, error) { + const code = typeof error?.code === "string" ? ` (${error.code})` : ""; + const detail = error instanceof Error ? error.message : String(error); + process.stderr.write(`[cc] ${eventName} cleanup failed${code}: ${detail}\n`); +} + +function transitionWithinCleanupBudget( + workspaceRoot, + job, + expectedStatuses, + nextStatus, + patch, + cleanupDeadlineAt +) { + if (remainingCleanupMs(cleanupDeadlineAt) < 1) { + return null; } + return transitionJob( + workspaceRoot, + job.id, + expectedStatuses, + nextStatus, + patch, + { + deadlineAt: cleanupDeadlineAt, + skipLockOwnerIdentity: process.platform === "win32", + } + ); +} + +function isRetryableCancelFailure(job) { + return ( + job.status === "cancel_failed" && + Number.isInteger(job.pid) && + job.pid > 0 && + typeof job.pidIdentity === "string" && + job.pidIdentity.length > 0 + ); +} + +function sessionStillNeedsOwnershipMarker(jobs, sessionId) { + return jobs.some( + (job) => + job.sessionId === sessionId && + (isRetryableCancelFailure(job) || + (ACTIVE_JOB_STATUSES.has(job.status) && + !( + job.status === "cancelling" && + job.phase === SESSION_CLEANUP_PENDING_PHASE + ))) + ); +} - const cleanupStartedAt = Date.now(); - for (const job of sessionJobs) { - const stillRunning = ACTIVE_JOB_STATUSES.has(job.status); - if (!stillRunning) { +function cleanupSessionJobs(workspaceRoot, jobs, trigger, cleanupDeadlineAt) { + const prepared = []; + const updatedJobsById = new Map(jobs.map((job) => [job.id, job])); + let preparationComplete = true; + + // Persist every unresolved process handle before any external identity check. + for (const job of jobs) { + if ( + job.status === "cancel_failed" || + (job.status === "cancelling" && + job.phase === SESSION_CLEANUP_PENDING_PHASE) + ) { + prepared.push(job); + continue; + } + if (!ACTIVE_JOB_STATUSES.has(job.status)) { continue; } - const hasPid = Number.isFinite(job.pid); + + try { + const transition = transitionWithinCleanupBudget( + workspaceRoot, + job, + [job.status], + "cancelling", + { + completedAt: null, + errorMessage: SESSION_CLEANUP_PENDING_MESSAGE, + phase: SESSION_CLEANUP_PENDING_PHASE, + }, + cleanupDeadlineAt + ); + if (!transition) { + preparationComplete = false; + break; + } + if (transition.job) { + updatedJobsById.set(transition.job.id, transition.job); + } + if (transition.transitioned) { + prepared.push(transition.job); + } else if ( + (transition.job?.status === "cancel_failed" || + (transition.job?.status === "cancelling" && + transition.job?.phase === SESSION_CLEANUP_PENDING_PHASE)) + ) { + prepared.push(transition.job); + } + } catch { + preparationComplete = false; + } + } + + for (const job of prepared) { + const timeout = Math.floor(remainingCleanupMs(cleanupDeadlineAt)); + if (timeout <= 0) { + // Every prepared job already carries a recoverable status or phase. + break; + } + + const hasPid = Number.isInteger(job.pid) && job.pid > 0; + const hasIdentity = + typeof job.pidIdentity === "string" && job.pidIdentity.length > 0; let canSafelyCancel = !hasPid; let cancellationFailure = "Refused to terminate a stored process without a matching PID identity."; - const cleanupBudgetExhausted = - hasPid && - Date.now() - cleanupStartedAt >= SESSION_CLEANUP_SOFT_BUDGET_MS; - if (cleanupBudgetExhausted) { - cancellationFailure = - "Skipped process-tree termination because the SessionEnd cleanup budget was exhausted."; - } else if ( - hasPid && - typeof job.pidIdentity === "string" && - job.pidIdentity - ) { + + if (hasPid && hasIdentity) { try { const result = terminateProcessTreeIfIdentityMatches( job.pid, - job.pidIdentity + job.pidIdentity, + { timeout } ); canSafelyCancel = result.delivered || @@ -121,26 +241,41 @@ function cleanupSessionJobs(cwd, sessionId) { cancellationFailure = `Failed to terminate the stored process tree: ${detail}`; } } + + if (remainingCleanupMs(cleanupDeadlineAt) < 1) { + // Keep the pre-termination marker for SessionStart recovery. + break; + } try { - transitionJob( + const transition = transitionJob( workspaceRoot, job.id, - [job.status], + ["cancelling", "cancel_failed"], canSafelyCancel ? "cancelled" : "cancel_failed", { completedAt: nowIso(), errorMessage: canSafelyCancel - ? "Cancelled when the Codex session ended." + ? `Cancelled because ${trigger}.` : cancellationFailure, pid: canSafelyCancel ? null : job.pid ?? null, pidIdentity: canSafelyCancel ? null : job.pidIdentity ?? null, + pgid: canSafelyCancel ? null : job.pgid ?? null, phase: canSafelyCancel ? "cancelled" : "cancel_failed", + }, + { + deadlineAt: cleanupDeadlineAt, + skipLockOwnerIdentity: process.platform === "win32", } ); + if (transition.job) { + updatedJobsById.set(transition.job.id, transition.job); + } } catch { - // Ignore state transition races during session shutdown. + // The pre-termination marker remains recoverable. } } + + return { jobs: [...updatedJobsById.values()], preparationComplete }; } // --------------------------------------------------------------------------- @@ -159,25 +294,103 @@ function handleSessionStart(input) { // Forward plugin data dir if set appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]); if (input.session_id && !nestedSession) { - setCurrentSession(cwd, input.session_id); + let workspaceRoot = null; + try { + const cleanupDeadlineAt = createCleanupDeadlineAt(); + workspaceRoot = resolveLifecycleWorkspaceRoot(cwd); + const jobs = listStoredJobs(workspaceRoot); + const pendingSessionIds = new Set( + listPendingSessionCleanups(workspaceRoot) + ); + const recoverableJobs = jobs.filter( + (job) => { + const pendingPhase = + job.status === "cancelling" && + job.phase === SESSION_CLEANUP_PENDING_PHASE; + const endedSession = + typeof job.sessionId === "string" && + pendingSessionIds.has(job.sessionId); + return ( + pendingPhase || + (endedSession && + (ACTIVE_JOB_STATUSES.has(job.status) || + isRetryableCancelFailure(job))) + ); + } + ); + const cleanup = cleanupSessionJobs( + workspaceRoot, + recoverableJobs, + "pending cleanup was retried", + cleanupDeadlineAt + ); + const cleanedJobsById = new Map( + cleanup.jobs.map((job) => [job.id, job]) + ); + const updatedJobs = jobs.map( + (job) => cleanedJobsById.get(job.id) ?? job + ); + for (const pendingSessionId of pendingSessionIds) { + if ( + !sessionStillNeedsOwnershipMarker(updatedJobs, pendingSessionId) + ) { + clearSessionCleanupPending(workspaceRoot, pendingSessionId); + } + } + } catch (error) { + reportLifecycleFailure("SessionStart", error); + } + if (workspaceRoot) { + setCurrentSession(workspaceRoot, input.session_id); + } } } function handleSessionEnd(input) { + const cleanupDeadlineAt = createCleanupDeadlineAt(); const cwd = input.cwd || process.cwd(); + let workspaceRoot = null; + let cleanupMarkerRecorded = false; let sessionId = input.session_id || process.env[SESSION_ID_ENV] || null; if (!sessionId) { try { - sessionId = getCurrentSession(resolveWorkspaceRoot(cwd)); + workspaceRoot = resolveLifecycleWorkspaceRoot(cwd); + sessionId = getCurrentSession(workspaceRoot); } catch { sessionId = null; } } // Clean up tracked jobs for this session - cleanupSessionJobs(cwd, sessionId); if (sessionId) { - clearCurrentSession(cwd, sessionId); + try { + workspaceRoot ??= resolveLifecycleWorkspaceRoot(cwd); + markSessionCleanupPending(workspaceRoot, sessionId); + cleanupMarkerRecorded = true; + const sessionJobs = listStoredJobs(workspaceRoot).filter( + (job) => + job.sessionId === sessionId && + (ACTIVE_JOB_STATUSES.has(job.status) || + isRetryableCancelFailure(job)) + ); + const cleanup = cleanupSessionJobs( + workspaceRoot, + sessionJobs, + "the Codex session ended", + cleanupDeadlineAt + ); + if ( + cleanup.preparationComplete && + !sessionStillNeedsOwnershipMarker(cleanup.jobs, sessionId) + ) { + clearSessionCleanupPending(workspaceRoot, sessionId); + } + } catch (error) { + reportLifecycleFailure("SessionEnd", error); + } + if (workspaceRoot && cleanupMarkerRecorded) { + clearCurrentSession(workspaceRoot, sessionId); + } } } diff --git a/package-lock.json b/package-lock.json index 776ac6c..2c90d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.5.2", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.5.2", + "version": "1.5.3", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index a13d661..1a45db9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.5.2", + "version": "1.5.3", "description": "Claude Code Plugin for Codex (CBEPX fork)", "type": "module", "author": { diff --git a/scripts/lib/git.mjs b/scripts/lib/git.mjs index 9cae8bd..d63f65d 100644 --- a/scripts/lib/git.mjs +++ b/scripts/lib/git.mjs @@ -23,16 +23,20 @@ function gitChecked(cwd, args, options = {}) { return runCommandChecked("git", args, { cwd, ...options }); } -export function ensureGitRepository(cwd) { - const result = git(cwd, ["rev-parse", "--show-toplevel"]); - const errorCode = result.error && "code" in result.error ? result.error.code : null; - if (errorCode === "ENOENT") { - throw new Error("git is not installed. Install Git and retry."); - } - if (result.status !== 0) { +export function ensureGitRepository(cwd, options = {}) { + try { + return gitChecked( + cwd, + ["rev-parse", "--show-toplevel"], + options + ).stdout.trim(); + } catch (error) { + if (error?.code === "ETIMEDOUT") throw error; + if (error?.code === "ENOENT") { + throw new Error("git is not installed. Install Git and retry."); + } throw new Error("This command must run inside a Git repository."); } - return result.stdout.trim(); } export function getRepoRoot(cwd) { diff --git a/scripts/lib/process.mjs b/scripts/lib/process.mjs index d5edc8d..450cd45 100644 --- a/scripts/lib/process.mjs +++ b/scripts/lib/process.mjs @@ -207,7 +207,7 @@ export function terminateProcessTreeIfIdentityMatches( const isAlive = options.isProcessAliveImpl ?? isProcessAlive; let actualIdentity; try { - actualIdentity = getIdentity(pid); + actualIdentity = getIdentity(pid, { timeout: options.timeout }); } catch { return { attempted: false, @@ -449,7 +449,11 @@ export function getProcessIdentity(pid, options = {}) { throw new Error("Windows process creation time was unavailable"); } } else if (platform === "darwin") { - const row = runCommandCheckedImpl("ps", ["-o", "lstart=,comm=", "-p", String(pid)]); + const row = runCommandCheckedImpl( + "ps", + ["-o", "lstart=,comm=", "-p", String(pid)], + { timeout: options.timeout } + ); identity = row.stdout.trim(); } else { const stat = readFileSyncImpl(`/proc/${pid}/stat`, "utf8"); diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 344d40f..3857e24 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -10,6 +10,11 @@ import path from "node:path"; import process from "node:process"; + +import { + SESSION_CLEANUP_PENDING_MESSAGE, + SESSION_CLEANUP_PENDING_PHASE, +} from "./session-cleanup.mjs"; import { parseStructuredOutput } from "./structured-output.mjs"; function severityRank(severity) { @@ -237,7 +242,18 @@ function appendModelFallbackSuffix(suffix, events, output = "") { } function isPendingJob(job) { - return job?.status === "queued" || job?.status === "running"; + return ( + job?.status === "queued" || + job?.status === "running" || + job?.status === "cancelling" + ); +} + +function isSessionCleanupPending(job) { + return ( + job?.status === "cancelling" && + job?.phase === SESSION_CLEANUP_PENDING_PHASE + ); } function collectStatusRows(report) { @@ -422,6 +438,16 @@ function formatManualCleanupCommand(pid, platform = process.platform) { : `kill -9 -${pid}`; } +function formatProcessVerificationCommand(pid, platform = process.platform) { + if (platform === "win32") { + return `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'"`; + } + if (platform === "darwin") { + return `ps -o lstart=,comm= -p ${pid}`; + } + return `ps -o pid=,lstart=,comm= -p ${pid} && cat /proc/${pid}/stat`; +} + export function renderJobStatusReport(job, platform = process.platform) { const lines = ["# Claude Code Job Status", "", "| Field | Value |", "| --- | --- |"]; pushKeyValueTableRow(lines, "Job", `\`${job.id}\``, { raw: true }); @@ -453,34 +479,37 @@ export function renderJobStatusReport(job, platform = process.platform) { } else { pushKeyValueTableRow(lines, "Result", `\`${formatClaudeSkillCommand("result", job.id)}\``, { raw: true }); } + const sessionCleanupPending = isSessionCleanupPending(job); + if (sessionCleanupPending) { + pushKeyValueTableRow( + lines, + "Cleanup", + job.errorMessage ?? SESSION_CLEANUP_PENDING_MESSAGE + ); + } const cleanupPid = resolveManualCleanupPid(job); - const needsWindowsPidVerification = - platform === "win32" && + const verificationPid = job.pid ?? cleanupPid; + const needsVerifiedManualCleanup = cleanupPid && - (job.status === "failed" || job.status === "cancel_failed"); - if (needsWindowsPidVerification) { + verificationPid && + job.pidIdentity && + (job.status === "failed" || + job.status === "cancel_failed" || + sessionCleanupPending); + if (needsVerifiedManualCleanup) { pushKeyValueTableRow( lines, "Verify process", - `\`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${cleanupPid}'"\``, + `\`${formatProcessVerificationCommand(verificationPid, platform)}\``, { raw: true } ); - if (job.pidIdentity) { - pushKeyValueTableRow(lines, "Recorded process identity", job.pidIdentity); - } + pushKeyValueTableRow(lines, "Recorded process identity", job.pidIdentity); pushKeyValueTableRow( lines, "Manual cleanup (after verification)", `\`${formatManualCleanupCommand(cleanupPid, platform)}\``, { raw: true } ); - } else if (job.status === "cancel_failed" && cleanupPid) { - pushKeyValueTableRow( - lines, - "Manual cleanup", - `\`${formatManualCleanupCommand(cleanupPid, platform)}\``, - { raw: true } - ); } return `${lines.join("\n").trimEnd()}\n`; } @@ -533,19 +562,18 @@ export function renderCancelReport(job, platform = process.platform) { if (job.summary) lines.push(`- Summary: ${job.summary}`); if (job.status === "cancel_failed") { const cleanupPid = resolveManualCleanupPid(job); + const verificationPid = job.pid ?? cleanupPid; const cleanup = formatManualCleanupCommand(cleanupPid, platform); if (!cleanupPid) { lines.push("- Warning: Process may still be alive, but no cleanup PID was recorded."); - } else if (platform === "win32") { + } else if (verificationPid && job.pidIdentity) { lines.push( - `- Verify process before cleanup: \`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${cleanupPid}'"\`` + `- Verify process before cleanup: \`${formatProcessVerificationCommand(verificationPid, platform)}\`` ); - if (job.pidIdentity) { - lines.push(`- Recorded process identity: ${job.pidIdentity}`); - } + lines.push(`- Recorded process identity: ${job.pidIdentity}`); lines.push(`- Manual cleanup (after verification): ${cleanup}`); } else { - lines.push(`- Warning: Process group may still be alive. Manual cleanup: ${cleanup}`); + lines.push("- Warning: Process may still be alive, but no recorded process identity is available for safe manual cleanup."); } } lines.push("- Check `$cc:status` for the updated queue."); diff --git a/scripts/lib/session-cleanup.mjs b/scripts/lib/session-cleanup.mjs new file mode 100644 index 0000000..fd75820 --- /dev/null +++ b/scripts/lib/session-cleanup.mjs @@ -0,0 +1,8 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SESSION_CLEANUP_PENDING_PHASE = "session_cleanup_pending"; +export const SESSION_CLEANUP_PENDING_MESSAGE = + "Automatic cleanup pending; it will retry on the next top-level Codex session."; diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index 8935927..84dbc3c 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -16,6 +16,7 @@ import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { performance } from "node:perf_hooks"; import process from "node:process"; import { @@ -53,6 +54,7 @@ const INVALID_LOCK_STALE_MS = 15_000; const LIVE_LOCK_LEASE_MS = 30_000; const LOCK_HARD_STALE_MS = LIVE_LOCK_LEASE_MS * 4; const RESERVED_JOB_FILE_MAX_AGE_MS = 60 * 60 * 1000; +const SESSION_CLEANUP_MARKER_PREFIX = "session-cleanup-pending-"; const HARD_LINK_UNSUPPORTED_CODES = new Set([ "EPERM", "ENOSYS", @@ -229,6 +231,14 @@ function resolveCurrentSessionFile(cwd) { return path.join(resolveStateDir(cwd), CURRENT_SESSION_FILE_NAME); } +function resolveSessionCleanupMarkerFile(cwd, sessionId) { + sanitizeId(sessionId, "session ID"); + return path.join( + resolveStateDir(cwd), + `${SESSION_CLEANUP_MARKER_PREFIX}${sessionId}.json` + ); +} + function resolveStopReviewLastFile(cwd) { return path.join(resolveStateDir(cwd), STOP_REVIEW_LAST_FILE_NAME); } @@ -308,6 +318,50 @@ export function clearCurrentSession(cwd, sessionId = null) { } catch {} } +export function markSessionCleanupPending(cwd, sessionId) { + ensureStateDir(cwd); + writeAtomic(resolveSessionCleanupMarkerFile(cwd, sessionId), { + sessionId, + updatedAt: nowIso(), + }); +} + +export function listPendingSessionCleanups(cwd) { + const stateDir = resolveStateDir(cwd); + try { + return [ + ...new Set( + fs + .readdirSync(stateDir) + .filter( + (name) => + name.startsWith(SESSION_CLEANUP_MARKER_PREFIX) && + name.endsWith(".json") + ) + .map((name) => { + try { + const payload = JSON.parse( + fs.readFileSync(path.join(stateDir, name), "utf8") + ); + return sanitizeId(payload.sessionId, "session ID"); + } catch { + return null; + } + }) + .filter(Boolean) + ), + ]; + } catch { + return []; + } +} + +export function clearSessionCleanupPending(cwd, sessionId) { + try { + fs.unlinkSync(resolveSessionCleanupMarkerFile(cwd, sessionId)); + } catch {} +} + // --------------------------------------------------------------------------- // Input sanitization // --------------------------------------------------------------------------- @@ -761,7 +815,24 @@ function unlinkLockIfUnchanged(lockFile, expectedSource) { } catch {} } -function recoverStaleLock(lockFile) { +function remainingLockDeadlineMs(options) { + if (!Number.isFinite(options.deadlineAt)) { + return null; + } + const remaining = Math.floor(options.deadlineAt - performance.now()); + if (remaining > 0) { + return remaining; + } + throw Object.assign(new Error("Job state lock deadline expired"), { + code: "ELOCKTIMEOUT", + }); +} + +function lockProcessTimeout(options) { + return remainingLockDeadlineMs(options) ?? undefined; +} + +function recoverStaleLock(lockFile, options = {}) { let lockSource; let lockFileAgeMs; try { @@ -811,17 +882,29 @@ function recoverStaleLock(lockFile) { } try { - if (getProcessIdentity(pid) !== lockData.identity) { + if (getProcessIdentity(pid, { timeout: lockProcessTimeout(options) }) !== lockData.identity) { unlinkLockIfUnchanged(lockFile, lockSource); } - } catch {} + } catch (error) { + if (error?.code === "ELOCKTIMEOUT") { + throw error; + } + } } -function acquireJobLock(lockFile) { +function acquireJobLock(lockFile, options = {}) { let lockOwnerIdentity = null; - try { - lockOwnerIdentity = getProcessIdentity(process.pid); - } catch {} + if (!options.skipLockOwnerIdentity) { + try { + lockOwnerIdentity = getProcessIdentity(process.pid, { + timeout: lockProcessTimeout(options), + }); + } catch (error) { + if (error?.code === "ELOCKTIMEOUT") { + throw error; + } + } + } const token = randomBytes(16).toString("hex"); const stagedLockFile = `${lockFile}.publish.${process.pid}.${token}`; @@ -840,7 +923,9 @@ function acquireJobLock(lockFile) { ); for (let attempt = 0; ; attempt += 1) { - recoverStaleLock(lockFile); + remainingLockDeadlineMs(options); + recoverStaleLock(lockFile, options); + remainingLockDeadlineMs(options); try { if (hardLinksUnsupported) { fs.writeFileSync(lockFile, lockSource, { @@ -862,8 +947,12 @@ function acquireJobLock(lockFile) { continue; } if (err.code === "EEXIST" && attempt < CAS_MAX_RETRIES - 1) { - const delay = + let delay = CAS_RETRY_DELAY_MS + Math.random() * CAS_RETRY_DELAY_MS; + const remaining = remainingLockDeadlineMs(options); + if (remaining !== null) { + delay = Math.min(delay, remaining); + } sleepSync(delay); continue; } @@ -960,13 +1049,20 @@ export function casJobStatus(cwd, jobId, expected, next, extra = {}) { return transitionJob(cwd, jobId, [expected], next, extra).transitioned; } -export function transitionJob(cwd, jobId, expectedStatuses, next, extra = {}) { +export function transitionJob( + cwd, + jobId, + expectedStatuses, + next, + extra = {}, + options = {} +) { const jobFile = resolveJobFile(cwd, jobId); const lockFile = jobFile + ".lock"; const expectedList = Array.isArray(expectedStatuses) ? expectedStatuses : [expectedStatuses]; - const lockToken = acquireJobLock(lockFile); + const lockToken = acquireJobLock(lockFile, options); try { const job = JSON.parse(fs.readFileSync(jobFile, "utf8")); diff --git a/scripts/lib/workspace.mjs b/scripts/lib/workspace.mjs index d6e1a4d..9099051 100644 --- a/scripts/lib/workspace.mjs +++ b/scripts/lib/workspace.mjs @@ -2,12 +2,56 @@ * Copyright 2026 Sendbird, Inc. * SPDX-License-Identifier: Apache-2.0 */ +import fs from "node:fs"; +import path from "node:path"; + import { ensureGitRepository } from "./git.mjs"; -export function resolveWorkspaceRoot(cwd) { +const cachedWorkspaceRoots = new Map(); + +function cacheWorkspaceRoot(cacheKey, workspaceRoot) { + cachedWorkspaceRoots.set(cacheKey, workspaceRoot); + cachedWorkspaceRoots.set(path.resolve(workspaceRoot), workspaceRoot); +} + +function findGitRoot(cwd) { + // Lifecycle-hook fast path for ordinary repositories and .git-file worktrees. + let current = path.resolve(cwd); + while (true) { + if (fs.existsSync(path.join(current, ".git"))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +export function resolveWorkspaceRoot(cwd, options = {}) { + const cacheKey = path.resolve(cwd); + // Lifecycle hooks opt in to this process-local handoff so state hashing does + // not spawn Git again before the short-lived hook process exits. + if (cachedWorkspaceRoots.has(cacheKey)) { + return cachedWorkspaceRoots.get(cacheKey); + } try { - return ensureGitRepository(cwd); - } catch { - return cwd; + const workspaceRoot = ensureGitRepository(cwd, { + timeout: options.gitTimeout, + }); + if (options.processLocalHandoff) { + cacheWorkspaceRoot(cacheKey, workspaceRoot); + } + return workspaceRoot; + } catch (error) { + const workspaceRoot = + error?.code === "ETIMEDOUT" && options.filesystemFallbackOnTimeout + ? findGitRoot(cwd) ?? cwd + : cwd; + if (options.processLocalHandoff) { + cacheWorkspaceRoot(cacheKey, workspaceRoot); + } + return workspaceRoot; } } diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index 40ad041..b3db7a5 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -14,19 +14,20 @@ const shards = { "scripts/lib/process.mjs:75-106", "scripts/lib/process.mjs:108-179", "scripts/lib/process.mjs:185-367", - "scripts/lib/process.mjs:390-500", + "scripts/lib/process.mjs:390-504", ], }, state: { command: "npm run test:mutation:state:unit", mutate: [ // Persistence lifecycle, session lookup, and terminal job transitions. - "scripts/lib/state.mjs:178-218", - "scripts/lib/state.mjs:341-389", - "scripts/lib/state.mjs:466-707", - "scripts/lib/state.mjs:756-893", - "scripts/lib/state.mjs:959-1009", - "scripts/lib/state.mjs:1015-1061", + "scripts/lib/state.mjs:180-220", + "scripts/lib/state.mjs:289-363", + "scripts/lib/state.mjs:395-443", + "scripts/lib/state.mjs:520-761", + "scripts/lib/state.mjs:810-982", + "scripts/lib/state.mjs:1048-1105", + "scripts/lib/state.mjs:1111-1157", "scripts/lib/tracked-jobs.mjs:26-39", "scripts/lib/tracked-jobs.mjs:356-482", ], diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 6c9f53a..f0421b8 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -196,6 +196,7 @@ function createHookEnvironment(options = {}) { USERPROFILE: homeDir, CODEX_HOME: path.join(homeDir, ".codex"), PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, + [SESSION_ID_ENV]: "", }, }; } @@ -225,13 +226,19 @@ function stateDirFor(homeDir, workspaceDir) { ); } -function runHook(scriptPath, args, input, env) { +function runHook(scriptPath, args, input, env, options = {}) { const result = spawnSync(process.execPath, [scriptPath, ...args], { cwd: PROJECT_ROOT, env, input: JSON.stringify(input), encoding: "utf8", + timeout: options.timeout, }); + assert.equal( + result.signal, + null, + result.error?.message || result.stderr || result.stdout + ); assert.equal(result.status, 0, result.stderr || result.stdout); return result; } @@ -265,6 +272,16 @@ function writeStateJob(testEnv, jobId, payload) { ); } +function writePendingSessionCleanup(testEnv, sessionId) { + const stateDir = stateDirFor(testEnv.homeDir, testEnv.workspaceDir); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, `session-cleanup-pending-${sessionId}.json`), + JSON.stringify({ sessionId, updatedAt: new Date().toISOString() }), + "utf8" + ); +} + function readStateJob(testEnv, jobId) { return JSON.parse( fs.readFileSync( @@ -467,11 +484,76 @@ describe("hooks", () => { } }); - it("bounds SessionEnd outside the internal cleanup deadline", () => { + it("SessionEnd does not rescan every job after deadline-bound cleanup", () => { + const testEnv = createHookEnvironment(); + + try { + writeStateJob(testEnv, "single-scan-job", { + id: "single-scan-job", + status: "queued", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: new Date().toISOString(), + }); + const preload = path.join(testEnv.rootDir, "single-job-scan.mjs"); + fs.writeFileSync( + preload, + `import fs from "node:fs"; +const originalReaddirSync = fs.readdirSync.bind(fs); +let jobDirectoryReads = 0; +fs.readdirSync = (directory, ...args) => { + if (String(directory).endsWith("/jobs")) { + jobDirectoryReads += 1; + if (jobDirectoryReads > 1) { + const error = new Error("simulated second jobs-directory scan"); + error.code = "ESECONDREAD"; + throw error; + } + } + return originalReaddirSync(directory, ...args); +}; +`, + "utf8" + ); + + const result = runHook( + SESSION_HOOK, + ["SessionEnd"], + { cwd: testEnv.workspaceDir, session_id: "hook-session" }, + { + ...testEnv.env, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(preload).href}`, + ] + .filter(Boolean) + .join(" "), + } + ); + + assert.equal(result.stderr, ""); + assert.equal(readStateJob(testEnv, "single-scan-job").status, "cancelled"); + assert.equal( + fs.existsSync( + path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir), + "session-cleanup-pending-hook-session.json" + ) + ), + false + ); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("keeps SessionEnd within the Codex three-second ceiling", () => { const manifest = JSON.parse(fs.readFileSync(HOOKS_MANIFEST, "utf8")); + const sessionStartHandler = manifest.hooks.SessionStart[0].hooks[0]; const handler = manifest.hooks.SessionEnd[0].hooks[0]; - assert.equal(handler.timeout, 45); + assert.equal("timeout" in sessionStartHandler, false); + assert.equal(handler.timeout, 3); assert.match(handler.command, /session-lifecycle-hook\.mjs.*SessionEnd/u); }); @@ -509,7 +591,7 @@ describe("hooks", () => { } }); - it("session lifecycle hook preserves recovery handles after its cleanup budget", () => { + it("session lifecycle hook reserves preparation time after a slow process start", () => { const testEnv = createHookEnvironment(); const createdAt = new Date().toISOString(); @@ -537,13 +619,18 @@ describe("hooks", () => { const clockPreload = path.join(testEnv.rootDir, "cleanup-clock.mjs"); fs.writeFileSync( clockPreload, - `const realNow = Date.now.bind(Date); -let cleanupReads = 0; -Date.now = () => { - const caller = new Error().stack?.split("\\n")[2] ?? ""; - if (caller.includes("cleanupSessionJobs")) { - cleanupReads += 1; - return cleanupReads === 1 ? 1_000_000 : 1_020_000; + `import { performance } from "node:perf_hooks"; +const realNow = performance.now.bind(performance); +performance.now = () => { + const stack = new Error().stack ?? ""; + if (stack.includes("createCleanupDeadlineAt")) { + return 1_600; + } + if (stack.includes("transitionWithinCleanupBudget")) { + return 1_600; + } + if (stack.includes("cleanupSessionJobs")) { + return 2_750; } return realNow(); }; @@ -571,12 +658,26 @@ Date.now = () => { for (const jobId of ["budget-job-one", "budget-job-two"]) { const job = readStateJob(testEnv, jobId); - assert.equal(job.status, "cancel_failed"); - assert.equal(job.phase, "cancel_failed"); + assert.equal(job.status, "cancelling"); + assert.equal(job.phase, "session_cleanup_pending"); assert.equal(job.pid, process.pid); assert.equal(job.pidIdentity, `${jobId}-identity`); - assert.match(job.errorMessage ?? "", /cleanup budget was exhausted/i); + assert.equal(job.completedAt, null); + assert.equal( + job.errorMessage, + "Automatic cleanup pending; it will retry on the next top-level Codex session." + ); } + const noPidJob = readStateJob(testEnv, "budget-job-without-pid"); + assert.equal(noPidJob.status, "cancelling"); + assert.equal(noPidJob.phase, "session_cleanup_pending"); + + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "next-session" }, + testEnv.env + ); assert.equal( readStateJob(testEnv, "budget-job-without-pid").status, "cancelled" @@ -586,6 +687,144 @@ Date.now = () => { } }); + it("session cleanup preserves process handles written after its snapshot", () => { + const testEnv = createHookEnvironment(); + const createdAt = new Date().toISOString(); + + try { + writeStateJob(testEnv, "racing-process-handles", { + id: "racing-process-handles", + status: "queued", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt, + pid: null, + pidIdentity: null, + pgid: null, + }); + const jobFile = path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir), + "jobs", + "racing-process-handles.json" + ); + const preload = path.join(testEnv.rootDir, "race-process-handles.mjs"); + fs.writeFileSync( + preload, + `import fs from "node:fs"; +import { performance } from "node:perf_hooks"; +const originalLinkSync = fs.linkSync.bind(fs); +let raced = false; +fs.linkSync = (source, destination) => { + if (!raced && String(destination).endsWith("racing-process-handles.json.lock")) { + raced = true; + const job = JSON.parse(fs.readFileSync(process.env.CC_TEST_RACE_JOB_FILE, "utf8")); + job.pidIdentity = "fresh-identity"; + job.pgid = 4242; + fs.writeFileSync(process.env.CC_TEST_RACE_JOB_FILE, JSON.stringify(job) + "\\n", "utf8"); + } + return originalLinkSync(source, destination); +}; +const realNow = performance.now.bind(performance); +performance.now = () => { + const stack = new Error().stack ?? ""; + if (stack.includes("createCleanupDeadlineAt") || stack.includes("transitionWithinCleanupBudget")) { + return 0; + } + if (stack.includes("cleanupSessionJobs")) { + return 2_750; + } + return realNow(); +}; +`, + "utf8" + ); + + runHook( + SESSION_HOOK, + ["SessionEnd"], + { cwd: testEnv.workspaceDir, session_id: "hook-session" }, + { + ...testEnv.env, + CC_TEST_RACE_JOB_FILE: jobFile, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(preload).href}`, + ] + .filter(Boolean) + .join(" "), + } + ); + + const job = readStateJob(testEnv, "racing-process-handles"); + assert.equal(job.status, "cancelling"); + assert.equal(job.phase, "session_cleanup_pending"); + assert.equal(job.pidIdentity, "fresh-identity"); + assert.equal(job.pgid, 4242); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("retains the current-session fallback when its cleanup marker cannot be written", () => { + const testEnv = createHookEnvironment(); + + try { + const stateDir = stateDirFor(testEnv.homeDir, testEnv.workspaceDir); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "current-session.json"), + JSON.stringify({ sessionId: "hook-session" }) + "\n", + "utf8" + ); + writeStateJob(testEnv, "marker-write-failure-job", { + id: "marker-write-failure-job", + status: "running", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: new Date().toISOString(), + pid: 999_999, + pidIdentity: "stored-identity", + }); + const preload = path.join(testEnv.rootDir, "fail-cleanup-marker.mjs"); + fs.writeFileSync( + preload, + `import fs from "node:fs"; +const originalWriteFileSync = fs.writeFileSync.bind(fs); +fs.writeFileSync = (file, ...args) => { + if (String(file).includes("session-cleanup-pending-hook-session.json")) { + const error = new Error("simulated cleanup marker ENOSPC"); + error.code = "ENOSPC"; + throw error; + } + return originalWriteFileSync(file, ...args); +}; +`, + "utf8" + ); + + const result = runHook( + SESSION_HOOK, + ["SessionEnd"], + { cwd: testEnv.workspaceDir, session_id: "hook-session" }, + { + ...testEnv.env, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(preload).href}`, + ] + .filter(Boolean) + .join(" "), + } + ); + + assert.equal(readCurrentSessionMarker(testEnv).sessionId, "hook-session"); + assert.equal(readStateJob(testEnv, "marker-write-failure-job").status, "running"); + assert.match(result.stderr, /SessionEnd cleanup failed.*ENOSPC/iu); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("session lifecycle hook marks an already-exited stored process cancelled", () => { const testEnv = createHookEnvironment(); @@ -621,8 +860,9 @@ Date.now = () => { } }); - it("session lifecycle hook preserves a live PID when POSIX identity lookup fails", async () => { + it("session lifecycle hook preserves a live PID when POSIX identity lookup fails", async (t) => { if (process.platform !== "darwin") { + t.skip("Darwin ps timeout behavior"); return; } @@ -642,7 +882,32 @@ Date.now = () => { const failingBin = path.join(testEnv.rootDir, "failing-ps"); fs.mkdirSync(failingBin); const fakePs = path.join(failingBin, "ps"); - fs.writeFileSync(fakePs, "#!/bin/sh\nexit 2\n", "utf8"); + const observedStatus = path.join(testEnv.rootDir, "status-before-ps.txt"); + const jobFile = path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir), + "jobs", + "identity-unavailable-job.json" + ); + fs.writeFileSync( + fakePs, + `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (args.at(-1) === process.env.CC_TEST_TARGET_PID) { + const job = JSON.parse(fs.readFileSync(process.env.CC_TEST_JOB_FILE, "utf8")); + fs.writeFileSync( + process.env.CC_TEST_OBSERVED_STATUS, + JSON.stringify({ status: job.status, completedAt: job.completedAt ?? null }), + "utf8" + ); + process.exit(2); +} +const result = spawnSync("/bin/ps", args, { stdio: "inherit" }); +process.exit(result.status ?? 1); +`, + "utf8" + ); fs.chmodSync(fakePs, 0o755); writeStateJob(testEnv, "identity-unavailable-job", { id: "identity-unavailable-job", @@ -665,10 +930,17 @@ Date.now = () => { { ...testEnv.env, PATH: `${failingBin}${path.delimiter}${testEnv.env.PATH}`, + CC_TEST_TARGET_PID: String(child.pid), + CC_TEST_JOB_FILE: jobFile, + CC_TEST_OBSERVED_STATUS: observedStatus, } ); const job = readStateJob(testEnv, "identity-unavailable-job"); + assert.deepEqual( + JSON.parse(fs.readFileSync(observedStatus, "utf8")), + { status: "cancelling", completedAt: null } + ); assert.equal(job.status, "cancel_failed"); assert.equal(job.phase, "cancel_failed"); assert.equal(job.pid, child.pid); @@ -680,6 +952,363 @@ Date.now = () => { } }); + it("SessionEnd stays below its hook ceiling when process identity lookup stalls", async (t) => { + if (process.platform !== "darwin") { + t.skip("Darwin ps timeout behavior"); + return; + } + + const testEnv = createHookEnvironment(); + const child = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore" } + ); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + + try { + const identity = getProcessIdentity(child.pid); + const slowBin = path.join(testEnv.rootDir, "slow-ps"); + fs.mkdirSync(slowBin); + const fakePs = path.join(slowBin, "ps"); + fs.writeFileSync( + fakePs, + `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const args = process.argv.slice(2); +if (args.at(-1) === process.env.CC_TEST_TARGET_PID) { + setInterval(() => {}, 1000); +} else { + const result = spawnSync("/bin/ps", args, { stdio: "inherit" }); + process.exit(result.status ?? 1); +} +`, + "utf8" + ); + fs.chmodSync(fakePs, 0o755); + writeStateJob(testEnv, "slow-identity-job", { + id: "slow-identity-job", + status: "running", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + pid: child.pid, + pidIdentity: identity, + }); + + const startedAt = performance.now(); + runHook( + SESSION_HOOK, + ["SessionEnd"], + { cwd: testEnv.workspaceDir, session_id: "hook-session" }, + { + ...testEnv.env, + PATH: `${slowBin}${path.delimiter}${testEnv.env.PATH}`, + CC_TEST_TARGET_PID: String(child.pid), + }, + { timeout: 2_900 } + ); + const elapsedMs = performance.now() - startedAt; + + const job = readStateJob(testEnv, "slow-identity-job"); + assert.ok(elapsedMs < 2_900, `SessionEnd took ${elapsedMs}ms`); + assert.equal(job.status, "cancelling"); + assert.equal(job.phase, "session_cleanup_pending"); + assert.equal(job.completedAt, null); + assert.equal(job.pid, child.pid); + assert.equal(job.pidIdentity, identity); + assert.doesNotThrow(() => process.kill(child.pid, 0)); + } finally { + child.kill(); + cleanupHookEnvironment(testEnv); + } + }); + + it("SessionEnd shares one deadline across stalled lock recovery attempts", async (t) => { + if (process.platform !== "darwin") { + t.skip("Darwin ps timeout behavior"); + return; + } + + const testEnv = createHookEnvironment(); + const lockOwner = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore" } + ); + await new Promise((resolve, reject) => { + lockOwner.once("spawn", resolve); + lockOwner.once("error", reject); + }); + + try { + const jobId = "stalled-lock-job"; + const jobFile = path.join( + stateDirFor(testEnv.homeDir, testEnv.workspaceDir), + "jobs", + `${jobId}.json` + ); + writeStateJob(testEnv, jobId, { + id: jobId, + status: "queued", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + }); + fs.writeFileSync( + `${jobFile}.lock`, + JSON.stringify({ + pid: lockOwner.pid, + identity: getProcessIdentity(lockOwner.pid), + timestamp: Date.now() - 31_000, + token: "stalled-lock-owner", + }), + { encoding: "utf8", mode: 0o600 } + ); + + const slowBin = path.join(testEnv.rootDir, "slow-lock-ps"); + fs.mkdirSync(slowBin); + const fakePs = path.join(slowBin, "ps"); + fs.writeFileSync( + fakePs, + `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const args = process.argv.slice(2); +if (args.at(-1) === process.env.CC_TEST_LOCK_OWNER_PID) { + setInterval(() => {}, 1000); +} else { + const result = spawnSync("/bin/ps", args, { stdio: "inherit" }); + process.exit(result.status ?? 1); +} +`, + "utf8" + ); + fs.chmodSync(fakePs, 0o755); + + const startedAt = performance.now(); + runHook( + SESSION_HOOK, + ["SessionEnd"], + { cwd: testEnv.workspaceDir, session_id: "hook-session" }, + { + ...testEnv.env, + PATH: `${slowBin}${path.delimiter}${testEnv.env.PATH}`, + CC_TEST_LOCK_OWNER_PID: String(lockOwner.pid), + }, + { timeout: 2_900 } + ); + const elapsedMs = performance.now() - startedAt; + + assert.ok(elapsedMs < 2_900, `SessionEnd took ${elapsedMs}ms`); + assert.equal(readStateJob(testEnv, jobId).status, "queued"); + + const lockOwnerExit = new Promise((resolve) => { + lockOwner.once("exit", resolve); + }); + lockOwner.kill(); + await lockOwnerExit; + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "new-session" }, + testEnv.env + ); + assert.equal(readStateJob(testEnv, jobId).status, "cancelled"); + } finally { + lockOwner.kill(); + cleanupHookEnvironment(testEnv); + } + }); + + it("SessionEnd resolves a subdirectory workspace without waiting on Git", (t) => { + if (process.platform === "win32") { + t.skip("POSIX executable fixture"); + return; + } + + const testEnv = createHookEnvironment(); + try { + const slowBin = path.join(testEnv.rootDir, "slow-git"); + fs.mkdirSync(slowBin); + const fakeGit = path.join(slowBin, "git"); + fs.writeFileSync( + fakeGit, + "#!/usr/bin/env node\nsetInterval(() => {}, 1000);\n", + "utf8" + ); + fs.chmodSync(fakeGit, 0o755); + writeStateJob(testEnv, "slow-workspace-job", { + id: "slow-workspace-job", + status: "queued", + sessionId: "hook-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + }); + const nestedCwd = path.join(testEnv.workspaceDir, "nested", "cwd"); + fs.mkdirSync(nestedCwd, { recursive: true }); + + runHook( + SESSION_HOOK, + ["SessionEnd"], + { cwd: nestedCwd, session_id: "hook-session" }, + { + ...testEnv.env, + PATH: `${slowBin}${path.delimiter}${testEnv.env.PATH}`, + }, + { timeout: 2_000 } + ); + assert.equal(readStateJob(testEnv, "slow-workspace-job").status, "cancelled"); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("top-level SessionStart retries cancel_failed jobs", () => { + const testEnv = createHookEnvironment(); + + try { + writePendingSessionCleanup(testEnv, "old-session"); + writeStateJob(testEnv, "retry-cancel-failed", { + id: "retry-cancel-failed", + status: "cancel_failed", + phase: "cancel_failed", + sessionId: "old-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + pid: 99_999_999, + pidIdentity: "missing-process-identity", + }); + + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "new-session" }, + testEnv.env + ); + + const job = readStateJob(testEnv, "retry-cancel-failed"); + assert.equal(job.status, "cancelled"); + assert.equal(job.pid, null); + assert.equal(job.pidIdentity, null); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("top-level SessionStart does not retry unverifiable cancel_failed jobs", () => { + const testEnv = createHookEnvironment(); + + try { + writePendingSessionCleanup(testEnv, "old-session"); + writeStateJob(testEnv, "unverifiable-cancel-failed", { + id: "unverifiable-cancel-failed", + status: "cancel_failed", + phase: "cancel_failed", + sessionId: "old-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + updatedAt: "2026-04-04T01:00:01Z", + completedAt: "2026-04-04T01:00:01Z", + errorMessage: "Original unverifiable cleanup failure.", + pid: process.pid, + pidIdentity: null, + }); + + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "new-session" }, + testEnv.env + ); + + const job = readStateJob(testEnv, "unverifiable-cancel-failed"); + assert.equal(job.errorMessage, "Original unverifiable cleanup failure."); + assert.equal(job.updatedAt, "2026-04-04T01:00:01Z"); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("top-level SessionStart retries pending SessionEnd cleanup", () => { + const testEnv = createHookEnvironment(); + + try { + writeStateJob(testEnv, "pending-session-cleanup", { + id: "pending-session-cleanup", + status: "cancelling", + phase: "session_cleanup_pending", + sessionId: "old-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + pid: 99_999_999, + pidIdentity: "missing-process-identity", + }); + + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "new-session" }, + testEnv.env + ); + + const job = readStateJob(testEnv, "pending-session-cleanup"); + assert.equal(job.status, "cancelled"); + assert.equal(job.pid, null); + assert.equal(job.pidIdentity, null); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + + it("top-level SessionStart leaves another live top-level session alone", async () => { + const testEnv = createHookEnvironment(); + const child = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { stdio: "ignore" } + ); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + + try { + const stateDir = stateDirFor(testEnv.homeDir, testEnv.workspaceDir); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "current-session.json"), + JSON.stringify({ sessionId: "new-session", updatedAt: "2026-04-04T01:00:00Z" }), + "utf8" + ); + writeStateJob(testEnv, "other-live-session-job", { + id: "other-live-session-job", + status: "running", + sessionId: "old-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + pid: child.pid, + pidIdentity: getProcessIdentity(child.pid), + }); + + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "new-session" }, + testEnv.env + ); + + assert.equal(readStateJob(testEnv, "other-live-session-job").status, "running"); + assert.doesNotThrow(() => process.kill(child.pid, 0)); + assert.equal(readCurrentSessionMarker(testEnv).sessionId, "new-session"); + } finally { + child.kill(); + cleanupHookEnvironment(testEnv); + } + }); + it("session start preserves the parent marker for nested sessions and exports hook suppression", () => { const testEnv = createHookEnvironment(); @@ -695,6 +1324,16 @@ Date.now = () => { ) + "\n", "utf8" ); + writeStateJob(testEnv, "parent-recovery-job", { + id: "parent-recovery-job", + status: "cancel_failed", + phase: "cancel_failed", + sessionId: "parent-session", + workspaceRoot: testEnv.workspaceDir, + createdAt: "2026-04-04T01:00:00Z", + pid: 99_999_999, + pidIdentity: "missing-process-identity", + }); const envFile = path.join(testEnv.rootDir, "child-session.env"); runHook( @@ -712,6 +1351,10 @@ Date.now = () => { ); assert.equal(readCurrentSessionMarker(testEnv).sessionId, "parent-session"); + assert.equal( + readStateJob(testEnv, "parent-recovery-job").status, + "cancel_failed" + ); const exportedEnv = fs.readFileSync(envFile, "utf8"); assert.match(exportedEnv, /CLAUDE_COMPANION_SESSION_ID='child-session'/); @@ -756,6 +1399,26 @@ Date.now = () => { } }); + it("session start preserves current-session fallback outside Git", () => { + const testEnv = createHookEnvironment({ initGit: false }); + + try { + runHook( + SESSION_HOOK, + [], + { cwd: testEnv.workspaceDir, session_id: "non-git-session" }, + testEnv.env + ); + + assert.equal( + readCurrentSessionMarker(testEnv).sessionId, + "non-git-session" + ); + } finally { + cleanupHookEnvironment(testEnv); + } + }); + it("nested session start does not override the parent Claude transcript path", () => { const testEnv = createHookEnvironment(); diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index 3472822..421d9cc 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -17,13 +17,14 @@ const expectations = [ ["scripts/lib/process.mjs:75-106", ["isCommandTimeout", "isWindowsIdentityCircuitOpen", "tripWindowsIdentityCircuit"]], ["scripts/lib/process.mjs:108-179", ["terminateProcessTree"]], ["scripts/lib/process.mjs:185-367", ["terminateProcessTreeIfIdentityMatches"]], - ["scripts/lib/process.mjs:390-500", ["getProcessIdentity", "getSpawnedProcessIdentity", "validateProcessIdentity", "isProcessAlive", "isProcessGroupAlive"]], - ["scripts/lib/state.mjs:178-218", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], - ["scripts/lib/state.mjs:341-389", ["writeJobFile", "normalizeStoredJob"]], - ["scripts/lib/state.mjs:466-707", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], - ["scripts/lib/state.mjs:756-893", ["unlinkLockIfUnchanged", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], - ["scripts/lib/state.mjs:959-1009", ["casJobStatus", "transitionJob", "writeAtomic"]], - ["scripts/lib/state.mjs:1015-1061", ["cleanupOldJobs"]], + ["scripts/lib/process.mjs:390-504", ["getProcessIdentity", "getSpawnedProcessIdentity", "validateProcessIdentity", "isProcessAlive", "isProcessGroupAlive"]], + ["scripts/lib/state.mjs:180-220", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], + ["scripts/lib/state.mjs:289-363", ["setCurrentSession", "getCurrentSession", "clearCurrentSession", "markSessionCleanupPending", "listPendingSessionCleanups", "clearSessionCleanupPending"]], + ["scripts/lib/state.mjs:395-443", ["writeJobFile", "normalizeStoredJob"]], + ["scripts/lib/state.mjs:520-761", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], + ["scripts/lib/state.mjs:810-982", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], + ["scripts/lib/state.mjs:1048-1105", ["casJobStatus", "transitionJob", "writeAtomic"]], + ["scripts/lib/state.mjs:1111-1157", ["cleanupOldJobs"]], ["scripts/lib/tracked-jobs.mjs:26-39", ["transitionTrackedJob"]], ["scripts/lib/tracked-jobs.mjs:356-482", ["runTrackedJob"]], ["scripts/lib/job-control.mjs:144-247", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 4bd5d24..969eeec 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -594,6 +594,7 @@ describe("terminateProcessTreeIfIdentityMatches", () => { it("distinguishes POSIX identity mismatch, lookup failure, and exit", () => { let terminated = false; + let capturedIdentityOptions = null; const mismatched = terminateProcessTreeIfIdentityMatches( 12345, "identity", @@ -610,7 +611,11 @@ describe("terminateProcessTreeIfIdentityMatches", () => { "identity", { platform: "linux", - getProcessIdentityImpl: () => "identity", + timeout: 321, + getProcessIdentityImpl: (_pid, options) => { + capturedIdentityOptions = options; + return "identity"; + }, terminateProcessTreeImpl: () => ({ attempted: true, delivered: true, @@ -644,6 +649,7 @@ describe("terminateProcessTreeIfIdentityMatches", () => { assert.equal(terminated, false); assert.equal(mismatched.reason, "identity-mismatch"); assert.equal(matched.delivered, true); + assert.deepEqual(capturedIdentityOptions, { timeout: 321 }); assert.equal(unavailable.reason, "identity-unavailable"); assert.equal(unavailable.delivered, false); assert.equal(missing.reason, "process-missing"); @@ -938,12 +944,18 @@ describe("getProcessIdentity", () => { }); it("trims Darwin ps identity output", () => { + let capturedOptions = null; const identity = getProcessIdentity(12345, { platform: "darwin", - runCommandCheckedImpl: () => ({ stdout: " stable-darwin-identity \n" }), + timeout: 321, + runCommandCheckedImpl: (_command, _args, options) => { + capturedOptions = options; + return { stdout: " stable-darwin-identity \n" }; + }, }); assert.equal(identity, "stable-darwin-identity"); + assert.deepEqual(capturedOptions, { timeout: 321 }); }); it("executes CIM identity and identity-checked tree termination on Windows", async () => { diff --git a/tests/render.test.mjs b/tests/render.test.mjs index d64af3e..6fdf8ef 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -613,10 +613,10 @@ describe("renderJobStatusReport", () => { ); assert.ok(output.includes("Verify process")); + assert.ok(output.includes("ProcessId = 12345")); assert.ok(output.includes("133820000000000000")); assert.ok(output.includes("Manual cleanup (after verification)")); assert.ok(output.includes("taskkill /PID 54321 /T /F")); - assert.ok(!output.includes("12345")); assert.ok(!output.includes("kill -9")); }); @@ -658,12 +658,56 @@ describe("renderJobStatusReport", () => { it("shows POSIX group cleanup for cancel_failed status", () => { const output = renderJobStatusReport( - { id: "j9", status: "cancel_failed", pgid: 12345 }, + { + id: "j9", + status: "cancel_failed", + pgid: 12345, + pidIdentity: "987654", + }, "linux" ); - assert.ok(output.includes("Manual cleanup")); + assert.ok(output.includes("Verify process")); + assert.ok(output.includes("cat /proc/12345/stat")); + assert.ok(output.includes("987654")); + assert.ok(output.includes("Manual cleanup (after verification)")); assert.ok(output.includes("kill -9 -12345")); - assert.ok(!output.includes("Verify process")); + }); + + it("surfaces pending SessionEnd cleanup without marking it terminal", () => { + const output = renderJobStatusReport( + { + id: "j-pending-cleanup", + status: "cancelling", + phase: "session_cleanup_pending", + pid: 12345, + pidIdentity: "recorded-identity", + }, + "linux" + ); + + assert.ok(output.includes("Automatic cleanup pending")); + assert.ok(output.includes("Verify process")); + assert.ok(output.includes("cat /proc/12345/stat")); + assert.ok(output.includes("recorded-identity")); + assert.ok(output.includes("Manual cleanup (after verification)")); + assert.ok(output.includes("kill -9 -12345")); + assert.ok(!output.includes("| Ended |")); + }); + + it("does not suggest POSIX cleanup without a recorded identity", () => { + const output = renderJobStatusReport( + { + id: "j-pending-unverified", + status: "cancelling", + phase: "session_cleanup_pending", + pid: 12345, + }, + "linux" + ); + + assert.ok(output.includes("Automatic cleanup pending")); + assert.ok(!output.includes("kill -9")); + assert.ok(!output.includes("Manual cleanup")); }); it("omits status cleanup when cancel_failed has no recorded process", () => { @@ -839,12 +883,28 @@ describe("renderCancelReport", () => { it("shows manual cleanup warning for cancel_failed", () => { const output = renderCancelReport( - { id: "j1", status: "cancel_failed", pgid: 12345 }, + { + id: "j1", + status: "cancel_failed", + pgid: 12345, + pidIdentity: "987654", + }, "linux" ); - assert.ok(output.includes("Manual cleanup")); + assert.ok(output.includes("Verify process before cleanup")); + assert.ok(output.includes("cat /proc/12345/stat")); + assert.ok(output.includes("987654")); + assert.ok(output.includes("Manual cleanup (after verification)")); assert.ok(output.includes("kill -9 -12345")); - assert.ok(!output.includes("Verify process")); + }); + + it("does not render a POSIX destructive command without identity", () => { + const output = renderCancelReport( + { id: "j1", status: "cancel_failed", pgid: 12345 }, + "linux" + ); + assert.ok(output.includes("no recorded process identity")); + assert.ok(!output.includes("kill -9")); }); it("shows Windows process-tree cleanup for cancel_failed", () => { @@ -860,10 +920,10 @@ describe("renderCancelReport", () => { ); assert.ok(output.includes("Verify process before cleanup")); assert.ok(output.includes("Get-CimInstance Win32_Process")); + assert.ok(output.includes("ProcessId = 12345")); assert.ok(output.includes("133820000000000000")); assert.ok(output.includes("Manual cleanup (after verification)")); assert.ok(output.includes("taskkill /PID 54321 /T /F")); - assert.ok(!output.includes("12345")); assert.ok(!output.includes("kill -9")); }); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 621ee90..5d849c8 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -37,6 +37,9 @@ import { setCurrentSession, getCurrentSession, clearCurrentSession, + markSessionCleanupPending, + listPendingSessionCleanups, + clearSessionCleanupPending, cleanupOldJobs, reapStaleJobs, appendStopReviewHistory, @@ -806,6 +809,35 @@ try { }); }); + it("can publish a deadline lock without resolving the lock-owner identity", () => { + writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); + const originalLinkSync = fs.linkSync; + let publishedIdentity = "not-observed"; + Reflect.set(fs, "linkSync", (source, destination) => { + if (String(destination).endsWith(".json.lock")) { + publishedIdentity = JSON.parse(fs.readFileSync(source, "utf8")).identity; + } + return originalLinkSync(source, destination); + }); + syncBuiltinESMExports(); + + try { + const result = transitionJob( + PROJECT_CWD, + jobId, + ["running"], + "completed", + {}, + { skipLockOwnerIdentity: true } + ); + assert.equal(result.transitioned, true); + assert.equal(publishedIdentity, null); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + } + }); + it("keeps a live owner's lock when its identity is unavailable", () => { writeJobFile(PROJECT_CWD, jobId, { id: jobId, status: "running" }); const lockFile = path.join(resolveJobsDir(PROJECT_CWD), `${jobId}.json.lock`); @@ -1185,6 +1217,69 @@ describe("current session marker", () => { }); }); +describe("pending session cleanup markers", () => { + const sessionIds = ["ended-session-a", "ended-session-b"]; + let repoDir; + + beforeEach(() => { + repoDir = createTempGitRepo(); + }); + + afterEach(() => { + for (const sessionId of sessionIds) { + clearSessionCleanupPending(repoDir, sessionId); + } + fs.rmSync(resolveStateDir(repoDir), { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it("round-trips and clears ended-session ownership", () => { + for (const sessionId of sessionIds) { + markSessionCleanupPending(repoDir, sessionId); + } + markSessionCleanupPending(repoDir, sessionIds[0]); + + const stateDir = resolveStateDir(repoDir); + fs.writeFileSync(path.join(stateDir, "unrelated.json"), JSON.stringify({ + sessionId: "unrelated-session", + })); + fs.writeFileSync( + path.join(stateDir, "session-cleanup-pending-wrong-extension.txt"), + JSON.stringify({ sessionId: "wrong-extension-session" }) + ); + fs.writeFileSync( + path.join(stateDir, "session-cleanup-pending-malformed.json"), + "{" + ); + fs.writeFileSync( + path.join(stateDir, "session-cleanup-pending-invalid-id.json"), + JSON.stringify({ sessionId: "../invalid" }) + ); + const duplicateMarkerFile = path.join( + stateDir, + "session-cleanup-pending-duplicate.json" + ); + fs.writeFileSync( + duplicateMarkerFile, + JSON.stringify({ sessionId: sessionIds[0] }) + ); + + assert.deepEqual( + new Set(listPendingSessionCleanups(repoDir)), + new Set(sessionIds) + ); + assert.equal(listPendingSessionCleanups(repoDir).length, sessionIds.length); + + fs.unlinkSync(duplicateMarkerFile); + clearSessionCleanupPending(repoDir, sessionIds[0]); + assert.deepEqual(listPendingSessionCleanups(repoDir), [sessionIds[1]]); + }); + + it("returns no pending cleanups when the state directory is absent", () => { + assert.deepEqual(listPendingSessionCleanups(repoDir), []); + }); +}); + // --------------------------------------------------------------------------- // sanitizeId (tested indirectly via job functions) // --------------------------------------------------------------------------- diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs new file mode 100644 index 0000000..a234ca4 --- /dev/null +++ b/tests/workspace.test.mjs @@ -0,0 +1,68 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs"; + +test("keeps the cwd fallback when git rev-parse refuses", { + skip: process.platform === "win32", +}, () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-root-test-")); + const workspaceDir = path.join(rootDir, "workspace"); + const childDir = path.join(workspaceDir, "nested"); + const binDir = path.join(rootDir, "bin"); + const originalPath = process.env.PATH; + + try { + fs.mkdirSync(path.join(workspaceDir, ".git"), { recursive: true }); + fs.mkdirSync(childDir, { recursive: true }); + fs.mkdirSync(binDir); + const gitPath = path.join(binDir, "git"); + fs.writeFileSync(gitPath, "#!/bin/sh\nexit 128\n", "utf8"); + fs.chmodSync(gitPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + assert.equal(resolveWorkspaceRoot(childDir), childDir); + } finally { + process.env.PATH = originalPath; + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("uses the physical Git ancestor when a bounded probe times out", { + skip: process.platform === "win32", +}, () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-root-timeout-test-")); + const workspaceDir = path.join(rootDir, "workspace"); + const childDir = path.join(workspaceDir, "nested"); + const binDir = path.join(rootDir, "bin"); + const originalPath = process.env.PATH; + + try { + fs.mkdirSync(path.join(workspaceDir, ".git"), { recursive: true }); + fs.mkdirSync(childDir, { recursive: true }); + fs.mkdirSync(binDir); + const gitPath = path.join(binDir, "git"); + fs.writeFileSync(gitPath, "#!/bin/sh\nwhile :; do sleep 1; done\n", "utf8"); + fs.chmodSync(gitPath, 0o755); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + assert.equal( + resolveWorkspaceRoot(childDir, { + filesystemFallbackOnTimeout: true, + gitTimeout: 50, + }), + workspaceDir + ); + } finally { + process.env.PATH = originalPath; + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); From 33b8e31ac12e3709ad8ab5571c6d98197bbafca2 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:23:45 +0300 Subject: [PATCH 2/2] test: support Node 18 clock injection --- tests/hooks.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index f0421b8..15ea374 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -621,7 +621,7 @@ fs.readdirSync = (directory, ...args) => { clockPreload, `import { performance } from "node:perf_hooks"; const realNow = performance.now.bind(performance); -performance.now = () => { +Object.defineProperty(performance, "now", { configurable: true, value: () => { const stack = new Error().stack ?? ""; if (stack.includes("createCleanupDeadlineAt")) { return 1_600; @@ -633,7 +633,7 @@ performance.now = () => { return 2_750; } return realNow(); -}; +} }); `, "utf8" ); @@ -725,7 +725,7 @@ fs.linkSync = (source, destination) => { return originalLinkSync(source, destination); }; const realNow = performance.now.bind(performance); -performance.now = () => { +Object.defineProperty(performance, "now", { configurable: true, value: () => { const stack = new Error().stack ?? ""; if (stack.includes("createCleanupDeadlineAt") || stack.includes("transitionWithinCleanupBudget")) { return 0; @@ -734,7 +734,7 @@ performance.now = () => { return 2_750; } return realNow(); -}; +} }); `, "utf8" );