Skip to content

Commit 95b7273

Browse files
committed
fix: clarify metro companion lease shutdown
1 parent 4e58681 commit 95b7273

3 files changed

Lines changed: 58 additions & 9 deletions

File tree

src/__tests__/client-metro-companion-worker.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,3 +597,44 @@ test('metro companion worker exits after its state file is removed', async () =>
597597
assert.equal(exit.signal, null, `unexpected worker stderr: ${stderr}`);
598598
assert.equal(exit.code, 0, `unexpected worker stderr: ${stderr}`);
599599
});
600+
601+
test('metro companion worker exits immediately when its state file is already missing', async () => {
602+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-worker-'));
603+
const statePath = path.join(tempRoot, 'missing-metro-companion.json');
604+
cleanupTasks.push(async () => {
605+
fs.rmSync(tempRoot, { recursive: true, force: true });
606+
});
607+
608+
const companion = spawn(
609+
process.execPath,
610+
['--experimental-strip-types', 'src/metro-companion.ts', '--agent-device-run-metro-companion'],
611+
{
612+
cwd: process.cwd(),
613+
env: {
614+
...process.env,
615+
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1',
616+
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
617+
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1',
618+
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
619+
},
620+
stdio: ['ignore', 'pipe', 'pipe'],
621+
},
622+
);
623+
cleanupTasks.push(() => stopChild(companion));
624+
625+
let stderr = '';
626+
companion.stderr.on('data', (chunk) => {
627+
stderr += chunk.toString();
628+
});
629+
630+
const exit = await waitFor(
631+
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
632+
companion.once('exit', (code, signal) => resolve({ code, signal }));
633+
}),
634+
5_000,
635+
'worker exit with missing state file',
636+
);
637+
638+
assert.equal(exit.signal, null, `unexpected worker stderr: ${stderr}`);
639+
assert.equal(exit.code, 0, `unexpected worker stderr: ${stderr}`);
640+
});

src/client-metro-companion-contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion';
22
export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000;
3+
export const METRO_COMPANION_LEASE_CHECK_INTERVAL_MS = 250;
34
export const WS_READY_STATE_OPEN = 1;
45

56
export const ENV_SERVER_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL';

src/client-metro-companion-worker.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ENV_LOCAL_BASE_URL,
77
ENV_SERVER_BASE_URL,
88
ENV_STATE_PATH,
9+
METRO_COMPANION_LEASE_CHECK_INTERVAL_MS,
910
METRO_COMPANION_RECONNECT_DELAY_MS,
1011
METRO_COMPANION_RUN_ARG,
1112
WS_READY_STATE_OPEN,
@@ -76,6 +77,12 @@ function normalizeCloseCode(code: number | undefined): number {
7677
return 1011;
7778
}
7879

80+
function normalizeOutgoingCloseCode(code: number): number {
81+
if (code === 1000) return code;
82+
if (code >= 3000 && code <= 4999) return code;
83+
return 3001;
84+
}
85+
7986
function sendJson(socket: WebSocket, payload: object): void {
8087
if (socket.readyState !== WS_READY_STATE_OPEN) return;
8188
socket.send(JSON.stringify(payload));
@@ -128,20 +135,14 @@ async function waitForSocketShutdown(socket: WebSocket): Promise<void> {
128135

129136
function closeSocketQuietly(socket: WebSocket, code: number, reason: string): void {
130137
try {
131-
socket.close(code, reason);
138+
socket.close(normalizeOutgoingCloseCode(code), reason);
132139
} catch {
133140
// ignore shutdown races
134141
}
135142
}
136143

137144
function shouldKeepWorkerRunning(options: CompanionOptions): boolean {
138-
if (!options.statePath) return true;
139-
try {
140-
fs.accessSync(options.statePath, fs.constants.F_OK);
141-
return true;
142-
} catch {
143-
return false;
144-
}
145+
return !options.statePath || fs.existsSync(options.statePath);
145146
}
146147

147148
async function handleBridgeMessage(
@@ -268,9 +269,12 @@ export async function runMetroCompanionWorker(options: CompanionOptions): Promis
268269
const upstreamSockets = new Map<string, WebSocket>();
269270
const lifetimeHandle = setInterval(() => {
270271
if (!shouldKeepWorkerRunning(options)) {
272+
// Node's built-in WebSocket client does not expose a force-close API. If the peer never
273+
// answers the close handshake, a detached worker can linger indefinitely, so lease expiry
274+
// uses a hard exit to guarantee teardown.
271275
process.exit(0);
272276
}
273-
}, METRO_COMPANION_RECONNECT_DELAY_MS);
277+
}, METRO_COMPANION_LEASE_CHECK_INTERVAL_MS);
274278
lifetimeHandle.unref();
275279
while (shouldKeepWorkerRunning(options)) {
276280
try {
@@ -290,6 +294,9 @@ export async function runMetroCompanionWorker(options: CompanionOptions): Promis
290294
upstreamSockets.forEach((socket) => closeSocketQuietly(socket, 1012, 'bridge disconnected'));
291295
upstreamSockets.clear();
292296
} catch (error) {
297+
if (!shouldKeepWorkerRunning(options)) {
298+
break;
299+
}
293300
console.error(error instanceof Error ? error.message : String(error));
294301
}
295302
if (!shouldKeepWorkerRunning(options)) {

0 commit comments

Comments
 (0)