Skip to content

Commit 1d23526

Browse files
committed
fix(sim): guard stale sidecar-child events, fail writes fast, fix boot/reclaim ownership races
1 parent f93afa8 commit 1d23526

3 files changed

Lines changed: 95 additions & 10 deletions

File tree

packages/host/engine/src/__tests__/simulator-service.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,59 @@ describe('SimulatorService', () => {
126126
expect(backend.close).toHaveBeenCalledTimes(1);
127127
});
128128

129+
it('reclaims a service-booted device whose session stops mid-boot', async () => {
130+
const backend = fakeBackend([device('A', 'Shutdown')]);
131+
let resolveBoot!: () => void;
132+
backend.boot.mockImplementation(
133+
() =>
134+
new Promise<void>((resolve) => {
135+
resolveBoot = resolve;
136+
}),
137+
);
138+
const service = new SimulatorService(backend, { idleReclaimMs: 1000 });
139+
140+
const booting = service.boot(S1, 'A');
141+
// Let boot() claim the device and reach the (still-pending) backend.boot() call.
142+
await vi.advanceTimersByTimeAsync(0);
143+
// The owning session stops before the boot finishes, dropping the not-yet-service-booted claim.
144+
service.releaseSession(S1);
145+
resolveBoot();
146+
await booting;
147+
148+
// The device we booted must not be left running untracked: it is re-tracked for reclaim, so it
149+
// is not shut down immediately but is once the idle window elapses (without the fix there is no
150+
// claim, so the booted device is never reclaimed).
151+
expect(backend.shutdownDevice).not.toHaveBeenCalled();
152+
await vi.advanceTimersByTimeAsync(1000);
153+
expect(backend.shutdownDevice).toHaveBeenCalledWith('A');
154+
expect(service.ownerOf('A')).toBeUndefined();
155+
});
156+
157+
it('holds the claim until the reclaim shutdown settles', async () => {
158+
const backend = fakeBackend([device('A', 'Shutdown')]);
159+
let resolveShutdown!: () => void;
160+
backend.shutdownDevice.mockImplementation(
161+
() =>
162+
new Promise<void>((resolve) => {
163+
resolveShutdown = resolve;
164+
}),
165+
);
166+
const service = new SimulatorService(backend, { idleReclaimMs: 1000 });
167+
168+
await service.boot(S1, 'A');
169+
service.releaseSession(S1);
170+
await vi.advanceTimersByTimeAsync(1000); // idle timer fires → shutdown in flight
171+
expect(backend.shutdownDevice).toHaveBeenCalledWith('A');
172+
// Another session must not grab the udid while its shutdown is still running.
173+
await expect(service.openUrl(S2, 'A', 'https://example.com')).rejects.toMatchObject({
174+
code: 'conflict',
175+
});
176+
// The device frees only once the shutdown settles.
177+
resolveShutdown();
178+
await vi.advanceTimersByTimeAsync(0);
179+
expect(service.ownerOf('A')).toBeUndefined();
180+
});
181+
129182
it('frees a device on owner-driven shutdown', async () => {
130183
const backend = fakeBackend([device('A', 'Shutdown')]);
131184
const service = new SimulatorService(backend);

packages/host/engine/src/simulator/service.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,18 @@ export class SimulatorService {
5858
if (state === 'Booted') return;
5959
await this.backend.boot(udid);
6060
const claim = this.claims.get(udid);
61-
if (claim?.sessionId === sessionId) claim.bootedByService = true;
61+
if (claim === undefined) {
62+
// The owning session stopped while the boot was in flight, so `releaseSession` dropped the
63+
// not-yet-service-booted claim. We booted a device that now has no owner: re-track it as
64+
// service-booted and arm idle reclaim, exactly as if the stop had arrived after the boot — a
65+
// resume within the window re-claims it, otherwise it is shut down instead of left running.
66+
const reclaimed: DeviceClaim = { sessionId, bootedByService: true };
67+
this.claims.set(udid, reclaimed);
68+
this.release(udid, reclaimed);
69+
} else if (claim.sessionId === sessionId) {
70+
claim.bootedByService = true;
71+
}
72+
// else: another session claimed the device during our boot — it is theirs to manage now.
6273
}
6374

6475
/** Shut a device down on the owner's behalf and free it. */
@@ -159,9 +170,14 @@ export class SimulatorService {
159170
}
160171
if (claim.idleTimer) clearTimeout(claim.idleTimer);
161172
claim.idleTimer = setTimeout(() => {
162-
this.drop(udid);
163-
// Reclaim is best-effort: the device may already be gone (deleted in Xcode, host reboot).
164-
this.backend.shutdownDevice(udid).catch(noop);
173+
// Shut the device down before releasing its claim: dropping first opens a window where
174+
// another session claims and boots the same udid while this shutdown is still in flight,
175+
// which would then tear down the device that session just acquired. Reclaim stays best-effort
176+
// (the device may already be gone — deleted in Xcode, host reboot).
177+
void this.backend
178+
.shutdownDevice(udid)
179+
.catch(noop)
180+
.finally(() => this.drop(udid));
165181
}, this.idleReclaimMs);
166182
claim.idleTimer.unref?.();
167183
}

packages/host/sim/src/client.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ChildProcessByStdio } from 'node:child_process';
22
import { spawn } from 'node:child_process';
33
import type { Readable, Writable } from 'node:stream';
4+
import { extractErrorMessage } from 'foxts/extract-error-message';
45
import type { Frame } from './codec';
56
import {
67
decodeScreenshotFrame,
@@ -137,7 +138,15 @@ export class SimSidecarClient {
137138
// Don't let a pending reply keep the host's event loop alive on its own.
138139
timer.unref();
139140
this.pending.set(requestId, { resolve, reject, timer });
140-
writeFrame(child.stdin, REQUEST, body);
141+
try {
142+
writeFrame(child.stdin, REQUEST, body);
143+
} catch (error) {
144+
// The child died between ensureChild() and the write: fail this request now instead of
145+
// leaving it registered to wait out the full reply deadline. (Async write failures land on
146+
// the stdin `error` listener below, which fails every pending request the same way.)
147+
this.take(requestId);
148+
reject(new Error(extractErrorMessage(error) ?? 'sim sidecar write failed'));
149+
}
141150
});
142151
}
143152

