mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 15:16:07 +00:00
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:
parent
9468868f3d
commit
0e2877bee3
6 changed files with 192 additions and 8 deletions
5
.changeset/fix-daemon-cwd.md
Normal file
5
.changeset/fix-daemon-cwd.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Stop the background local server from locking the directory it was started in.
|
||||
5
.changeset/fix-native-server-start.md
Normal file
5
.changeset/fix-native-server-start.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix the local server failing to start in the background on the native binary.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue