fix(server): use execPath for daemon/supervisor re-exec in SEA (#860)

* fix(server): use execPath for daemon/supervisor re-exec in SEA

Detect SEA via node:sea and re-exec process.execPath instead of resolving argv[1] against cwd, which produced a bogus <cwd>/kimi and crashed the spawn with ENOENT for the native binary (kimi web).

Apply the same fix to both resolveDaemonProgram (kimi web daemon spawner) and resolveSupervisorProgram (launchd/systemd/schtasks), and handle the spawn error event so a launch failure is logged instead of crashing the parent with an unhandled error event.

* chore: add changeset for native server start fix

* fix(server): run background daemon from its log directory

Spawn the detached server child with cwd set to the server log directory instead of inheriting the caller's cwd, so the long-lived daemon does not pin the directory it was launched from (notably blocking its deletion on Windows).
This commit is contained in:
Haozhe 2026-06-17 23:37:23 +08:00 committed by GitHub
parent 9468868f3d
commit 0e2877bee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 192 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Stop the background local server from locking the directory it was started in.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the local server failing to start in the background on the native binary.

View file

@ -17,7 +17,8 @@
*/
import { spawn } from 'node:child_process';
import { closeSync, mkdirSync, openSync } from 'node:fs';
import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs';
import { createRequire } from 'node:module';
import { createServer } from 'node:net';
import { dirname, isAbsolute, join, resolve } from 'node:path';
@ -127,17 +128,51 @@ export async function resolveDaemonPort(
return getFreePort(host);
}
interface NodeSeaModule {
isSea(): boolean;
}
const nodeRequire = createRequire(import.meta.url);
let cachedSea: NodeSeaModule | null | undefined;
function loadSeaModule(): NodeSeaModule | null {
if (cachedSea !== undefined) return cachedSea;
try {
cachedSea = nodeRequire('node:sea') as NodeSeaModule;
} catch {
cachedSea = null;
}
return cachedSea;
}
/** True when running as a compiled single-executable (SEA / native) binary. */
function detectSea(): boolean {
const sea = loadSeaModule();
if (sea === null) return false;
try {
return sea.isSea();
} catch {
return false;
}
}
/**
* Absolute path to the CLI entry that should be re-execed to run the daemon.
* Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`:
* when the CLI is a compiled single binary, `argv[1]` is literally `server`
* and we must fall back to `process.execPath`.
* when the CLI is a compiled single binary, `argv[1]` is the invoked command
* name (e.g. `kimi`) or the first user argument never a script path so we
* must re-exec `process.execPath` itself.
*/
function resolveDaemonProgram(
export function resolveDaemonProgram(
argv: readonly string[] = process.argv,
cwd: string = process.cwd(),
execPath: string = process.execPath,
isSea: boolean = detectSea(),
): string {
// In a SEA binary `argv[1]` is not a script path, so resolving it against
// `cwd` would produce a bogus path (e.g. `<cwd>/kimi`) and crash the spawn
// with ENOENT. Always re-exec the binary itself.
if (isSea) return execPath;
const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath);
return isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
}
@ -149,10 +184,11 @@ interface SpawnDaemonChildOptions {
idleGraceMs?: number;
}
function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
const program = resolveDaemonProgram();
const logPath = daemonLogPath();
mkdirSync(dirname(logPath), { recursive: true });
const logDir = dirname(logPath);
mkdirSync(logDir, { recursive: true });
const args = [
'server',
'run',
@ -170,7 +206,25 @@ function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
}
const logFd = openSync(logPath, 'a');
try {
const child = spawn(program, args, { detached: true, stdio: ['ignore', logFd, logFd] });
const child = spawn(program, args, {
detached: true,
// Run from the server log directory instead of inheriting the caller's
// cwd, so the long-lived daemon does not pin the directory it was
// launched from (notably blocking its deletion on Windows).
cwd: logDir,
stdio: ['ignore', logFd, logFd],
});
child.once('error', (error) => {
// A spawn failure (e.g. ENOENT) surfaces asynchronously on the child,
// not as a thrown error. Without a listener Node would crash the parent
// with an unhandled 'error' event; record it instead and let the polling
// loop in `ensureDaemon` report the timeout.
try {
appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`);
} catch {
// Best-effort; the log directory may already be gone.
}
});
child.unref();
} finally {
// `spawn` dups the fd into the child; the parent must not keep it open.

View file

@ -8,8 +8,11 @@
* Foreground startup behavior is exercised end-to-end in `server-e2e/`.
*/
import { readFileSync } from 'node:fs';
import type { ChildProcess } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer, type Server } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import chalk, { Chalk } from 'chalk';
import { Command } from 'commander';
@ -20,6 +23,11 @@ import { addLifecycleCommands } from '#/cli/sub/server/lifecycle';
import type { KillCommandDeps } from '#/cli/sub/server/kill';
import { darkColors } from '#/tui/theme/colors';
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: vi.fn() };
});
function stripAnsi(text: string): string {
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
}
@ -612,6 +620,69 @@ describe('resolveDaemonPort', () => {
});
});
describe('resolveDaemonProgram', () => {
it('uses the absolute script path outside SEA mode', async () => {
const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon');
expect(resolveDaemonProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs');
});
it('normalizes a relative executable path against cwd outside SEA mode', async () => {
const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon');
expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe('/tmp/kimi-bin/kimi');
});
it('returns execPath in SEA mode when argv[1] is a bare command name', async () => {
// Reproduces `kimi web` from the shell: argv[1] is the invoked command
// name (`kimi`), not a path. Resolving it against cwd produced `<cwd>/kimi`
// and crashed the spawn with ENOENT.
const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon');
expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi');
});
it('returns execPath in SEA mode for a spawned `server` child', async () => {
const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon');
expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi');
});
});
describe('spawnDaemonChild', () => {
let workDir: string;
let prevHome: string | undefined;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-cwd-'));
prevHome = process.env['KIMI_CODE_HOME'];
process.env['KIMI_CODE_HOME'] = workDir;
vi.resetModules();
});
afterEach(() => {
if (prevHome === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = prevHome;
}
rmSync(workDir, { recursive: true, force: true });
});
it('spawns the daemon with cwd set to the server log directory', async () => {
const { spawn } = await import('node:child_process');
const spawnMock = vi.mocked(spawn);
spawnMock.mockClear();
spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess);
const { spawnDaemonChild, daemonLogPath } = await import('#/cli/sub/server/daemon');
spawnDaemonChild({ port: 58627, logLevel: 'info' });
expect(spawnMock).toHaveBeenCalledOnce();
const [program, args, options] = spawnMock.mock.calls[0]!;
expect(program).toBeTruthy();
expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon']));
expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) });
expect(options?.cwd).not.toBe(process.cwd());
});
});
describe('createIdleShutdownHandler', () => {
beforeEach(() => {
vi.useFakeTimers();

View file

@ -1,10 +1,45 @@
import { createRequire } from 'node:module';
import { isAbsolute, resolve } from 'node:path';
interface NodeSeaModule {
isSea(): boolean;
}
const nodeRequire = createRequire(import.meta.url);
let cachedSea: NodeSeaModule | null | undefined;
function loadSeaModule(): NodeSeaModule | null {
if (cachedSea !== undefined) return cachedSea;
try {
cachedSea = nodeRequire('node:sea') as NodeSeaModule;
} catch {
cachedSea = null;
}
return cachedSea;
}
/** True when running as a compiled single-executable (SEA / native) binary. */
function detectSea(): boolean {
const sea = loadSeaModule();
if (sea === null) return false;
try {
return sea.isSea();
} catch {
return false;
}
}
export function resolveSupervisorProgram(
argv: readonly string[] = process.argv,
cwd: string = process.cwd(),
execPath: string = process.execPath,
isSea: boolean = detectSea(),
): string {
// In a SEA binary `argv[1]` is the invoked command name (e.g. `kimi`) or the
// first user argument — never a script path — so the re-exec target is always
// the binary itself. Resolving it against `cwd` would produce a bogus path
// (e.g. `<cwd>/kimi`) and crash the spawn with ENOENT.
if (isSea) return execPath;
const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath);
return isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
}

View file

@ -140,6 +140,20 @@ describe('resolveSupervisorProgram', () => {
it('normalizes a relative executable path to an absolute path', () => {
expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe('/tmp/kimi-bin/kimi');
});
it('uses the absolute script path outside SEA mode', () => {
expect(resolveSupervisorProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs');
});
it('returns execPath in SEA mode even when argv[1] is a bare command name', () => {
// Reproduces `kimi web` from the shell: argv[1] is the invoked command
// name, not a path — resolving it against cwd produced `<cwd>/kimi` (ENOENT).
expect(resolveSupervisorProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi');
});
it('returns execPath in SEA mode for a spawned `server` child', () => {
expect(resolveSupervisorProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi');
});
});
describe('launchd manager — install', () => {