Skip to content

Commit c4117b7

Browse files
jrvb-rlclaudeReflex
authored
feat(devbox): add devbox.onEvict eviction notifications (SSE) (#820)
Co-authored-by: Reflex <noreply@anthropic.com> Co-authored-by: Reflex <reflex@runloop.ai>
1 parent fc944fc commit c4117b7

4 files changed

Lines changed: 262 additions & 0 deletions

File tree

src/sdk/devbox.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { LongPollRequestOptions, PollingOptions } from '../lib/polling';
2222
import { Snapshot } from './snapshot';
2323
import { Execution } from './execution';
2424
import { ExecutionResult } from './execution-result';
25+
import { EvictionCallback, getEvictionMonitor } from './eviction';
2526
import { uuidv7 } from 'uuidv7';
2627

2728
// Re-export Execution and ExecutionResult for Devbox namespace
@@ -1029,6 +1030,35 @@ export class Devbox {
10291030
async keepAlive(options?: Core.RequestOptions): Promise<DevboxKeepAliveResponse> {
10301031
return this.client.devboxes.keepAlive(this._id, options);
10311032
}
1033+
1034+
/**
1035+
* Register a callback fired once if this devbox gets a pending infrastructure eviction.
1036+
*
1037+
* The first `onEvict` across any devbox on this client opens a single account-wide
1038+
* notification stream; it closes automatically once every registered devbox has been
1039+
* notified. The callback runs at most once and is invoked as
1040+
* `callback(devbox, evictionDeadlineMs)` — this devbox and the Unix millisecond
1041+
* deadline by which it will be suspended. Use it to run cleanup before suspend.
1042+
*
1043+
* @param {EvictionCallback} callback - Invoked with this devbox and its eviction deadline (ms).
1044+
*
1045+
* @example
1046+
* ```typescript
1047+
* devbox.onEvict((devbox, evictionDeadlineMs) => {
1048+
* console.log(`${devbox.id} will be suspended at ${evictionDeadlineMs}`);
1049+
* });
1050+
* ```
1051+
*/
1052+
onEvict(callback: EvictionCallback): void {
1053+
getEvictionMonitor(this.client).register(this, callback);
1054+
}
1055+
1056+
/**
1057+
* Withdraw this devbox's eviction interest registered via {@link onEvict}.
1058+
*/
1059+
cancelOnEvict(): void {
1060+
getEvictionMonitor(this.client).unregister(this._id);
1061+
}
10321062
}
10331063

10341064
/**

src/sdk/eviction.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Runloop } from '../index';
2+
import { Stream } from '../streaming';
3+
import type { DevboxEvictionEventView } from '../resources/devboxes/devboxes';
4+
import type { Devbox } from './devbox';
5+
6+
/**
7+
* Invoked once when the devbox it was registered for has a pending eviction.
8+
*
9+
* @param devbox - The devbox with a pending eviction.
10+
* @param evictionDeadlineMs - Unix timestamp (ms) by which the devbox will be suspended.
11+
*/
12+
export type EvictionCallback = (devbox: Devbox, evictionDeadlineMs: number) => void;
13+
14+
/**
15+
* Fans account-wide eviction notifications out to per-devbox callbacks.
16+
*
17+
* One monitor is shared by every {@link Devbox} built from the same generated
18+
* client (see {@link getEvictionMonitor}), so all registered devboxes are served
19+
* by a single SSE connection. The stream opens the moment the first devbox
20+
* registers interest and closes as soon as the last interested devbox has been
21+
* notified.
22+
*
23+
* Delivery contract:
24+
* - The server replays every currently-pending eviction on connect, so a devbox
25+
* that registers after its eviction was scheduled is still notified.
26+
* - Notifications for devboxes not in the interest set are discarded.
27+
* - A devbox is removed from the interest set *before* its callback runs, so the
28+
* callback fires at most once even if the server repeats the notification.
29+
*/
30+
export class EvictionMonitor {
31+
private entries = new Map<string, { devbox: Devbox; callback: EvictionCallback }>();
32+
private stream: Stream<DevboxEvictionEventView> | null = null;
33+
private running = false;
34+
35+
constructor(private client: Runloop) {}
36+
37+
/** Add `devbox` to the interest set, opening the stream if idle. */
38+
register(devbox: Devbox, callback: EvictionCallback): void {
39+
this.entries.set(devbox.id, { devbox, callback });
40+
if (!this.running) {
41+
this.running = true;
42+
void this.run();
43+
}
44+
}
45+
46+
/** Drop `devboxId`; close the stream if it was the last interested devbox. */
47+
unregister(devboxId: string): void {
48+
this.entries.delete(devboxId);
49+
if (this.entries.size === 0) {
50+
this.close();
51+
}
52+
}
53+
54+
/** Clear all interest and tear down the stream. */
55+
close(): void {
56+
this.entries.clear();
57+
this.stream?.controller.abort();
58+
this.stream = null;
59+
this.running = false;
60+
}
61+
62+
private async run(): Promise<void> {
63+
// The server force-closes the stream on purpose (leader change / slow consumer) and a
64+
// long-lived HTTP/2 stream can drop; the client is expected to reconnect and re-read the
65+
// snapshot, which re-delivers anything missed. So reconnect (with backoff) until no devbox is
66+
// still interested.
67+
const INITIAL_BACKOFF_MS = 500;
68+
const MAX_BACKOFF_MS = 30_000;
69+
let backoff = INITIAL_BACKOFF_MS;
70+
try {
71+
while (this.entries.size > 0) {
72+
try {
73+
// Force the SSE Accept header: the endpoint only streams for text/event-stream; the
74+
// generated client's default (application/json) gets an empty text/plain response, so the
75+
// feed would silently deliver nothing.
76+
const stream = await this.client.devboxes.watchEvictions({
77+
headers: { Accept: 'text/event-stream' },
78+
});
79+
this.stream = stream;
80+
for await (const event of stream) {
81+
this.dispatch(event);
82+
if (this.entries.size === 0) return;
83+
}
84+
// Clean end: reset backoff and reconnect if still interested.
85+
backoff = INITIAL_BACKOFF_MS;
86+
} catch (error) {
87+
// Aborting the stream on close surfaces as an AbortError; that is an intentional teardown.
88+
if (error instanceof Error && error.name === 'AbortError') return;
89+
if (this.entries.size === 0) return;
90+
// Otherwise a routine disconnect — fall through to backoff + reconnect.
91+
}
92+
if (this.entries.size === 0) return;
93+
await new Promise((resolve) => setTimeout(resolve, backoff));
94+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
95+
}
96+
} finally {
97+
this.stream = null;
98+
this.running = false;
99+
}
100+
}
101+
102+
private dispatch(event: DevboxEvictionEventView): void {
103+
const entry = this.entries.get(event.devbox_id);
104+
if (!entry) return;
105+
// Remove before signaling so a duplicate notification for the same devbox is
106+
// discarded and the callback fires at most once.
107+
this.entries.delete(event.devbox_id);
108+
try {
109+
entry.callback(entry.devbox, event.eviction_deadline_ms);
110+
} catch (error) {
111+
console.error(`Error in eviction callback for devbox ${event.devbox_id}:`, error);
112+
}
113+
}
114+
}
115+
116+
const monitors = new WeakMap<Runloop, EvictionMonitor>();
117+
118+
/** Return the shared {@link EvictionMonitor} for `client`, creating it once. */
119+
export function getEvictionMonitor(client: Runloop): EvictionMonitor {
120+
let monitor = monitors.get(client);
121+
if (!monitor) {
122+
monitor = new EvictionMonitor(client);
123+
monitors.set(client, monitor);
124+
}
125+
return monitor;
126+
}

src/sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Devbox, DevboxCmdOps, DevboxFileOps, DevboxNetOps, type ExecuteStreamingCallbacks } from './devbox';
2+
export { EvictionMonitor, getEvictionMonitor, type EvictionCallback } from './eviction';
23
export { Blueprint } from './blueprint';
34
export { Snapshot } from './snapshot';
45
export { StorageObject } from './storage-object';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Devbox } from '@runloop/api-client/sdk';
2+
import type { DevboxEvictionEventView } from '@runloop/api-client/resources/devboxes/devboxes';
3+
4+
// Hermetic tests: they drive the shared EvictionMonitor through an in-memory fake
5+
// SSE stream instead of a live devbox eviction, so `devbox.onEvict` / `cancelOnEvict`
6+
// (and the monitor's dispatch + teardown) are exercised deterministically.
7+
8+
// The generated watch_evictions stream: an async-iterable of eviction events plus an
9+
// AbortController the monitor aborts on close().
10+
type FakeStream = {
11+
controller: AbortController;
12+
[Symbol.asyncIterator](): AsyncIterator<DevboxEvictionEventView>;
13+
};
14+
15+
function streamOf(events: DevboxEvictionEventView[]): FakeStream {
16+
return {
17+
controller: new AbortController(),
18+
async *[Symbol.asyncIterator]() {
19+
for (const event of events) {
20+
yield event;
21+
}
22+
},
23+
};
24+
}
25+
26+
// A stream that emits nothing until the monitor aborts it (i.e. on cancelOnEvict).
27+
function blockingStream(): FakeStream {
28+
const controller = new AbortController();
29+
return {
30+
controller,
31+
async *[Symbol.asyncIterator]() {
32+
await new Promise<void>((resolve) => {
33+
if (controller.signal.aborted) {
34+
resolve();
35+
} else {
36+
controller.signal.addEventListener('abort', () => resolve(), { once: true });
37+
}
38+
});
39+
},
40+
};
41+
}
42+
43+
// Type the fake as whatever Devbox.fromId expects, without importing the generated client.
44+
type Client = Parameters<typeof Devbox.fromId>[0];
45+
function fakeClient(watchEvictions: () => Promise<FakeStream>): Client {
46+
return { devboxes: { watchEvictions } } as unknown as Client;
47+
}
48+
49+
const tick = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms));
50+
51+
(process.env['RUN_SMOKETESTS'] ? describe : describe.skip)('object-oriented eviction notifications', () => {
52+
test('onEvict fires once for a matching devbox and ignores others', async () => {
53+
const events: DevboxEvictionEventView[] = [
54+
{ devbox_id: 'dbx_other', eviction_deadline_ms: 1 },
55+
{ devbox_id: 'dbx_match', eviction_deadline_ms: 1_720_000_000_000 },
56+
];
57+
const devbox = Devbox.fromId(
58+
fakeClient(async () => streamOf(events)),
59+
'dbx_match',
60+
);
61+
62+
let receivedDevbox: Devbox | undefined;
63+
const calls: Array<{ id: string; deadline: number }> = [];
64+
const fired = new Promise<void>((resolve) => {
65+
devbox.onEvict((evicted, evictionDeadlineMs) => {
66+
receivedDevbox = evicted;
67+
calls.push({ id: evicted.id, deadline: evictionDeadlineMs });
68+
resolve();
69+
});
70+
});
71+
72+
let guard: ReturnType<typeof setTimeout>;
73+
const timeout = new Promise<never>((_resolve, reject) => {
74+
guard = setTimeout(() => reject(new Error('onEvict callback never fired')), 2000);
75+
});
76+
try {
77+
await Promise.race([fired, timeout]);
78+
} finally {
79+
clearTimeout(guard!);
80+
}
81+
await tick();
82+
83+
// Fired once, with the devbox object and its deadline; the unmatched id was discarded.
84+
expect(receivedDevbox).toBe(devbox);
85+
expect(calls).toEqual([{ id: 'dbx_match', deadline: 1_720_000_000_000 }]);
86+
});
87+
88+
test('cancelOnEvict stops watching and aborts the stream', async () => {
89+
const stream = blockingStream();
90+
const devbox = Devbox.fromId(
91+
fakeClient(async () => stream),
92+
'dbx_cancel',
93+
);
94+
95+
const callback = jest.fn();
96+
devbox.onEvict(callback);
97+
await tick(); // let the monitor open the stream
98+
99+
devbox.cancelOnEvict();
100+
101+
expect(stream.controller.signal.aborted).toBe(true);
102+
await tick();
103+
expect(callback).not.toHaveBeenCalled();
104+
});
105+
});

0 commit comments

Comments
 (0)