Skip to content

Commit e051f6f

Browse files
committed
Merge remote-tracking branch 'origin/main' into refactor/reduce-duplication-simplify
* origin/main: test: stabilize android emulator boot tests (#377) fix: clean up metro companion workers (#376) fix: carry remote-config run id for install-from-source (#375)
2 parents 8d0f408 + 937ce8f commit e051f6f

9 files changed

Lines changed: 468 additions & 125 deletions

src/__tests__/cli-config.test.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async function runCliCapture(
2727
options?: {
2828
cwd?: string;
2929
env?: Record<string, string | undefined>;
30+
sendToDaemon?: (req: Omit<DaemonRequest, 'token'>) => Promise<DaemonResponse>;
3031
},
3132
): Promise<RunResult> {
3233
let stdout = '';
@@ -61,10 +62,12 @@ async function runCliCapture(
6162
return true;
6263
}) as typeof process.stderr.write;
6364

64-
const sendToDaemon = async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
65-
calls.push(req);
66-
return { ok: true, data: {} };
67-
};
65+
const sendToDaemon =
66+
options?.sendToDaemon ??
67+
(async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
68+
calls.push(req);
69+
return { ok: true, data: {} };
70+
});
6871

6972
try {
7073
await runCli(argv, { sendToDaemon });
@@ -265,6 +268,65 @@ test('remote config defaults override generic config and env for remote workflow
265268
fs.rmSync(root, { recursive: true, force: true });
266269
});
267270

