Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/dispatch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ClickButton } from './click-button.ts';
import type { ElementSelectorKey } from './interactor-types.ts';
import type { SwipePattern } from './scroll-gesture.ts';
import type { SessionSurface } from './session-surface.ts';
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';

export type MaestroRuntimeFlags = {
allowNonHittableCoordinateFallback?: boolean;
Expand Down Expand Up @@ -43,6 +44,7 @@ export type DispatchContext = ScreenshotDispatchFlags & {
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
iosXctestEnvDir?: string;
runnerLeaseContext?: RunnerLogicalLeaseContext;
snapshotInteractiveOnly?: boolean;
snapshotDepth?: number;
snapshotScope?: string;
Expand Down
1 change: 1 addition & 0 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export async function dispatchCommand(
iosXctestrunFile: context?.iosXctestrunFile,
iosXctestDerivedDataPath: context?.iosXctestDerivedDataPath,
iosXctestEnvDir: context?.iosXctestEnvDir,
runnerLeaseContext: context?.runnerLeaseContext,
};
const interactor = await getInteractor(device, runnerCtx);
emitDiagnostic({
Expand Down
3 changes: 3 additions & 0 deletions src/core/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ScrollDirection, TransformGestureParams } from './scroll-gesture.t
import type { SettingOptions } from '../platforms/permission-utils.ts';
import type { SessionSurface } from './session-surface.ts';
import type { BackendSnapshotResult } from '../backend.ts';
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';
import type {
RawSnapshotNode,
SnapshotBackend,
Expand All @@ -19,6 +20,7 @@ export type RunnerContext = {
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
iosXctestEnvDir?: string;
runnerLeaseContext?: RunnerLogicalLeaseContext;
};

/** Subset of {@link RunnerContext} forwarded to runner command invocations. */
Expand All @@ -31,6 +33,7 @@ export type RunnerCallOptions = Pick<
| 'iosXctestrunFile'
| 'iosXctestDerivedDataPath'
| 'iosXctestEnvDir'
| 'runnerLeaseContext'
>;

export type { BackMode };
Expand Down
8 changes: 8 additions & 0 deletions src/core/runner-lease-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type RunnerLogicalLeaseContext = {
leaseId?: string;
clientId?: string;
tenantId?: string;
runId?: string;
leaseProvider?: string;
deviceKey?: string;
};
5 changes: 5 additions & 0 deletions src/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from './daemon/transport.ts';
import { prewarmPngWorker, terminatePngWorker } from './utils/png-worker-client.ts';
import { sleep } from './utils/timeouts.ts';
import { setRunnerLeaseOwnerStateDir } from './platforms/ios/runner-lease.ts';

const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000;
const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000;
Expand Down Expand Up @@ -66,6 +67,7 @@ export async function startDaemonRuntime(
const daemonPaths = resolveDaemonPaths(env.AGENT_DEVICE_STATE_DIR);
const { baseDir, infoPath, lockPath, logPath, sessionsDir } = daemonPaths;
const daemonServerMode = resolveDaemonServerMode(env.AGENT_DEVICE_DAEMON_SERVER_MODE);
setRunnerLeaseOwnerStateDir(baseDir);

cleanupStaleAppLogProcesses(sessionsDir);

Expand Down Expand Up @@ -182,6 +184,7 @@ export async function startDaemonRuntime(
};
if (!acquireDaemonLock(baseDir, lockPath, lockData)) {
stderr.write('Daemon lock is held by another process; exiting.\n');
setRunnerLeaseOwnerStateDir(undefined);
exit(0);
return null;
}
Expand All @@ -201,6 +204,7 @@ export async function startDaemonRuntime(
closeServersBestEffort(servers);
removeInfo(infoPath);
releaseDaemonLock(lockPath);
setRunnerLeaseOwnerStateDir(undefined);
exit(1);
return null;
}
Expand Down Expand Up @@ -228,6 +232,7 @@ export async function startDaemonRuntime(
]);
removeInfo(infoPath);
releaseDaemonLock(lockPath);
setRunnerLeaseOwnerStateDir(undefined);
exit(shutdownOptions.exitCode ?? 0);
};

Expand Down
4 changes: 4 additions & 0 deletions src/daemon/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type ScreenshotRuntimeFlags,
} from '../contracts/screenshot.ts';
import { getDiagnosticsMeta } from '../utils/diagnostics.ts';
import { resolveRunnerLogicalLeaseContext } from './lease-context.ts';
import type { DaemonRequest } from './types.ts';

export type DaemonCommandContext = DispatchContext & ScreenshotRuntimeFlags;

Expand All @@ -17,11 +19,13 @@ export function contextFromFlags(
appBundleId?: string,
traceLogPath?: string,
requestId?: string,
meta?: DaemonRequest['meta'],
): DaemonCommandContext {
const effectiveRequestId = requestId ?? getDiagnosticsMeta().requestId;
return {
requestId: effectiveRequestId,
appBundleId,
runnerLeaseContext: resolveRunnerLogicalLeaseContext({ meta }),
activity: flags?.activity,
launchConsole: flags?.launchConsole,
launchArgs: flags?.launchArgs,
Expand Down
8 changes: 8 additions & 0 deletions src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ async function completeOpenCommand(params: {
logPath,
traceLogPath,
requestId: req.meta?.requestId,
runnerLeaseContext: contextFromFlags(
logPath,
req.flags,
sessionAppBundleId,
traceLogPath,
req.meta?.requestId,
req.meta,
).runnerLeaseContext,
iosXctestrunFile: req.flags?.iosXctestrunFile,
iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath,
iosXctestEnvDir: req.flags?.iosXctestEnvDir,
Expand Down
21 changes: 21 additions & 0 deletions src/daemon/lease-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DaemonRequest } from './types.ts';
import type { RunnerLogicalLeaseContext } from '../core/runner-lease-context.ts';
import type { LeaseBackend } from '../contracts.ts';

export type LeaseScope = {
Expand All @@ -18,3 +19,23 @@ export function resolveLeaseScope(req: Pick<DaemonRequest, 'flags' | 'meta'>): L
leaseBackend: req.meta?.leaseBackend,
};
}

export function resolveRunnerLogicalLeaseContext(
req: Pick<DaemonRequest, 'meta'>,
): RunnerLogicalLeaseContext | undefined {
const meta = req.meta as (DaemonRequest['meta'] & Record<string, unknown>) | undefined;
const context = {
leaseId: readNonEmptyString(meta?.leaseId),
clientId: readNonEmptyString(meta?.clientId),
tenantId: readNonEmptyString(meta?.tenantId),
runId: readNonEmptyString(meta?.runId),
leaseProvider:
readNonEmptyString(meta?.leaseProvider) ?? readNonEmptyString(meta?.leaseBackend),
deviceKey: readNonEmptyString(meta?.deviceKey),
};
return Object.values(context).some((value) => value !== undefined) ? context : undefined;
}

function readNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
6 changes: 4 additions & 2 deletions src/daemon/request-execution-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ export function prepareLockedRequestScope(params: {
flags: CommandFlags | undefined,
appBundleId?: string,
traceLogPath?: string,
): DaemonCommandContext => contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath);
): DaemonCommandContext =>
contextFromRequestFlags(logPath, flags, appBundleId, traceLogPath, lockedReq.meta);

return {
type: 'scope',
Expand All @@ -233,10 +234,11 @@ function contextFromRequestFlags(
flags: CommandFlags | undefined,
appBundleId?: string,
traceLogPath?: string,
meta?: DaemonRequest['meta'],
): DaemonCommandContext {
const requestId = getDiagnosticsMeta().requestId;
return {
...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId),
...contextFromFlagsWithLog(logPath, flags, appBundleId, traceLogPath, requestId, meta),
requestId,
};
}
Expand Down
106 changes: 106 additions & 0 deletions src/platforms/ios/__tests__/runner-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,15 @@ import {
cleanupRunnerLeasesForOwner,
RUNNER_OWNER_START_TIME,
RUNNER_OWNER_TOKEN,
setRunnerLeaseOwnerStateDir,
writeRunnerLease,
type RunnerLease,
} from '../runner-lease.ts';

beforeEach(async () => {
await abortAllIosRunnerSessions();
vi.resetAllMocks();
setRunnerLeaseOwnerStateDir(undefined);
process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-device-runner-lease-test-'),
);
Expand Down Expand Up @@ -609,6 +611,29 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv
});
});

