Skip to content

Commit d4db110

Browse files
riglarclaude
andcommitted
fix(device): install --wait waits for the install itself
install --wait returned as soon as the session was usable, but a session stays ready while a new build installs, so it came back before the app was there. It now also polls the session block's install.status (dcd #1748) until the build is installed or failed. A failed install exits 2 with the reason and says the session is still open; start --app --wait does the same. An API without install status returns as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tQfgpTsA9UhcopKoDezUA
1 parent ff5b7c0 commit d4db110

3 files changed

Lines changed: 121 additions & 3 deletions

File tree

‎src/commands/device.ts‎

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,53 @@ async function waitUntilUsable(ctx: Ctx, id: string): Promise<DeviceSession> {
131131
}
132132
}
133133

134+
/**
135+
* Poll until the device has finished installing `binaryId`: the session
136+
* block's `install.status` leaves `installing`. The session is usable
137+
* throughout (it stays ready while a new build installs), so waiting on the
138+
* state alone returned too early. An API without `install` returns at once.
139+
*/
140+
async function waitForInstall(ctx: Ctx, id: string, binaryId: string): Promise<DeviceSession> {
141+
const deadline = Date.now() + WAIT_TIMEOUT_MS;
142+
for (;;) {
143+
const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id);
144+
const install = session.install;
145+
if (ENDED_STATES.includes(session.state)) {
146+
throw new DeviceApiError(
147+
`Device session ${id} ended while installing (${session.release_reason ?? session.state}).`,
148+
410,
149+
'LIVE_SESSION_ENDED',
150+
session,
151+
);
152+
}
153+
154+
// Done, failed, superseded by another install, or an API too old to say.
155+
if (!install || install.binary_upload_id !== binaryId || install.status !== 'installing') {
156+
return session;
157+
}
158+
159+
if (Date.now() >= deadline) {
160+
throw new DeviceApiError(
161+
`Timed out after ${WAIT_TIMEOUT_MS / 60_000} minutes waiting for the install on ${id}. It may still finish: check it with \`dcd device status ${id}\`.`,
162+
408,
163+
null,
164+
session,
165+
);
166+
}
167+
168+
await sleep(WAIT_POLL_MS);
169+
}
170+
}
171+
172+
/** A failed install as a command failure (exit 2); the session stays open. */
173+
function installFailure(session: DeviceSession): { code: null; message: string } | undefined {
174+
if (session.install?.status !== 'failed') return undefined;
175+
return {
176+
code: null,
177+
message: `The app could not be installed: ${session.install.error ?? 'unknown error'} Session ${session.id} is still open.`,
178+
};
179+
}
180+
134181
/**
135182
* Turn `<path|url>` into what /install takes: a URL goes to the API's
136183
* install-from-URL, a local file is uploaded first.
@@ -260,15 +307,23 @@ const startSub = defineCommand({
260307

261308
// The session exists and bills from here on: every later failure must
262309
// still name it, so the caller can stop it.
310+
let installedId: string | undefined;
263311
try {
264312
if (resolved && 'url' in resolved) {
265313
ctx.progress(ui.running('Installing the app from its URL…'));
266-
({ session } = await DeviceGateway.install(ctx.apiUrl, ctx.auth, session.id, resolved));
314+
const fromUrl = await DeviceGateway.install(ctx.apiUrl, ctx.auth, session.id, resolved);
315+
session = fromUrl.session;
316+
installedId = fromUrl.app.binary_upload_id;
267317
}
268318

269319
if (args.wait) {
270320
ctx.progress(ui.running('Waiting for the device…'));
271321
session = await waitUntilUsable(ctx, session.id);
322+
const expected = installedId ?? uploadedId;
323+
if (expected) {
324+
ctx.progress(ui.running('Waiting for the app to install…'));
325+
session = await waitForInstall(ctx, session.id, expected);
326+
}
272327
}
273328
} catch (error) {
274329
if (error instanceof DeviceApiError && error.code === 'LIVE_SESSION_ENDED') throw error;
@@ -280,9 +335,11 @@ const startSub = defineCommand({
280335
throw new DeviceApiError(message, 500, null, session);
281336
}
282337

338+
const startFailure = args.wait ? installFailure(session) : undefined;
283339
return {
284340
session,
285-
data: { binary_upload_id: uploadedId ?? null },
341+
data: { binary_upload_id: installedId ?? uploadedId ?? null },
342+
...(startFailure && { failure: startFailure }),
286343
print() {
287344
logger.log(ui.section('Device session'));
288345
printSession(session);
@@ -455,14 +512,25 @@ const installSub = defineCommand({
455512
if (args.wait) {
456513
ctx.progress(ui.running('Waiting for the device…'));
457514
session = await waitUntilUsable(ctx, id);
515+
ctx.progress(ui.running('Waiting for the app to install…'));
516+
session = await waitForInstall(ctx, id, installed.app.binary_upload_id);
458517
}
459518

460519
const appInfo: DeviceInstalledApp = installed.app;
520+
const failure = args.wait ? installFailure(session) : undefined;
461521
return {
462522
session,
463523
data: { app: appInfo },
524+
...(failure && { failure }),
464525
print() {
465-
logger.log(ui.success(`Installing ${appInfo.app_id ?? appInfo.binary_upload_id}`));
526+
if (failure) {
527+
logger.log(`${ui.statusSymbol('FAILED')} ${failure.message}`);
528+
return;
529+
}
530+
531+
logger.log(
532+
ui.success(`${args.wait ? 'Installed' : 'Installing'} ${appInfo.app_id ?? appInfo.binary_upload_id}`),
533+
);
466534
logger.log(
467535
ui.branch(
468536
ui.fields([

‎src/types/domain/device-session.types.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export interface DeviceSession {
3838
idle_remaining_s: null | number;
3939
cost_so_far_usd: null | number;
4040
last_crash: { at: string; summary: string } | null;
41+
/**
42+
* The build the session asked for and where its install is. Null when no
43+
* build was asked for; absent from APIs older than this field.
44+
*/
45+
install?: {
46+
binary_upload_id: string;
47+
error: null | string;
48+
status: 'failed' | 'installed' | 'installing';
49+
} | null;
4150
warnings: string[];
4251
monitor_url: string;
4352
}

