From c1de62745aeac9c30a9d538e2b298f1effc3d5d9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Apr 2026 00:01:03 +0900 Subject: [PATCH 1/6] Diagnose Unity editor state after CLI connection failures The CLI previously trusted isServerRunning before attempting a connection, which made stale settings after crashes or forced termination look like a clean Unity-not-running state. That hid the difference between an editor that is closed and an editor that is still open while the Unity CLI Loop server is unavailable. This change keeps port resolution focused on reading customPort from project settings and moves the final diagnosis to the point where a real connection attempt fails. When a retryable connection error happens for a known project, the CLI now checks the OS process list for that project and reports either UnityNotRunningError or UnityServerNotRunningError with dedicated user-facing guidance. It also adds a standalone unity-process module with macOS, Linux, and Windows process queries plus parser-focused unit tests, so the OS-level detection can be verified without launching Unity. The affected surface is limited to CLI connection diagnostics, project error messaging, and the related unit tests. --- .../src/__tests__/cli-project-error.test.ts | 14 +- .../Cli~/src/__tests__/execute-tool.test.ts | 69 +++++- .../Cli~/src/__tests__/port-resolver.test.ts | 8 +- .../Cli~/src/__tests__/unity-process.test.ts | 171 ++++++++++++++ Packages/src/Cli~/src/cli-project-error.ts | 14 +- Packages/src/Cli~/src/cli.ts | 8 +- Packages/src/Cli~/src/execute-tool.ts | 82 +++++-- Packages/src/Cli~/src/port-resolver.ts | 12 +- Packages/src/Cli~/src/unity-process.ts | 219 ++++++++++++++++++ 9 files changed, 565 insertions(+), 32 deletions(-) create mode 100644 Packages/src/Cli~/src/__tests__/unity-process.test.ts create mode 100644 Packages/src/Cli~/src/unity-process.ts diff --git a/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts b/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts index 4a10144ac..fc26464d3 100644 --- a/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts +++ b/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts @@ -1,5 +1,5 @@ import { getProjectResolutionErrorLines } from '../cli-project-error.js'; -import { UnityNotRunningError } from '../port-resolver.js'; +import { UnityNotRunningError, UnityServerNotRunningError } from '../port-resolver.js'; import { ProjectMismatchError } from '../project-validator.js'; describe('getProjectResolutionErrorLines', () => { @@ -30,4 +30,16 @@ describe('getProjectResolutionErrorLines', () => { 'Start the Unity Editor for this project, or use --project-path to specify the target.', ]); }); + + it('returns server-not-running guidance for UnityServerNotRunningError', () => { + const lines = getProjectResolutionErrorLines(new UnityServerNotRunningError('/project/root')); + + expect(lines).toEqual([ + 'Error: Unity Editor is running, but Unity CLI Loop server is not.', + '', + ' Project: /project/root', + '', + 'Start the server from: Window > Unity CLI Loop > Server', + ]); + }); }); diff --git a/Packages/src/Cli~/src/__tests__/execute-tool.test.ts b/Packages/src/Cli~/src/__tests__/execute-tool.test.ts index 11e192f8c..4afb5f00d 100644 --- a/Packages/src/Cli~/src/__tests__/execute-tool.test.ts +++ b/Packages/src/Cli~/src/__tests__/execute-tool.test.ts @@ -1,5 +1,8 @@ -import { isTransportDisconnectError } from '../execute-tool.js'; -import { UnityNotRunningError } from '../port-resolver.js'; +import { + diagnoseRetryableProjectConnectionError, + isTransportDisconnectError, +} from '../execute-tool.js'; +import { UnityNotRunningError, UnityServerNotRunningError } from '../port-resolver.js'; import { ProjectMismatchError } from '../project-validator.js'; describe('isTransportDisconnectError', () => { @@ -35,7 +38,69 @@ describe('isTransportDisconnectError', () => { expect(isTransportDisconnectError(new UnityNotRunningError('/project'))).toBe(false); }); + it('returns false for UnityServerNotRunningError', () => { + expect(isTransportDisconnectError(new UnityServerNotRunningError('/project'))).toBe(false); + }); + it('returns false for ProjectMismatchError', () => { expect(isTransportDisconnectError(new ProjectMismatchError('/a', '/b'))).toBe(false); }); }); + +describe('diagnoseRetryableProjectConnectionError', () => { + it('returns UnityNotRunningError when connection fails and Unity is not running', async () => { + const error = await diagnoseRetryableProjectConnectionError( + new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'), + '/project', + true, + { + findRunningUnityProcessForProjectFn: jest.fn().mockResolvedValue(null), + }, + ); + + expect(error).toBeInstanceOf(UnityNotRunningError); + }); + + it('returns UnityServerNotRunningError when Unity is running but server is unavailable', async () => { + const error = await diagnoseRetryableProjectConnectionError( + new Error('UNITY_NO_RESPONSE'), + '/project', + true, + { + findRunningUnityProcessForProjectFn: jest.fn().mockResolvedValue({ pid: 1234 }), + }, + ); + + expect(error).toBeInstanceOf(UnityServerNotRunningError); + }); + + it('preserves non-retryable errors', async () => { + const originalError = new ProjectMismatchError('/expected', '/actual'); + + const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', true, { + findRunningUnityProcessForProjectFn: jest.fn(), + }); + + expect(error).toBe(originalError); + }); + + it('preserves retryable errors when project diagnosis is disabled', async () => { + const originalError = new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'); + + const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', false, { + findRunningUnityProcessForProjectFn: jest.fn(), + }); + + expect(error).toBe(originalError); + }); + + it('preserves the original error when OS-level process inspection fails', async () => { + const originalError = new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'); + + const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', true, { + findRunningUnityProcessForProjectFn: jest.fn().mockRejectedValue(new Error('ps failed')), + }); + + expect(error).toBe(originalError); + }); +}); diff --git a/Packages/src/Cli~/src/__tests__/port-resolver.test.ts b/Packages/src/Cli~/src/__tests__/port-resolver.test.ts index 3c4331108..b78a3799a 100644 --- a/Packages/src/Cli~/src/__tests__/port-resolver.test.ts +++ b/Packages/src/Cli~/src/__tests__/port-resolver.test.ts @@ -5,7 +5,6 @@ import { resolvePortFromUnitySettings, validateProjectPath, resolveUnityPort, - UnityNotRunningError, } from '../port-resolver.js'; describe('resolvePortFromUnitySettings', () => { @@ -93,15 +92,14 @@ describe('resolveUnityPort with project settings', () => { rmSync(tempProjectRoot, { recursive: true }); }); - it('throws UnityNotRunningError when isServerRunning is false', async () => { + it('returns port when isServerRunning is false', async () => { writeFileSync( join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'), JSON.stringify({ isServerRunning: false, customPort: 8700 }), ); - await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow( - UnityNotRunningError, - ); + const port = await resolveUnityPort(undefined, tempProjectRoot); + expect(port).toBe(8700); }); it('returns port when isServerRunning is true', async () => { diff --git a/Packages/src/Cli~/src/__tests__/unity-process.test.ts b/Packages/src/Cli~/src/__tests__/unity-process.test.ts new file mode 100644 index 000000000..e3be8043c --- /dev/null +++ b/Packages/src/Cli~/src/__tests__/unity-process.test.ts @@ -0,0 +1,171 @@ +import { + buildUnityProcessCommand, + extractUnityProjectPath, + findRunningUnityProcessForProject, + isUnityProcessForProject, + normalizeUnityProjectPath, + parseUnityProcesses, + tokenizeCommandLine, +} from '../unity-process.js'; + +describe('buildUnityProcessCommand', () => { + it('builds ps command for macOS', () => { + expect(buildUnityProcessCommand('darwin')).toEqual({ + command: 'ps', + args: ['-Ao', 'pid=,command='], + }); + }); + + it('builds powershell command for Windows', () => { + expect(buildUnityProcessCommand('win32')).toEqual({ + command: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress', + ], + }); + }); +}); + +describe('tokenizeCommandLine', () => { + it('keeps quoted project path as one token', () => { + expect( + tokenizeCommandLine( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/My Project"', + ), + ).toEqual([ + '/Applications/Unity.app/Contents/MacOS/Unity', + '-projectPath', + '/Users/me/My Project', + ]); + }); +}); + +describe('extractUnityProjectPath', () => { + it('extracts macOS project path', () => { + expect( + extractUnityProjectPath( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project', + ), + ).toBe('/Users/me/project'); + }); + + it('extracts Windows project path case-insensitively', () => { + expect( + extractUnityProjectPath( + 'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectpath "C:\\Work\\My Project"', + ), + ).toBe('C:\\Work\\My Project'); + }); +}); + +describe('normalizeUnityProjectPath', () => { + it('normalizes Windows paths case-insensitively', () => { + expect(normalizeUnityProjectPath('C:\\Work\\My Project\\', 'win32')).toBe('c:/work/my project'); + }); +}); + +describe('parseUnityProcesses', () => { + it('parses ps output', () => { + expect( + parseUnityProcesses( + 'darwin', + '123 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project\n', + ), + ).toEqual([ + { + pid: 123, + commandLine: '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project', + }, + ]); + }); + + it('parses Windows powershell JSON array output', () => { + expect( + parseUnityProcesses( + 'win32', + '[{"ProcessId":101,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\Unity.exe -projectPath \\"C:\\\\Work\\\\Project A\\""}]', + ), + ).toEqual([ + { + pid: 101, + commandLine: + 'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectPath "C:\\Work\\Project A"', + }, + ]); + }); + + it('returns empty array when Windows output is empty', () => { + expect(parseUnityProcesses('win32', '')).toEqual([]); + }); +}); + +describe('isUnityProcessForProject', () => { + it('matches project path on macOS', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/Project A"', + '/Users/me/Project A', + 'darwin', + ), + ).toBe(true); + }); + + it('matches project path on Windows case-insensitively', () => { + expect( + isUnityProcessForProject( + 'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectPath "C:\\Work\\Project A"', + 'c:/work/project a', + 'win32', + ), + ).toBe(true); + }); +}); + +describe('findRunningUnityProcessForProject', () => { + it('returns null when no Unity process is running', async () => { + const runCommand = jest.fn, [string, string[]]>().mockResolvedValue(''); + + await expect( + findRunningUnityProcessForProject('/Users/me/project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toBeNull(); + }); + + it('returns matching Unity process on macOS', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + [ + '111 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/other', + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/project"', + ].join('\n'), + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + + it('returns matching Unity process on Windows', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + '[{"ProcessId":333,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\Unity.exe -projectPath \\"C:\\\\Work\\\\My Project\\""}]', + ); + + await expect( + findRunningUnityProcessForProject('c:/work/my project', { + platform: 'win32', + runCommand, + }), + ).resolves.toEqual({ pid: 333 }); + }); +}); diff --git a/Packages/src/Cli~/src/cli-project-error.ts b/Packages/src/Cli~/src/cli-project-error.ts index 5f052396f..393929d14 100644 --- a/Packages/src/Cli~/src/cli-project-error.ts +++ b/Packages/src/Cli~/src/cli-project-error.ts @@ -1,9 +1,19 @@ -import { UnityNotRunningError } from './port-resolver.js'; +import { UnityNotRunningError, UnityServerNotRunningError } from './port-resolver.js'; import { ProjectMismatchError } from './project-validator.js'; export function getProjectResolutionErrorLines( - error: UnityNotRunningError | ProjectMismatchError, + error: UnityNotRunningError | UnityServerNotRunningError | ProjectMismatchError, ): string[] { + if (error instanceof UnityServerNotRunningError) { + return [ + 'Error: Unity Editor is running, but Unity CLI Loop server is not.', + '', + ` Project: ${error.projectRoot}`, + '', + 'Start the server from: Window > Unity CLI Loop > Server', + ]; + } + if (error instanceof UnityNotRunningError) { return [ 'Error: Unity Editor for this project is not running.', diff --git a/Packages/src/Cli~/src/cli.ts b/Packages/src/Cli~/src/cli.ts index bf3acb678..fc1558804 100644 --- a/Packages/src/Cli~/src/cli.ts +++ b/Packages/src/Cli~/src/cli.ts @@ -36,7 +36,11 @@ import { registerLaunchCommand } from './commands/launch.js'; import { registerFocusWindowCommand } from './commands/focus-window.js'; import { VERSION } from './version.js'; import { findUnityProjectRoot } from './project-root.js'; -import { validateProjectPath, UnityNotRunningError } from './port-resolver.js'; +import { + validateProjectPath, + UnityNotRunningError, + UnityServerNotRunningError, +} from './port-resolver.js'; import { ProjectMismatchError } from './project-validator.js'; import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js'; import { getProjectResolutionErrorLines } from './cli-project-error.js'; @@ -430,7 +434,7 @@ async function runWithErrorHandling(fn: () => Promise): Promise { try { await fn(); } catch (error) { - if (error instanceof UnityNotRunningError) { + if (error instanceof UnityNotRunningError || error instanceof UnityServerNotRunningError) { for (const line of getProjectResolutionErrorLines(error)) { console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line); } diff --git a/Packages/src/Cli~/src/execute-tool.ts b/Packages/src/Cli~/src/execute-tool.ts index c441afbf1..462690177 100644 --- a/Packages/src/Cli~/src/execute-tool.ts +++ b/Packages/src/Cli~/src/execute-tool.ts @@ -13,12 +13,18 @@ import { existsSync } from 'fs'; import { join } from 'path'; import * as semver from 'semver'; import { DirectUnityClient } from './direct-unity-client.js'; -import { resolveUnityPort, validateProjectPath } from './port-resolver.js'; +import { + resolveUnityPort, + UnityNotRunningError, + UnityServerNotRunningError, + validateProjectPath, +} from './port-resolver.js'; import { validateConnectedProject } from './project-validator.js'; import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js'; import { VERSION } from './version.js'; import { createSpinner } from './spinner.js'; import { findUnityProjectRoot } from './project-root.js'; +import { findRunningUnityProcessForProject } from './unity-process.js'; import { type CompileExecutionOptions, ensureCompileRequestId, @@ -77,6 +83,14 @@ const MAX_RETRIES = 3; const COMPILE_WAIT_TIMEOUT_MS = 90000; const COMPILE_WAIT_POLL_INTERVAL_MS = 100; +interface ConnectionFailureDiagnosisDependencies { + findRunningUnityProcessForProjectFn: typeof findRunningUnityProcessForProject; +} + +const defaultConnectionFailureDiagnosisDependencies: ConnectionFailureDiagnosisDependencies = { + findRunningUnityProcessForProjectFn: findRunningUnityProcessForProject, +}; + function getCompileExecutionOptions( toolName: string, params: Record, @@ -103,6 +117,54 @@ function isRetryableError(error: unknown): boolean { ); } +export async function diagnoseRetryableProjectConnectionError( + error: unknown, + projectRoot: string | null, + shouldDiagnoseProjectState: boolean, + dependencies: ConnectionFailureDiagnosisDependencies = defaultConnectionFailureDiagnosisDependencies, +): Promise { + if (!shouldDiagnoseProjectState || projectRoot === null || !isRetryableError(error)) { + return error; + } + + const runningProcess = await dependencies + .findRunningUnityProcessForProjectFn(projectRoot) + .catch(() => undefined); + + if (runningProcess === undefined) { + return error; + } + + if (runningProcess === null) { + return new UnityNotRunningError(projectRoot); + } + + return new UnityServerNotRunningError(projectRoot); +} + +async function throwFinalToolError( + error: unknown, + projectRoot: string | null, + shouldDiagnoseProjectState: boolean, +): Promise { + const diagnosedError = await diagnoseRetryableProjectConnectionError( + error, + projectRoot, + shouldDiagnoseProjectState, + ); + + if (diagnosedError instanceof Error) { + throw diagnosedError; + } + + if (typeof diagnosedError === 'string') { + throw new Error(diagnosedError); + } + + const serializedError = JSON.stringify(diagnosedError); + throw new Error(serializedError ?? 'Unknown error'); +} + // Distinct from isRetryableError(): that function covers pre-connection failures // (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch. // This function covers post-dispatch TCP failures where Unity may have received @@ -317,8 +379,8 @@ export async function executeToolCommand( if (immediateResult === undefined && !requestDispatched) { spinner.stop(); restoreStdin(); - if (lastError instanceof Error) { - throw lastError; + if (lastError !== undefined) { + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } throw new Error( 'Compile request never reached Unity. Check that Unity is running and retry.', @@ -380,15 +442,7 @@ export async function executeToolCommand( if (lastError === undefined) { throw new Error('Tool execution failed without error details.'); } - if (lastError instanceof Error) { - throw lastError; - } - if (typeof lastError === 'string') { - throw new Error(lastError); - } - - const serializedError = JSON.stringify(lastError); - throw new Error(serializedError ?? 'Unknown error'); + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } export async function listAvailableTools(globalOptions: GlobalOptions): Promise { @@ -455,7 +509,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise< spinner.stop(); restoreStdin(); - throw lastError; + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } interface UnityToolInfo { @@ -573,5 +627,5 @@ export async function syncTools(globalOptions: GlobalOptions): Promise { spinner.stop(); restoreStdin(); - throw lastError; + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } diff --git a/Packages/src/Cli~/src/port-resolver.ts b/Packages/src/Cli~/src/port-resolver.ts index 406ac30c3..28d8af5a8 100644 --- a/Packages/src/Cli~/src/port-resolver.ts +++ b/Packages/src/Cli~/src/port-resolver.ts @@ -23,6 +23,12 @@ export class UnityNotRunningError extends Error { } } +export class UnityServerNotRunningError extends Error { + constructor(public readonly projectRoot: string) { + super('UNITY_SERVER_NOT_RUNNING'); + } +} + interface UnityMcpSettings { isServerRunning?: boolean; customPort?: number; @@ -130,12 +136,6 @@ async function readPortFromSettingsOrThrow(projectRoot: string): Promise } const settings = parsed as UnityMcpSettings; - // Only block when isServerRunning is explicitly false (Unity clean shutdown). - // undefined/missing means old settings format — proceed to next validation stage. - if (settings.isServerRunning === false) { - throw new UnityNotRunningError(projectRoot); - } - const port = resolvePortFromUnitySettings(settings); if (port !== null) { return port; diff --git a/Packages/src/Cli~/src/unity-process.ts b/Packages/src/Cli~/src/unity-process.ts new file mode 100644 index 000000000..7cc9eadd2 --- /dev/null +++ b/Packages/src/Cli~/src/unity-process.ts @@ -0,0 +1,219 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const UNITY_WINDOWS_PROCESS_NAME = 'Unity.exe'; +const WINDOWS_PROCESS_QUERY = + 'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress'; + +export interface RunningUnityProcess { + pid: number; +} + +interface RawUnityProcess { + pid: number; + commandLine: string; +} + +interface UnityProcessCommand { + command: string; + args: string[]; +} + +interface UnityProcessDependencies { + platform: NodeJS.Platform; + runCommand: (command: string, args: string[]) => Promise; +} + +const defaultDependencies: UnityProcessDependencies = { + platform: process.platform, + runCommand: runUnityProcessQuery, +}; + +export function buildUnityProcessCommand(platform: NodeJS.Platform): UnityProcessCommand | null { + if (platform === 'darwin') { + return { + command: 'ps', + args: ['-Ao', 'pid=,command='], + }; + } + + if (platform === 'linux') { + return { + command: 'ps', + args: ['-eo', 'pid=,args='], + }; + } + + if (platform === 'win32') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROCESS_QUERY], + }; + } + + return null; +} + +export function parseUnityProcesses(platform: NodeJS.Platform, output: string): RawUnityProcess[] { + if (platform === 'win32') { + return parseWindowsUnityProcesses(output); + } + + return parsePsUnityProcesses(output); +} + +export function tokenizeCommandLine(commandLine: string): string[] { + const tokens: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < commandLine.length; i++) { + const character = commandLine[i]; + + if (character === '"') { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && /\s/.test(character)) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + continue; + } + + current += character; + } + + if (current.length > 0) { + tokens.push(current); + } + + return tokens; +} + +export function extractUnityProjectPath(commandLine: string): string | null { + const tokens = tokenizeCommandLine(commandLine); + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].toLowerCase(); + if (token !== '-projectpath') { + continue; + } + + const projectPath = tokens[i + 1]; + return projectPath ?? null; + } + + return null; +} + +export function normalizeUnityProjectPath(projectPath: string, platform: NodeJS.Platform): string { + const normalizedSeparators = projectPath.replace(/\\/g, '/').replace(/\/+$/, ''); + if (platform === 'win32') { + return normalizedSeparators.toLowerCase(); + } + + return normalizedSeparators; +} + +export function isUnityProcessForProject( + commandLine: string, + projectRoot: string, + platform: NodeJS.Platform, +): boolean { + const extractedProjectPath = extractUnityProjectPath(commandLine); + if (extractedProjectPath === null) { + return false; + } + + return ( + normalizeUnityProjectPath(extractedProjectPath, platform) === + normalizeUnityProjectPath(projectRoot, platform) + ); +} + +export async function findRunningUnityProcessForProject( + projectRoot: string, + dependencies: UnityProcessDependencies = defaultDependencies, +): Promise { + const unityProcessCommand = buildUnityProcessCommand(dependencies.platform); + if (unityProcessCommand === null) { + return null; + } + + const output = await dependencies.runCommand( + unityProcessCommand.command, + unityProcessCommand.args, + ); + const runningProcesses = parseUnityProcesses(dependencies.platform, output); + const matchingProcess = runningProcesses.find((processInfo) => + isUnityProcessForProject(processInfo.commandLine, projectRoot, dependencies.platform), + ); + + if (matchingProcess === undefined) { + return null; + } + + return { + pid: matchingProcess.pid, + }; +} + +async function runUnityProcessQuery(command: string, args: string[]): Promise { + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + return stdout; +} + +function parsePsUnityProcesses(output: string): RawUnityProcess[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const match = line.match(/^(\d+)\s+(.+)$/); + if (match === null) { + return null; + } + + return { + pid: Number.parseInt(match[1], 10), + commandLine: match[2], + }; + }) + .filter((processInfo): processInfo is RawUnityProcess => processInfo !== null); +} + +function parseWindowsUnityProcesses(output: string): RawUnityProcess[] { + const trimmed = output.trim(); + if (trimmed.length === 0) { + return []; + } + + const parsed = JSON.parse(trimmed) as WindowsUnityProcessJson | WindowsUnityProcessJson[]; + const processArray = Array.isArray(parsed) ? parsed : [parsed]; + + return processArray + .filter(isWindowsUnityProcessWithCommandLine) + .filter((processInfo) => processInfo.CommandLine.includes(UNITY_WINDOWS_PROCESS_NAME)) + .map((processInfo) => ({ + pid: processInfo.ProcessId, + commandLine: processInfo.CommandLine, + })); +} + +interface WindowsUnityProcessJson { + ProcessId: number; + CommandLine?: string; +} + +function isWindowsUnityProcessWithCommandLine( + processInfo: WindowsUnityProcessJson, +): processInfo is WindowsUnityProcessJson & { CommandLine: string } { + return typeof processInfo.ProcessId === 'number' && typeof processInfo.CommandLine === 'string'; +} From ce6f7d2d9f3e3aa747eb06c16b1213f03763892c Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Apr 2026 00:01:20 +0900 Subject: [PATCH 2/6] Clarify that commit and PR text must be written in English The repository already expected code comments, commit messages, and pull request text to be written in English, but the previous sentence bundled those rules together in a way that was easy to skim past. This updates AGENTS.md to say the same policy more explicitly by calling out commit messages, PR titles, and PR descriptions individually. The goal is to reduce future conflicts between repository-specific conventions and local automation prompts. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e98ec5386..a2743f2ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ The MCP server (TypeScriptServer~) exists as a separate component but is not the The C# namespace is `io.github.hatayama.uLoopMCP` for historical reasons, but this is a CLI-based tool, not an MCP tool. -Comments in the code, commit messages, and PR titles and bodies should be written in English. +Comments in the code, commit messages, PR titles, and PR descriptions must all be written in English. ## Skill Description Guidelines From a3515d989f0b55c7affdd48ca80ec7f460586e29 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Apr 2026 00:15:48 +0900 Subject: [PATCH 3/6] fix: remove unused Unity process export from the CLI build The build-typescript-server GitHub Actions job failed in the CLI knip step because RunningUnityProcess was exported from src/unity-process.ts but never imported anywhere else. This change keeps the type internal to the module while preserving the public helper functions used by the CLI and the unit tests. The goal is to satisfy the unused-export check without changing the runtime behavior of the Unity process detection code. Verification: - npx knip - npx tsc --noEmit - npm run build - npx jest src/__tests__/unity-process.test.ts --runInBand --testTimeout=60000 --- Packages/src/Cli~/src/unity-process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Cli~/src/unity-process.ts b/Packages/src/Cli~/src/unity-process.ts index 7cc9eadd2..d7ad4d1b0 100644 --- a/Packages/src/Cli~/src/unity-process.ts +++ b/Packages/src/Cli~/src/unity-process.ts @@ -6,7 +6,7 @@ const UNITY_WINDOWS_PROCESS_NAME = 'Unity.exe'; const WINDOWS_PROCESS_QUERY = 'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress'; -export interface RunningUnityProcess { +interface RunningUnityProcess { pid: number; } From 58f714e4eee6c2815f663bf6ecd4eea2189ec3d4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Apr 2026 00:44:56 +0900 Subject: [PATCH 4/6] Harden Unity process detection across platforms Address the PR review feedback around OS-level Unity detection so the CLI can distinguish real Unity editor processes from unrelated commands more reliably. The previous implementation could reject valid Windows processes when the executable casing differed from 'Unity.exe', and it could also accept non-Unity macOS/Linux processes as long as they reused the same -projectPath argument. Key changes: - removed the redundant Windows command-line filter that re-checked for 'Unity.exe' after the PowerShell query had already limited results to Unity.exe processes - added an isUnityEditorProcess predicate and required findRunningUnityProcessForProject to validate both the executable identity and the project path before returning a match - covered the new behavior with unit tests for uppercase Windows executable names and for non-Unity processes that happen to share the same projectPath Impact: - reduces false negatives on Windows when CommandLine casing differs from the expected literal - reduces false positives on macOS/Linux by avoiding matches against arbitrary tools that expose a -projectPath flag - keeps the detection logic small and local to unity-process.ts instead of spreading platform-specific checks across callers --- .../Cli~/src/__tests__/unity-process.test.ts | 50 ++++++++++++++++++- Packages/src/Cli~/src/unity-process.ts | 49 ++++++++++++++---- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/Packages/src/Cli~/src/__tests__/unity-process.test.ts b/Packages/src/Cli~/src/__tests__/unity-process.test.ts index e3be8043c..3b24f537d 100644 --- a/Packages/src/Cli~/src/__tests__/unity-process.test.ts +++ b/Packages/src/Cli~/src/__tests__/unity-process.test.ts @@ -2,6 +2,7 @@ import { buildUnityProcessCommand, extractUnityProjectPath, findRunningUnityProcessForProject, + isUnityEditorProcess, isUnityProcessForProject, normalizeUnityProjectPath, parseUnityProcesses, @@ -124,6 +125,35 @@ describe('isUnityProcessForProject', () => { }); }); +describe('isUnityEditorProcess', () => { + it('detects the Unity editor on macOS', () => { + expect( + isUnityEditorProcess( + '/Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/Project A"', + 'darwin', + ), + ).toBe(true); + }); + + it('rejects a non-Unity process on macOS even when projectPath is present', () => { + expect( + isUnityEditorProcess( + '/usr/local/bin/custom-tool -projectPath "/Users/me/Project A"', + 'darwin', + ), + ).toBe(false); + }); + + it('detects the Unity editor on Windows without relying on command line casing', () => { + expect( + isUnityEditorProcess( + 'C:\\Program Files\\Unity\\Editor\\UNITY.EXE -projectPath "C:\\Work\\Project A"', + 'win32', + ), + ).toBe(true); + }); +}); + describe('findRunningUnityProcessForProject', () => { it('returns null when no Unity process is running', async () => { const runCommand = jest.fn, [string, string[]]>().mockResolvedValue(''); @@ -154,11 +184,29 @@ describe('findRunningUnityProcessForProject', () => { ).resolves.toEqual({ pid: 222 }); }); + it('ignores non-Unity processes that happen to share the same projectPath', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + [ + '111 /usr/local/bin/custom-tool -projectPath "/Users/me/project"', + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/project"', + ].join('\n'), + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + it('returns matching Unity process on Windows', async () => { const runCommand = jest .fn, [string, string[]]>() .mockResolvedValue( - '[{"ProcessId":333,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\Unity.exe -projectPath \\"C:\\\\Work\\\\My Project\\""}]', + '[{"ProcessId":333,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\UNITY.EXE -projectPath \\"C:\\\\Work\\\\My Project\\""}]', ); await expect( diff --git a/Packages/src/Cli~/src/unity-process.ts b/Packages/src/Cli~/src/unity-process.ts index d7ad4d1b0..4534e7644 100644 --- a/Packages/src/Cli~/src/unity-process.ts +++ b/Packages/src/Cli~/src/unity-process.ts @@ -2,7 +2,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); -const UNITY_WINDOWS_PROCESS_NAME = 'Unity.exe'; const WINDOWS_PROCESS_QUERY = 'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress'; @@ -135,6 +134,37 @@ export function isUnityProcessForProject( ); } +export function isUnityEditorProcess(commandLine: string, platform: NodeJS.Platform): boolean { + const lowerCommandLine = commandLine.toLowerCase(); + if (lowerCommandLine.length === 0) { + return false; + } + + const projectPathFlagIndex = lowerCommandLine.indexOf(' -projectpath'); + const executableSection = + projectPathFlagIndex === -1 + ? lowerCommandLine + : lowerCommandLine.slice(0, projectPathFlagIndex); + + if (platform === 'win32') { + return executableSection.includes('unity.exe'); + } + + if (platform === 'darwin') { + return executableSection.includes('/unity.app/contents/macos/unity'); + } + + if (platform === 'linux') { + return ( + executableSection.endsWith('/unity') || + executableSection.endsWith('/unity-editor') || + executableSection.includes('/editor/unity') + ); + } + + return false; +} + export async function findRunningUnityProcessForProject( projectRoot: string, dependencies: UnityProcessDependencies = defaultDependencies, @@ -149,8 +179,10 @@ export async function findRunningUnityProcessForProject( unityProcessCommand.args, ); const runningProcesses = parseUnityProcesses(dependencies.platform, output); - const matchingProcess = runningProcesses.find((processInfo) => - isUnityProcessForProject(processInfo.commandLine, projectRoot, dependencies.platform), + const matchingProcess = runningProcesses.find( + (processInfo) => + isUnityEditorProcess(processInfo.commandLine, dependencies.platform) && + isUnityProcessForProject(processInfo.commandLine, projectRoot, dependencies.platform), ); if (matchingProcess === undefined) { @@ -198,13 +230,10 @@ function parseWindowsUnityProcesses(output: string): RawUnityProcess[] { const parsed = JSON.parse(trimmed) as WindowsUnityProcessJson | WindowsUnityProcessJson[]; const processArray = Array.isArray(parsed) ? parsed : [parsed]; - return processArray - .filter(isWindowsUnityProcessWithCommandLine) - .filter((processInfo) => processInfo.CommandLine.includes(UNITY_WINDOWS_PROCESS_NAME)) - .map((processInfo) => ({ - pid: processInfo.ProcessId, - commandLine: processInfo.CommandLine, - })); + return processArray.filter(isWindowsUnityProcessWithCommandLine).map((processInfo) => ({ + pid: processInfo.ProcessId, + commandLine: processInfo.CommandLine, + })); } interface WindowsUnityProcessJson { From 425fb53c82aaec1cc198b5e1454db5aefa3c0319 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Apr 2026 01:22:58 +0900 Subject: [PATCH 5/6] Handle flattened POSIX project paths in Unity process detection Fix the remaining PR review issue around OS-level Unity detection on macOS and Linux. POSIX `ps` output does not reliably preserve shell quoting, so a Unity command like `-projectPath /Users/me/My Project` could be flattened into a plain string that the existing token-based parser split at the first space. When that happened, `findRunningUnityProcessForProject` could return `null` even though the editor was running, which reintroduced the false `Unity not running` diagnosis this branch is meant to remove. Key changes: - changed `isUnityProcessForProject` to use a POSIX-specific raw command-line matcher instead of relying on `extractUnityProjectPath` tokenization - added `commandLineContainsProjectRoot` and a terminator check so the matcher accepts flattened paths with spaces but rejects prefix-only matches such as `/Users/me/My Project Backup` - kept the Windows path extraction flow unchanged so the existing `-projectPath` parsing behavior still applies there - added regression tests for flattened macOS `ps` output and for the prefix false-positive case, including the end-to-end `findRunningUnityProcessForProject` path Impact: - reduces false `Unity not running` errors when the Unity project path contains spaces on macOS or Linux - avoids expanding the detection to unrelated neighboring paths that merely share the same prefix - keeps the platform-specific behavior localized to `unity-process.ts`, which makes later changes to process detection easier to reason about --- .../Cli~/src/__tests__/unity-process.test.ts | 35 +++++++++ Packages/src/Cli~/src/unity-process.ts | 75 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/Packages/src/Cli~/src/__tests__/unity-process.test.ts b/Packages/src/Cli~/src/__tests__/unity-process.test.ts index 3b24f537d..75cf54acb 100644 --- a/Packages/src/Cli~/src/__tests__/unity-process.test.ts +++ b/Packages/src/Cli~/src/__tests__/unity-process.test.ts @@ -114,6 +114,26 @@ describe('isUnityProcessForProject', () => { ).toBe(true); }); + it('matches a macOS project path even when ps output has flattened quotes', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(true); + }); + + it('does not match a different macOS project that only shares the prefix', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project Backup', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(false); + }); + it('matches project path on Windows case-insensitively', () => { expect( isUnityProcessForProject( @@ -184,6 +204,21 @@ describe('findRunningUnityProcessForProject', () => { ).resolves.toEqual({ pid: 222 }); }); + it('returns a matching macOS Unity process when ps output has flattened quotes', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project', + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/My Project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + it('ignores non-Unity processes that happen to share the same projectPath', async () => { const runCommand = jest .fn, [string, string[]]>() diff --git a/Packages/src/Cli~/src/unity-process.ts b/Packages/src/Cli~/src/unity-process.ts index 4534e7644..6ab48661f 100644 --- a/Packages/src/Cli~/src/unity-process.ts +++ b/Packages/src/Cli~/src/unity-process.ts @@ -123,6 +123,10 @@ export function isUnityProcessForProject( projectRoot: string, platform: NodeJS.Platform, ): boolean { + if (platform !== 'win32') { + return commandLineContainsProjectRoot(commandLine, projectRoot, platform); + } + const extractedProjectPath = extractUnityProjectPath(commandLine); if (extractedProjectPath === null) { return false; @@ -134,6 +138,77 @@ export function isUnityProcessForProject( ); } +function commandLineContainsProjectRoot( + commandLine: string, + projectRoot: string, + platform: NodeJS.Platform, +): boolean { + const projectPathFlagIndex = commandLine.toLowerCase().indexOf(' -projectpath'); + if (projectPathFlagIndex === -1) { + return false; + } + + const normalizedProjectRoot = normalizeUnityProjectPath(projectRoot, platform); + let projectRootIndex = commandLine.indexOf(normalizedProjectRoot, projectPathFlagIndex); + + while (projectRootIndex !== -1) { + const beforeProjectRoot = commandLine[projectRootIndex - 1]; + if ( + isProjectPathBoundaryCharacter(beforeProjectRoot) && + isProjectPathTerminator(commandLine, projectRootIndex + normalizedProjectRoot.length) + ) { + return true; + } + + projectRootIndex = commandLine.indexOf(normalizedProjectRoot, projectRootIndex + 1); + } + + return false; +} + +function isProjectPathBoundaryCharacter(character: string | undefined): boolean { + return character === undefined || /\s|["']/.test(character); +} + +function isProjectPathTerminator(commandLine: string, projectRootEndIndex: number): boolean { + const character = readCharacterAt(commandLine, projectRootEndIndex); + if (character === null) { + return true; + } + + if (character === '"' || character === "'") { + return true; + } + + if (!/\s/.test(character)) { + return false; + } + + for (let i = projectRootEndIndex; i < commandLine.length; i++) { + const trailingCharacter = readCharacterAt(commandLine, i); + if (trailingCharacter === null) { + return true; + } + + if (/\s/.test(trailingCharacter)) { + continue; + } + + return trailingCharacter === '-'; + } + + return true; +} + +function readCharacterAt(value: string, index: number): string | null { + const character = value.slice(index, index + 1); + if (character.length === 0) { + return null; + } + + return character; +} + export function isUnityEditorProcess(commandLine: string, platform: NodeJS.Platform): boolean { const lowerCommandLine = commandLine.toLowerCase(); if (lowerCommandLine.length === 0) { From 46cee42a5126de1b488e24acb17355cd5ba946cf Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Apr 2026 01:39:03 +0900 Subject: [PATCH 6/6] Handle trailing slashes in POSIX Unity project matching Fix the latest PR review issue around the POSIX project matcher in `unity-process.ts`. After the previous raw command-line matching change, macOS and Linux could still misclassify a running Unity editor when the `-projectPath` value ended with one or more trailing `/` characters. That left a gap where normalized project roots such as `/Users/me/My Project` no longer matched raw process output like `/Users/me/My Project/`, which could surface as another false `Unity not running` diagnosis. Key changes: - added `skipTrailingProjectPathSeparators` so the POSIX matcher ignores trailing `/` characters before evaluating the end of the project path - kept the existing prefix guard intact so neighboring paths such as `/Users/me/My Project Backup` still do not match the target project - added regression tests for single and repeated trailing slash variants in both `isUnityProcessForProject` and `findRunningUnityProcessForProject` Impact: - reduces false negative Unity process detection when POSIX process output preserves trailing slashes on `-projectPath` - preserves the earlier fix for flattened paths with spaces without reopening the prefix-only false positive case - keeps the path-normalization behavior localized to the POSIX-specific matcher, so Windows detection remains unchanged --- .../Cli~/src/__tests__/unity-process.test.ts | 35 +++++++++++++++++++ Packages/src/Cli~/src/unity-process.ts | 16 ++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Packages/src/Cli~/src/__tests__/unity-process.test.ts b/Packages/src/Cli~/src/__tests__/unity-process.test.ts index 75cf54acb..976ba5ecb 100644 --- a/Packages/src/Cli~/src/__tests__/unity-process.test.ts +++ b/Packages/src/Cli~/src/__tests__/unity-process.test.ts @@ -124,6 +124,26 @@ describe('isUnityProcessForProject', () => { ).toBe(true); }); + it('matches a macOS project path when ps output keeps trailing slashes', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project/', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(true); + }); + + it('matches a macOS project path when ps output keeps repeated trailing slashes', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project///', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(true); + }); + it('does not match a different macOS project that only shares the prefix', () => { expect( isUnityProcessForProject( @@ -219,6 +239,21 @@ describe('findRunningUnityProcessForProject', () => { ).resolves.toEqual({ pid: 222 }); }); + it('returns a matching macOS Unity process when ps output keeps trailing slashes', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project///', + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/My Project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + it('ignores non-Unity processes that happen to share the same projectPath', async () => { const runCommand = jest .fn, [string, string[]]>() diff --git a/Packages/src/Cli~/src/unity-process.ts b/Packages/src/Cli~/src/unity-process.ts index 6ab48661f..ae8de6e7e 100644 --- a/Packages/src/Cli~/src/unity-process.ts +++ b/Packages/src/Cli~/src/unity-process.ts @@ -153,9 +153,13 @@ function commandLineContainsProjectRoot( while (projectRootIndex !== -1) { const beforeProjectRoot = commandLine[projectRootIndex - 1]; + const projectPathEndIndex = skipTrailingProjectPathSeparators( + commandLine, + projectRootIndex + normalizedProjectRoot.length, + ); if ( isProjectPathBoundaryCharacter(beforeProjectRoot) && - isProjectPathTerminator(commandLine, projectRootIndex + normalizedProjectRoot.length) + isProjectPathTerminator(commandLine, projectPathEndIndex) ) { return true; } @@ -170,6 +174,16 @@ function isProjectPathBoundaryCharacter(character: string | undefined): boolean return character === undefined || /\s|["']/.test(character); } +function skipTrailingProjectPathSeparators(commandLine: string, startIndex: number): number { + let index = startIndex; + + while (readCharacterAt(commandLine, index) === '/') { + index += 1; + } + + return index; +} + function isProjectPathTerminator(commandLine: string, projectRootEndIndex: number): boolean { const character = readCharacterAt(commandLine, projectRootEndIndex); if (character === null) {