From 02e152e89548e14d08ca345ea232213f0435f612 Mon Sep 17 00:00:00 2001 From: Vincent Manapat Date: Sun, 27 Sep 2026 16:27:42 +0200 Subject: [PATCH 1/2] fix: Find app processes on Xcode 27 in terminateApp --- lib/mixins/process.ts | 28 ++++++++++--- test/unit/devicectl.spec.ts | 83 ++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/lib/mixins/process.ts b/lib/mixins/process.ts index 07d29cc..61352ed 100644 --- a/lib/mixins/process.ts +++ b/lib/mixins/process.ts @@ -106,10 +106,28 @@ export function escapeProcessFilterValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } +const EXECUTABLE_PATH_FILTER_FIELDS = ['ExecutablePath', 'executable.path'] as const; +const UNKNOWN_FILTER_FIELD_ERROR = /CoreDeviceError error 28001\b|Unknown filter field/; + +/** + * Lists the app's processes. Xcode 27's devicectl filters on `ExecutablePath` and rejects + * `executable.path` (CoreDeviceError 28001); earlier versions use `executable.path`. + */ async function listProcessesForAppPath(devicectl: Devicectl, appPath: string): Promise { - const filter = `executable.path BEGINSWITH "${escapeProcessFilterValue(appPath)}"`; - const {stdout} = await devicectl.execute(['device', 'info', 'processes'], { - subcommandOptions: ['--filter', filter], - }); - return JSON.parse(stdout).result.runningProcesses; + const value = escapeProcessFilterValue(appPath); + let lastError: unknown; + for (const field of EXECUTABLE_PATH_FILTER_FIELDS) { + try { + const {stdout} = await devicectl.execute(['device', 'info', 'processes'], { + subcommandOptions: ['--filter', `${field} BEGINSWITH "${value}"`], + }); + return JSON.parse(stdout).result.runningProcesses; + } catch (err) { + if (!(err instanceof Error) || !UNKNOWN_FILTER_FIELD_ERROR.test(err.message)) { + throw err; + } + lastError = err; + } + } + throw lastError; } diff --git a/test/unit/devicectl.spec.ts b/test/unit/devicectl.spec.ts index c7fd366..90eeb1f 100644 --- a/test/unit/devicectl.spec.ts +++ b/test/unit/devicectl.spec.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {describe, it, beforeEach} from 'node:test'; +import {afterEach, beforeEach, describe, it, mock} from 'node:test'; import {Devicectl} from '../../lib/devicectl.js'; import {appUrlToFilesystemPath, escapeProcessFilterValue} from '../../lib/mixins/process.js'; @@ -88,6 +88,87 @@ describe('Devicectl', function () { assert.strictEqual(typeof devicectl.terminateApp, 'function'); }); + describe('finding the app process', function () { + const APP_URL = 'file:///private/var/containers/Bundle/Application/ABC/App.app/'; + const APP_PATH = '/private/var/containers/Bundle/Application/ABC/App.app'; + const PROCESSES = JSON.stringify({ + result: {runningProcesses: [{processIdentifier: 42, executable: `${APP_URL}App`}]}, + }); + + function unknownFieldError(field: string): Error { + return new Error( + `'xcrun devicectl device info processes' failed. Original error: ERROR: Unknown filter field '${field}'. ` + + '(com.apple.dt.CoreDeviceError error 28001 (0x6D61))', + ); + } + + function fakeDevicectl(knownFields: string[], failure?: Error) { + const filters: string[] = []; + const terminated: string[] = []; + mock.method(devicectl, 'execute', async (subcommand: string[], opts: {subcommandOptions: string[]}) => { + if (subcommand.join(' ') === 'device process terminate') { + terminated.push(opts.subcommandOptions[1]); + return {stdout: '{}'}; + } + const filter = opts.subcommandOptions[1]; + filters.push(filter); + if (failure) { + throw failure; + } + const field = filter.slice(0, filter.indexOf(' BEGINSWITH ')); + if (!knownFields.includes(field)) { + throw unknownFieldError(field); + } + return {stdout: PROCESSES}; + }); + return {filters, terminated}; + } + + beforeEach(function () { + mock.method(devicectl, 'listApps', async () => [{url: APP_URL}]); + }); + + afterEach(function () { + mock.restoreAll(); + }); + + it('should filter on ExecutablePath, which Xcode 27 accepts', async function () { + const {filters, terminated} = fakeDevicectl(['ExecutablePath']); + + assert.strictEqual(await devicectl.terminateApp('com.example.app'), true); + assert.deepStrictEqual(filters, [`ExecutablePath BEGINSWITH "${APP_PATH}"`]); + assert.deepStrictEqual(terminated, ['42']); + }); + + it('should fall back to executable.path when devicectl does not know ExecutablePath', async function () { + const {filters, terminated} = fakeDevicectl(['executable.path']); + + assert.strictEqual(await devicectl.terminateApp('com.example.app'), true); + assert.deepStrictEqual(filters, [ + `ExecutablePath BEGINSWITH "${APP_PATH}"`, + `executable.path BEGINSWITH "${APP_PATH}"`, + ]); + assert.deepStrictEqual(terminated, ['42']); + }); + + it('should not retry when devicectl fails for any other reason', async function () { + const {filters} = fakeDevicectl( + [], + new Error("'xcrun devicectl device info processes' failed. Original error: ERROR: The device was not found."), + ); + + await assert.rejects(devicectl.terminateApp('com.example.app'), /The device was not found/); + assert.strictEqual(filters.length, 1); + }); + + it('should report the rejection when devicectl knows neither field', async function () { + const {filters} = fakeDevicectl([]); + + await assert.rejects(devicectl.terminateApp('com.example.app'), /Unknown filter field 'executable\.path'/); + assert.strictEqual(filters.length, 2); + }); + }); + describe('appUrlToFilesystemPath', function () { it('should strip the file:// prefix', function () { assert.strictEqual(appUrlToFilesystemPath('file:///path/to/App.app'), '/path/to/App.app'); From e3e5931ad142aa738a95fd0d3f626b23fc6af24b Mon Sep 17 00:00:00 2001 From: Vincent Manapat Date: Fri, 2 Oct 2026 10:49:39 +0200 Subject: [PATCH 2/2] fix: Pick the process filter field from devicectl's JSON version terminateApp no longer tries ExecutablePath and falls back to executable.path when devicectl rejects it with error 28001. It reads info.jsonVersion once per instance and filters on ExecutablePath from version 5 (Xcode 27), and on executable.path before that. Xcode 15.0 through 26.6 on GitHub's macOS runners report JSON version 2 or 3; Xcode 27.0 reports 5. --- lib/devicectl.ts | 15 ++++++ lib/mixins/process.ts | 33 +++++-------- test/unit/devicectl.spec.ts | 95 ++++++++++++++++++++++--------------- 3 files changed, 83 insertions(+), 60 deletions(-) diff --git a/lib/devicectl.ts b/lib/devicectl.ts index 577425b..d024fc4 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -37,6 +37,7 @@ export class Devicectl { private readonly preferNonRootWhenSudo: boolean; private readonly sudoUser: SudoUser | null; + private jsonVersion: number | undefined; /** * Creates a new Devicectl instance @@ -106,6 +107,20 @@ export class Devicectl { } } + /** + * Retrieves the version of the JSON output (`info.jsonVersion`) from devicectl + * + * @returns Promise that resolves to the JSON output format version + */ + async getJsonVersion(): Promise { + if (this.jsonVersion === undefined) { + const {stdout} = await this.execute(['list', 'devices'], {noDevice: true}); + const jsonVersion: number = JSON.parse(stdout).info.jsonVersion; + this.jsonVersion = jsonVersion; + } + return this.jsonVersion; + } + private resolveSudoUser(): SudoUser | null { if (!process.geteuid || process.geteuid() !== 0) { return null; diff --git a/lib/mixins/process.ts b/lib/mixins/process.ts index 61352ed..e82ae06 100644 --- a/lib/mixins/process.ts +++ b/lib/mixins/process.ts @@ -106,28 +106,19 @@ export function escapeProcessFilterValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } -const EXECUTABLE_PATH_FILTER_FIELDS = ['ExecutablePath', 'executable.path'] as const; -const UNKNOWN_FILTER_FIELD_ERROR = /CoreDeviceError error 28001\b|Unknown filter field/; - /** - * Lists the app's processes. Xcode 27's devicectl filters on `ExecutablePath` and rejects - * `executable.path` (CoreDeviceError 28001); earlier versions use `executable.path`. + * Picks the process filter field for the executable path. + * Xcode 27 renamed it. */ +export function executablePathField(jsonVersion: number): string { + return jsonVersion >= 5 ? 'ExecutablePath' : 'executable.path'; +} + async function listProcessesForAppPath(devicectl: Devicectl, appPath: string): Promise { - const value = escapeProcessFilterValue(appPath); - let lastError: unknown; - for (const field of EXECUTABLE_PATH_FILTER_FIELDS) { - try { - const {stdout} = await devicectl.execute(['device', 'info', 'processes'], { - subcommandOptions: ['--filter', `${field} BEGINSWITH "${value}"`], - }); - return JSON.parse(stdout).result.runningProcesses; - } catch (err) { - if (!(err instanceof Error) || !UNKNOWN_FILTER_FIELD_ERROR.test(err.message)) { - throw err; - } - lastError = err; - } - } - throw lastError; + const field = executablePathField(await devicectl.getJsonVersion()); + const filter = `${field} BEGINSWITH "${escapeProcessFilterValue(appPath)}"`; + const {stdout} = await devicectl.execute(['device', 'info', 'processes'], { + subcommandOptions: ['--filter', filter], + }); + return JSON.parse(stdout).result.runningProcesses; } diff --git a/test/unit/devicectl.spec.ts b/test/unit/devicectl.spec.ts index 90eeb1f..4ca7c64 100644 --- a/test/unit/devicectl.spec.ts +++ b/test/unit/devicectl.spec.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {afterEach, beforeEach, describe, it, mock} from 'node:test'; import {Devicectl} from '../../lib/devicectl.js'; -import {appUrlToFilesystemPath, escapeProcessFilterValue} from '../../lib/mixins/process.js'; +import {appUrlToFilesystemPath, escapeProcessFilterValue, executablePathField} from '../../lib/mixins/process.js'; describe('Devicectl', function () { let devicectl: Devicectl; @@ -95,33 +95,31 @@ describe('Devicectl', function () { result: {runningProcesses: [{processIdentifier: 42, executable: `${APP_URL}App`}]}, }); - function unknownFieldError(field: string): Error { - return new Error( - `'xcrun devicectl device info processes' failed. Original error: ERROR: Unknown filter field '${field}'. ` + - '(com.apple.dt.CoreDeviceError error 28001 (0x6D61))', - ); - } - - function fakeDevicectl(knownFields: string[], failure?: Error) { + function fakeDevicectl(jsonVersion: number, failures: {processes?: Error; jsonVersion?: Error} = {}) { const filters: string[] = []; const terminated: string[] = []; + const calls = {filters, terminated, jsonVersionLookups: 0}; mock.method(devicectl, 'execute', async (subcommand: string[], opts: {subcommandOptions: string[]}) => { - if (subcommand.join(' ') === 'device process terminate') { - terminated.push(opts.subcommandOptions[1]); - return {stdout: '{}'}; - } - const filter = opts.subcommandOptions[1]; - filters.push(filter); - if (failure) { - throw failure; - } - const field = filter.slice(0, filter.indexOf(' BEGINSWITH ')); - if (!knownFields.includes(field)) { - throw unknownFieldError(field); + switch (subcommand.join(' ')) { + case 'list devices': + calls.jsonVersionLookups++; + // only the first lookup fails + if (failures.jsonVersion && calls.jsonVersionLookups === 1) { + throw failures.jsonVersion; + } + return {stdout: JSON.stringify({info: {jsonVersion}})}; + case 'device process terminate': + calls.terminated.push(opts.subcommandOptions[1]); + return {stdout: '{}'}; + default: + calls.filters.push(opts.subcommandOptions[1]); + if (failures.processes) { + throw failures.processes; + } + return {stdout: PROCESSES}; } - return {stdout: PROCESSES}; }); - return {filters, terminated}; + return calls; } beforeEach(function () { @@ -133,39 +131,58 @@ describe('Devicectl', function () { }); it('should filter on ExecutablePath, which Xcode 27 accepts', async function () { - const {filters, terminated} = fakeDevicectl(['ExecutablePath']); + const {filters, terminated} = fakeDevicectl(5); assert.strictEqual(await devicectl.terminateApp('com.example.app'), true); assert.deepStrictEqual(filters, [`ExecutablePath BEGINSWITH "${APP_PATH}"`]); assert.deepStrictEqual(terminated, ['42']); }); - it('should fall back to executable.path when devicectl does not know ExecutablePath', async function () { - const {filters, terminated} = fakeDevicectl(['executable.path']); + it('should filter on executable.path before JSON version 5', async function () { + const {filters, terminated} = fakeDevicectl(4); assert.strictEqual(await devicectl.terminateApp('com.example.app'), true); - assert.deepStrictEqual(filters, [ - `ExecutablePath BEGINSWITH "${APP_PATH}"`, - `executable.path BEGINSWITH "${APP_PATH}"`, - ]); + assert.deepStrictEqual(filters, [`executable.path BEGINSWITH "${APP_PATH}"`]); assert.deepStrictEqual(terminated, ['42']); }); - it('should not retry when devicectl fails for any other reason', async function () { - const {filters} = fakeDevicectl( - [], - new Error("'xcrun devicectl device info processes' failed. Original error: ERROR: The device was not found."), - ); + it('should not retry when devicectl fails', async function () { + const {filters} = fakeDevicectl(5, { + processes: new Error( + "'xcrun devicectl device info processes' failed. Original error: ERROR: The device was not found.", + ), + }); await assert.rejects(devicectl.terminateApp('com.example.app'), /The device was not found/); assert.strictEqual(filters.length, 1); }); - it('should report the rejection when devicectl knows neither field', async function () { - const {filters} = fakeDevicectl([]); + it('should look up the JSON version only once', async function () { + const calls = fakeDevicectl(5); + + await devicectl.terminateApp('com.example.app'); + await devicectl.terminateApp('com.example.app'); + assert.strictEqual(calls.jsonVersionLookups, 1); + assert.strictEqual(calls.filters.length, 2); + }); + + it('should look up the JSON version again after a failed lookup', async function () { + const calls = fakeDevicectl(5, {jsonVersion: new Error("'xcrun devicectl list devices' failed.")}); + + await assert.rejects(devicectl.terminateApp('com.example.app'), /list devices' failed/); + assert.strictEqual(await devicectl.terminateApp('com.example.app'), true); + assert.strictEqual(calls.jsonVersionLookups, 2); + }); + }); + + describe('executablePathField', function () { + it('should be ExecutablePath as of JSON version 5', function () { + assert.strictEqual(executablePathField(5), 'ExecutablePath'); + assert.strictEqual(executablePathField(6), 'ExecutablePath'); + }); - await assert.rejects(devicectl.terminateApp('com.example.app'), /Unknown filter field 'executable\.path'/); - assert.strictEqual(filters.length, 2); + it('should be executable.path before JSON version 5', function () { + assert.strictEqual(executablePathField(4), 'executable.path'); }); });