‎test/unit/device-command.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,47 @@ describe('dcd device', () => {
250250
});
251251
});
252252

253+
describe('install --wait', () => {
254+
const installed = { app: { binary_upload_id: 'b-2', app_id: 'com.x', reused: true } };
255+
const withInstall = (status: 'failed' | 'installed' | 'installing', error: null | string = null) =>
256+
session('a-b-c', { install: { binary_upload_id: 'b-2', status, error } });
257+
258+
it('waits until the device has installed the build, not just until it is ready', async () => {
259+
let polls = 0;
260+
const calls = stubFetch((call) => {
261+
if (call.method === 'POST') return { body: { ...installed, session: withInstall('installing') } };
262+
polls += 1;
263+
return { body: { session: withInstall(polls < 2 ? 'installing' : 'installed') } };
264+
});
265+
const { exit, out } = await runJson('install', { id: 'a-b-c', 'app-binary-id': 'b-2', wait: true });
266+
expect(exit).to.equal(0);
267+
expect((out.session as DeviceSession).install?.status).to.equal('installed');
268+
expect(calls.filter((c) => c.method === 'GET').length).to.be.greaterThan(1);
269+
}).timeout(15_000);
270+
271+
it('exits 2 with the reason when the install failed, and says the session is open', async () => {
272+
stubFetch((call) =>
273+
call.method === 'POST'
274+
? { body: { ...installed, session: withInstall('installing') } }
275+
: { body: { session: withInstall('failed', 'App binary decryption failed.') } },
276+
);
277+
const { exit, out } = await runJson('install', { id: 'a-b-c', 'app-binary-id': 'b-2', wait: true });
278+
expect(exit).to.equal(DEVICE_EXIT.failed);
279+
const message = (out.error as { message: string }).message;
280+
expect(message).to.include('App binary decryption failed.').and.include('a-b-c is still open');
281+
}).timeout(15_000);
282+
283+
it('does not hang on an API without install status', async () => {
284+
stubFetch((call) =>
285+
call.method === 'POST'
286+
? { body: { ...installed, session: session('a-b-c') } }
287+
: { body: { session: session('a-b-c') } },
288+
);
289+
const { exit } = await runJson('install', { id: 'a-b-c', 'app-binary-id': 'b-2', wait: true });
290+
expect(exit).to.equal(0);
291+
}).timeout(15_000);
292+
});
293+
253294
describe('requests', () => {
254295
it('start sends the device, the label and the source header', async () => {
255296
const calls = stubFetch(() => ({ body: { session: session('a-b-c', { state: 'queued' }) } }));

0 commit comments

Comments
 (0)