mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix(runner): release cleanup timers without terminating later runs
This commit is contained in:
1 parent
1be9ac7e38
commit
286035ba61
4 files changed
+144
-19
No files matched your search
@@ -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
@@ -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
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user