@@ -150,21 +159,28 @@ export class SimSidecarClient {
150159
windowsHide: true,
151160
});
152161
this.child = child;
162+
// Every listener is scoped to THIS child: after a crash a new request spawns a replacement, and
163+
// the old child's delayed `exit`/`error`/`data` must not tear down (or feed stale bytes into)
164+
// the current one. `onChildGone` only fires while `child` is still the live child.
165+
const teardown = (): void => {
166+
if (this.child === child) this.onChildGone();
167+
};
153168
child.stdout.on('data', (chunk: Buffer) => {
169+
if (this.child !== child) return;
154170
try {
155171
for (const frame of this.decoder.feed(chunk)) this.handleFrame(frame);
156172
} catch {
157173
// A corrupt stream cannot be resynchronized mid-flight; drop the child and start over.
158174
child.kill();
159-
this.onChildGone();
175+
teardown();
160176
}
161177
});
162178
// A failed spawn (e.g. missing binary) errors the pipes; a broken pipe means the child is
163179
// gone. Without these listeners the unhandled stream error would crash the host process.
164-
child.stdin.on('error', () => this.onChildGone());
165-
child.stdout.on('error', () => this.onChildGone());
166-
child.on('exit', () => this.onChildGone());
167-
child.on('error', () => this.onChildGone());
180+
child.stdin.on('error', teardown);
181+
child.stdout.on('error', teardown);
182+
child.on('exit', teardown);
183+
child.on('error', teardown);
168184
return child;
169185
}
170186

0 commit comments

Comments
 (0)