Skip to content

Commit 7f0ff53

Browse files
authored
fix(remote-control): exit the web server when the relay takes the device offline (#4182)
* fix(remote-control): exit the web server when the relay takes the device offline * test(cli): satisfy the remote-control handle contract in tui web tests
1 parent c151d5a commit 7f0ff53

8 files changed

Lines changed: 200 additions & 10 deletions

File tree

‎apps/kimi-code/src/cli/sub/web/remote-control.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
} from '@moonshot-ai/remote-control';
2424
export type {
2525
ParsedRawHttpRequest,
26+
RemoteControlCloseReason,
2627
RemoteControlHandle,
2728
RemoteControlLock,
2829
RemoteControlLockInfo,
@@ -84,6 +85,8 @@ export function formatRemoteControlStatus(status: RemoteControlStatus): string {
8485
return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\n`;
8586
case 'relay_disconnected':
8687
return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\n`;
88+
case 'relay_terminated':
89+
return ` ${value('✕')} ${label('Taken offline from the web; exiting…')}\n`;
8790
case 'device_connected':
8891
return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\n`;
8992
case 'device_disconnected':

‎apps/kimi-code/src/cli/sub/web/run.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,14 @@ export interface WebCliOptions extends ServerCliOptions {
7676
remoteControl?: boolean;
7777
}
7878

79+
export interface ForegroundControls {
80+
/** Ask the foreground runner to shut the server down and exit (like SIGINT). */
81+
requestShutdown(reason: string): void;
82+
}
83+
7984
export interface StartForegroundHooks {
8085
/** Fires once the server is listening, before the foreground runner blocks. */
81-
onReady?: (origin: string) => void | Promise<void>;
86+
onReady?: (origin: string, controls?: ForegroundControls) => void | Promise<void>;
8287
onShutdown?: (reason: string) => void | Promise<void>;
8388
}
8489

@@ -202,7 +207,7 @@ export async function handleWebCommand(
202207
const run = deps.startServerForeground ?? startServerForeground;
203208
let remoteControl: RemoteControlHandle | undefined;
204209
await run(parsed, {
205-
onReady: async (origin) => {
210+
onReady: async (origin, controls) => {
206211
// Resolve the persistent token only once the server is up: a fresh
207212
// server writes `server.token` on first boot, so reading it beforehand
208213
// would miss first-time starts and the browser would hit the auth gate.
@@ -232,6 +237,19 @@ export async function handleWebCommand(
232237
stderr: deps.stderr,
233238
onStatus,
234239
});
240+
void remoteControl.closed.then(
241+
(reason) => {
242+
if (reason === 'user_requested') {
243+
controls?.requestShutdown('remote_control_user_requested');
244+
}
245+
},
246+
(error) => {
247+
deps.stderr.write(
248+
`Remote Control lock release failed: ${error instanceof Error ? error.message : String(error)}\n`,
249+
);
250+
controls?.requestShutdown('remote_control_closed');
251+
},
252+
);
235253
const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir);
236254
deps.stdout.write(
237255
formatRemoteControlOutput({
@@ -404,7 +422,9 @@ async function runServerInProcess(
404422
running.logger.info({ address: running.address }, 'server ready');
405423

406424
try {
407-
await hooks.onReady?.(running.address);
425+
await hooks.onReady?.(running.address, {
426+
requestShutdown: (reason) => void shutdown(reason),
427+
});
408428
} catch (error) {
409429
try {
410430
await hooks.onShutdown?.('startup_failed');

‎apps/kimi-code/src/tui/commands/web.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis
5757
let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined;
5858
try {
5959
await startServerForeground(options, {
60-
onReady: async (origin) => {
60+
onReady: async (origin, controls) => {
6161
const dataDir = getDataDir();
6262
const token = tryResolveServerToken(dataDir);
6363
if (token === undefined) throw new Error('Unable to read the local server token.');
@@ -78,6 +78,19 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis
7878
configuredOAuthHost: persisted?.oauthHost,
7979
onStatus,
8080
});
81+
void remoteControl.closed.then(
82+
(reason) => {
83+
if (reason === 'user_requested') {
84+
controls?.requestShutdown('remote_control_user_requested');
85+
}
86+
},
87+
(error) => {
88+
process.stderr.write(
89+
`Remote Control lock release failed: ${formatErrorMessage(error)}\n`,
90+
);
91+
controls?.requestShutdown('remote_control_closed');
92+
},
93+
);
8194
const url = buildRemoteControlUrl(
8295
remoteControl.deviceId,
8396
session?.id,

‎apps/kimi-code/test/cli/web/remote-control.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ describe('Remote Control output', () => {
5959
it('formats relay and device lifecycle states', () => {
6060
expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected');
6161
expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected');
62+
expect(formatRemoteControlStatus('relay_terminated')).toContain('offline');
63+
expect(formatRemoteControlStatus('relay_terminated')).toContain('exiting');
64+
expect(formatRemoteControlStatus('relay_terminated')).not.toContain('reconnecting');
6265
expect(formatRemoteControlStatus('device_connected').toLowerCase()).toContain('connected');
6366
expect(formatRemoteControlStatus('device_disconnected')).toContain('disconnected');
6467
});

‎apps/kimi-code/test/cli/web/web.test.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { resetCapabilitiesCache, setCapabilities } from '@moonshot-ai/pi-tui';
1919

2020
import { registerWebCommand } from '#/cli/sub/web';
2121
import type { LegacyKillDeps } from '#/cli/sub/web/legacy-kill';
22+
import type {
23+
RemoteControlCloseReason,
24+
RemoteControlStatus,
25+
} from '#/cli/sub/web/remote-control';
2226
import type { WebCommandDeps } from '#/cli/sub/web/run';
2327
import type { ParsedServerOptions } from '#/cli/sub/web/shared';
2428
import { darkColors } from '#/tui/theme/colors';
@@ -464,6 +468,100 @@ describe('`kimi web` opens the browser', () => {
464468
).rejects.toThrow('--remote-control requires a loopback host.');
465469
});
466470

471+
it('requests foreground shutdown when the relay takes the device offline', async () => {
472+
const { handleWebCommand } = await import('#/cli/sub/web/run');
473+
const home = mkdtempSync(join(tmpdir(), 'kimi-web-rc-'));
474+
vi.stubEnv('KIMI_CODE_HOME', home);
475+
const { stdout, stderr, readStdout } = makeIo();
476+
const requestShutdown = vi.fn();
477+
const runner: ForegroundRunner = async (_options, hooks) => {
478+
await hooks?.onReady?.('http://127.0.0.1:58627', { requestShutdown });
479+
return undefined as never;
480+
};
481+
let emitStatus: ((status: RemoteControlStatus) => void) | undefined;
482+
let resolveClosed: ((reason: RemoteControlCloseReason) => void) | undefined;
483+
const startRemoteControl: WebCommandDeps['startRemoteControl'] = async (options) => {
484+
emitStatus = options.onStatus;
485+
return {
486+
deviceId: 'device-1',
487+
deviceName: 'example-device',
488+
url: 'https://example.test/devices/device-1/?rc=1&from=kimi_code_cli',
489+
relayOrigin: 'https://example.test',
490+
closed: new Promise<RemoteControlCloseReason>((resolve) => {
491+
resolveClosed = resolve;
492+
}),
493+
close: async () => {},
494+
};
495+
};
496+
497+
try {
498+
await handleWebCommand(
499+
{ remoteControl: true, open: false },
500+
{
501+
startServerForeground: runner,
502+
startRemoteControl,
503+
resolveToken: () => 'tok',
504+
openUrl: vi.fn(),
505+
stdout,
506+
stderr,
507+
},
508+
);
509+
510+
emitStatus?.('relay_terminated');
511+
resolveClosed?.('user_requested');
512+
await vi.waitFor(() => {
513+
expect(requestShutdown).toHaveBeenCalledWith('remote_control_user_requested');
514+
});
515+
expect(readStdout()).toContain('offline');
516+
expect(readStdout()).not.toContain('reconnecting');
517+
} finally {
518+
rmSync(home, { recursive: true, force: true });
519+
}
520+
});
521+
522+
it('does not request shutdown when the tunnel closes locally', async () => {
523+
const { handleWebCommand } = await import('#/cli/sub/web/run');
524+
const home = mkdtempSync(join(tmpdir(), 'kimi-web-rc-'));
525+
vi.stubEnv('KIMI_CODE_HOME', home);
526+
const { stdout, stderr } = makeIo();
527+
const requestShutdown = vi.fn();
528+
const runner: ForegroundRunner = async (_options, hooks) => {
529+
await hooks?.onReady?.('http://127.0.0.1:58627', { requestShutdown });
530+
return undefined as never;
531+
};
532+
let resolveClosed: ((reason: RemoteControlCloseReason) => void) | undefined;
533+
const startRemoteControl: WebCommandDeps['startRemoteControl'] = async () => ({
534+
deviceId: 'device-1',
535+
deviceName: 'example-device',
536+
url: 'https://example.test/devices/device-1/?rc=1&from=kimi_code_cli',
537+
relayOrigin: 'https://example.test',
538+
closed: new Promise<RemoteControlCloseReason>((resolve) => {
539+
resolveClosed = resolve;
540+
}),
541+
close: async () => {},
542+
});
543+
544+
try {
545+
await handleWebCommand(
546+
{ remoteControl: true, open: false },
547+
{
548+
startServerForeground: runner,
549+
startRemoteControl,
550+
resolveToken: () => 'tok',
551+
openUrl: vi.fn(),
552+
stdout,
553+
stderr,
554+
},
555+
);
556+
557+
resolveClosed?.('local_close');
558+
await new Promise((resolve) => setTimeout(resolve, 10));
559+
expect(requestShutdown).not.toHaveBeenCalled();
560+
} finally {
561+
rmSync(home, { recursive: true, force: true });
562+
}
563+
});
564+
467565
it('shows --remote-control in help', () => {
468566
const remoteControlOption = makeProgram()
469567
.commands.find((command) => command.name() === 'web')!

‎apps/kimi-code/test/tui/commands/web.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ describe('handleRemoteControlCommand', () => {
205205
deviceName: 'example-device',
206206
url: entryUrl,
207207
relayOrigin: 'https://code-rc.kimi.com',
208+
closed: new Promise(() => {}),
208209
close,
209210
});
210211
mocks.startServerForeground.mockImplementation(
@@ -275,6 +276,7 @@ describe('handleRemoteControlCommand', () => {
275276
deviceName: 'example-device',
276277
url: entryUrl,
277278
relayOrigin: 'https://code-rc.kimi.com',
279+
closed: new Promise(() => {}),
278280
close,
279281
});
280282
mocks.startServerForeground.mockImplementation(

‎packages/remote-control/src/remote-control.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,12 @@ export interface ParsedRawHttpRequest {
9292
export type RemoteControlStatus =
9393
| 'relay_connected'
9494
| 'relay_disconnected'
95+
| 'relay_terminated'
9596
| 'device_connected'
9697
| 'device_disconnected';
9798

99+
export type RemoteControlCloseReason = 'local_close' | 'user_requested';
100+
98101
export interface RemoteControlOptions {
99102
readonly homeDir: string;
100103
readonly localOrigin: string;
@@ -114,7 +117,7 @@ export interface RemoteControlHandle {
114117
readonly deviceName: string;
115118
readonly url: string;
116119
readonly relayOrigin: string;
117-
readonly closed: Promise<void>;
120+
readonly closed: Promise<RemoteControlCloseReason>;
118121
close(): Promise<void>;
119122
}
120123

@@ -316,8 +319,9 @@ export async function startRemoteControl(
316319
await lock.release();
317320
throw error;
318321
}
319-
const closed = client.closed.then(async () => {
322+
const closed = client.closed.then(async (reason) => {
320323
await lock.release();
324+
return reason;
321325
});
322326
return {
323327
deviceId,
@@ -352,6 +356,7 @@ class RemoteControlClient {
352356
private readonly pingIntervalMs: number;
353357
private readonly silenceTimeoutMs: number;
354358
private stopped = false;
359+
private userRequested = false;
355360
private connected = false;
356361
private relayOnline = false;
357362
private runPromise: Promise<void> | undefined;
@@ -387,8 +392,10 @@ class RemoteControlClient {
387392
await initial;
388393
}
389394

390-
get closed(): Promise<void> {
391-
return this.runPromise ?? Promise.resolve();
395+
get closed(): Promise<RemoteControlCloseReason> {
396+
return (this.runPromise ?? Promise.resolve()).then(() =>
397+
this.userRequested ? 'user_requested' : 'local_close',
398+
);
392399
}
393400

394401
async close(): Promise<void> {
@@ -550,7 +557,10 @@ class RemoteControlClient {
550557
}
551558
if (message.type === 'disconnect') {
552559
const reason = stringField(message.payload, 'reason');
553-
if (reason === 'user_requested') this.stopped = true;
560+
if (reason === 'user_requested') {
561+
this.stopped = true;
562+
this.userRequested = true;
563+
}
554564
if (reason === 'server_shutting_down') this.reconnectImmediately = true;
555565
this.closeCycle();
556566
}
@@ -722,7 +732,7 @@ class RemoteControlClient {
722732
this.http?.close();
723733
if (this.relayOnline) {
724734
this.relayOnline = false;
725-
this.onStatus('relay_disconnected');
735+
this.onStatus(this.userRequested ? 'relay_terminated' : 'relay_disconnected');
726736
}
727737
this.management = undefined;
728738
this.http = undefined;

‎packages/remote-control/test/remote-control.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,47 @@ describe('Remote Control single-instance lock', () => {
817817
expect(existsSync(remoteControlLockPath(homeDir))).toBe(false);
818818
});
819819

820+
it('reports a terminal status and close reason on user_requested only', async () => {
821+
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
822+
const relay = await startAuthRelay();
823+
const statuses: string[] = [];
824+
let handle: RemoteControlHandle | undefined;
825+
cleanups.push(async () => handle?.close());
826+
827+
handle = await startRemoteControl({
828+
homeDir,
829+
localOrigin: 'http://127.0.0.1:58627',
830+
localServerToken: 'local-server-token',
831+
clientVersion: CLIENT_VERSION,
832+
relayOrigin: `http://127.0.0.1:${relay.port}`,
833+
stderr: { write: () => true },
834+
onStatus: (status) => statuses.push(status),
835+
});
836+
837+
relay.managementSockets.at(-1)!.send(
838+
JSON.stringify({ type: 'disconnect', payload: { reason: 'user_requested' } }),
839+
);
840+
await expect(handle.closed).resolves.toBe('user_requested');
841+
expect(statuses).toEqual(['relay_connected', 'relay_terminated']);
842+
843+
const localStatuses: string[] = [];
844+
let second: RemoteControlHandle | undefined;
845+
cleanups.push(async () => second?.close());
846+
second = await startRemoteControl({
847+
homeDir,
848+
localOrigin: 'http://127.0.0.1:58627',
849+
localServerToken: 'local-server-token',
850+
clientVersion: CLIENT_VERSION,
851+
relayOrigin: `http://127.0.0.1:${relay.port}`,
852+
stderr: { write: () => true },
853+
onStatus: (status) => localStatuses.push(status),
854+
});
855+
const closing = second.close();
856+
await expect(second.closed).resolves.toBe('local_close');
857+
await closing;
858+
expect(localStatuses).toEqual(['relay_connected', 'relay_disconnected']);
859+
});
860+
820861
it('does not remove a successor lock when closing', async () => {
821862
const homeDir = await createRemoteControlHome(TOKEN.refreshToken);
822863
const relay = await startAuthRelay();

0 commit comments

Comments
 (0)