From 77fc807aa2bd1a889b9fc6a823e3d5501580dd72 Mon Sep 17 00:00:00 2001 From: Reflex Date: Mon, 17 Aug 2026 23:48:47 +0000 Subject: [PATCH] fix(devboxes): clean up create timeout --- src/resources/devboxes/devboxes.ts | 21 ++++++++++--- src/sdk.ts | 21 ++++++------- src/sdk/devbox.ts | 25 ++++++++-------- tests/api-resources/devboxes/devboxes.test.ts | 30 +++++++++++++++++++ tests/objects/devbox.test.ts | 4 +-- 5 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/resources/devboxes/devboxes.ts b/src/resources/devboxes/devboxes.ts index af8bc6aa6..c1f60bb0d 100644 --- a/src/resources/devboxes/devboxes.ts +++ b/src/resources/devboxes/devboxes.ts @@ -38,6 +38,7 @@ import { type Response } from '../../_shims/index'; import { longPollUntil, LongPollRequestOptions, + PollingTimeoutError, resolveLongPollTimeoutMs, } from '@runloop/api-client/lib/polling'; import { awaitDevboxState } from '@runloop/api-client/lib/devbox-state'; @@ -47,6 +48,11 @@ import { uuidv7 } from 'uuidv7'; type DevboxStatus = DevboxView['status']; const DEVBOX_BOOTING_STATES: DevboxStatus[] = ['provisioning', 'initializing']; +export type CreateAndAwaitRunningOptions = LongPollRequestOptions & { + /** Shutdown the created devbox when waiting for it to run times out. Defaults to true. */ + shutdownOnTimeout?: boolean; +}; + export class Devboxes extends APIResource { diskSnapshots: DiskSnapshotsAPI.DiskSnapshots = new DiskSnapshotsAPI.DiskSnapshots(this._client); logs: LogsAPI.Logs = new LogsAPI.Logs(this._client); @@ -124,15 +130,22 @@ export class Devboxes extends APIResource { * This is a convenience method that combines create() and awaitDevboxRunning(). * * @param body - DevboxCreateParams - * @param options - request options with optional long-poll configuration. + * @param options - request options with optional long-poll and timeout cleanup configuration. */ async createAndAwaitRunning( body?: DevboxCreateParams, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { - const { longPoll, polling, ...requestOptions } = options ?? {}; + const { longPoll, polling, shutdownOnTimeout = true, ...requestOptions } = options ?? {}; const devbox = await this.create(body, requestOptions); - return this.awaitRunning(devbox.id, { ...requestOptions, longPoll, polling }); + try { + return await this.awaitRunning(devbox.id, { ...requestOptions, longPoll, polling }); + } catch (error) { + if (!(error instanceof PollingTimeoutError)) throw error; + if (!shutdownOnTimeout) return devbox; + await this.shutdown(devbox.id); + throw error; + } } /** * Updates a devbox by doing a complete update the existing name,metadata fields. diff --git a/src/sdk.ts b/src/sdk.ts index 3b20360ba..b17d5c1ac 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -19,8 +19,8 @@ import { Secret } from './sdk/secret'; import type { DevboxCreateParams, DevboxListParams, - DevboxView, DevboxListDiskSnapshotsParams, + CreateAndAwaitRunningOptions, } from './resources/devboxes/devboxes'; import type { BlueprintListParams } from './resources/blueprints'; import type { ObjectCreateParams, ObjectListParams } from './resources/objects'; @@ -581,13 +581,10 @@ export class DevboxOps { * ``` * * @param {SDKDevboxCreateParams} [params] - Parameters for creating the devbox, with SDK mount syntax support. - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration. * @returns {Promise} A {@link Devbox} instance. */ - async create( - params?: SDKDevboxCreateParams, - options?: LongPollRequestOptions, - ): Promise { + async create(params?: SDKDevboxCreateParams, options?: CreateAndAwaitRunningOptions): Promise { const transformedParams = transformSDKDevboxCreateParams(params); return Devbox.create(this.client, transformedParams, options); } @@ -596,13 +593,13 @@ export class DevboxOps { * Create a new devbox from a blueprint ID. * @param {string} blueprintId - The ID of the blueprint to use. * @param {Omit} [params] - Additional parameters for creating the devbox (excluding blueprint_id, snapshot_id, and blueprint_name). - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration. * @returns {Promise} A {@link Devbox} instance. */ async createFromBlueprintId( blueprintId: string, params?: Omit, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { return Devbox.createFromBlueprintId(this.client, blueprintId, params, options); } @@ -611,13 +608,13 @@ export class DevboxOps { * Create a new devbox from a blueprint name. * @param {string} blueprintName - The name of the blueprint to use. * @param {Omit} [params] - Additional parameters for creating the devbox (excluding blueprint_id, snapshot_id, and blueprint_name). - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration. * @returns {Promise} A {@link Devbox} instance. */ async createFromBlueprintName( blueprintName: string, params?: Omit, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { return Devbox.createFromBlueprintName(this.client, blueprintName, params, options); } @@ -636,13 +633,13 @@ export class DevboxOps { * * @param {string} snapshotId - The ID of the snapshot to use. * @param {Omit} [params] - Additional parameters for creating the devbox (excluding snapshot_id, blueprint_id, and blueprint_name). - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration. + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration. * @returns {Promise} A {@link Devbox} instance. */ async createFromSnapshot( snapshotId: string, params?: Omit, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { return Devbox.createFromSnapshot(this.client, snapshotId, params, options); } diff --git a/src/sdk/devbox.ts b/src/sdk/devbox.ts index 6d8627f58..18ac7d02f 100644 --- a/src/sdk/devbox.ts +++ b/src/sdk/devbox.ts @@ -16,6 +16,7 @@ import type { DevboxSnapshotView, DevboxKeepAliveResponse, TunnelView, + CreateAndAwaitRunningOptions, } from '../resources/devboxes/devboxes'; import type { DevboxLogsListView, LogListParams } from '../resources/devboxes/logs'; import { LongPollRequestOptions, PollingOptions } from '../lib/polling'; @@ -565,13 +566,13 @@ export class Devbox { * * @param {Runloop} client - The Runloop client instance * @param {DevboxCreateParams} [params] - Parameters for creating the devbox - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration - * @returns {Promise} A {@link Devbox} instance in the running state + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration + * @returns {Promise} A {@link Devbox} instance, normally in the running state */ static async create( client: Runloop, params?: DevboxCreateParams, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { const devboxData = await client.devboxes.createAndAwaitRunning(params, options); return new Devbox(client, devboxData.id); @@ -586,14 +587,14 @@ export class Devbox { * @param {Runloop} client - The Runloop client instance * @param {string} blueprintId - The blueprint ID to create from * @param {Omit} [params] - Additional devbox creation parameters - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration - * @returns {Promise} A {@link Devbox} instance in the running state + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration + * @returns {Promise} A {@link Devbox} instance, normally in the running state */ static async createFromBlueprintId( client: Runloop, blueprintId: string, params?: Omit, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, @@ -612,14 +613,14 @@ export class Devbox { * @param {Runloop} client - The Runloop client instance * @param {string} blueprintName - The blueprint name to create from * @param {Omit} [params] - Additional devbox creation parameters - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration - * @returns {Promise} A {@link Devbox} instance in the running state + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration + * @returns {Promise} A {@link Devbox} instance, normally in the running state */ static async createFromBlueprintName( client: Runloop, blueprintName: string, params?: Omit, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, @@ -647,14 +648,14 @@ export class Devbox { * @param {Runloop} client - The Runloop client instance * @param {string} snapshotId - The snapshot ID to create from * @param {Omit} [params] - Additional devbox creation parameters - * @param {LongPollRequestOptions} [options] - Request options with optional long-poll configuration - * @returns {Promise} A {@link Devbox} instance in the running state + * @param {CreateAndAwaitRunningOptions} [options] - Request options with optional long-poll and timeout cleanup configuration + * @returns {Promise} A {@link Devbox} instance, normally in the running state */ static async createFromSnapshot( client: Runloop, snapshotId: string, params?: Omit, - options?: LongPollRequestOptions, + options?: CreateAndAwaitRunningOptions, ): Promise { const createParams: DevboxCreateParams = { ...params, diff --git a/tests/api-resources/devboxes/devboxes.test.ts b/tests/api-resources/devboxes/devboxes.test.ts index 9f547d3bf..d209e9775 100644 --- a/tests/api-resources/devboxes/devboxes.test.ts +++ b/tests/api-resources/devboxes/devboxes.test.ts @@ -1,8 +1,10 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import { Runloop, toFile } from '@runloop/api-client'; +import type { DevboxView } from '../../../src/resources/devboxes/devboxes'; import { Response } from 'node-fetch'; import { APIError } from '../../../src/error'; +import { PollingTimeoutError } from '../../../src/lib/polling'; const client = new Runloop({ bearerToken: 'My Bearer Token', @@ -745,6 +747,34 @@ describe('resource devboxes', () => { mockPost.mockRestore(); }); + test('createAndAwaitRunning: shuts down the devbox and rethrows when waiting times out', async () => { + const devbox = { id: 'new-devbox-id', status: 'provisioning' } as DevboxView; + const timeout = new PollingTimeoutError('Timed out', devbox); + jest.spyOn(client.devboxes, 'create').mockResolvedValueOnce(devbox); + jest.spyOn(client.devboxes, 'awaitRunning').mockRejectedValueOnce(timeout); + const shutdown = jest.spyOn(client.devboxes, 'shutdown').mockResolvedValueOnce(devbox); + + await expect(client.devboxes.createAndAwaitRunning()).rejects.toBe(timeout); + + expect(shutdown).toHaveBeenCalledWith('new-devbox-id'); + jest.restoreAllMocks(); + }); + + test('createAndAwaitRunning: returns the devbox without shutting it down when configured', async () => { + const devbox = { id: 'new-devbox-id', status: 'provisioning' } as DevboxView; + const timeout = new PollingTimeoutError('Timed out', devbox); + jest.spyOn(client.devboxes, 'create').mockResolvedValueOnce(devbox); + jest.spyOn(client.devboxes, 'awaitRunning').mockRejectedValueOnce(timeout); + const shutdown = jest.spyOn(client.devboxes, 'shutdown'); + + await expect( + client.devboxes.createAndAwaitRunning(undefined, { shutdownOnTimeout: false }), + ).resolves.toBe(devbox); + + expect(shutdown).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + test('executeAndAwaitCompletion: passes last_n to waitForCommand when execute is not completed', async () => { const mockPost = jest.spyOn(client.devboxes['_client'], 'post'); try { diff --git a/tests/objects/devbox.test.ts b/tests/objects/devbox.test.ts index 2cb930c97..fff3b42a0 100644 --- a/tests/objects/devbox.test.ts +++ b/tests/objects/devbox.test.ts @@ -86,11 +86,11 @@ describe('Devbox (New API)', () => { it('should pass options to the API client', async () => { mockClient.devboxes.createAndAwaitRunning.mockResolvedValue(mockDevboxData); - await Devbox.create(mockClient, { name: 'test-devbox' }, { polling: { maxAttempts: 10 } }); + await Devbox.create(mockClient, { name: 'test-devbox' }, { shutdownOnTimeout: false }); expect(mockClient.devboxes.createAndAwaitRunning).toHaveBeenCalledWith( { name: 'test-devbox' }, - { polling: { maxAttempts: 10 } }, + { shutdownOnTimeout: false }, ); }); });