Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-daemon-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Stop the background local server from locking the directory it was started in.
5 changes: 5 additions & 0 deletions .changeset/fix-native-server-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the local server failing to start in the background on the native binary.
68 changes: 61 additions & 7 deletions apps/kimi-code/src/cli/sub/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand All @@ -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',
Expand All @@ -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.
Expand Down
73 changes: 72 additions & 1 deletion apps/kimi-code/test/cli/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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, '');
}
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions packages/server/src/svc/program.ts
Original file line number Diff line number Diff line change
@@ -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);
}
14 changes: 14 additions & 0 deletions packages/server/test/svc/launchd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading