From 9dc1bdd29881dc0eba7098c2e692df672caa3301 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 17 Jun 2026 23:18:58 +0800 Subject: [PATCH 1/3] 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 /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. --- apps/kimi-code/src/cli/sub/server/daemon.ts | 54 +++++++++++++++++-- apps/kimi-code/test/cli/server/server.test.ts | 25 +++++++++ packages/server/src/svc/program.ts | 35 ++++++++++++ packages/server/test/svc/launchd.test.ts | 14 +++++ 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 3e72730c87..83f9150774 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -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. `/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); } @@ -171,6 +206,17 @@ function spawnDaemonChild(options: SpawnDaemonChildOptions): void { const logFd = openSync(logPath, 'a'); try { const child = spawn(program, args, { detached: true, 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. diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index e2a8aab122..af5d2b1048 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -612,6 +612,31 @@ 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 `/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('createIdleShutdownHandler', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/server/src/svc/program.ts b/packages/server/src/svc/program.ts index 12959397c5..1d55f83a2c 100644 --- a/packages/server/src/svc/program.ts +++ b/packages/server/src/svc/program.ts @@ -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. `/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); } diff --git a/packages/server/test/svc/launchd.test.ts b/packages/server/test/svc/launchd.test.ts index d44291a210..cce6ed29f7 100644 --- a/packages/server/test/svc/launchd.test.ts +++ b/packages/server/test/svc/launchd.test.ts @@ -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 `/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', () => { From 414bb0ffceddbc685f4dafa6bba9ab78dcf67c1a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 17 Jun 2026 23:23:35 +0800 Subject: [PATCH 2/3] chore: add changeset for native server start fix --- .changeset/fix-native-server-start.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-native-server-start.md diff --git a/.changeset/fix-native-server-start.md b/.changeset/fix-native-server-start.md new file mode 100644 index 0000000000..961ed38cc9 --- /dev/null +++ b/.changeset/fix-native-server-start.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the local server failing to start in the background on the native binary. From 2e4989a42914f5b4bdf3ed425950a0da05afafed Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 17 Jun 2026 23:31:45 +0800 Subject: [PATCH 3/3] 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). --- .changeset/fix-daemon-cwd.md | 5 ++ apps/kimi-code/src/cli/sub/server/daemon.ts | 14 ++++-- apps/kimi-code/test/cli/server/server.test.ts | 48 ++++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-daemon-cwd.md diff --git a/.changeset/fix-daemon-cwd.md b/.changeset/fix-daemon-cwd.md new file mode 100644 index 0000000000..e438cc9c62 --- /dev/null +++ b/.changeset/fix-daemon-cwd.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop the background local server from locking the directory it was started in. diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 83f9150774..1c840d418a 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -184,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', @@ -205,7 +206,14 @@ 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 diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index af5d2b1048..80304ef372 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -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(); + return { ...actual, spawn: vi.fn() }; +}); + function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -637,6 +645,44 @@ describe('resolveDaemonProgram', () => { }); }); +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();