Skip to content

Commit b0d598c

Browse files
authored
chore: split session handler modules (#230)
* chore: split session handler modules * chore: tighten session handler cleanup
1 parent d66a580 commit b0d598c

11 files changed

Lines changed: 1632 additions & 1380 deletions
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
DEFAULT_BATCH_MAX_STEPS,
3+
type BatchStepResult,
4+
type NormalizedBatchStep,
5+
validateAndNormalizeBatchSteps,
6+
} from '../../core/batch.ts';
7+
import type { BatchStep, CommandFlags } from '../../core/dispatch.ts';
8+
import { asAppError } from '../../utils/errors.ts';
9+
import type { DaemonRequest, DaemonResponse } from '../types.ts';
10+
11+
const BATCH_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'target', 'device', 'udid', 'serial', 'verbose', 'out'];
12+
13+
export async function runBatchCommands(
14+
req: DaemonRequest,
15+
sessionName: string,
16+
invoke: (req: DaemonRequest) => Promise<DaemonResponse>,
17+
): Promise<DaemonResponse> {
18+
const batchOnError = req.flags?.batchOnError ?? 'stop';
19+
if (batchOnError !== 'stop') {
20+
return {
21+
ok: false,
22+
error: {
23+
code: 'INVALID_ARGS',
24+
message: `Unsupported batch on-error mode: ${batchOnError}.`,
25+
},
26+
};
27+
}
28+
const batchMaxSteps = req.flags?.batchMaxSteps ?? DEFAULT_BATCH_MAX_STEPS;
29+
if (!Number.isInteger(batchMaxSteps) || batchMaxSteps < 1 || batchMaxSteps > 1000) {
30+
return {
31+
ok: false,
32+
error: {
33+
code: 'INVALID_ARGS',
34+
message: `Invalid batch max-steps: ${String(req.flags?.batchMaxSteps)}`,
35+
},
36+
};
37+
}
38+
try {
39+
const steps = validateAndNormalizeBatchSteps(req.flags?.batchSteps, batchMaxSteps);
40+
const startedAt = Date.now();
41+
const partialResults: BatchStepResult[] = [];
42+
for (let index = 0; index < steps.length; index += 1) {
43+
const step = steps[index];
44+
const stepResponse = await runBatchStep(req, sessionName, step, invoke, index + 1);
45+
if (!stepResponse.ok) {
46+
return {
47+
ok: false,
48+
error: {
49+
code: stepResponse.error.code,
50+
message: `Batch failed at step ${stepResponse.step} (${step.command}): ${stepResponse.error.message}`,
51+
hint: stepResponse.error.hint,
52+
diagnosticId: stepResponse.error.diagnosticId,
53+
logPath: stepResponse.error.logPath,
54+
details: {
55+
...(stepResponse.error.details ?? {}),
56+
step: stepResponse.step,
57+
command: step.command,
58+
positionals: step.positionals,
59+
executed: index,
60+
total: steps.length,
61+
partialResults,
62+
},
63+
},
64+
};
65+
}
66+
partialResults.push(stepResponse.result);
67+
}
68+
return {
69+
ok: true,
70+
data: {
71+
total: steps.length,
72+
executed: steps.length,
73+
totalDurationMs: Date.now() - startedAt,
74+
results: partialResults,
75+
},
76+
};
77+
} catch (error) {
78+
const appErr = asAppError(error);
79+
return {
80+
ok: false,
81+
error: { code: appErr.code, message: appErr.message, details: appErr.details },
82+
};
83+
}
84+
}
85+
86+
async function runBatchStep(
87+
req: DaemonRequest,
88+
sessionName: string,
89+
step: NormalizedBatchStep,
90+
invoke: (req: DaemonRequest) => Promise<DaemonResponse>,
91+
stepNumber: number,
92+
): Promise<
93+
| { ok: true; step: number; result: BatchStepResult }
94+
| {
95+
ok: false;
96+
step: number;
97+
error: {
98+
code: string;
99+
message: string;
100+
hint?: string;
101+
diagnosticId?: string;
102+
logPath?: string;
103+
details?: Record<string, unknown>;
104+
};
105+
}
106+
> {
107+
const stepStartedAt = Date.now();
108+
const response = await invoke({
109+
token: req.token,
110+
session: sessionName,
111+
command: step.command,
112+
positionals: step.positionals,
113+
flags: buildBatchStepFlags(req.flags, step.flags),
114+
runtime: step.runtime as DaemonRequest['runtime'],
115+
meta: req.meta,
116+
});
117+
const durationMs = Date.now() - stepStartedAt;
118+
if (!response.ok) {
119+
return { ok: false, step: stepNumber, error: response.error };
120+
}
121+
return {
122+
ok: true,
123+
step: stepNumber,
124+
result: {
125+
step: stepNumber,
126+
command: step.command,
127+
ok: true,
128+
data: response.data ?? {},
129+
durationMs,
130+
},
131+
};
132+
}
133+
134+
function buildBatchStepFlags(
135+
parentFlags: CommandFlags | undefined,
136+
stepFlags: BatchStep['flags'] | undefined,
137+
): CommandFlags {
138+
const {
139+
batchSteps: _batchSteps,
140+
batchOnError: _batchOnError,
141+
batchMaxSteps: _batchMaxSteps,
142+
...merged
143+
} = stepFlags ?? {};
144+
const parentRecord = (parentFlags ?? {}) as Record<string, unknown>;
145+
const mergedRecord = merged as Record<string, unknown>;
146+
for (const key of BATCH_PARENT_FLAG_KEYS) {
147+
if (mergedRecord[key] === undefined && parentRecord[key] !== undefined) {
148+
mergedRecord[key] = parentRecord[key];
149+
}
150+
}
151+
return merged as CommandFlags;
152+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { normalizeError } from '../../utils/errors.ts';
2+
import { runCmd } from '../../utils/exec.ts';
3+
import type { DeviceInfo } from '../../utils/device.ts';
4+
import { contextFromFlags } from '../context.ts';
5+
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
6+
import { SessionStore } from '../session-store.ts';
7+
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
8+
import { shutdownSimulator } from '../../platforms/ios/simulator.ts';
9+
import {
10+
clearRuntimeHintsFromApp,
11+
hasRuntimeTransportHints,
12+
} from '../runtime-hints.ts';
13+
import { cleanupRetainedMaterializedPathsForSession } from '../materialized-path-registry.ts';
14+
import {
15+
IOS_SIMULATOR_POST_CLOSE_SETTLE_MS,
16+
isAndroidEmulator,
17+
isIosSimulator,
18+
settleIosSimulator,
19+
} from './session-device-utils.ts';
20+
21+
type AppLogStream = NonNullable<SessionState['appLog']>;
22+
23+
async function shutdownAndroidEmulator(device: DeviceInfo): Promise<{
24+
success: boolean;
25+
exitCode: number;
26+
stdout: string;
27+
stderr: string;
28+
}> {
29+
const result = await runCmd('adb', ['-s', device.id, 'emu', 'kill'], { allowFailure: true, timeoutMs: 15_000 });
30+
return {
31+
success: result.exitCode === 0,
32+
exitCode: result.exitCode,
33+
stdout: String(result.stdout ?? ''),
34+
stderr: String(result.stderr ?? ''),
35+
};
36+
}
37+
38+
export type ShutdownAndroidEmulatorFn = typeof shutdownAndroidEmulator;
39+
40+
type SessionShutdownResult = {
41+
success: boolean;
42+
exitCode: number;
43+
stdout: string;
44+
stderr: string;
45+
error?: ReturnType<typeof normalizeError>;
46+
};
47+
48+
async function maybeShutdownSessionTarget(params: {
49+
device: DeviceInfo;
50+
shutdownRequested: boolean | undefined;
51+
shutdownSimulator: typeof shutdownSimulator;
52+
shutdownAndroidEmulator: typeof shutdownAndroidEmulator;
53+
}): Promise<SessionShutdownResult | undefined> {
54+
const { device, shutdownRequested, shutdownSimulator, shutdownAndroidEmulator } = params;
55+
if (!shutdownRequested) return undefined;
56+
if (!isIosSimulator(device) && !isAndroidEmulator(device)) return undefined;
57+
try {
58+
return isIosSimulator(device)
59+
? await shutdownSimulator(device)
60+
: await shutdownAndroidEmulator(device);
61+
} catch (error) {
62+
const normalized = normalizeError(error);
63+
return {
64+
success: false,
65+
exitCode: -1,
66+
stdout: '',
67+
stderr: normalized.message,
68+
error: normalized,
69+
};
70+
}
71+
}
72+
73+
export async function handleCloseCommand(params: {
74+
req: DaemonRequest;
75+
sessionName: string;
76+
logPath: string;
77+
sessionStore: SessionStore;
78+
dispatch: (device: DeviceInfo, command: string, positionals: string[], out?: string, context?: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
79+
stopIosRunner?: typeof stopIosRunnerSession;
80+
clearRuntimeHints?: typeof clearRuntimeHintsFromApp;
81+
settleSimulator?: typeof settleIosSimulator;
82+
shutdownSimulator?: typeof shutdownSimulator;
83+
shutdownAndroidEmulator?: ShutdownAndroidEmulatorFn;
84+
appLogOps: {
85+
stop: (stream: AppLogStream) => Promise<void>;
86+
};
87+
}): Promise<DaemonResponse> {
88+
const {
89+
req,
90+
sessionName,
91+
logPath,
92+
sessionStore,
93+
dispatch,
94+
stopIosRunner = stopIosRunnerSession,
95+
clearRuntimeHints = clearRuntimeHintsFromApp,
96+
settleSimulator = settleIosSimulator,
97+
shutdownSimulator: shutdownSimulatorFn = shutdownSimulator,
98+
shutdownAndroidEmulator: shutdownAndroidEmulatorFn = shutdownAndroidEmulator,
99+
appLogOps,
100+
} = params;
101+
const session = sessionStore.get(sessionName);
102+
if (!session) {
103+
return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } };
104+
}
105+
if (session.appLog) {
106+
await appLogOps.stop(session.appLog);
107+
}
108+
if (req.positionals && req.positionals.length > 0) {
109+
if (session.device.platform === 'ios') {
110+
await stopIosRunner(session.device.id);
111+
}
112+
await dispatch(session.device, 'close', req.positionals, req.flags?.out, {
113+
...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath),
114+
});
115+
await settleSimulator(session.device, IOS_SIMULATOR_POST_CLOSE_SETTLE_MS);
116+
}
117+
if (session.device.platform === 'ios') {
118+
// The targeted close path stops before dispatch to avoid runner/app races.
119+
// Stop again here so both plain and targeted closes end with the runner down.
120+
await stopIosRunner(session.device.id);
121+
}
122+
const runtime = sessionStore.getRuntimeHints(sessionName);
123+
if (hasRuntimeTransportHints(runtime) && session.appBundleId) {
124+
await clearRuntimeHints({
125+
device: session.device,
126+
appId: session.appBundleId,
127+
}).catch(() => {});
128+
}
129+
sessionStore.recordAction(session, {
130+
command: 'close',
131+
positionals: req.positionals ?? [],
132+
flags: req.flags ?? {},
133+
result: { session: sessionName },
134+
});
135+
if (req.flags?.saveScript) {
136+
session.recordSession = true;
137+
}
138+
sessionStore.writeSessionLog(session);
139+
await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {});
140+
sessionStore.delete(sessionName);
141+
const shutdownResult = await maybeShutdownSessionTarget({
142+
device: session.device,
143+
shutdownRequested: req.flags?.shutdown,
144+
shutdownSimulator: shutdownSimulatorFn,
145+
shutdownAndroidEmulator: shutdownAndroidEmulatorFn,
146+
});
147+
if (shutdownResult) {
148+
return {
149+
ok: true,
150+
data: { session: sessionName, shutdown: shutdownResult },
151+
};
152+
}
153+
return { ok: true, data: { session: sessionName } };
154+
}

0 commit comments

Comments
 (0)