Skip to content

Commit 26f3886

Browse files
committed
fix: allow fresh session device binding
1 parent 491ad7e commit 26f3886

4 files changed

Lines changed: 347 additions & 38 deletions

File tree

src/daemon/__tests__/request-lock-policy.test.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,25 @@ const ANDROID_SESSION: SessionState = {
3232
},
3333
};
3434

35-
test('rejects fresh-session selector conflicts under request lock policy', () => {
36-
assert.throws(
37-
() =>
38-
applyRequestLockPolicy({
39-
token: 'token',
40-
session: 'qa-ios',
41-
command: 'snapshot',
42-
positionals: [],
43-
flags: {
44-
device: 'Pixel 9',
45-
},
46-
meta: {
47-
lockPolicy: 'reject',
48-
lockPlatform: 'ios',
49-
},
50-
}),
51-
/--device=Pixel 9/i,
52-
);
35+
test('allows compatible fresh-session selectors under request lock policy', () => {
36+
const req = applyRequestLockPolicy({
37+
token: 'token',
38+
session: 'qa-ios',
39+
command: 'snapshot',
40+
positionals: [],
41+
flags: {
42+
device: 'iPhone 16',
43+
udid: 'SIM-001',
44+
},
45+
meta: {
46+
lockPolicy: 'reject',
47+
lockPlatform: 'ios',
48+
},
49+
});
50+
51+
assert.equal(req.flags?.platform, 'ios');
52+
assert.equal(req.flags?.device, 'iPhone 16');
53+
assert.equal(req.flags?.udid, 'SIM-001');
5354
});
5455

5556
test('allows open to choose a fresh-session target under request lock policy', () => {
@@ -74,7 +75,7 @@ test('allows open to choose a fresh-session target under request lock policy', (
7475
assert.equal(req.flags?.udid, 'SIM-001');
7576
});
7677

77-
test('strips fresh-session selector conflicts and restores lock platform', () => {
78+
test('strips only fresh-session selector conflicts and restores lock platform', () => {
7879
const req = applyRequestLockPolicy({
7980
token: 'token',
8081
session: 'qa-ios',
@@ -92,7 +93,7 @@ test('strips fresh-session selector conflicts and restores lock platform', () =>
9293
});
9394

9495
assert.equal(req.flags?.platform, 'ios');
95-
assert.equal(req.flags?.target, undefined);
96+
assert.equal(req.flags?.target, 'tv');
9697
assert.equal(req.flags?.serial, undefined);
9798
});
9899

src/daemon/__tests__/request-router-lock-policy.test.ts

Lines changed: 242 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
77
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
88
});
99

10+
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
11+
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
12+
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
13+
});
14+
15+
vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));
16+
1017
import { dispatchCommand } from '../../core/dispatch.ts';
1118
import { createRequestHandler } from '../request-router.ts';
1219
import type { SessionState } from '../types.ts';
@@ -34,7 +41,7 @@ function makeIosSession(name: string): SessionState {
3441

3542
beforeEach(() => {
3643
mockDispatch.mockReset();
37-
mockDispatch.mockResolvedValue({});
44+
mockDispatch.mockResolvedValue({ nodes: [] });
3845
});
3946

4047
test('direct daemon requests cannot bypass reject lock policy for existing sessions', async () => {
@@ -72,6 +79,240 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi
7279
}
7380
});
7481

82+
test('fresh named sessions with matching explicit udid bind and serialize on the selected device', async () => {
83+
const sessionStore = makeSessionStore('agent-device-router-lock-');
84+
const order: string[] = [];
85+
const gates: Array<() => void> = [];
86+
let active = 0;
87+
let maxActive = 0;
88+
89+
mockDispatch.mockImplementation(async (device, command) => {
90+
order.push(`start-${command}-${device.id}`);
91+
active += 1;
92+
maxActive = Math.max(maxActive, active);
93+
await new Promise<void>((resolve) => {
94+
gates.push(() => {
95+
active -= 1;
96+
order.push(`end-${command}-${device.id}`);
97+
resolve();
98+
});
99+
});
100+
return { nodes: [] };
101+
});
102+
103+
const handler = createRequestHandler({
104+
logPath: path.join(os.tmpdir(), 'daemon.log'),
105+
token: 'test-token',
106+
sessionStore,
107+
leaseRegistry: new LeaseRegistry(),
108+
deviceInventoryProvider: async () => [makeIosSession('inventory').device],
109+
trackDownloadableArtifact: () => 'artifact-id',
110+
});
111+
112+
const first = handler({
113+
token: 'test-token',
114+
session: 'qa-ios-a',
115+
command: 'snapshot',
116+
positionals: [],
117+
flags: {
118+
udid: 'SIM-001',
119+
},
120+
meta: {
121+
requestId: 'req-fresh-lock-a',
122+
lockPolicy: 'reject',
123+
lockPlatform: 'ios',
124+
},
125+
});
126+
127+
await vi.waitFor(() => {
128+
expect(order).toEqual(['start-snapshot-SIM-001']);
129+
});
130+
131+
const second = handler({
132+
token: 'test-token',
133+
session: 'qa-ios-b',
134+
command: 'snapshot',
135+
positionals: [],
136+
flags: {
137+
udid: 'SIM-001',
138+
},
139+
meta: {
140+
requestId: 'req-fresh-lock-b',
141+
lockPolicy: 'reject',
142+
lockPlatform: 'ios',
143+
},
144+
});
145+
146+
await new Promise((resolve) => setTimeout(resolve, 20));
147+
expect(order).toEqual(['start-snapshot-SIM-001']);
148+
149+
gates.shift()?.();
150+
151+
await vi.waitFor(() => {
152+
expect(order).toEqual([
153+
'start-snapshot-SIM-001',
154+
'end-snapshot-SIM-001',
155+
'start-snapshot-SIM-001',
156+
]);
157+
});
158+
159+
gates.shift()?.();
160+
161+
const [firstResponse, secondResponse] = await Promise.all([first, second]);
162+
163+
expect(firstResponse.ok).toBe(true);
164+
expect(secondResponse.ok).toBe(true);
165+
expect(maxActive).toBe(1);
166+
expect(sessionStore.get('qa-ios-a')?.device.id).toBe('SIM-001');
167+
expect(sessionStore.get('qa-ios-b')?.device.id).toBe('SIM-001');
168+
});
169+
170+
test('fresh named sessions with only lock platform default serialize on the selected device', async () => {
171+
const sessionStore = makeSessionStore('agent-device-router-lock-');
172+
const order: string[] = [];
173+
const gates: Array<() => void> = [];
174+
let active = 0;
175+
let maxActive = 0;
176+
177+
mockDispatch.mockImplementation(async (device, command) => {
178+
order.push(`start-${command}-${device.id}`);
179+
active += 1;
180+
maxActive = Math.max(maxActive, active);
181+
await new Promise<void>((resolve) => {
182+
gates.push(() => {
183+
active -= 1;
184+
order.push(`end-${command}-${device.id}`);
185+
resolve();
186+
});
187+
});
188+
return { nodes: [] };
189+
});
190+
191+
const handler = createRequestHandler({
192+
logPath: path.join(os.tmpdir(), 'daemon.log'),
193+
token: 'test-token',
194+
sessionStore,
195+
leaseRegistry: new LeaseRegistry(),
196+
deviceInventoryProvider: async () => [makeIosSession('inventory').device],
197+
trackDownloadableArtifact: () => 'artifact-id',
198+
});
199+
200+
const first = handler({
201+
token: 'test-token',
202+
session: 'qa-default-a',
203+
command: 'snapshot',
204+
positionals: [],
205+
flags: {},
206+
meta: {
207+
requestId: 'req-fresh-default-lock-a',
208+
lockPolicy: 'reject',
209+
lockPlatform: 'ios',
210+
},
211+
});
212+
213+
await vi.waitFor(() => {
214+
expect(order).toEqual(['start-snapshot-SIM-001']);
215+
});
216+
217+
const second = handler({
218+
token: 'test-token',
219+
session: 'qa-default-b',
220+
command: 'snapshot',
221+
positionals: [],
222+
flags: {},
223+
meta: {
224+
requestId: 'req-fresh-default-lock-b',
225+
lockPolicy: 'reject',
226+
lockPlatform: 'ios',
227+
},
228+
});
229+
230+
await new Promise((resolve) => setTimeout(resolve, 20));
231+
expect(order).toEqual(['start-snapshot-SIM-001']);
232+
233+
gates.shift()?.();
234+
235+
await vi.waitFor(() => {
236+
expect(order).toEqual([
237+
'start-snapshot-SIM-001',
238+
'end-snapshot-SIM-001',
239+
'start-snapshot-SIM-001',
240+
]);
241+
});
242+
243+
gates.shift()?.();
244+
245+
const [firstResponse, secondResponse] = await Promise.all([first, second]);
246+
247+
expect(firstResponse.ok).toBe(true);
248+
expect(secondResponse.ok).toBe(true);
249+
expect(maxActive).toBe(1);
250+
expect(sessionStore.get('qa-default-a')?.device.id).toBe('SIM-001');
251+
expect(sessionStore.get('qa-default-b')?.device.id).toBe('SIM-001');
252+
});
253+
254+
test('fresh named sessions reject incompatible selector combinations before binding', async () => {
255+
const cases = [
256+
{
257+
name: 'ios-serial',
258+
flags: { serial: 'emulator-5554' },
259+
meta: { lockPolicy: 'reject', lockPlatform: 'ios' },
260+
conflict: /--serial=emulator-5554/i,
261+
},
262+
{
263+
name: 'ios-android-platform',
264+
flags: { platform: 'android', udid: 'SIM-001' },
265+
meta: { lockPolicy: 'reject', lockPlatform: 'ios' },
266+
conflict: /--platform=android/i,
267+
},
268+
{
269+
name: 'ios-desktop-target',
270+
flags: { target: 'desktop' },
271+
meta: { lockPolicy: 'reject', lockPlatform: 'ios' },
272+
conflict: /--target=desktop/i,
273+
},
274+
{
275+
name: 'macos-udid',
276+
flags: { udid: 'SIM-001', iosSimulatorDeviceSet: '/tmp/tenant-a/set' },
277+
meta: { lockPolicy: 'reject', lockPlatform: 'macos' },
278+
conflict: /--udid=SIM-001/i,
279+
},
280+
] as const;
281+
282+
for (const testCase of cases) {
283+
const sessionStore = makeSessionStore('agent-device-router-lock-');
284+
const handler = createRequestHandler({
285+
logPath: path.join(os.tmpdir(), 'daemon.log'),
286+
token: 'test-token',
287+
sessionStore,
288+
leaseRegistry: new LeaseRegistry(),
289+
deviceInventoryProvider: async () => [makeIosSession('inventory').device],
290+
trackDownloadableArtifact: () => 'artifact-id',
291+
});
292+
293+
const response = await handler({
294+
token: 'test-token',
295+
session: testCase.name,
296+
command: 'snapshot',
297+
positionals: [],
298+
flags: testCase.flags,
299+
meta: {
300+
requestId: `req-${testCase.name}`,
301+
...testCase.meta,
302+
},
303+
});
304+
305+
expect(response.ok).toBe(false);
306+
if (!response.ok) {
307+
expect(response.error.code).toBe('INVALID_ARGS');
308+
expect(response.error.message).toMatch(testCase.conflict);
309+
}
310+
expect(mockDispatch).not.toHaveBeenCalled();
311+
expect(sessionStore.get(testCase.name)).toBeUndefined();
312+
mockDispatch.mockClear();
313+
}
314+
});
315+
75316
test('batch steps cannot bypass reject lock policy on nested direct requests', async () => {
76317
const sessionStore = makeSessionStore('agent-device-router-lock-');
77318
sessionStore.set('qa-ios', makeIosSession('qa-ios'));

src/daemon/request-admission.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts';
55
import { hasExplicitDeviceSelector } from './handlers/session-device-utils.ts';
66
import { resolveLeaseScope } from './lease-context.ts';
77
import type { LeaseRegistry } from './lease-registry.ts';
8+
import { applyRequestLockPolicy } from './request-lock-policy.ts';
89
import { SessionStore } from './session-store.ts';
910
import type { DaemonRequest } from './types.ts';
1011

@@ -90,13 +91,25 @@ export async function resolveExecutionLockKey(params: {
9091
if (existingSession) {
9192
return `device:${existingSession.device.id}`;
9293
}
93-
if (req.command === 'open' || hasExplicitDeviceSelector(req.flags)) {
94+
const lockReq = resolveFreshSessionLockRequest(req);
95+
if (lockReq.command === 'open' || hasExplicitDeviceSelector(lockReq.flags)) {
9496
try {
95-
const device = await resolveTargetDevice(req.flags ?? {});
97+
const device = await resolveTargetDevice(lockReq.flags ?? {});
9698
return `device:${device.id}`;
9799
} catch {
98100
// Fall back to session scoping when device resolution is not yet available.
99101
}
100102
}
101103
return `session:${sessionName}`;
102104
}
105+
106+
function resolveFreshSessionLockRequest(req: DaemonRequest): DaemonRequest {
107+
if (!req.meta?.lockPolicy) return req;
108+
try {
109+
return applyRequestLockPolicy(req);
110+
} catch {
111+
// The request will be rejected during locked scope preparation. Keep lock
112+
// selection best-effort so invalid selectors do not block unrelated work.
113+
return req;
114+
}
115+
}

0 commit comments

Comments
 (0)