fix(runner): release cleanup timers without terminating later runs

This commit is contained in:
max bittker committed 2026-10-11 00:49:30 -04:00
1 parent 1be9ac7e38
commit 286035ba61
4 files changed
+144 -19

No files matched your search

+16
View File
@@ -73,6 +73,22 @@ After filing the bug, keep working on your goal!
## Programmatic Usage
`runScript` returns after cleaning up its connection and timers. It does not
schedule a forced process exit, so consecutive runs and retry delays can use
the same process. A failed managed run sets `process.exitCode = 1`.
One-shot CLI scripts that need to terminate handles they own can explicitly
exit after their final run:
```typescript
import { runScript } from './sdk/runner';
const result = await runScript(async ({ bot }) => {
await bot.chopTree();
});
process.exit(process.exitCode ?? (result.success ? 0 : 1));
```
Create a script file (e.g., `my-bot.ts`):
```typescript
+22 -19
View File
@@ -263,6 +263,10 @@ async function requireLiveClient(sdk: BotSDK, username: string, server: string |
* 2. From bot.env sibling to the script (bun bots/mybot/script.ts)
* 3. From command line arg (bun script.ts mybot)
*
* Returns after cleanup without scheduling process termination, so later
* runs and retry delays can safely use the same process. One-shot CLI callers
* can explicitly exit after their final run if their own handles remain open.
*
* @example
* // Just run from the bot directory - auto-finds bot.env:
* // bun bots/mybot/script.ts
@@ -378,6 +382,7 @@ export async function runScript(
let error: Error | undefined;
let unsubscribeConnection: (() => void) | null = null;
let disconnectReject: ((err: Error) => void) | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
// Set up connection monitoring
@@ -395,7 +400,6 @@ export async function runScript(
const promises: Promise<any>[] = [scriptPromise];
// Add timeout if specified
let timeoutId: ReturnType<typeof setTimeout> | null = null;
if (timeout) {
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Script timeout after ${timeout}ms`)), timeout);
@@ -431,12 +435,12 @@ export async function runScript(
}
result = await Promise.race(promises);
// Clear timeout if it was set
if (timeoutId) clearTimeout(timeoutId);
} catch (e: any) {
error = e;
} finally {
// Failures and disconnects must release the deadline too, or a finished
// run keeps the process alive until its original timeout expires.
if (timeoutId) clearTimeout(timeoutId);
// Clean up connection listener
if (unsubscribeConnection) {
unsubscribeConnection();
@@ -481,21 +485,20 @@ export async function runScript(
const username = process.env.BOT_USERNAME;
if (username) {
console.error(`[Runner] Disconnecting bot "${username}"...`);
// Bound the disconnect - a wedged socket must not hang the exit.
await Promise.race([
sdk.disconnect(),
new Promise(resolve => setTimeout(resolve, 10000)),
]);
connections.delete(username);
// A leaked handle (reconnect timer, half-closed socket) used to
// keep the bun process alive for minutes after the script was
// done. Unref'd, so it never delays a clean exit; it only fires
// when something else is wedging the event loop.
const forceExit = setTimeout(() => {
console.error('[Runner] Event loop still alive after disconnect - forcing exit');
process.exit(process.exitCode ?? 0);
}, 5000);
forceExit.unref?.();
// Bound cleanup, but don't leave its losing timer alive or kill
// unrelated work (including the caller's next run) afterwards.
let disconnectTimeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
sdk.disconnect(),
new Promise(resolve => {
disconnectTimeout = setTimeout(resolve, 10000);
}),
]);
} finally {
clearTimeout(disconnectTimeout);
connections.delete(username);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import { runScript } from '../../runner';
const mode = process.argv[2];
const options = { printState: false, timeout: 60_000 };
if (mode === 'consecutive') {
const first = await runScript(async () => {}, options);
const second = await runScript(async () => {
// Outlast the old cleanup's five-second forced exit.
await Bun.sleep(5500);
console.log('SECOND_SCRIPT_FINISHED');
}, options);
console.log(`RUN_FINISHED:${first.success}:${second.success}`);
} else if (mode === 'failure') {
const result = await runScript(async () => {
throw new Error('expected script failure');
}, options);
console.log(`RUN_FINISHED:${result.success}:${result.error?.message}`);
} else if (mode === 'success') {
const result = await runScript(async () => {}, options);
console.log(`RUN_FINISHED:${result.success}`);
} else {
throw new Error(`Unknown runner lifecycle mode: ${mode}`);
}
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from 'bun:test';
import { join } from 'node:path';
async function runChild(mode: string) {
// Exercise the real managed SDK connection and process lifetime. The
// gateway is local and supplies fabricated ticks; no game client is used.
const server = Bun.serve<{ timer?: ReturnType<typeof setInterval>; tick: number }, {}>({
port: 0,
fetch(req, srv) {
if (srv.upgrade(req, { data: { tick: 0 } })) return;
return new Response('WebSocket only', { status: 400 });
},
websocket: {
message(ws, raw) {
if (JSON.parse(String(raw)).type !== 'sdk_connect') return;
ws.send(JSON.stringify({ type: 'sdk_connected', success: true }));
const publish = () => ws.send(JSON.stringify({
type: 'sdk_state',
state: {
tick: ++ws.data.tick,
inGame: true,
player: { worldX: 3222, worldZ: 3218 },
skills: [],
nearbyLocs: [{ id: 1, name: 'Tree', x: 3223, z: 3218 }]
},
stateReceivedAt: Date.now()
}));
publish();
ws.data.timer = setInterval(publish, 50);
},
close(ws) {
clearInterval(ws.data.timer);
}
}
});
const start = Date.now();
const child = Bun.spawn({
cmd: [process.execPath, join(import.meta.dir, 'fixtures/runner-lifecycle.ts'), mode],
env: {
...process.env,
BOT_USERNAME: 'runner-test', PASSWORD: 'test-password', SERVER: 'localhost',
GATEWAY_URL: `ws://localhost:${server.port}`
},
stdout: 'pipe',
stderr: 'pipe'
});
// A leaked 60-second script deadline must fail promptly, not stall CI.
const watchdog = setTimeout(() => child.kill(), 12_000);
try {
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited
]);
return { stdout, stderr, exitCode, duration: Date.now() - start };
} finally {
clearTimeout(watchdog);
server.stop(true);
}
}
test('managed run cleanup cannot terminate a subsequent run', async () => {
const result = await runChild('consecutive');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('SECOND_SCRIPT_FINISHED');
expect(result.stdout).toContain('RUN_FINISHED:true:true');
expect(result.stderr).not.toContain('forcing exit');
}, 15_000);
test('a completed managed script exits naturally without the disconnect deadline', async () => {
const result = await runChild('success');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('RUN_FINISHED:true');
expect(result.duration).toBeLessThan(4000);
}, 15_000);
test('a failed managed script clears its deadline and retains a nonzero exit code', async () => {
const result = await runChild('failure');
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain('RUN_FINISHED:false:expected script failure');
expect(result.duration).toBeLessThan(4000);
}, 15_000);