271+
test('install-from-source forwards remote-config run id with explicit lease binding', async () => {
272+
const { root, home, project } = makeTempWorkspace();
273+
const remoteConfig = path.join(project, 'agent-device.remote.json');
274+
fs.writeFileSync(
275+
remoteConfig,
276+
JSON.stringify({
277+
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
278+
tenant: 'micha-pierzcha-a',
279+
sessionIsolation: 'tenant',
280+
runId: 'demo-run-001',
281+
platform: 'android',
282+
}),
283+
'utf8',
284+
);
285+
286+
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
287+
const result = await runCliCapture(
288+
[
289+
'install-from-source',
290+
'https://example.com/app.apk',
291+
'--remote-config',
292+
remoteConfig,
293+
'--lease-id',
294+
'lease-demo-001',
295+
'--json',
296+
],
297+
{
298+
cwd: project,
299+
env: { HOME: home },
300+
sendToDaemon: async (req) => {
301+
calls.push(req);
302+
return {
303+
ok: true,
304+
data: {
305+
launchTarget: 'com.example.demo',
306+
packageName: 'com.example.demo',
307+
},
308+
};
309+
},
310+
},
311+
);
312+
313+
assert.equal(result.code, null);
314+
assert.equal(result.stderr, '');
315+
const payload = JSON.parse(result.stdout);
316+
assert.equal(payload.success, true);
317+
assert.equal(payload.data.launchTarget, 'com.example.demo');
318+
assert.equal(calls.length, 1);
319+
assert.equal(calls[0]?.meta?.tenantId, 'micha-pierzcha-a');
320+
assert.equal(calls[0]?.meta?.runId, 'demo-run-001');
321+
assert.equal(calls[0]?.meta?.leaseId, 'lease-demo-001');
322+
assert.equal(calls[0]?.flags?.tenant, 'micha-pierzcha-a');
323+
assert.equal(calls[0]?.flags?.runId, 'demo-run-001');
324+
assert.equal(calls[0]?.flags?.leaseId, 'lease-demo-001');
325+
assert.deepEqual(calls[0]?.positionals, []);
326+
327+
fs.rmSync(root, { recursive: true, force: true });
328+
});
329+
268330
test('missing explicit remote config path returns parse error before daemon dispatch', async () => {
269331
const { root, home, project } = makeTempWorkspace();
270332

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

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { spawn } from 'node:child_process';
22
import assert from 'node:assert/strict';
33
import crypto from 'node:crypto';
4+
import fs from 'node:fs';
45
import http from 'node:http';
6+
import os from 'node:os';
7+
import path from 'node:path';
58
import type { Duplex } from 'node:stream';
69
import { setTimeout as delay } from 'node:timers/promises';
710
import { afterEach, test } from 'vitest';
@@ -488,3 +491,150 @@ test('metro companion worker reconnects after the bridge closes immediately afte
488491

489492
assert.equal(bridgeConnections, 2);
490493
});
494+
495+
test('metro companion worker exits after its state file is removed', async () => {
496+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-worker-'));
497+
const statePath = path.join(tempRoot, 'metro-companion.json');
498+
fs.writeFileSync(statePath, '{}', 'utf8');
499+
cleanupTasks.push(async () => {
500+
fs.rmSync(tempRoot, { recursive: true, force: true });
501+
});
502+
503+
const bridgeSocketReady = createDeferred<void>();
504+
let bridgePort = 0;
505+
let bridgeSocketRef: Duplex | null = null;
506+
507+
const localServer = http.createServer((_, res) => {
508+
res.writeHead(404);
509+
res.end('not found');
510+
});
511+
cleanupTasks.push(() => closeServer(localServer));
512+
const localPort = await listen(localServer);
513+
514+
const bridgeServer = http.createServer((req, res) => {
515+
const url = new URL(req.url || '/', 'http://127.0.0.1');
516+
if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') {
517+
req.resume();
518+
req.on('end', () => {
519+
res.writeHead(200, { 'content-type': 'application/json' });
520+
res.end(
521+
JSON.stringify({
522+
ok: true,
523+
data: { ws_url: `ws://127.0.0.1:${bridgePort}/bridge` },
524+
}),
525+
);
526+
});
527+
return;
528+
}
529+
res.writeHead(404);
530+
res.end('not found');
531+
});
532+
bridgeServer.on('upgrade', (req, socket) => {
533+
if (req.url !== '/bridge') {
534+
socket.destroy();
535+
return;
536+
}
537+
bridgeSocketRef = socket;
538+
const key = req.headers['sec-websocket-key'];
539+
if (typeof key !== 'string') {
540+
socket.destroy();
541+
return;
542+
}
543+
const accept = crypto
544+
.createHash('sha1')
545+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
546+
.digest('base64');
547+
socket.write(
548+
[
549+
'HTTP/1.1 101 Switching Protocols',
550+
'Upgrade: websocket',
551+
'Connection: Upgrade',
552+
`Sec-WebSocket-Accept: ${accept}`,
553+
'\r\n',
554+
].join('\r\n'),
555+
);
556+
bridgeSocketReady.resolve();
557+
});
558+
cleanupTasks.push(() => closeServer(bridgeServer));
559+
cleanupTasks.push(async () => {
560+
bridgeSocketRef?.destroy();
561+
});
562+
bridgePort = await listen(bridgeServer);
563+
564+
const companion = spawn(
565+
process.execPath,
566+
['--experimental-strip-types', 'src/metro-companion.ts', '--agent-device-run-metro-companion'],
567+
{
568+
cwd: process.cwd(),
569+
env: {
570+
...process.env,
571+
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
572+
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
573+
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
574+
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
575+
},
576+
stdio: ['ignore', 'pipe', 'pipe'],
577+
},
578+
);
579+
cleanupTasks.push(() => stopChild(companion));
580+
581+
let stderr = '';
582+
companion.stderr.on('data', (chunk) => {
583+
stderr += chunk.toString();
584+
});
585+
586+
await waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection');
587+
fs.unlinkSync(statePath);
588+
589+
const exit = await waitFor(
590+
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
591+
companion.once('exit', (code, signal) => resolve({ code, signal }));
592+
}),
593+
5_000,
594+
'worker exit after state cleanup',
595+
);
596+
597+
assert.equal(exit.signal, null, `unexpected worker stderr: ${stderr}`);
598+
assert.equal(exit.code, 0, `unexpected worker stderr: ${stderr}`);
599+
});
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/__tests__/client-metro-companion.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
8787
assert.notEqual(stagingFirst.statePath, prod.statePath);
8888
assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 2);
8989
assertCompanionSpawnTarget();
90+
assert.equal(fs.existsSync(stagingFirst.logPath), true);
91+
assert.equal(fs.existsSync(prod.logPath), true);
9092