test('runner session startup diagnostics include logical lease context', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-lease-context-sim' };

const diagnostics = await captureDiagnostics(async () => {
await ensureRunnerSession(device, {
runnerLeaseContext: {
tenantId: 'tenant-123',
runId: 'run-456',
leaseId: 'lease-789',
leaseProvider: 'ios-simulator',
},
});
});

assert.match(diagnostics, /ios_runner_session_startup/);
assert.match(diagnostics, /"logicalLeaseContext"/);
assert.match(diagnostics, /"tenantId":"tenant-123"/);
assert.match(diagnostics, /"runId":"run-456"/);
assert.match(diagnostics, /"leaseId":"lease-789"/);
assert.match(diagnostics, /"leaseProvider":"ios-simulator"/);
assert.match(diagnostics, /"deviceKey":"runner-session-lease-context-sim"/);
});

test('runner session fails early for physical iOS devices when Apple developer mode is disabled', async () => {
const device = { ...IOS_DEVICE, id: 'runner-session-devtools-disabled-device' };
mockDevToolsSecurityDisabled();
Expand Down Expand Up @@ -693,6 +718,10 @@ test('runner session startup rejects live foreign runner lease', async () => {
String((thrown as { details?: Record<string, unknown> }).details?.hint),
/Do not run prepare ios-runner/,
);
assert.match(
String((thrown as { details?: Record<string, unknown> }).details?.hint),
/PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/,
);
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
assert.equal(
mockRunAppleToolCommand.mock.calls.some((call) => call[0] === 'pkill'),
Expand All @@ -704,6 +733,51 @@ test('runner session startup rejects live foreign runner lease', async () => {
}
});

test('runner session busy error includes logical lease context after admission', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-logical-busy-lease-sim' };
writeRunnerLease(
makeRunnerLease({
deviceId: device.id,
ownerToken: 'owner-foreign-logical-live',
ownerPid: process.pid,
ownerStartTime: RUNNER_OWNER_START_TIME,
ownerStateDir: '/tmp/agent-device-owner',
}),
);

let thrown: unknown;
await assert.rejects(async () => {
try {
await ensureRunnerSession(device, {
runnerLeaseContext: {
tenantId: 'tenant-123',
runId: 'run-456',
leaseId: 'lease-789',
leaseProvider: 'ios-simulator',
},
});
} catch (error) {
thrown = error;
throw error;
}
}, /busy after device lease admission/);

assert.ok(thrown instanceof AppError);
assert.deepEqual(thrown.details?.logicalLeaseContext, {
tenantId: 'tenant-123',
runId: 'run-456',
leaseId: 'lease-789',
leaseProvider: 'ios-simulator',
deviceKey: device.id,
});
assert.match(String(thrown.details?.hint), /five-minute inactivity lease expires/);
assert.match(
String(thrown.details?.hint),
/Runner owner: PID \d+ with AGENT_DEVICE_STATE_DIR=\/tmp\/agent-device-owner/,
);
assert.equal(mockRunCmdBackground.mock.calls.length, 0);
});

test('runner session startup reclaims live foreign runner lease from same state dir', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-same-state-lease-sim' };
const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR;
Expand Down Expand Up @@ -738,6 +812,38 @@ test('runner session startup reclaims live foreign runner lease from same state
}
});

test('runner session startup reclaims same-state live lease from daemon runtime owner state dir', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-runtime-state-lease-sim' };
const previousStateDir = process.env.AGENT_DEVICE_STATE_DIR;
const stateDir = '/tmp/agent-device-runtime-state';
delete process.env.AGENT_DEVICE_STATE_DIR;
setRunnerLeaseOwnerStateDir(stateDir);
writeRunnerLease(
makeRunnerLease({
deviceId: device.id,
ownerToken: 'owner-foreign-runtime-state',
ownerPid: process.pid,
ownerStartTime: RUNNER_OWNER_START_TIME,
ownerStateDir: stateDir,
runnerPid: 4_321,
}),
);

try {
const session = await ensureRunnerSession(device, {});

assert.equal(session.deviceId, device.id);
assert.equal(mockRunCmdBackground.mock.calls.length, 1);
const pkillCalls = mockRunAppleToolCommand.mock.calls.filter(isXcodebuildPkillCall);
assert.ok(pkillCalls.length >= 2);
assert.match(String(pkillCalls[0]?.[1]?.[2] ?? ''), /owner-foreign-runtime-state/);
} finally {
setRunnerLeaseOwnerStateDir(undefined);
if (previousStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
else process.env.AGENT_DEVICE_STATE_DIR = previousStateDir;
}
});

test('runner session startup reclaims dead foreign runner lease before launching', async () => {
const device = { ...IOS_SIMULATOR, id: 'runner-session-dead-lease-sim' };
mockIsProcessAlive.mockImplementation((pid) => pid !== 999_999_999 && pid !== 999_999_998);
Expand Down
1 change: 1 addition & 0 deletions src/platforms/ios/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function iosRunnerOverrides(
iosXctestrunFile: ctx.iosXctestrunFile,
iosXctestDerivedDataPath: ctx.iosXctestDerivedDataPath,
iosXctestEnvDir: ctx.iosXctestEnvDir,
runnerLeaseContext: ctx.runnerLeaseContext,
};
return {
runnerOpts,
Expand Down
Loading
Loading