Skip to content
Merged
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
74 changes: 71 additions & 3 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,53 @@ async function waitUntilUsable(ctx: Ctx, id: string): Promise<DeviceSession> {
}
}

/**
* Poll until the device has finished installing `binaryId`: the session
* block's `install.status` leaves `installing`. The session is usable
* throughout (it stays ready while a new build installs), so waiting on the
* state alone returned too early. An API without `install` returns at once.
*/
async function waitForInstall(ctx: Ctx, id: string, binaryId: string): Promise<DeviceSession> {
const deadline = Date.now() + WAIT_TIMEOUT_MS;
for (;;) {
const { session } = await DeviceGateway.status(ctx.apiUrl, await freshAuth(ctx), id);
const install = session.install;
if (ENDED_STATES.includes(session.state)) {
throw new DeviceApiError(
`Device session ${id} ended while installing (${session.release_reason ?? session.state}).`,
410,
'LIVE_SESSION_ENDED',
session,
);
}

// Done, failed, superseded by another install, or an API too old to say.
if (!install || install.binary_upload_id !== binaryId || install.status !== 'installing') {
return session;
}

if (Date.now() >= deadline) {
throw new DeviceApiError(
`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}\`.`,
408,
null,
session,
);
}

await sleep(WAIT_POLL_MS);
}
}

/** A failed install as a command failure (exit 2); the session stays open. */
function installFailure(session: DeviceSession): { code: null; message: string } | undefined {
if (session.install?.status !== 'failed') return undefined;
return {
code: null,
message: `The app could not be installed: ${session.install.error ?? 'unknown error'} Session ${session.id} is still open.`,
};
}

/**
* Turn `<path|url>` into what /install takes: a URL goes to the API's
* install-from-URL, a local file is uploaded first.
Expand Down Expand Up @@ -260,15 +307,23 @@ const startSub = defineCommand({

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

if (args.wait) {
ctx.progress(ui.running('Waiting for the device…'));
session = await waitUntilUsable(ctx, session.id);
const expected = installedId ?? uploadedId;
if (expected) {
ctx.progress(ui.running('Waiting for the app to install…'));
session = await waitForInstall(ctx, session.id, expected);
}
}
} catch (error) {
if (error instanceof DeviceApiError && error.code === 'LIVE_SESSION_ENDED') throw error;
Expand All @@ -280,9 +335,11 @@ const startSub = defineCommand({
throw new DeviceApiError(message, 500, null, session);
}

const startFailure = args.wait ? installFailure(session) : undefined;
return {
session,
data: { binary_upload_id: uploadedId ?? null },
data: { binary_upload_id: installedId ?? uploadedId ?? null },
...(startFailure && { failure: startFailure }),
print() {
logger.log(ui.section('Device session'));
printSession(session);
Expand Down Expand Up @@ -455,14 +512,25 @@ const installSub = defineCommand({
if (args.wait) {
ctx.progress(ui.running('Waiting for the device…'));
session = await waitUntilUsable(ctx, id);
ctx.progress(ui.running('Waiting for the app to install…'));
session = await waitForInstall(ctx, id, installed.app.binary_upload_id);
}

const appInfo: DeviceInstalledApp = installed.app;
const failure = args.wait ? installFailure(session) : undefined;
return {
session,
data: { app: appInfo },
...(failure && { failure }),
print() {
logger.log(ui.success(`Installing ${appInfo.app_id ?? appInfo.binary_upload_id}`));
if (failure) {
logger.log(`${ui.statusSymbol('FAILED')} ${failure.message}`);
return;
}

logger.log(
ui.success(`${args.wait ? 'Installed' : 'Installing'} ${appInfo.app_id ?? appInfo.binary_upload_id}`),
);
logger.log(
ui.branch(
ui.fields([
Expand Down
9 changes: 9 additions & 0 deletions src/types/domain/device-session.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export interface DeviceSession {
idle_remaining_s: null | number;
cost_so_far_usd: null | number;
last_crash: { at: string; summary: string } | null;
/**
* The build the session asked for and where its install is. Null when no
* build was asked for; absent from APIs older than this field.
*/
install?: {
binary_upload_id: string;
error: null | string;
status: 'failed' | 'installed' | 'installing';
} | null;
warnings: string[];
monitor_url: string;
}
Expand Down
41 changes: 41 additions & 0 deletions test/unit/device-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,47 @@ describe('dcd device', () => {
});
});

describe('install --wait', () => {
const installed = { app: { binary_upload_id: 'b-2', app_id: 'com.x', reused: true } };
const withInstall = (status: 'failed' | 'installed' | 'installing', error: null | string = null) =>
session('a-b-c', { install: { binary_upload_id: 'b-2', status, error } });

it('waits until the device has installed the build, not just until it is ready', async () => {
let polls = 0;
const calls = stubFetch((call) => {
if (call.method === 'POST') return { body: { ...installed, session: withInstall('installing') } };
polls += 1;
return { body: { session: withInstall(polls < 2 ? 'installing' : 'installed') } };
});
const { exit, out } = await runJson('install', { id: 'a-b-c', 'app-binary-id': 'b-2', wait: true });
expect(exit).to.equal(0);
expect((out.session as DeviceSession).install?.status).to.equal('installed');
expect(calls.filter((c) => c.method === 'GET').length).to.be.greaterThan(1);
}).timeout(15_000);

it('exits 2 with the reason when the install failed, and says the session is open', async () => {
stubFetch((call) =>
call.method === 'POST'
? { body: { ...installed, session: withInstall('installing') } }
: { body: { session: withInstall('failed', 'App binary decryption failed.') } },
);
const { exit, out } = await runJson('install', { id: 'a-b-c', 'app-binary-id': 'b-2', wait: true });
expect(exit).to.equal(DEVICE_EXIT.failed);
const message = (out.error as { message: string }).message;
expect(message).to.include('App binary decryption failed.').and.include('a-b-c is still open');
}).timeout(15_000);

it('does not hang on an API without install status', async () => {
stubFetch((call) =>
call.method === 'POST'
? { body: { ...installed, session: session('a-b-c') } }
: { body: { session: session('a-b-c') } },
);
const { exit } = await runJson('install', { id: 'a-b-c', 'app-binary-id': 'b-2', wait: true });
expect(exit).to.equal(0);
}).timeout(15_000);
});

describe('requests', () => {
it('start sends the device, the label and the source header', async () => {
const calls = stubFetch(() => ({ body: { session: session('a-b-c', { state: 'queued' }) } }));
Expand Down
Loading