9193
const stagingState = JSON.parse(fs.readFileSync(stagingFirst.statePath, 'utf8')) as {
9294
consumers: string[];
@@ -114,6 +116,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
114116
assert.equal(finalStop.stopped, true);
115117
assert.equal(killSpy.mock.calls.length, 1);
116118
assert.deepEqual(killSpy.mock.calls[0], [111, 'SIGTERM']);
119+
assert.equal(fs.existsSync(stagingFirst.statePath), false);
120+
assert.equal(fs.existsSync(stagingFirst.logPath), false);
117121

118122
const prodStop = await stopMetroCompanion({
119123
projectRoot,
@@ -123,6 +127,8 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
123127
assert.equal(prodStop.stopped, true);
124128
assert.equal(killSpy.mock.calls.length, 2);
125129
assert.deepEqual(killSpy.mock.calls[1], [222, 'SIGTERM']);
130+
assert.equal(fs.existsSync(prod.statePath), false);
131+
assert.equal(fs.existsSync(prod.logPath), false);
126132
} finally {
127133
fs.rmSync(projectRoot, { recursive: true, force: true });
128134
}

src/client-metro-companion-contract.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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';
67
export const ENV_BEARER_TOKEN = 'AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN';
78
export const ENV_LOCAL_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL';
89
export const ENV_LAUNCH_URL = 'AGENT_DEVICE_METRO_COMPANION_LAUNCH_URL';
10+
export const ENV_STATE_PATH = 'AGENT_DEVICE_METRO_COMPANION_STATE_PATH';
911

1012
export type { MetroTunnelRequestMessage as MetroCompanionRequest } from './metro.ts';
1113

@@ -14,4 +16,5 @@ export type CompanionOptions = {
1416
bearerToken: string;
1517
localBaseUrl: string;
1618
launchUrl?: string;
19+
statePath?: string;
1720
};

src/client-metro-companion-worker.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import fs from 'node:fs';
12
import { setTimeout as delay } from 'node:timers/promises';
23
import {
34
ENV_BEARER_TOKEN,
45
ENV_LAUNCH_URL,
56
ENV_LOCAL_BASE_URL,
67
ENV_SERVER_BASE_URL,
8+
ENV_STATE_PATH,
9+
METRO_COMPANION_LEASE_CHECK_INTERVAL_MS,
710
METRO_COMPANION_RECONNECT_DELAY_MS,
811
METRO_COMPANION_RUN_ARG,
912
WS_READY_STATE_OPEN,
@@ -74,6 +77,12 @@ function normalizeCloseCode(code: number | undefined): number {
7477
return 1011;
7578
}
7679

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+
7786
function sendJson(socket: WebSocket, payload: object): void {
7887
if (socket.readyState !== WS_READY_STATE_OPEN) return;
7988
socket.send(JSON.stringify(payload));
@@ -126,12 +135,16 @@ async function waitForSocketShutdown(socket: WebSocket): Promise<void> {
126135

127136
function closeSocketQuietly(socket: WebSocket, code: number, reason: string): void {
128137
try {
129-
socket.close(code, reason);
138+
socket.close(normalizeOutgoingCloseCode(code), reason);
130139
} catch {
131140
// ignore shutdown races
132141
}
133142
}
134143

144+
function shouldKeepWorkerRunning(options: CompanionOptions): boolean {
145+
return !options.statePath || fs.existsSync(options.statePath);
146+
}
147+
135148
async function handleBridgeMessage(
136149
bridgeSocket: WebSocket,
137150
message: MetroCompanionRequest,
@@ -254,7 +267,16 @@ async function handleBridgeMessage(
254267

255268
export async function runMetroCompanionWorker(options: CompanionOptions): Promise<void> {
256269
const upstreamSockets = new Map<string, WebSocket>();
257-
while (true) {
270+
const lifetimeHandle = setInterval(() => {
271+
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.
275+
process.exit(0);
276+
}
277+
}, METRO_COMPANION_LEASE_CHECK_INTERVAL_MS);
278+
lifetimeHandle.unref();
279+
while (shouldKeepWorkerRunning(options)) {
258280
try {
259281
const registration = await registerCompanion(options);
260282
const bridgeSocket = new WebSocket(registration.wsUrl);
@@ -272,10 +294,17 @@ export async function runMetroCompanionWorker(options: CompanionOptions): Promis
272294
upstreamSockets.forEach((socket) => closeSocketQuietly(socket, 1012, 'bridge disconnected'));
273295
upstreamSockets.clear();
274296
} catch (error) {
297+
if (!shouldKeepWorkerRunning(options)) {
298+
break;
299+
}
275300
console.error(error instanceof Error ? error.message : String(error));
276301
}
302+
if (!shouldKeepWorkerRunning(options)) {
303+
break;
304+
}
277305
await delay(METRO_COMPANION_RECONNECT_DELAY_MS);
278306
}
307+
clearInterval(lifetimeHandle);
279308
}
280309

281310
function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOptions | null {
@@ -291,6 +320,7 @@ function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOpt
291320
bearerToken,
292321
localBaseUrl,
293322
launchUrl: env[ENV_LAUNCH_URL]?.trim() || undefined,
323+
statePath: env[ENV_STATE_PATH]?.trim() || undefined,
294324
};
295325
}
296326

0 commit comments

Comments
 (0)