feat(cli): add close subcommand for Unity Editor#690
Conversation
Adds `unity-mcp-cli close <path>` to gracefully terminate the Unity Editor instance running for a given project. Symmetric counterpart of `open` — lets scripted workflows (CI agents, pipeline executors, integration test fixtures) reach a clean tear-down state without unsafe OS-level kills. Resolution strategy: read PID from `<project>/Temp/UnityLockfile` (little-endian uint32, first 4 bytes), cross-check via process enumeration to handle stale lock files. On match, send the polite-quit signal — `SIGTERM` on Linux/macOS, `taskkill` (no `/F`) on Windows — then poll every 250ms until the process exits or `--timeout` elapses (default 30s, configurable). On timeout with `--force`, fall back to `SIGKILL` / `taskkill /F`. Refuses to act on any path that is not a Unity project root (`ProjectSettings/ProjectVersion.txt` must exist) — protects against accidental kill-all-Unity-on-host invocations. Idempotent: closing an already-closed Editor exits 0 with a clear no-op message. The graceful-quit path uses an OS signal rather than a plugin-side `editor-quit` MCP tool. Defining that tool would require touching plugin code currently being refactored under PR #689 (config-loader priority). The OS path is sufficient for the issue's acceptance criteria — `SIGTERM` triggers Unity's normal exit hooks (autosave, asset-import finalisation, plugin disconnect). A plugin-hub graceful path can be added in a follow-up once #689 lands. Test coverage (Vitest): 38 tests, 37 active + 1 gated. Unit tests cover path normalisation, refusal on non-Unity paths, idempotent no-op, flag parsing, lockfile PID parsing (including stale-byte and zero-PID edge cases), cross-platform shutdown signal selection, and the `waitForExit` poll loop. The live-editor integration test is gated behind `UMCP_LIVE=1` so CI does not require Unity bring-up. Closes #688
There was a problem hiding this comment.
Pull request overview
Adds a new unity-mcp-cli close subcommand to terminate a Unity Editor instance associated with a given project, intended to complement the existing open flow for automation/CI tear-down.
Changes:
- Introduces
closecommand with project-root validation, lockfile PID reading, process cross-checking, graceful shutdown, and optional forced kill. - Adds a new
unity-shutdownutility module (signals, lockfile PID parsing, liveness checks, wait loop). - Extends CLI docs and adds a comprehensive unit test suite (plus a gated live-editor smoke test).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/src/commands/close.ts | New close subcommand implementation and helper utilities (path resolution, project-root check, PID resolution, shutdown flow). |
| cli/src/utils/unity-shutdown.ts | New cross-platform shutdown utilities (signals, lockfile PID parsing, liveness polling, taskkill integration). |
| cli/src/index.ts | Registers close in the CLI’s subcommand list. |
| cli/tests/close.test.ts | Adds unit tests for helpers/utilities and a gated live-editor integration smoke test. |
| cli/README.md | Documents the new close subcommand, options, and behavior. |
| export function sendForceKill(pid: number, platform: SupportedPlatform = nodePlatform() as SupportedPlatform): boolean { | ||
| if (!Number.isFinite(pid) || pid <= 0) return false; | ||
| const sig = forceKillSignal(platform); | ||
| verbose(`Force-killing PID ${pid} via ${sig}`); | ||
|
|
||
| try { | ||
| if (sig === 'TASKKILL_FORCE') { | ||
| execFileSync('taskkill', ['/F', '/PID', String(pid)], { | ||
| timeout: 3000, | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }); | ||
| return true; | ||
| } | ||
| process.kill(pid, sig); | ||
| return true; | ||
| } catch (err) { | ||
| verbose(`Force kill failed: ${err instanceof Error ? err.message : String(err)}`); | ||
| return false; | ||
| } |
| export function resolveEditorPid(projectPath: string, platform: SupportedPlatform = nodePlatform() as SupportedPlatform): number | null { | ||
| const lockPid = readLockfilePid(projectPath); | ||
| if (lockPid !== null && isProcessAlive(lockPid, platform)) { | ||
| // Cross-check: the live PID's project path must match our target. The | ||
| // unity-process util already does case-insensitive normalisation on | ||
| // Windows, so reuse it rather than re-implementing comparison here. | ||
| const proc = findUnityProcess(projectPath); | ||
| if (proc && proc.pid === lockPid) { | ||
| verbose(`Lockfile PID ${lockPid} confirmed via process enumeration`); | ||
| return lockPid; | ||
| } | ||
| verbose(`Lockfile PID ${lockPid} did not match enumerated Unity process for project — treating as stale`); | ||
| } | ||
|
|
||
| const proc = findUnityProcess(projectPath); | ||
| return proc ? proc.pid : null; | ||
| } |
| } | ||
|
|
||
| function runCli(args: string[], opts: RunCliOptions = {}): { stdout: string; exitCode: number } { | ||
| try { | ||
| const stdout = execFileSync('node', [CLI_PATH, ...args], { | ||
| encoding: 'utf-8', | ||
| timeout: 15000, |
| export function sendGracefulShutdown(pid: number, platform: SupportedPlatform = nodePlatform() as SupportedPlatform): boolean { | ||
| if (!Number.isFinite(pid) || pid <= 0) return false; | ||
| const sig = gracefulShutdownSignal(platform); | ||
| verbose(`Sending graceful shutdown to PID ${pid} via ${sig}`); | ||
|
|
||
| try { | ||
| if (sig === 'WM_CLOSE') { | ||
| execFileSync('taskkill', ['/PID', String(pid)], { | ||
| timeout: 3000, | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }); | ||
| return true; | ||
| } | ||
| process.kill(pid, sig); | ||
| return true; | ||
| } catch (err) { | ||
| verbose(`Graceful shutdown failed: ${err instanceof Error ? err.message : String(err)}`); | ||
| return false; | ||
| } |
Test Results 12 files 468 suites 41m 1s ⏱️ Results for commit 4bf0ca5. ♻️ This comment has been updated with latest results. |
Hoist findUnityProcess in resolveEditorPid so the stale-lockfile path no longer pays for two enumerations. Add a TOCTOU note near signal delivery. Clarify parseTimeoutSeconds(undefined) is test-only. Switch isProcessAlive to try process.kill(pid, 0) first on Windows (works via OpenProcess) before falling back to tasklist; parse the tasklist CSV field-wise (column 1 = PID) so digit runs in other columns can't yield false positives. Drop the dead Number.isFinite guard after readUInt32LE. Document the Windows session 0 / WM_CLOSE caveat on sendGracefulShutdown. Live-editor smoke test now asserts "exited cleanly" and forbids "no running Editor", so a missing precondition fails loudly. Sentence-case the "Not a Unity project root" refusal message for consistency with the sibling errors. Note the headless / session 0 caveat in the README. simplify-pass: 1
The "cheapest first / fall back to enumerating" framing no longer matched the post-hoist control flow (enumeration is now unconditional and the lockfile picks between lockPid and proc.pid). Reword the JSDoc so it describes what the code actually does. simplify-pass: 2
There was a problem hiding this comment.
Pull request overview
Adds a new unity-mcp-cli close <path> subcommand to terminate the Unity Editor for a specific Unity project, enabling cleaner scripted bring-up/tear-down workflows alongside the existing open command.
Changes:
- Introduces
closecommand with--timeoutand--force, including Unity-project-root refusal and idempotent “no editor running” behavior. - Adds cross-platform shutdown utilities for lockfile PID parsing, signal/
taskkillselection, and exit polling. - Adds CLI tests and README documentation for the new subcommand.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/src/commands/close.ts | Implements the close subcommand (path resolution, Unity-root validation, PID resolution, graceful/force shutdown). |
| cli/src/utils/unity-shutdown.ts | Adds platform-specific shutdown primitives (signals, lockfile PID parsing, process-alive check, wait loop). |
| cli/src/index.ts | Registers the new close subcommand in the CLI. |
| cli/tests/close.test.ts | Adds unit/CLI tests for close helpers and shutdown utilities, plus a gated live-editor smoke test. |
| cli/README.md | Documents the new close command, flags, behavior, and Windows headless caveat. |
| export function resolveEditorPid(projectPath: string, platform: SupportedPlatform = nodePlatform() as SupportedPlatform): number | null { | ||
| const lockPid = readLockfilePid(projectPath); | ||
| // Enumerate Unity processes once. `findUnityProcess` shells out (3s timeout | ||
| // on Windows via PowerShell+CIM, 5s on POSIX via `ps`), so calling it twice | ||
| // on the stale-lockfile path used to roughly double close latency. | ||
| const proc = findUnityProcess(projectPath); | ||
|
|
||
| if (lockPid !== null && isProcessAlive(lockPid, platform)) { | ||
| if (proc && proc.pid === lockPid) { | ||
| verbose(`Lockfile PID ${lockPid} confirmed via process enumeration`); | ||
| return lockPid; | ||
| } | ||
| verbose(`Lockfile PID ${lockPid} did not match enumerated Unity process for project — treating as stale`); | ||
| } | ||
|
|
||
| return proc ? proc.pid : null; |
| /** | ||
| * Resolve the editor PID for a given project path. | ||
| * | ||
| * Reads `<project>/Temp/UnityLockfile` (4 bytes LE uint32) and enumerates | ||
| * Unity processes by command line, then prefers the lockfile PID when it | ||
| * is alive and matches the enumerated process for this project (handles | ||
| * stale lockfiles); otherwise uses the enumerated PID. | ||
| * | ||
| * Returns `null` when no live editor matches the project. Exported for tests. | ||
| */ | ||
| export function resolveEditorPid(projectPath: string, platform: SupportedPlatform = nodePlatform() as SupportedPlatform): number | null { | ||
| const lockPid = readLockfilePid(projectPath); | ||
| // Enumerate Unity processes once. `findUnityProcess` shells out (3s timeout | ||
| // on Windows via PowerShell+CIM, 5s on POSIX via `ps`), so calling it twice | ||
| // on the stale-lockfile path used to roughly double close latency. | ||
| const proc = findUnityProcess(projectPath); | ||
|
|
||
| if (lockPid !== null && isProcessAlive(lockPid, platform)) { | ||
| if (proc && proc.pid === lockPid) { | ||
| verbose(`Lockfile PID ${lockPid} confirmed via process enumeration`); | ||
| return lockPid; | ||
| } | ||
| verbose(`Lockfile PID ${lockPid} did not match enumerated Unity process for project — treating as stale`); | ||
| } | ||
|
|
||
| return proc ? proc.pid : null; | ||
| } |
| // where PIDs cycle aggressively). We accept the residual risk — the | ||
| // lockfile cross-check above already narrows it, holding a lock across | ||
| // a spawned Editor's lifetime is impractical, and the signals we send | ||
| // (SIGTERM / WM_CLOSE) are politest-effort, not destructive. |
| // PID 0 is special on POSIX; using a very large number that is unlikely | ||
| // to be assigned. The helper rejects non-positive inputs, so 0 returns | ||
| // false directly without touching the OS. |
sendGracefulShutdown / sendForceKill now treat "process gone" errors as success by re-checking isProcessAlive in the catch — preserves the PR's idempotency guarantee when Unity exits between PID resolution and signal delivery. resolveEditorPid falls back to a path.resolve match when realpath canonicalisation finds no enumerated process, fixing the silent no-op when Unity is launched with a symlinked project path. Live-editor smoke test now passes timeoutMs=80000 to runCli so the 15s exec timeout no longer kills the runner before the 60s polite-quit window elapses. Comment typo and PID-0 test comment fixes. simplify-pass: 1
Replace the misleading "scan some later column" comment in the tasklist CSV parser with an honest note that the simple cols[1] check accepts a known false-negative for image names containing commas (Unity exec names never do; the cost is one extra poll tick at most). Add a one-liner explaining why waitForExit's post-loop isProcessAlive check is not redundant — it catches an exit during the trailing poll-interval sleep. simplify-pass: 2
There was a problem hiding this comment.
Pull request overview
Adds a new unity-mcp-cli close subcommand to terminate the Unity Editor instance associated with a specific Unity project, complementing the existing scripted lifecycle commands (open, wait-for-ready) in the CLI.
Changes:
- Register a new
closeCLI subcommand and document its usage in the CLI README. - Implement cross-platform Unity shutdown helpers (lockfile PID parsing, liveness checks, graceful/force termination, exit waiting).
- Add a dedicated test suite covering path validation/normalization, timeout parsing, lockfile parsing, and shutdown helper behavior (plus a gated live-editor smoke test).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/src/commands/close.ts | Implements the close subcommand, PID resolution, and shutdown flow. |
| cli/src/utils/unity-shutdown.ts | New utilities for PID parsing, liveness detection, and graceful/force shutdown behavior. |
| cli/src/index.ts | Registers the new close subcommand with the CLI. |
| cli/tests/close.test.ts | Adds unit tests + a gated live-editor integration smoke test for close. |
| cli/README.md | Documents close, including flags, behavior, and Windows caveats. |
| if (lockPid !== null && isProcessAlive(lockPid, platform)) { | ||
| if (proc && proc.pid === lockPid) { | ||
| verbose(`Lockfile PID ${lockPid} confirmed via process enumeration`); | ||
| return lockPid; | ||
| } | ||
| // Lockfile alive but no matching enumerated process — the lockfile may | ||
| // still be authoritative if the asymmetry is purely realpath/symlink | ||
| // related (process listed under symlink path while we queried by realpath). | ||
| // Trust the live lockfile PID in that case rather than discarding it. | ||
| if (!proc) { | ||
| verbose(`Lockfile PID ${lockPid} alive but no enumerated match — using lockfile PID (likely symlink/path mismatch)`); | ||
| return lockPid; | ||
| } | ||
| verbose(`Lockfile PID ${lockPid} did not match enumerated Unity process for project — treating as stale`); |
| const pid = resolveEditorPid(projectPath, platform); | ||
|
|
||
| if (pid === null) { | ||
| ui.success(`no running Editor for project at ${projectPath}`); |
Summary
unity-mcp-cli close <path>— gracefully terminates the Unity Editor running for a given project. Symmetric counterpart ofopenfor scripted bring-up/tear-down lifecycles (CI agents, pipeline executors, integration test fixtures).<project>/Temp/UnityLockfile(LE uint32, first 4 bytes), cross-check via process enumeration to handle stale lock files. Cross-platform:SIGTERMon Linux/macOS,taskkill(no/F) on Windows.--forcefalls back toSIGKILL/taskkill /Fafter--timeout(default 30s).ProjectSettings/ProjectVersion.txtmust exist). Idempotent: no running editor → exit 0 withno running Editor for project at <path>.Design notes
The graceful-quit path uses an OS signal rather than a plugin-side
editor-quitMCP tool. Adding that tool would require touching plugin code currently being refactored under #689 (config-loader priority).SIGTERMalready triggers Unity's normal exit hooks (autosave, asset-import finalisation, plugin disconnect), which satisfies the issue's acceptance criteria. A plugin-hub graceful path can be added as a follow-up once #689 lands.Test plan
npm testinUnity-MCP/cli/): 265 passed, 1 skipped (the live-editor smoke test, gated byUMCP_LIVE=1).The new
tests/close.test.tsadds 38 tests (37 active + 1 gated) covering, by acceptance criterion:UMCP_LIVE=1 UMCP_LIVE_PROJECT=<path> npm test); kept off CI by default so CI does not require Unity.--force— covered indirectly by thewaitForExitunit tests; the live wedged-editor scenario is impractical to reproduce in CI.--force— same as (b).exits 0 when the project has no running editor).resolveCloseProjectPath(relative, trailing-separator, realpath canonicalisation).refuses to act on a non-Unity-project directory,isUnityProjectRoot).gracefulShutdownSignal/forceKillSignalforlinux/darwin/win32.parseTimeoutSecondsplus CLI-level help / refusal tests.Closes #688