Skip to content

feat(cli): add close subcommand for Unity Editor#690

Merged
IvanMurzak merged 5 commits into
mainfrom
worktree-688-close-subcommand-for-unity-editor
Apr 29, 2026
Merged

feat(cli): add close subcommand for Unity Editor#690
IvanMurzak merged 5 commits into
mainfrom
worktree-688-close-subcommand-for-unity-editor

Conversation

@IvanMurzak

Copy link
Copy Markdown
Owner

Summary

  • Adds unity-mcp-cli close <path> — gracefully terminates the Unity Editor running for a given project. Symmetric counterpart of open for scripted bring-up/tear-down lifecycles (CI agents, pipeline executors, integration test fixtures).
  • PID resolution: read <project>/Temp/UnityLockfile (LE uint32, first 4 bytes), cross-check via process enumeration to handle stale lock files. Cross-platform: SIGTERM on Linux/macOS, taskkill (no /F) on Windows. --force falls back to SIGKILL / taskkill /F after --timeout (default 30s).
  • Project-scoped — refuses to act on any path that is not a Unity project root (ProjectSettings/ProjectVersion.txt must exist). Idempotent: no running editor → exit 0 with no running Editor for project at <path>.

Design notes

The graceful-quit path uses an OS signal rather than a plugin-side editor-quit MCP tool. Adding that tool would require touching plugin code currently being refactored under #689 (config-loader priority). SIGTERM already 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

  • CLI suite (npm test in Unity-MCP/cli/): 265 passed, 1 skipped (the live-editor smoke test, gated by UMCP_LIVE=1).

The new tests/close.test.ts adds 38 tests (37 active + 1 gated) covering, by acceptance criterion:

  • (a) Graceful close of a running editor — covered by the gated live-editor smoke test (UMCP_LIVE=1 UMCP_LIVE_PROJECT=<path> npm test); kept off CI by default so CI does not require Unity.
  • (b) Timeout → soft-failure exit without --force — covered indirectly by the waitForExit unit tests; the live wedged-editor scenario is impractical to reproduce in CI.
  • (c) Timeout → hard kill with --force — same as (b).
  • (d) No-editor-running → exit 0 — direct unit test (exits 0 when the project has no running editor).
  • (e) Cross-platform path normalisation — direct unit tests on resolveCloseProjectPath (relative, trailing-separator, realpath canonicalisation).
  • (f) Refusal on non-Unity-project paths — direct unit tests (refuses to act on a non-Unity-project directory, isUnityProjectRoot).
  • Cross-platform shutdown-signal selection — direct unit tests on gracefulShutdownSignal / forceKillSignal for linux / darwin / win32.
  • Lockfile PID parsing — direct unit tests including missing lockfile, too-small file, zero PID, and trailing-byte robustness.
  • Flag parsing — direct unit tests on parseTimeoutSeconds plus CLI-level help / refusal tests.

Closes #688

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
Copilot AI review requested due to automatic review settings April 29, 2026 00:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 close command with project-root validation, lockfile PID reading, process cross-checking, graceful shutdown, and optional forced kill.
  • Adds a new unity-shutdown utility 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.

Comment on lines +148 to +166
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;
}
Comment thread cli/src/commands/close.ts
Comment on lines +87 to +103
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;
}
Comment thread cli/tests/close.test.ts Outdated
Comment on lines +25 to +31
}

function runCli(args: string[], opts: RunCliOptions = {}): { stdout: string; exitCode: number } {
try {
const stdout = execFileSync('node', [CLI_PATH, ...args], {
encoding: 'utf-8',
timeout: 15000,
Comment on lines +123 to +141
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;
}
@github-actions

github-actions Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Test Results

   12 files    468 suites   41m 1s ⏱️
  799 tests   799 ✅ 0 💤 0 ❌
4 794 runs  4 794 ✅ 0 💤 0 ❌

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
Copilot AI review requested due to automatic review settings April 29, 2026 01:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 close command with --timeout and --force, including Unity-project-root refusal and idempotent “no editor running” behavior.
  • Adds cross-platform shutdown utilities for lockfile PID parsing, signal/taskkill selection, 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.

Comment thread cli/src/commands/close.ts
Comment on lines +90 to +105
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;
Comment thread cli/src/commands/close.ts
Comment on lines +80 to +106
/**
* 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;
}
Comment thread cli/src/commands/close.ts Outdated
// 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.
Comment thread cli/tests/close.test.ts Outdated
Comment on lines +312 to +314
// 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.
@IvanMurzak IvanMurzak self-assigned this Apr 29, 2026
@IvanMurzak IvanMurzak added the enhancement New feature or request label Apr 29, 2026
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
Copilot AI review requested due to automatic review settings April 29, 2026 05:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 close CLI 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.

Comment thread cli/src/commands/close.ts
Comment on lines +113 to +126
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`);
Comment thread cli/src/commands/close.ts
const pid = resolveEditorPid(projectPath, platform);

if (pid === null) {
ui.success(`no running Editor for project at ${projectPath}`);
@IvanMurzak IvanMurzak merged commit 3a7d381 into main Apr 29, 2026
24 checks passed
@IvanMurzak IvanMurzak deleted the worktree-688-close-subcommand-for-unity-editor branch April 29, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add 'close' subcommand to terminate Unity Editor cleanly across OS

2 participants