From 552e11954f5cf5c97101e5b567fd826a8062d393 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 13:31:53 +0800 Subject: [PATCH 01/42] feat(kimi-code): support automatic updates for native installations via staged swap Native (SEA) installs previously could not self-update on Windows and relied on 'curl | bash' re-install on Unix. Replace both with a staged swap updater: - startup swaps in a staged binary (verified against the release manifest sha256, smoke-checked via --version) and re-execs it, so the running process never replaces itself (Windows-safe) - downloads run in a self-spawned hidden sub-command, in the background from the update preflight or in the foreground from 'kimi upgrade' - rollback from .bak on any swap failure; install failures keep the existing retry/prompt thresholds --- .changeset/native-staged-auto-update.md | 5 + apps/kimi-code/src/cli/commands.ts | 11 + apps/kimi-code/src/cli/sub/update-download.ts | 35 +++ .../src/cli/update/native-manifest.ts | 91 ++++++ apps/kimi-code/src/cli/update/native-stage.ts | 203 ++++++++++++ apps/kimi-code/src/cli/update/native-swap.ts | 296 ++++++++++++++++++ apps/kimi-code/src/cli/update/preflight.ts | 68 ++-- apps/kimi-code/src/cli/update/unzip.ts | 128 ++++++++ apps/kimi-code/src/constant/app.ts | 9 + apps/kimi-code/src/main.ts | 32 ++ apps/kimi-code/src/utils/paths.ts | 22 +- apps/kimi-code/test/cli/main.test.ts | 62 +++- .../test/cli/update-download.test.ts | 78 +++++ .../test/cli/update/native-manifest.test.ts | 120 +++++++ .../test/cli/update/native-stage.test.ts | 282 +++++++++++++++++ .../test/cli/update/native-swap.test.ts | 258 +++++++++++++++ .../test/cli/update/preflight.test.ts | 62 ++-- apps/kimi-code/test/cli/update/unzip.test.ts | 142 +++++++++ 18 files changed, 1834 insertions(+), 70 deletions(-) create mode 100644 .changeset/native-staged-auto-update.md create mode 100644 apps/kimi-code/src/cli/sub/update-download.ts create mode 100644 apps/kimi-code/src/cli/update/native-manifest.ts create mode 100644 apps/kimi-code/src/cli/update/native-stage.ts create mode 100644 apps/kimi-code/src/cli/update/native-swap.ts create mode 100644 apps/kimi-code/src/cli/update/unzip.ts create mode 100644 apps/kimi-code/test/cli/update-download.test.ts create mode 100644 apps/kimi-code/test/cli/update/native-manifest.test.ts create mode 100644 apps/kimi-code/test/cli/update/native-stage.test.ts create mode 100644 apps/kimi-code/test/cli/update/native-swap.test.ts create mode 100644 apps/kimi-code/test/cli/update/unzip.test.ts diff --git a/.changeset/native-staged-auto-update.md b/.changeset/native-staged-auto-update.md new file mode 100644 index 0000000000..97628bd34c --- /dev/null +++ b/.changeset/native-staged-auto-update.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Support automatic updates for native (single-binary) installations, including Windows: new versions download in the background, verify against the release checksum, and swap in on the next launch. Run `kimi upgrade` to update now, or let the background updater handle it. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index a090df4d0f..fd47538d3b 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -15,6 +15,7 @@ export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; +export type UpdateDownloadHandler = (version: string) => void; export function createProgram( version: string, @@ -22,6 +23,7 @@ export function createProgram( onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, onUpgrade: UpgradeCommandHandler = () => {}, + onUpdateDownload: UpdateDownloadHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') @@ -138,6 +140,15 @@ export function createProgram( onPluginNodeRunner(entry, args); }); + // Self-spawned worker for native staged updates (detached background + // download, or foreground from `kimi upgrade`). Hidden: not user-facing. + program + .command('__update_download', { hidden: true }) + .argument('') + .action((targetVersion: string) => { + onUpdateDownload(targetVersion); + }); + program.argument('[args...]').action((args: string[]) => { if (args.length > 0) { program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`); diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts new file mode 100644 index 0000000000..191d958029 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -0,0 +1,35 @@ +/** + * Hidden `kimi __update_download ` sub-command: the self-spawned + * worker behind native staged updates. Preflight spawns it detached in the + * background (and the `upgrade` command in the foreground); it downloads, + * verifies and stages the binary next to the running exe. The swap into + * place happens on the next startup (see `cli/update/native-swap.ts`). + */ + +import { log } from '@moonshot-ai/kimi-code-sdk'; + +import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; +import { stageNativeUpdate } from '#/cli/update/native-stage'; +import { detectNativeInstall } from '#/cli/update/source'; + +export async function runUpdateDownloadCommand(version: string): Promise { + if (!detectNativeInstall()) { + process.stderr.write('error: update download is only available in the native build\n'); + return 1; + } + // Another instance is already staging this version (30-min stale window + // covers crashed downloaders): the outcome is equivalent, exit quietly. + const lock = await tryAcquireUpdateInstallLock({ version }); + if (lock === null) return 0; + try { + await stageNativeUpdate({ version, exePath: process.execPath, stdout: process.stdout }); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`error: failed to download update ${version}: ${message}\n`); + log.warn('native update download failed', { version, error: message }); + return 1; + } finally { + await lock.release().catch(() => {}); + } +} diff --git a/apps/kimi-code/src/cli/update/native-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts new file mode 100644 index 0000000000..077e34999c --- /dev/null +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -0,0 +1,91 @@ +/** + * Per-release native artifact manifest (`/binaries//manifest.json`). + * + * Produced by `scripts/native/produce-manifest.mjs` and consumed by the + * install scripts; the staged updater reuses the same file so checksums and + * file names have a single source of truth. + */ + +import { valid } from 'semver'; +import { z } from 'zod'; + +import { KIMI_CODE_CDN_BINARIES_BASE } from '#/constant/app'; + +const MANIFEST_FETCH_TIMEOUT_MS = 10_000; + +const PlatformEntrySchema = z.object({ + filename: z.string().min(1), + checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }), +}); + +/** + * Deliberately NOT `.strict()` — unknown fields are ignored so future + * manifest additions never break shipped clients (same contract philosophy + * as the rollout manifest in `cdn.ts`). + */ +export const NativeReleaseManifestSchema = z.object({ + version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), + platforms: z.record(z.string(), PlatformEntrySchema), +}); + +export type NativeReleaseManifest = z.infer; +export type NativePlatformEntry = z.infer; + +export function nativeManifestUrl(version: string): string { + return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/manifest.json`; +} + +export function nativeBinaryUrl(version: string, filename: string): string { + return `${KIMI_CODE_CDN_BINARIES_BASE}/${version}/${filename}`; +} + +/** + * Fetch and parse the per-release manifest. **Throws** on any failure + * (network, non-2xx, malformed body, unknown version) — callers treat a + * throw as "staging failed" and record an install failure. + * + * `version` goes into the URL, so it must be a valid semver (it always is: + * upstream sources are the CDN `latest.json` / the `upgrade` command). + * `fetchImpl` is injectable for tests. + */ +export async function fetchNativeReleaseManifest( + version: string, + fetchImpl: typeof fetch = fetch, +): Promise { + if (valid(version) === null) { + throw new Error(`invalid semver for native manifest lookup: ${JSON.stringify(version)}`); + } + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, MANIFEST_FETCH_TIMEOUT_MS); + let response: Response; + try { + response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + if (!response.ok) { + throw new Error(`native manifest for ${version} returned HTTP ${response.status}`); + } + return NativeReleaseManifestSchema.parse(JSON.parse(await response.text())); +} + +/** + * Pick the entry for the running platform. The release pipeline keys + * platforms by `-` (win32-x64, darwin-arm64, …). + * **Throws** when the platform is missing — a silent skip would strand the + * update in a retry loop. + */ +export function selectPlatformEntry( + manifest: NativeReleaseManifest, + platform: NodeJS.Platform, + arch: string, +): NativePlatformEntry { + const target = `${platform}-${arch}`; + const entry = manifest.platforms[target]; + if (entry === undefined) { + throw new Error(`platform ${target} not found in native manifest for ${manifest.version}`); + } + return entry; +} diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts new file mode 100644 index 0000000000..d7023a1ce0 --- /dev/null +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -0,0 +1,203 @@ +/** + * Native staged update: download + verify + unpack into `/.staging/`, + * without touching the running executable. The actual swap happens on the + * next startup (see `native-swap.ts`). + * + * Trust chain: the zip's sha256 comes from the per-release manifest (served + * over HTTPS), and the unpacked exe is re-checked against the zip entry's + * crc32, so a staged binary is byte-exact what the release pipeline produced. + */ + +import { createHash } from 'node:crypto'; +import { mkdir, open, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { z } from 'zod'; + +import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; +import { writeJsonFile } from '#/utils/persistence'; + +import { + fetchNativeReleaseManifest, + nativeBinaryUrl, + selectPlatformEntry, +} from './native-manifest'; +import { unzipFirstFile } from './unzip'; + +const StagedNativeUpdateSchema = z + .object({ + version: z.string().min(1), + target: z.string().min(1), + /** Base name of the staged executable inside `.staging/`. */ + exeFileName: z.string().min(1), + /** sha256 of the zip the exe was unpacked from. */ + sha256: z.string().regex(/^[a-f0-9]{64}$/), + exeSize: z.number().int().min(1), + stagedAt: z.string().min(1), + }) + .strict(); + +export type StagedNativeUpdate = z.infer; + +export function stagedExeFileName(version: string, platform: NodeJS.Platform): string { + return platform === 'win32' ? `kimi-${version}.exe` : `kimi-${version}`; +} + +export function stagedExePath(exePath: string, staged: StagedNativeUpdate): string { + return join(getNativeStagingDir(exePath), staged.exeFileName); +} + +/** + * Read the staged-update metadata, returning null when anything is off: + * missing/corrupt `staged.json`, or the staged exe went away / changed size. + * A null result makes callers behave as if no update was ever staged. + */ +export async function readStagedNativeUpdate( + exePath: string, + filePath: string = getNativeStagedStateFile(exePath), +): Promise { + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch { + return null; + } + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + const parsed = StagedNativeUpdateSchema.safeParse(json); + if (!parsed.success) return null; + const staged = parsed.data; + const info = await stat(stagedExePath(exePath, staged)).catch(() => null); + if (info === null || info.size !== staged.exeSize) return null; + return staged; +} + +/** Remove staged.json + the staged exe; used on downgrade-guard discards and swap failures. */ +export async function removeStagedNativeUpdate(exePath: string): Promise { + const stagingDir = getNativeStagingDir(exePath); + const staged = await readStagedNativeUpdate(exePath).catch(() => null); + if (staged !== null) { + await rm(stagedExePath(exePath, staged), { force: true }).catch(() => {}); + } + await rm(getNativeStagedStateFile(exePath), { force: true }).catch(() => {}); + // Best effort: drop the staging dir itself when empty (leftover `.part` + // files keep it around; the downloader truncates those on the next run). + await rmdir(stagingDir).catch(() => {}); +} + +export interface StageNativeUpdateOptions { + readonly version: string; + /** Path of the installed executable the staged binary will later replace. */ + readonly exePath: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly fetchImpl?: typeof fetch; + readonly stdout?: { write(chunk: string): boolean }; +} + +export type StageNativeUpdateStatus = 'already-staged' | 'staged'; + +export interface StageNativeUpdateResult { + readonly status: StageNativeUpdateStatus; + readonly staged: StagedNativeUpdate; +} + +async function downloadAndHash( + url: string, + partPath: string, + expectedSha256: string, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl(url); + if (!response.ok || response.body === null) { + throw new Error(`native binary download returned HTTP ${response.status}`); + } + const hash = createHash('sha256'); + const file = await open(partPath, 'w'); + try { + for await (const chunk of response.body as AsyncIterable) { + hash.update(chunk); + await file.write(chunk); + } + } finally { + await file.close(); + } + const digest = hash.digest('hex'); + if (digest !== expectedSha256) { + throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`); + } +} + +/** + * Download + verify + unpack `version` next to the running executable. + * + * Short-circuits with `already-staged` when the same version is ready on + * disk (repeat `kimi upgrade`, or foreground/background overlap). **Throws** + * on any failure after cleaning up this version's leftovers — the caller + * records an install failure. + */ +export async function stageNativeUpdate( + options: StageNativeUpdateOptions, +): Promise { + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const fetchImpl = options.fetchImpl ?? fetch; + const target = `${platform}-${arch}`; + const exeFileName = stagedExeFileName(options.version, platform); + + const existing = await readStagedNativeUpdate(options.exePath); + if (existing !== null && existing.version === options.version) { + return { status: 'already-staged', staged: existing }; + } + + // A different version was staged earlier and never swapped (skipped + // rollout, user stayed offline, …): supersede it before writing ours. + if (existing !== null) { + await removeStagedNativeUpdate(options.exePath); + } + const stagingDir = getNativeStagingDir(options.exePath); + await mkdir(stagingDir, { recursive: true }); + + const staged: StagedNativeUpdate = { + version: options.version, + target, + exeFileName, + sha256: '', + exeSize: 0, + stagedAt: new Date().toISOString(), + }; + + try { + const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); + const entry = selectPlatformEntry(manifest, platform, arch); + const partPath = join(stagingDir, `${entry.filename}.part`); + options.stdout?.write(`Downloading Kimi Code ${options.version} (${target})…\n`); + await downloadAndHash( + nativeBinaryUrl(options.version, entry.filename), + partPath, + entry.checksum, + fetchImpl, + ); + options.stdout?.write('Verifying and unpacking…\n'); + const { data } = unzipFirstFile(await readFile(partPath)); + await writeFile(stagedExePath(options.exePath, staged), data, { mode: 0o755 }); + await rm(partPath, { force: true }); + + staged.sha256 = entry.checksum; + staged.exeSize = data.length; + // Atomic write: staged.json only ever appears complete and consistent. + await writeJsonFile( + getNativeStagedStateFile(options.exePath), + StagedNativeUpdateSchema, + staged, + ); + return { status: 'staged', staged }; + } catch (error) { + await removeStagedNativeUpdate(options.exePath); + throw error; + } +} diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts new file mode 100644 index 0000000000..c1748a7c15 --- /dev/null +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -0,0 +1,296 @@ +/** + * Native staged swap, executed at the very top of startup. + * + * When a staged update is ready (`.staging/staged.json` next to the running + * exe), swap it in atomically and re-exec so the user session runs the new + * binary immediately. Everything here is best-effort: any failure leaves the + * current exe intact (rollback from `.bak`) and startup continues normally. + * + * Windows semantics make this safe: a running exe can be renamed but not + * overwritten, so the sequence is `rename exe→.bak` (the running process is + * unaffected), `rename staged→exe`, then delete `.bak` (best effort — a + * concurrent old instance keeps it locked until it exits). This is the same + * mechanism install.ps1 already relies on, and the Squirrel/NSIS-style + * "next launch performs the swap" pattern. + */ + +import { spawn } from 'node:child_process'; +import { readdir, rename, rmdir, unlink } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { gt } from 'semver'; + +import { log } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_UPDATE_REEXEC_ENV } from '#/constant/app'; + +import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { + readStagedNativeUpdate, + removeStagedNativeUpdate, + stagedExePath, + type StagedNativeUpdate, +} from './native-stage'; +import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; + +export interface NativeSwapDeps { + readonly exePath: string; + readonly argv: readonly string[]; + readonly env: NodeJS.ProcessEnv; + readonly currentVersion: string; + readonly isNative: boolean; + readonly spawnImpl?: typeof spawn; + readonly exitImpl?: (code: number) => void; +} + +export interface SpawnedChild { + once(event: 'error', listener: (error: Error) => void): void; + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; + once(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void; +} + +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); +} + +function logSwap(message: string, payload: Record): void { + try { + log.info(`native update swap: ${message}`, payload); + } catch { + // Diagnostics must never affect startup. + } +} + +/** Record a swap failure so preflight stops re-staging the same bad version. */ +async function recordSwapFailure(version: string): Promise { + try { + const state = await readUpdateInstallState(); + const attempts = + (state.lastFailure?.version === version ? state.lastFailure.attempts : 0) + 1; + await writeUpdateInstallState({ + ...state, + active: null, + lastFailure: { version, failedAt: new Date().toISOString(), attempts }, + }); + } catch { + // Never block startup on bookkeeping. + } +} + +/** + * Run `exe --version` as a smoke check: exit code 0 and the staged version in + * the output. A swapped binary that cannot even print its version must not + * replace the known-good exe. + */ +function smokeCheck( + exePath: string, + staged: StagedNativeUpdate, + spawnImpl: typeof spawn, +): Promise { + return new Promise((resolve) => { + let stdout = ''; + let settled = false; + const finish = (ok: boolean): void => { + if (settled) return; + settled = true; + resolve(ok); + }; + let child: SpawnedChild & { readonly stdout?: NodeJS.ReadableStream | null; kill(): void }; + try { + child = spawnImpl(exePath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) as unknown as typeof child; + } catch { + finish(false); + return; + } + const timeout = setTimeout(() => { + try { + child.kill(); + } catch { + // Already gone. + } + finish(false); + }, 15_000); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + child.once('error', () => { + clearTimeout(timeout); + finish(false); + }); + // 'close', not 'exit': stdio may still be flushing when 'exit' fires, and + // the check needs the complete version output. + child.once('close', (code) => { + clearTimeout(timeout); + finish(code === 0 && stdout.includes(staged.version)); + }); + }); +} + +interface ClaimedStaged { + readonly staged: StagedNativeUpdate; + readonly claimedPath: string; +} + +/** + * Atomically claim the staged metadata file (rename is atomic on both NTFS + * and POSIX, so exactly one of several concurrently starting instances wins). + * Returns null when there is nothing staged, the file disappeared under us, + * or the staged exe failed consistency checks. + */ +async function claimStagedUpdate(exePath: string): Promise { + const stateFile = getNativeStagedStateFile(exePath); + const staged = await readStagedNativeUpdate(exePath, stateFile); + if (staged === null) return null; + + const claimedPath = `${stateFile}.swap-${process.pid}`; + try { + await rename(stateFile, claimedPath); + } catch { + return null; + } + return { staged, claimedPath }; +} + +async function rollback(bakPath: string, exePath: string): Promise { + await rename(bakPath, exePath).catch(() => {}); +} + +/** + * Remove leftover `.bak` siblings of the exe from earlier swaps/installs. + * A `.bak` still mapped by a running old instance cannot be deleted on + * Windows — it is simply left for a later launch. + */ +async function cleanupBackups(exePath: string, keepPath: string): Promise { + const dir = dirname(exePath); + const base = basename(exePath); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; + } + for (const entry of entries) { + if (!entry.startsWith(`${base}.`) || !entry.endsWith('.bak')) continue; + const full = join(dir, entry); + if (full === keepPath) continue; + await unlink(full).catch(() => {}); + } +} + +/** + * Re-exec the (newly swapped) exe with the original argv, forwarding its exit + * code so the swap is invisible to the caller. Returns false when the spawn + * itself failed — the caller then continues startup with the old in-memory + * code; the binary on disk is already the new version. + */ +function reexec( + deps: NativeSwapDeps & { readonly spawnImpl: typeof spawn }, +): Promise { + return new Promise((resolve) => { + let child: SpawnedChild; + try { + child = deps.spawnImpl(deps.exePath, deps.argv.slice(2), { + stdio: 'inherit', + env: { ...deps.env, [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' }, + }) as unknown as SpawnedChild; + } catch (error) { + logSwap('re-exec spawn threw', { error: String(error) }); + resolve(false); + return; + } + child.once('error', (error) => { + logSwap('re-exec spawn failed', { error: error.message }); + resolve(false); + }); + child.once('exit', (code, _signal) => { + resolve(true); + const exitImpl = deps.exitImpl ?? ((exitCode: number) => process.exit(exitCode)); + exitImpl(code ?? 0); + }); + }); +} + +/** + * Swap in a staged native update and re-exec when one is ready. + * + * Returns true only when the process was re-launched (the caller must not + * continue startup — the exit handler fires once the child exits). Every + * other outcome returns false so startup proceeds untouched. + */ +export async function maybeRelaunchWithStagedNativeUpdate( + deps: NativeSwapDeps, +): Promise { + if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) return false; + if (!deps.isNative) return false; + + const claimed = await claimStagedUpdate(deps.exePath); + if (claimed === null) return false; + const { staged, claimedPath } = claimed; + const spawnImpl = deps.spawnImpl ?? spawn; + + const discard = async (): Promise => { + await removeStagedNativeUpdate(deps.exePath); + await unlink(claimedPath).catch(() => {}); + return false; + }; + + // Downgrade guard: the staged version must be newer than what is running. + // (The user may have installed a newer build manually after we staged.) + if (!gt(staged.version, deps.currentVersion)) { + logSwap('discarding staged update (not newer)', { + staged: staged.version, + current: deps.currentVersion, + }); + return discard(); + } + + const stagedExe = stagedExePath(deps.exePath, staged); + + // 1. Pick a backup slot and move the running exe aside (rename of a running + // exe is legal on Windows and POSIX alike; overwriting is not). + let bakPath = `${deps.exePath}.bak`; + const oldBakCleared = await unlink(bakPath) + .then(() => true) + .catch(() => false); + if (!oldBakCleared) { + // The leftover `.bak` is locked by a still-running old instance (or + // undeletable for another reason) — take a unique backup name, the same + // fallback install.ps1 uses. It is best-effort cleaned up on later runs. + bakPath = `${deps.exePath}.${process.pid}.bak`; + } + try { + await rename(deps.exePath, bakPath); + } catch (error) { + // Nothing was moved: startup continues with the old exe. Keep the staged + // files so a later launch can retry (transient locks clear on reboot). + logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); + await unlink(claimedPath).catch(() => {}); + return false; + } + + // 2. Move the staged exe into place; roll back on failure. + if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) { + logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath }); + await rollback(bakPath, deps.exePath); + await recordSwapFailure(staged.version); + return discard(); + } + + // 3. Smoke-check the new exe; roll back when it cannot start or lies about + // its version. + if (!(await smokeCheck(deps.exePath, staged, spawnImpl))) { + logSwap('smoke check failed, rolling back', { version: staged.version }); + await unlink(deps.exePath).catch(() => {}); + await rollback(bakPath, deps.exePath); + await recordSwapFailure(staged.version); + return discard(); + } + + // 4. Success: clean up and re-exec into the new binary. + await unlink(claimedPath).catch(() => {}); + await unlink(bakPath).catch(() => {}); + await cleanupBackups(deps.exePath, bakPath); + await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {}); + logSwap('swap succeeded, re-launching', { version: staged.version }); + return reexec({ ...deps, spawnImpl }); +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 5bcad7c9bf..f3b028fb2b 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -88,7 +88,7 @@ export function installCommandFor( } } -export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { +export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': case 'pnpm-global': @@ -100,7 +100,8 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform) // behind the CDN release — prompt the user to run `brew upgrade` manually. return false; case 'native': - return platform !== 'win32'; + // Staged-swap self update works on every platform (win32 included). + return true; case 'unsupported': return false; } @@ -128,12 +129,12 @@ export function spawnForSource( case 'homebrew': return { cmd: 'brew', args: ['upgrade', 'kimi-code'] }; case 'native': - // `curl … | bash` reports only the trailing bash's exit status, so a - // failed download (curl can't connect → empty stdin → bash exits 0) - // would look like a successful update. `pipefail` makes the pipeline - // surface curl's non-zero status so installUpdate() rejects and we warn - // instead of printing "Updated …". - return { cmd: 'bash', args: ['-c', `set -o pipefail; ${NATIVE_INSTALL_COMMAND_UNIX}`] }; + // Native installs self-spawn the hidden downloader sub-command, which + // stages the binary next to the exe (verified against the release + // manifest's sha256); the swap happens on the next startup. This + // replaces the old `curl|bash` / `irm|iex` re-install dance — no shell, + // no pipeline exit-status loss, no PowerShell dependency on Windows. + return { cmd: process.execPath, args: ['__update_download', version] }; case 'unsupported': throw new Error('unsupported install source cannot be auto-installed'); } @@ -158,6 +159,28 @@ function resolveSpawnCommand(cmd: string, platform: NodeJS.Platform): string | u return platform === 'win32' ? `"${resolved}"` : resolved; } +/** + * Resolve the spawn target for an install. Package managers are resolved from + * `PATH` to an absolute executable via `resolveSpawnCommand` (workspace-trust + * safety, see above). The native self-spawn instead uses `process.execPath` + * verbatim — already absolute — and never goes through a shell. Returns the + * shell flag alongside, since Windows package-manager shims (.cmd) still + * need one. + */ +function resolveInstallSpawn( + source: InstallSource, + version: string, + platform: NodeJS.Platform, +): { readonly resolvedCmd: string; readonly args: readonly string[]; readonly shell: boolean } | undefined { + const { cmd, args } = spawnForSource(source, version, platform); + if (source === 'native') { + return { resolvedCmd: cmd, args, shell: false }; + } + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) return undefined; + return { resolvedCmd, args, shell: platform === 'win32' }; +} + const THIRD_PARTY_SOURCE_NOTE = '\nNote: Third-party sources may lag behind the official release.\n' + `For the latest updates, use the official installer: ${KIMI_CODE_OFFICIAL_INSTALL_URL}\n`; @@ -180,7 +203,7 @@ export function renderManualUpdateMessage( sourceDesc = 'homebrew'; break; case 'native': - sourceDesc = 'native (windows). Auto-update is not supported on this platform.'; + sourceDesc = 'native installer'; break; case 'unsupported': sourceDesc = 'unsupported package manager or layout.'; @@ -508,19 +531,21 @@ export async function installUpdate( version: string, platform: NodeJS.Platform, ): Promise { - const { cmd, args } = spawnForSource(source, version, platform); - const resolvedCmd = resolveSpawnCommand(cmd, platform); - if (resolvedCmd === undefined) { - throw new Error(`${cmd} was not found in PATH; cannot install the update`); + const spawnTarget = resolveInstallSpawn(source, version, platform); + if (spawnTarget === undefined) { + throw new Error( + `${spawnForSource(source, version, platform).cmd} was not found in PATH; cannot install the update`, + ); } await new Promise((resolve, reject) => { // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without // a shell, so run through the shell on win32. The version is a validated - // semver and the package name is a constant, so args are shell-safe. - const child = spawn(resolvedCmd, [...args], { + // semver and the package name is a constant, so args are shell-safe. The + // native self-spawn is an .exe and needs no shell. + const child = spawn(spawnTarget.resolvedCmd, [...spawnTarget.args], { stdio: 'inherit', - shell: platform === 'win32' ? true : undefined, + shell: spawnTarget.shell ? true : undefined, }); child.once('error', reject); child.once('exit', (code, signal) => { @@ -529,7 +554,7 @@ export async function installUpdate( return; } const detail = signal !== null ? `signal ${signal}` : `code ${String(code)}`; - reject(new Error(`${cmd} exited with ${detail}`)); + reject(new Error(`update install exited with ${detail}`)); }); }); } @@ -577,7 +602,7 @@ async function startBackgroundInstall( source, }); - const { cmd, args } = spawnForSource(source, target.version, platform); + const spawnTarget = resolveInstallSpawn(source, target.version, platform); let settled = false; const finish = (succeeded: boolean): void => { @@ -629,18 +654,17 @@ async function startBackgroundInstall( }); }; - const resolvedCmd = resolveSpawnCommand(cmd, platform); - if (resolvedCmd === undefined) { + if (spawnTarget === undefined) { // The package manager cannot be resolved to an absolute path outside // the cwd — record a normal install failure instead of spawning a bare // command name that Windows would resolve into the untrusted workspace. finish(false); return; } - const child = spawn(resolvedCmd, [...args], { + const child = spawn(spawnTarget.resolvedCmd, [...spawnTarget.args], { detached: true, stdio: 'ignore', - shell: platform === 'win32' ? true : undefined, + shell: spawnTarget.shell ? true : undefined, // On Windows a detached child gets its own console window; with shell:true // that window would flash during a passive background update. Hide it so // the silent updater stays silent. diff --git a/apps/kimi-code/src/cli/update/unzip.ts b/apps/kimi-code/src/cli/update/unzip.ts new file mode 100644 index 0000000000..3a8faab8df --- /dev/null +++ b/apps/kimi-code/src/cli/update/unzip.ts @@ -0,0 +1,128 @@ +/** + * Minimal single-file ZIP extraction for the native staged update. + * + * The release pipeline packages each native binary as a one-entry zip + * (`scripts/native/package.mjs`), so this parser deliberately supports only + * what that producer emits: store/deflate entries, no ZIP64, no encryption. + * Anything outside that envelope is rejected — a corrupt staged binary must + * fail here, before it can replace the running executable. + * + * No third-party dependency:Node >= 20.15 provides `zlib.crc32`. + */ + +import { crc32, inflateRawSync } from 'node:zlib'; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_ENTRY_SIGNATURE = 0x02014b50; +const LOCAL_HEADER_SIGNATURE = 0x04034b50; + +const METHOD_STORE = 0; +const METHOD_DEFLATE = 8; + +/** A staged exe is a Node SEA binary (~150 MB today); cap far above that. */ +const MAX_ENTRIES = 16; +const MAX_UNCOMPRESSED_SIZE = 1024 * 1024 * 1024; // 1 GiB +/** EOCD record (22 B) + the largest legal comment (64 KiB). */ +const EOCD_SEARCH_WINDOW = 22 + 0xffff; + +const ZIP64_MARKER = 0xffffffff; + +function findEndOfCentralDirectory(buffer: Buffer): number { + const minOffset = Math.max(0, buffer.length - EOCD_SEARCH_WINDOW); + for (let offset = buffer.length - 22; offset >= minOffset; offset--) { + if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) { + return offset; + } + } + throw new Error('invalid zip: end of central directory not found'); +} + +export interface UnzipFirstFileOptions { + /** Drop directory entries while scanning for the first regular file. */ + readonly maxUncompressedSize?: number; +} + +/** + * Extract the first regular file entry. **Throws** on any structural or + * integrity problem (bad signature, ZIP64, unsupported method, multi-entry + * archive, size cap, crc mismatch) — callers must treat this as "the staged + * download is corrupt" and discard it. + */ +export function unzipFirstFile( + buffer: Buffer, + options: UnzipFirstFileOptions = {}, +): { readonly name: string; readonly data: Buffer } { + const maxUncompressedSize = options.maxUncompressedSize ?? MAX_UNCOMPRESSED_SIZE; + const eocdOffset = findEndOfCentralDirectory(buffer); + const entryCount = buffer.readUInt16LE(eocdOffset + 10); + const centralOffset = buffer.readUInt32LE(eocdOffset + 16); + if (entryCount < 1 || entryCount > MAX_ENTRIES) { + throw new Error(`invalid zip: unexpected entry count ${entryCount}`); + } + if (centralOffset >= eocdOffset) { + throw new Error('invalid zip: central directory out of bounds'); + } + + let offset = centralOffset; + for (let index = 0; index < entryCount; index++) { + if (offset + 46 > eocdOffset || buffer.readUInt32LE(offset) !== CENTRAL_ENTRY_SIGNATURE) { + throw new Error('invalid zip: malformed central directory entry'); + } + const method = buffer.readUInt16LE(offset + 10); + const expectedCrc = buffer.readUInt32LE(offset + 16); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localHeaderOffset = buffer.readUInt32LE(offset + 42); + const name = buffer.toString('utf-8', offset + 46, offset + 46 + nameLength); + offset += 46 + nameLength + extraLength + commentLength; + + // Directory entry. + if (name.endsWith('/')) continue; + + if ( + compressedSize === ZIP64_MARKER || + uncompressedSize === ZIP64_MARKER || + localHeaderOffset === ZIP64_MARKER + ) { + throw new Error('unsupported zip: ZIP64 entries are not handled'); + } + if (method !== METHOD_STORE && method !== METHOD_DEFLATE) { + throw new Error(`unsupported zip: compression method ${method}`); + } + if (uncompressedSize > maxUncompressedSize) { + throw new Error(`unsupported zip: entry too large (${uncompressedSize} bytes)`); + } + if (localHeaderOffset + 30 > centralOffset) { + throw new Error('invalid zip: local header out of bounds'); + } + if (buffer.readUInt32LE(localHeaderOffset) !== LOCAL_HEADER_SIGNATURE) { + throw new Error('invalid zip: malformed local header'); + } + const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength; + if (dataStart + compressedSize > centralOffset) { + throw new Error('invalid zip: entry data out of bounds'); + } + + const compressed = buffer.subarray(dataStart, dataStart + compressedSize); + const data = method === METHOD_STORE ? Buffer.from(compressed) : inflateRawSync(compressed); + if (data.length !== uncompressedSize) { + throw new Error( + `corrupt zip: size mismatch (expected ${uncompressedSize}, got ${data.length})`, + ); + } + const actualCrc = crc32(data); + if (actualCrc !== expectedCrc) { + throw new Error( + `corrupt zip: crc32 mismatch (expected ${expectedCrc.toString(16)}, got ${actualCrc.toString(16)})`, + ); + } + return { name, data }; + } + + throw new Error('invalid zip: no file entry found'); +} diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 19b4acc25c..84445aeb0a 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -53,6 +53,12 @@ export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; export const KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json'; +// Native staged update: the staged binary + metadata live next to the running +// executable (`/.staging/`); the re-exec guard env breaks the +// swap → re-exec → swap loop. +export const KIMI_CODE_NATIVE_STAGING_DIR_NAME = '.staging'; +export const KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME = 'staged.json'; +export const KIMI_CODE_UPDATE_REEXEC_ENV = 'KIMI_CODE_UPDATE_REEXEC'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; @@ -84,6 +90,9 @@ export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; // stays unchanged forever — already-shipped clients hard-fail on non-semver // bodies, and the CDN install scripts read it for fresh installs. export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; +// Per-release native artifacts: `/binaries//manifest.json` + +// `/binaries//kimi-code-.zip` (same layout install.ps1 uses). +export const KIMI_CODE_CDN_BINARIES_BASE = `${KIMI_CODE_CDN_BASE}/binaries`; export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; // The marketplace catalog location constants live in the shared // agent-core-v2 plugin domain (kap-server consumes them from there). diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index cfcfb09285..104d0a4321 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -31,9 +31,12 @@ import { runPrompt } from './cli/run-prompt'; import { runShell } from './cli/run-shell'; import { formatStartupError } from './cli/startup-error'; import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; +import { runUpdateDownloadCommand } from './cli/sub/update-download'; import { handleUpgrade } from './cli/sub/upgrade'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; import { runUpdatePreflight } from './cli/update/preflight'; +import { detectNativeInstall } from './cli/update/source'; +import { maybeRelaunchWithStagedNativeUpdate } from './cli/update/native-swap'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; @@ -144,6 +147,24 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = { export function main(): void { process.title = PROCESS_NAME; installCrashHandlers(); + // A staged native update is swapped in and re-exec'd here, before any other + // initialization, so the user session immediately runs the new binary (and + // the old process never replaces itself while running). Every failure path + // inside falls back to a normal startup with the current exe. + void maybeRelaunchWithStagedNativeUpdate({ + exePath: process.execPath, + argv: process.argv, + env: process.env, + currentVersion: getVersion(), + isNative: detectNativeInstall(), + }) + .catch(() => false) + .then((relaunched) => { + if (!relaunched) bootstrap(); + }); +} + +function bootstrap(): void { // Route all outbound fetch through HTTP_PROXY/HTTPS_PROXY (honoring NO_PROXY) // before any client is constructed. No-op when no proxy variable is set; an // invalid proxy URL is reported and ignored rather than aborting startup. @@ -246,6 +267,17 @@ export function main(): void { process.exit(1); }); }, + (targetVersion) => { + void runUpdateDownloadCommand(targetVersion).then( + (code) => { + process.exit(code); + }, + async (error: unknown) => { + await logStartupFailure('download update', error); + process.exit(1); + }, + ); + }, ); program.parse(process.argv); diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index 2127726ccd..f9d595837c 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -7,7 +7,7 @@ import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { KIMI_CODE_BANNER_DIR_NAME, @@ -18,6 +18,8 @@ import { KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME, + KIMI_CODE_NATIVE_STAGING_DIR_NAME, KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, @@ -99,6 +101,24 @@ export function getPluginUpdateNoticeStateFile(): string { ); } +/** + * Return the native staged-update directory: `/.staging/`. + * + * Anchored on the running executable (not `~/.kimi-code/bin`) because the + * Windows installer honors `KIMI_INSTALL_DIR`, and the swap's atomic renames + * require the staged binary to sit on the same volume as the exe. + */ +export function getNativeStagingDir(exePath: string): string { + return join(dirname(exePath), KIMI_CODE_NATIVE_STAGING_DIR_NAME); +} + +/** + * Return the staged-update metadata file: `/.staging/staged.json`. + */ +export function getNativeStagedStateFile(exePath: string): string { + return join(getNativeStagingDir(exePath), KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME); +} + /** * Return the banner display state file: `/cache/banner/state.json`. */ diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index 8e058068a4..d115c6e768 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -49,6 +49,8 @@ const mocks = vi.hoisted(() => { }, KimiHarness: vi.fn(), createKimiHarness: vi.fn(), + maybeRelaunch: vi.fn(async () => false), + runUpdateDownloadCommand: vi.fn(async () => 0), }; }); @@ -122,6 +124,14 @@ vi.mock('../../src/cli/update/preflight', () => ({ runUpdatePreflight: mocks.runUpdatePreflight, })); +vi.mock('../../src/cli/update/native-swap', () => ({ + maybeRelaunchWithStagedNativeUpdate: mocks.maybeRelaunch, +})); + +vi.mock('../../src/cli/sub/update-download', () => ({ + runUpdateDownloadCommand: mocks.runUpdateDownloadCommand, +})); + vi.mock('../../src/cli/run-shell', () => ({ runShell: mocks.runShell, })); @@ -170,6 +180,14 @@ async function waitForAssertion(assertion: () => void): Promise { throw lastError; } +/** main() now boots asynchronously (after the staged-swap check resolves). */ +async function waitForProgramArgs(): Promise { + await waitForAssertion(() => { + expect(mocks.createProgram).toHaveBeenCalled(); + }); + return mocks.createProgram.mock.calls[0] as unknown as unknown[]; +} + async function runHandleMainCommand(opts: CLIOptions): Promise { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); @@ -294,7 +312,7 @@ describe('main entry command handling', () => { mocks.finalizeHeadlessRun.mockResolvedValue(void 0); main(); - const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const programArgs = await waitForProgramArgs(); const mainAction = programArgs[1] as (opts: CLIOptions) => void; mainAction(opts); @@ -319,7 +337,7 @@ describe('main entry command handling', () => { try { main(); - const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const programArgs = await waitForProgramArgs(); const mainAction = programArgs[1] as (opts: CLIOptions) => void; mainAction(opts); @@ -349,14 +367,44 @@ describe('main entry command handling', () => { expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); - it('installs crash handlers before parsing CLI arguments', () => { + it('installs crash handlers before parsing CLI arguments', async () => { main(); expect(mocks.installCrashHandlers).toHaveBeenCalledTimes(1); - expect(mocks.installCrashHandlers.mock.invocationCallOrder[0]).toBeLessThan( - mocks.createProgram.mock.invocationCallOrder[0]!, - ); - expect(mocks.parse).toHaveBeenCalledWith(process.argv); + await waitForAssertion(() => { + expect(mocks.installCrashHandlers.mock.invocationCallOrder[0]).toBeLessThan( + mocks.createProgram.mock.invocationCallOrder[0]!, + ); + expect(mocks.parse).toHaveBeenCalledWith(process.argv); + }); + }); + + it('runs the staged-swap check before bootstrap and skips startup when it relaunches', async () => { + mocks.maybeRelaunch.mockResolvedValueOnce(true); + + main(); + + await waitForAssertion(() => { + expect(mocks.maybeRelaunch).toHaveBeenCalledTimes(1); + }); + // Relaunched → the parent must sit on the child, never bootstrap. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mocks.createProgram).not.toHaveBeenCalled(); + }); + + it('passes the runtime context to the staged-swap check', async () => { + main(); + + await waitForAssertion(() => { + expect(mocks.maybeRelaunch).toHaveBeenCalledWith( + expect.objectContaining({ + exePath: process.execPath, + argv: process.argv, + currentVersion: '0.0.1-alpha.2', + isNative: false, + }), + ); + }); }); it('sets the process title during startup', () => { diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts new file mode 100644 index 0000000000..af17d13b6e --- /dev/null +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runUpdateDownloadCommand } from '#/cli/sub/update-download'; + +const mocks = vi.hoisted(() => ({ + detectNativeInstall: vi.fn(() => true), + tryAcquireUpdateInstallLock: vi.fn(), + stageNativeUpdate: vi.fn(), +})); + +vi.mock('#/cli/update/source', () => ({ + detectNativeInstall: mocks.detectNativeInstall, +})); + +vi.mock('#/cli/update/install-lock', () => ({ + tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, +})); + +vi.mock('#/cli/update/native-stage', () => ({ + stageNativeUpdate: mocks.stageNativeUpdate, +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async () => { + const actual = await vi.importActual( + '@moonshot-ai/kimi-code-sdk', + ); + return { + ...actual, + log: { ...actual.log, warn: vi.fn() }, + }; +}); + +describe('runUpdateDownloadCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.detectNativeInstall.mockReturnValue(true); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/install.lock', + release: vi.fn(async () => {}), + }); + mocks.stageNativeUpdate.mockResolvedValue({ status: 'staged', staged: {} }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('refuses on non-native installs', async () => { + mocks.detectNativeInstall.mockReturnValue(false); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(1); + expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('native build')); + }); + + it('exits quietly when another instance holds the install lock', async () => { + mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); + expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + }); + + it('stages against the running exe and releases the lock', async () => { + const release = vi.fn(async () => {}); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ filePath: '/tmp/install.lock', release }); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); + expect(mocks.stageNativeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ version: '0.7.0', exePath: process.execPath }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('reports staging failures with a non-zero exit code', async () => { + mocks.stageNativeUpdate.mockRejectedValue(new Error('sha256 mismatch')); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(1); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('sha256 mismatch')); + }); +}); diff --git a/apps/kimi-code/test/cli/update/native-manifest.test.ts b/apps/kimi-code/test/cli/update/native-manifest.test.ts new file mode 100644 index 0000000000..5a3e31da31 --- /dev/null +++ b/apps/kimi-code/test/cli/update/native-manifest.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + fetchNativeReleaseManifest, + nativeBinaryUrl, + nativeManifestUrl, + selectPlatformEntry, +} from '#/cli/update/native-manifest'; +import { KIMI_CODE_CDN_BINARIES_BASE } from '#/constant/app'; + +const VERSION = '0.7.0'; + +function mockFetch(response: { + readonly ok: boolean; + readonly status: number; + readonly body?: string; +}): typeof fetch { + return vi.fn(async () => ({ + ok: response.ok, + status: response.status, + text: async () => response.body ?? '', + })) as unknown as typeof fetch; +} + +const MANIFEST_BODY = JSON.stringify({ + version: VERSION, + tag: `@moonshot-ai/kimi-code@${VERSION}`, + platforms: { + 'win32-x64': { + filename: `kimi-code-win32-x64.zip`, + checksum: 'a'.repeat(64), + }, + 'darwin-arm64': { + filename: `kimi-code-darwin-arm64.zip`, + checksum: 'b'.repeat(64), + }, + }, +}); + +describe('fetchNativeReleaseManifest', () => { + it('fetches and parses the manifest for the given version', async () => { + const f = mockFetch({ ok: true, status: 200, body: MANIFEST_BODY }); + const manifest = await fetchNativeReleaseManifest(VERSION, f); + expect(manifest.version).toBe(VERSION); + expect(Object.keys(manifest.platforms)).toEqual(['win32-x64', 'darwin-arm64']); + expect(f).toHaveBeenCalledWith( + nativeManifestUrl(VERSION), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('ignores unknown fields (lenient parsing)', async () => { + const body = JSON.stringify({ + version: VERSION, + platforms: {}, + futureField: { nested: true }, + }); + const manifest = await fetchNativeReleaseManifest(VERSION, mockFetch({ ok: true, status: 200, body })); + expect(manifest.version).toBe(VERSION); + }); + + it('rejects a non-semver version argument before hitting the network', async () => { + const f = mockFetch({ ok: true, status: 200, body: MANIFEST_BODY }); + await expect(fetchNativeReleaseManifest('nope', f)).rejects.toThrow(/invalid semver/); + expect(f).not.toHaveBeenCalled(); + }); + + it('throws on non-2xx', async () => { + await expect( + fetchNativeReleaseManifest(VERSION, mockFetch({ ok: false, status: 404 })), + ).rejects.toThrow(/HTTP 404/); + }); + + it('throws on a malformed checksum', async () => { + const body = JSON.stringify({ + version: VERSION, + platforms: { 'win32-x64': { filename: 'kimi-code-win32-x64.zip', checksum: 'xyz' } }, + }); + await expect( + fetchNativeReleaseManifest(VERSION, mockFetch({ ok: true, status: 200, body })), + ).rejects.toThrow(); + }); + + it('propagates fetch errors', async () => { + const f = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + await expect(fetchNativeReleaseManifest(VERSION, f)).rejects.toThrow(/network down/); + }); +}); + +describe('selectPlatformEntry', () => { + const manifest = { + version: VERSION, + platforms: { + 'win32-x64': { filename: 'kimi-code-win32-x64.zip', checksum: 'a'.repeat(64) }, + }, + }; + + it('returns the entry matching platform-arch', () => { + expect(selectPlatformEntry(manifest, 'win32', 'x64')).toEqual( + manifest.platforms['win32-x64'], + ); + }); + + it('throws when the platform is missing', () => { + expect(() => selectPlatformEntry(manifest, 'linux', 'arm64')).toThrow( + /linux-arm64 not found/, + ); + }); +}); + +describe('url helpers', () => { + it('builds the manifest and binary URLs from the binaries base', () => { + expect(nativeManifestUrl(VERSION)).toBe(`${KIMI_CODE_CDN_BINARIES_BASE}/${VERSION}/manifest.json`); + expect(nativeBinaryUrl(VERSION, 'kimi-code-win32-x64.zip')).toBe( + `${KIMI_CODE_CDN_BINARIES_BASE}/${VERSION}/kimi-code-win32-x64.zip`, + ); + }); +}); diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts new file mode 100644 index 0000000000..d60a0defec --- /dev/null +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -0,0 +1,282 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { crc32, deflateRawSync } from 'node:zlib'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchNativeReleaseManifest, + nativeBinaryUrl, + nativeManifestUrl, +} from '#/cli/update/native-manifest'; +import { + readStagedNativeUpdate, + removeStagedNativeUpdate, + stagedExePath, + stageNativeUpdate, +} from '#/cli/update/native-stage'; +import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; + +const VERSION = '0.7.0'; +const PAYLOAD = Buffer.from('fake-sea-binary-payload'); + +/** Same minimal zip builder as unzip.test.ts (single deflated entry). */ +function buildZip(name: string, data: Buffer): Buffer { + const nameBuf = Buffer.from(name, 'utf-8'); + const compressed = deflateRawSync(data); + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(8, 8); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(8, 10); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(0, 42); + + const centralBuf = Buffer.concat([central, nameBuf]); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16); + + return Buffer.concat([local, nameBuf, compressed, centralBuf, eocd]); +} + +function sha256Hex(data: Buffer): string { + return createHash('sha256').update(data).digest('hex'); +} + +interface MockCdnOptions { + readonly version?: string; + readonly zip: Buffer; + readonly checksum?: string; +} + +function mockCdnFetch(options: MockCdnOptions): typeof fetch { + const version = options.version ?? VERSION; + const manifestBody = JSON.stringify({ + version, + tag: `v${version}`, + platforms: { + 'linux-x64': { + filename: 'kimi-code-linux-x64.zip', + checksum: options.checksum ?? sha256Hex(options.zip), + }, + }, + }); + return vi.fn(async (input: string | URL) => { + const url = String(input); + if (url === nativeManifestUrl(version)) { + return { ok: true, status: 200, text: async () => manifestBody, body: null }; + } + if (url === nativeBinaryUrl(version, 'kimi-code-linux-x64.zip')) { + return { ok: true, status: 200, text: async () => '', body: [options.zip] }; + } + return { ok: false, status: 404, text: async () => '', body: null }; + }) as unknown as typeof fetch; +} + +describe('stageNativeUpdate', () => { + let workDir: string; + let exePath: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'kimi-stage-test-')); + exePath = join(workDir, 'bin', 'kimi'); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('downloads, verifies, unpacks and records the staged metadata', async () => { + const zip = buildZip('kimi', PAYLOAD); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip }), + }); + + expect(result.status).toBe('staged'); + expect(result.staged).toMatchObject({ + version: VERSION, + target: 'linux-x64', + exeFileName: `kimi-${VERSION}`, + sha256: sha256Hex(zip), + exeSize: PAYLOAD.length, + }); + + const stagedOnDisk = await readStagedNativeUpdate(exePath); + expect(stagedOnDisk).toEqual(result.staged); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + // The zip intermediate is gone once unpacking succeeded. + await expect(stat(join(getNativeStagingDir(exePath), 'kimi-code-linux-x64.zip.part'))).rejects.toThrow(); + }); + + it('short-circuits when the same version is already staged', async () => { + const zip = buildZip('kimi', PAYLOAD); + const firstFetch = mockCdnFetch({ zip }); + await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', fetchImpl: firstFetch }); + + const secondFetch = mockCdnFetch({ zip }); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: secondFetch, + }); + + expect(result.status).toBe('already-staged'); + expect(secondFetch).not.toHaveBeenCalled(); + }); + + it('re-stages when the staged exe went missing', async () => { + const zip = buildZip('kimi', PAYLOAD); + const first = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip }), + }); + // The metadata stays but the exe is deleted → not trustworthy, re-stage. + await rm(stagedExePath(exePath, first.staged)); + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + + const second = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip }), + }); + expect(second.status).toBe('staged'); + }); + + it('throws on a checksum mismatch and cleans up leftovers', async () => { + const zip = buildZip('kimi', PAYLOAD); + await expect( + stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip, checksum: 'f'.repeat(64) }), + }), + ).rejects.toThrow(/sha256 mismatch/); + + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + }); + + it('throws when the platform is missing from the manifest', async () => { + const zip = buildZip('kimi', PAYLOAD); + await expect( + stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'win32', + arch: 'arm64', + fetchImpl: mockCdnFetch({ zip }), + }), + ).rejects.toThrow(/win32-arm64 not found/); + }); + + it('supersedes a staged older version', async () => { + const oldZip = buildZip('kimi', Buffer.from('old-payload')); + await stageNativeUpdate({ + version: '0.6.0', + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ version: '0.6.0', zip: oldZip }), + }); + + const newZip = buildZip('kimi', PAYLOAD); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip: newZip }), + }); + + expect(result.status).toBe('staged'); + expect(result.staged.version).toBe(VERSION); + await expect( + stat(join(getNativeStagingDir(exePath), 'kimi-0.6.0')), + ).rejects.toThrow(); + }); +}); + +describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { + let workDir: string; + let exePath: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'kimi-staged-read-test-')); + exePath = join(workDir, 'bin', 'kimi'); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('returns null for malformed staged.json content', async () => { + const stagingDir = getNativeStagingDir(exePath); + await rm(stagingDir, { recursive: true, force: true }); + const { mkdir } = await import('node:fs/promises'); + await mkdir(stagingDir, { recursive: true }); + await writeFile(getNativeStagedStateFile(exePath), '{not json', 'utf-8'); + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + }); + + it('returns null when the exe size drifted from the metadata', async () => { + const zip = buildZip('kimi', PAYLOAD); + const { staged } = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip }), + }); + await writeFile(stagedExePath(exePath, staged), Buffer.alloc(PAYLOAD.length + 1)); + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + }); + + it('removes staged artifacts', async () => { + const zip = buildZip('kimi', PAYLOAD); + await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ zip }), + }); + await removeStagedNativeUpdate(exePath); + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); + }); +}); diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts new file mode 100644 index 0000000000..37294e1ccc --- /dev/null +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -0,0 +1,258 @@ +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readUpdateInstallState } from '#/cli/update/install-state'; +import { stagedExeFileName } from '#/cli/update/native-stage'; +import { + maybeRelaunchWithStagedNativeUpdate, + type NativeSwapDeps, +} from '#/cli/update/native-swap'; +import { KIMI_CODE_UPDATE_REEXEC_ENV } from '#/constant/app'; +import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; + +const CURRENT_VERSION = '0.6.0'; +const STAGED_VERSION = '0.7.0'; +const STAGED_EXE_SIZE = 42; + +interface FakeChildHandlers { + readonly onEvent: (event: 'error' | 'exit' | 'close', cb: (...args: unknown[]) => void) => void; + readonly child: unknown; +} + +function fakeChild(options: { + readonly code?: number; + readonly stdout?: string; + readonly error?: Error; +}): FakeChildHandlers { + const listeners = new Map void>(); + const stdoutChunks: string[] = []; + const stdoutListeners: Array<(chunk: Buffer) => void> = []; + const child = { + once(event: string, cb: (...args: unknown[]) => void) { + listeners.set(event, cb); + }, + stdout: { + on(_event: 'data', cb: (chunk: Buffer) => void) { + stdoutListeners.push(cb); + }, + }, + kill: vi.fn(), + }; + queueMicrotask(() => { + if (options.error !== undefined) { + listeners.get('error')?.(options.error); + return; + } + if (options.stdout !== undefined) { + for (const cb of stdoutListeners) cb(Buffer.from(options.stdout)); + } + // The smoke check listens on 'close', the re-exec waiter on 'exit'. + listeners.get('close')?.(options.code ?? 0, null); + listeners.get('exit')?.(options.code ?? 0, null); + }); + void stdoutChunks; + return { onEvent: () => {}, child }; +} + +interface SpawnCall { + readonly cmd: string; + readonly args: readonly string[]; + readonly options: Record; +} + +function createSpawnMock(routes: { + readonly smokeCode?: number; + readonly smokeStdout?: string; + readonly reexecCode?: number; + readonly reexecError?: Error; +}): { readonly calls: SpawnCall[]; readonly spawnImpl: NativeSwapDeps['spawnImpl'] } { + const calls: SpawnCall[] = []; + const spawnImpl = ((cmd: string, args: readonly string[], options: Record) => { + calls.push({ cmd, args, options }); + if (args[0] === '--version') { + return fakeChild({ + code: routes.smokeCode ?? 0, + stdout: routes.smokeStdout ?? `${STAGED_VERSION}\n`, + }).child; + } + return fakeChild({ code: routes.reexecCode ?? 0, error: routes.reexecError }).child; + }) as unknown as NativeSwapDeps['spawnImpl']; + return { calls, spawnImpl }; +} + +async function seedStagedUpdate(exePath: string, version: string): Promise { + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + await writeFile( + join(stagingDir, stagedExeFileName(version, 'linux')), + Buffer.alloc(STAGED_EXE_SIZE, 1), + ); + await writeFile( + getNativeStagedStateFile(exePath), + `${JSON.stringify({ + version, + target: 'linux-x64', + exeFileName: stagedExeFileName(version, 'linux'), + sha256: 'a'.repeat(64), + exeSize: STAGED_EXE_SIZE, + stagedAt: new Date().toISOString(), + }, null, 2)}\n`, + 'utf-8', + ); +} + +function makeDeps( + exePath: string, + overrides: Partial & { readonly spawnImpl: NativeSwapDeps['spawnImpl'] }, +): NativeSwapDeps { + return { + exePath, + argv: ['node', exePath, '--flag', 'value'], + env: { PATH: '/usr/bin' }, + currentVersion: CURRENT_VERSION, + isNative: true, + exitImpl: vi.fn(), + ...overrides, + }; +} + +describe('maybeRelaunchWithStagedNativeUpdate', () => { + let workDir: string; + let exePath: string; + let homeDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'kimi-swap-test-')); + homeDir = join(workDir, 'home'); + exePath = join(workDir, 'bin', 'kimi'); + await mkdir(join(workDir, 'bin'), { recursive: true }); + await writeFile(exePath, 'old-binary'); + vi.stubEnv('KIMI_CODE_HOME', homeDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(workDir, { recursive: true, force: true }); + }); + + it('does nothing when the re-exec guard env is set', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl, env: { [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' } }), + ); + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // Staged files untouched for the "real" next launch. + await expect(stat(getNativeStagedStateFile(exePath))).resolves.toBeDefined(); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + + it('does nothing when not running as a native binary', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl, isNative: false }), + ); + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + + it('does nothing when nothing is staged', async () => { + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + }); + + it('discards a staged update that is not newer than the running version', async () => { + await seedStagedUpdate(exePath, CURRENT_VERSION); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + // Staged artifacts are gone, so future launches do not retry the discard. + await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + }); + + it('discards staged metadata whose exe is missing', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + await rm(join(getNativeStagingDir(exePath), stagedExeFileName(STAGED_VERSION, 'linux'))); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + + it('swaps in the staged exe, re-execs with the original argv and forwards the exit code', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({ reexecCode: 3 }); + const exitImpl = vi.fn(); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl, exitImpl }), + ); + + expect(relaunched).toBe(true); + // Smoke check + re-exec. + expect(calls).toHaveLength(2); + expect(calls[0]?.args).toEqual(['--version']); + expect(calls[1]?.cmd).toBe(exePath); + expect(calls[1]?.args).toEqual(['--flag', 'value']); + expect((calls[1]?.options['env'] as Record)[KIMI_CODE_UPDATE_REEXEC_ENV]).toBe('1'); + expect(calls[1]?.options['stdio']).toBe('inherit'); + expect(exitImpl).toHaveBeenCalledWith(3); + + // The exe was replaced with the staged payload; backup and staging are gone. + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + await expect(stat(`${exePath}.bak`)).rejects.toThrow(); + await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); + }); + + it('rolls back when the smoke check fails and records an install failure', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({ smokeCode: 1 }); + const exitImpl = vi.fn(); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl, exitImpl }), + ); + + expect(relaunched).toBe(false); + expect(exitImpl).not.toHaveBeenCalled(); + expect(calls).toHaveLength(1); // smoke only, no re-exec + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + + const state = await readUpdateInstallState(); + expect(state.lastFailure).toMatchObject({ version: STAGED_VERSION, attempts: 1 }); + }); + + it('rolls back when the smoke output does not contain the staged version', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { spawnImpl } = createSpawnMock({ smokeStdout: '0.0.0-bogus\n' }); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + expect(relaunched).toBe(false); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + + it('continues startup with the old in-memory code when the re-exec spawn fails', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { spawnImpl } = createSpawnMock({ reexecError: new Error('spawn EACCES') }); + const exitImpl = vi.fn(); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl, exitImpl }), + ); + expect(relaunched).toBe(false); + expect(exitImpl).not.toHaveBeenCalled(); + // The binary on disk is already the new version; the next launch picks it up. + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 3382d622eb..90d19131cd 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -1,5 +1,4 @@ import type * as ChildProcess from 'node:child_process'; -import { spawnSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -10,7 +9,7 @@ import { readUpdateInstallState, writeUpdateInstallState, } from '#/cli/update/install-state'; -import { runUpdatePreflight, spawnForSource } from '#/cli/update/preflight'; +import { runUpdatePreflight } from '#/cli/update/preflight'; import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; @@ -500,7 +499,7 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { + it('native: self-spawns the staged downloader sub-command', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -510,35 +509,38 @@ describe('runUpdatePreflight', () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin' }); try { - const { options } = captureOutput(); - await runUpdatePreflight('0.4.0', options); - const call = mocks.spawn.mock.calls[0]; - expect(call?.[0]).toBe('bash'); - expect(call?.[2]).toEqual({ stdio: 'inherit' }); - const [flag, script] = call?.[1] as string[]; - expect(flag).toBe('-c'); - // pipefail must come before the pipeline so a failed `curl` is not masked - // by the trailing `bash` exiting 0 (see "surfaces a failed curl" below). - expect(script).toContain('set -o pipefail'); - expect(script).toContain('curl -fsSL https://code.kimi.com/kimi-code/install.sh'); - expect(script).toContain('| bash'); + const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + ['__update_download', '0.5.0'], + expect.objectContaining({ stdio: 'inherit' }), + ); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } }); - it('native on win32: prints manual powershell command, does not spawn', async () => { + it('native on win32: auto-installs via the staged downloader sub-command', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('native'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32' }); try { const { stdout, options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(stdout.join('')).toContain('irm https://code.kimi.com/kimi-code/install.ps1 | iex'); - expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + ['__update_download', '0.5.0'], + expect.objectContaining({ stdio: 'inherit' }), + ); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + expect(stdout.join('')).not.toContain('Auto-update is not supported'); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } @@ -1179,23 +1181,3 @@ describe('runUpdatePreflight', () => { }); }); }); - -describe('spawnForSource native', () => { - // No spawn mock here — we run real bash to prove the failure contract - // end-to-end. `curl … | bash` reports only the trailing bash's exit status, - // so a curl that never connects (exit 7, empty stdin → bash exits 0) is - // masked and the update is wrongly reported as successful. `set -o pipefail` - // makes the pipeline surface curl's failure. Shadowing `curl` with a shell - // function keeps this offline and deterministic; skipped on Windows (no bash, - // and native auto-install is unsupported there anyway). - it.skipIf(process.platform === 'win32')( - 'surfaces a failed curl download as a non-zero exit', - () => { - const { cmd, args } = spawnForSource('native', '0.5.0', 'darwin'); - const script = `curl() { return 7; }\n${args[1] ?? ''}`; - const result = spawnSync(cmd, [args[0] ?? '-c', script], { encoding: 'utf8' }); - expect(result.error).toBeUndefined(); - expect(result.status).toBeGreaterThan(0); - }, - ); -}); diff --git a/apps/kimi-code/test/cli/update/unzip.test.ts b/apps/kimi-code/test/cli/update/unzip.test.ts new file mode 100644 index 0000000000..b19586af76 --- /dev/null +++ b/apps/kimi-code/test/cli/update/unzip.test.ts @@ -0,0 +1,142 @@ +import { crc32, deflateRawSync } from 'node:zlib'; + +import { describe, expect, it } from 'vitest'; + +import { unzipFirstFile } from '#/cli/update/unzip'; + +interface TestEntry { + readonly name: string; + readonly data: Buffer; + readonly method: 0 | 8; +} + +/** Build a minimal well-formed zip in memory (store/deflate, no ZIP64). */ +function buildZip(entries: readonly TestEntry[]): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf-8'); + const compressed = + entry.method === 8 ? deflateRawSync(entry.data) : Buffer.from(entry.data); + const crc = crc32(entry.data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(entry.method, 8); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(entry.data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); // extra length + locals.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); // flags + central.writeUInt16LE(entry.method, 10); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(entry.data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push(central, Buffer.from(name)); + + offset += 30 + name.length + compressed.length; + } + + const centralStart = offset; + const centralBuf = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(centralStart, 16); + + return Buffer.concat([...locals, centralBuf, eocd]); +} + +describe('unzipFirstFile', () => { + it('extracts a stored entry', () => { + const zip = buildZip([{ name: 'kimi', data: Buffer.from('hello store'), method: 0 }]); + expect(unzipFirstFile(zip)).toEqual({ name: 'kimi', data: Buffer.from('hello store') }); + }); + + it('extracts a deflated entry', () => { + const payload = Buffer.alloc(64 * 1024, 7); + const zip = buildZip([{ name: 'kimi.exe', data: payload, method: 8 }]); + const out = unzipFirstFile(zip); + expect(out.name).toBe('kimi.exe'); + expect(out.data.equals(payload)).toBe(true); + }); + + it('skips directory entries and returns the first file', () => { + const zip = buildZip([ + { name: 'bin/', data: Buffer.alloc(0), method: 0 }, + { name: 'bin/kimi', data: Buffer.from('x'), method: 0 }, + ]); + expect(unzipFirstFile(zip).name).toBe('bin/kimi'); + }); + + it('rejects a buffer without an EOCD record', () => { + expect(() => unzipFirstFile(Buffer.from('not a zip'))).toThrow( + /end of central directory not found/, + ); + }); + + it('rejects an unsupported compression method', () => { + const zip = buildZip([{ name: 'kimi', data: Buffer.from('data'), method: 0 }]); + // Central directory starts at local header (30) + name (4) + data (4) = 38; + // its method field sits at +10. Patch it to 9 (unsupported). + zip.writeUInt16LE(9, 38 + 10); + expect(() => unzipFirstFile(zip)).toThrow(/compression method 9/); + }); + + it('rejects when the central directory claims an implausible entry count', () => { + const zip = buildZip([{ name: 'kimi', data: Buffer.from('data'), method: 0 }]); + const eocdOffset = zip.length - 22; + zip.writeUInt16LE(17, eocdOffset + 10); // beyond MAX_ENTRIES + expect(() => unzipFirstFile(zip)).toThrow(/unexpected entry count/); + }); + + it('rejects a corrupt payload (crc32 mismatch)', () => { + const zip = buildZip([{ name: 'kimi', data: Buffer.from('original payload'), method: 0 }]); + // Flip a byte inside the stored data (right after the 30-byte local header + name). + const dataOffset = 30 + 4 + 1; + zip.writeUInt8(zip.readUInt8(dataOffset) ^ 0xff, dataOffset); + expect(() => unzipFirstFile(zip)).toThrow(/crc32 mismatch/); + }); + + it('rejects a corrupt deflated payload', () => { + const payload = Buffer.alloc(4096, 1); + const zip = buildZip([{ name: 'kimi', data: payload, method: 8 }]); + // Corrupt one byte of the deflate stream. + const dataOffset = 30 + 4 + 10; + zip.writeUInt8(zip.readUInt8(dataOffset) ^ 0xff, dataOffset); + expect(() => unzipFirstFile(zip)).toThrow(); + }); + + it('rejects entries above the uncompressed size cap', () => { + const zip = buildZip([{ name: 'kimi', data: Buffer.from('payload'), method: 0 }]); + expect(() => unzipFirstFile(zip, { maxUncompressedSize: 4 })).toThrow(/entry too large/); + }); + + it('rejects ZIP64 markers', () => { + const zip = buildZip([{ name: 'kimi', data: Buffer.from('payload'), method: 0 }]); + // Patch the central entry's uncompressed size to the ZIP64 sentinel. + // Layout: local header (30) + name (4) + data (7) → central starts at 41; size field at +24. + zip.writeUInt32LE(0xffffffff, 41 + 24); + expect(() => unzipFirstFile(zip)).toThrow(/ZIP64/); + }); + + it('rejects an archive with no file entries', () => { + const zip = buildZip([{ name: 'bin/', data: Buffer.alloc(0), method: 0 }]); + expect(() => unzipFirstFile(zip)).toThrow(/no file entry/); + }); +}); From 965e94b5621c0e1ad0fcaba66cf36d8abf65056a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 15:03:42 +0800 Subject: [PATCH 02/42] fix(kimi-code): fully clean staged artifacts on swap discard paths Real-binary smoke testing on macOS surfaced two cleanup gaps in the discard path: the claimed metadata file was unlinked after the staging dir rmdir (so the empty dir survived), and the staged exe was rediscovered via the already-claimed staged.json (so it leaked on the downgrade-guard path). Pass the known metadata through and order the unlink before the rmdir. --- apps/kimi-code/src/cli/update/native-stage.ts | 10 ++++++++-- apps/kimi-code/src/cli/update/native-swap.ts | 6 +++++- apps/kimi-code/test/cli/update/native-swap.test.ts | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index d7023a1ce0..08017dd43c 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -77,9 +77,15 @@ export async function readStagedNativeUpdate( } /** Remove staged.json + the staged exe; used on downgrade-guard discards and swap failures. */ -export async function removeStagedNativeUpdate(exePath: string): Promise { +export async function removeStagedNativeUpdate( + exePath: string, + knownStaged?: StagedNativeUpdate, +): Promise { const stagingDir = getNativeStagingDir(exePath); - const staged = await readStagedNativeUpdate(exePath).catch(() => null); + // The swap flow claims staged.json by renaming it away first, so callers + // there must pass the already-read metadata — discovering it from the + // (now missing) state file would find nothing and leak the staged exe. + const staged = knownStaged ?? (await readStagedNativeUpdate(exePath).catch(() => null)); if (staged !== null) { await rm(stagedExePath(exePath, staged), { force: true }).catch(() => {}); } diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index c1748a7c15..2d2e7d6986 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -229,8 +229,12 @@ export async function maybeRelaunchWithStagedNativeUpdate( const spawnImpl = deps.spawnImpl ?? spawn; const discard = async (): Promise => { - await removeStagedNativeUpdate(deps.exePath); + // claimedPath lives inside `.staging/` — remove it first so the + // best-effort rmdir in removeStagedNativeUpdate can actually succeed. + // The staged metadata must be passed along: claiming already renamed the + // state file away, so rediscovery would find nothing and leak the exe. await unlink(claimedPath).catch(() => {}); + await removeStagedNativeUpdate(deps.exePath, staged); return false; }; diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 37294e1ccc..11b1602a20 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -178,6 +178,7 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); // Staged artifacts are gone, so future launches do not retry the discard. await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); }); it('discards staged metadata whose exe is missing', async () => { @@ -229,6 +230,7 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(calls).toHaveLength(1); // smoke only, no re-exec expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); const state = await readUpdateInstallState(); expect(state.lastFailure).toMatchObject({ version: STAGED_VERSION, attempts: 1 }); From 6a21aed7e018eee5e6ede6f978fbbb29482da54c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 17:00:17 +0800 Subject: [PATCH 03/42] fix(kimi-code): restore staged metadata on swap failure and sweep update leftovers at startup --- apps/kimi-code/src/cli/update/native-swap.ts | 125 +++++++++++++++--- .../test/cli/update/native-swap.test.ts | 104 ++++++++++++++- 2 files changed, 209 insertions(+), 20 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 2d2e7d6986..1e53f2dda8 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -11,18 +11,23 @@ * unaffected), `rename staged→exe`, then delete `.bak` (best effort — a * concurrent old instance keeps it locked until it exits). This is the same * mechanism install.ps1 already relies on, and the Squirrel/NSIS-style - * "next launch performs the swap" pattern. + * "next launch performs the swap" pattern. Leftovers a swap cannot remove + * (its own `.bak` while still running, crash residue in `.staging/`) are + * swept best-effort on every launch. */ import { spawn } from 'node:child_process'; -import { readdir, rename, rmdir, unlink } from 'node:fs/promises'; +import { readdir, readFile, rename, rmdir, stat, unlink } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { gt } from 'semver'; import { log } from '@moonshot-ai/kimi-code-sdk'; -import { KIMI_CODE_UPDATE_REEXEC_ENV } from '#/constant/app'; +import { + KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME, + KIMI_CODE_UPDATE_REEXEC_ENV, +} from '#/constant/app'; import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; import { @@ -53,6 +58,23 @@ function isTruthy(value: string | undefined): boolean { return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); } +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' + ); +} + +/** + * A `staged.json.swap-` claim file younger than this marks a swap in + * progress in another instance; older ones are crash residue. The bound + * comfortably exceeds the slowest swap (smoke-check timeout included). + */ +const SWAP_CLAIM_STALE_MS = 5 * 60 * 1000; + +// First launch of a fresh ~150 MB unsigned exe can sit in an antivirus scan; +// give Windows extra headroom so a slow scan is not misread as a broken binary. +const SMOKE_CHECK_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 15_000; + function logSwap(message: string, payload: Record): void { try { log.info(`native update swap: ${message}`, payload); @@ -109,7 +131,7 @@ function smokeCheck( // Already gone. } finish(false); - }, 15_000); + }, SMOKE_CHECK_TIMEOUT_MS); child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf-8'); }); @@ -160,7 +182,7 @@ async function rollback(bakPath: string, exePath: string): Promise { * A `.bak` still mapped by a running old instance cannot be deleted on * Windows — it is simply left for a later launch. */ -async function cleanupBackups(exePath: string, keepPath: string): Promise { +async function cleanupBackups(exePath: string, keepPath?: string): Promise { const dir = dirname(exePath); const base = basename(exePath); let entries: string[]; @@ -177,6 +199,67 @@ async function cleanupBackups(exePath: string, keepPath: string): Promise } } +/** + * Prune `staged.json.swap-` claim files left by instances that died + * mid-swap, together with the staged exe they reference (re-downloaded on the + * next update cycle if still wanted). Returns true when a FRESH claim file + * was seen — i.e. another instance is swapping right now. + */ +async function cleanupStaleSwapClaims(exePath: string): Promise { + const stagingDir = getNativeStagingDir(exePath); + let entries: string[]; + try { + entries = await readdir(stagingDir); + } catch { + return false; + } + let swapInProgress = false; + for (const entry of entries) { + if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; + const full = join(stagingDir, entry); + const info = await stat(full).catch(() => null); + if (info === null) continue; + if (Date.now() - info.mtimeMs < SWAP_CLAIM_STALE_MS) { + swapInProgress = true; + continue; + } + const raw = await readFile(full, 'utf-8').catch(() => null); + if (raw !== null) { + try { + const exeFileName: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName; + if (typeof exeFileName === 'string' && exeFileName.length > 0) { + // basename(): the metadata contract is a plain file name — never + // let a hand-crafted path escape the staging dir. + await unlink(join(stagingDir, basename(exeFileName))).catch(() => {}); + } + } catch { + // Unparseable claim file — remove it anyway. + } + } + await unlink(full).catch(() => {}); + } + return swapInProgress; +} + +/** + * Best-effort startup hygiene for update leftovers, run on every native + * launch. The swap itself can never fully clean up after its own run — the + * old process still holds its renamed image (`.bak`) on Windows — so later + * launches sweep what the previous run could not. + */ +async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise { + try { + if (await cleanupStaleSwapClaims(exePath)) { + // Another instance is mid-swap: leave every artifact alone — the `.bak` + // next to the exe is its rollback source. + return; + } + await cleanupBackups(exePath); + } catch { + // Hygiene must never affect startup. + } +} + /** * Re-exec the (newly swapped) exe with the original argv, forwarding its exit * code so the swap is invisible to the caller. Returns false when the spawn @@ -220,8 +303,14 @@ function reexec( export async function maybeRelaunchWithStagedNativeUpdate( deps: NativeSwapDeps, ): Promise { - if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) return false; if (!deps.isNative) return false; + await sweepStaleNativeUpdateArtifacts(deps.exePath); + if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) { + // Read-once guard: drop it so this session's children (and any nested + // kimi launches from them) do not inherit the swap skip. + delete deps.env[KIMI_CODE_UPDATE_REEXEC_ENV]; + return false; + } const claimed = await claimStagedUpdate(deps.exePath); if (claimed === null) return false; @@ -253,22 +342,24 @@ export async function maybeRelaunchWithStagedNativeUpdate( // 1. Pick a backup slot and move the running exe aside (rename of a running // exe is legal on Windows and POSIX alike; overwriting is not). let bakPath = `${deps.exePath}.bak`; - const oldBakCleared = await unlink(bakPath) - .then(() => true) - .catch(() => false); - if (!oldBakCleared) { - // The leftover `.bak` is locked by a still-running old instance (or - // undeletable for another reason) — take a unique backup name, the same - // fallback install.ps1 uses. It is best-effort cleaned up on later runs. - bakPath = `${deps.exePath}.${process.pid}.bak`; + try { + await unlink(bakPath); + } catch (error) { + if (!isNotFound(error)) { + // The leftover `.bak` is locked by a still-running old instance (or + // undeletable for another reason) — take a unique backup name, the same + // fallback install.ps1 uses. It is best-effort cleaned up on later runs. + bakPath = `${deps.exePath}.${process.pid}.bak`; + } } try { await rename(deps.exePath, bakPath); } catch (error) { - // Nothing was moved: startup continues with the old exe. Keep the staged - // files so a later launch can retry (transient locks clear on reboot). + // Nothing was moved: startup continues with the old exe. Restore the + // claimed metadata so a later launch retries the swap (transient locks + // clear on reboot) instead of silently dropping the staged update. logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); - await unlink(claimedPath).catch(() => {}); + await rename(claimedPath, getNativeStagedStateFile(deps.exePath)).catch(() => {}); return false; } diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 11b1602a20..c6608cc452 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -1,11 +1,11 @@ -import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readUpdateInstallState } from '#/cli/update/install-state'; -import { stagedExeFileName } from '#/cli/update/native-stage'; +import { readStagedNativeUpdate, stagedExeFileName } from '#/cli/update/native-stage'; import { maybeRelaunchWithStagedNativeUpdate, type NativeSwapDeps, @@ -141,11 +141,14 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { it('does nothing when the re-exec guard env is set', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); const { calls, spawnImpl } = createSpawnMock({}); + const env = { [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' }; const relaunched = await maybeRelaunchWithStagedNativeUpdate( - makeDeps(exePath, { spawnImpl, env: { [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' } }), + makeDeps(exePath, { spawnImpl, env }), ); expect(relaunched).toBe(false); expect(calls).toHaveLength(0); + // Read-once: the guard is dropped so children of this session do not inherit it. + expect(env[KIMI_CODE_UPDATE_REEXEC_ENV]).toBeUndefined(); // Staged files untouched for the "real" next launch. await expect(stat(getNativeStagedStateFile(exePath))).resolves.toBeDefined(); expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); @@ -257,4 +260,99 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { const newExe = await readFile(exePath); expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); }); + + it('restores the staged metadata when the exe cannot be moved aside', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // rename(exe → bak) fails when the in-service exe is gone. + await rm(exePath); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // The staged update is restored, not dropped: a later launch retries the swap. + const restored = await readStagedNativeUpdate(exePath); + expect(restored).toMatchObject({ version: STAGED_VERSION }); + await expect( + stat(join(getNativeStagingDir(exePath), stagedExeFileName(STAGED_VERSION, 'linux'))), + ).resolves.toBeDefined(); + }); + + it('falls back to a pid-named backup when the plain .bak cannot be removed', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // A directory at `${exePath}.bak` cannot be removed via unlink → pid fallback. + await mkdir(`${exePath}.bak`); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(true); + expect(calls).toHaveLength(2); + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + // The pid-named backup was cleaned after the swap; the directory is untouched. + const names = await readdir(join(workDir, 'bin')); + expect(names.toSorted()).toEqual(['kimi', 'kimi.bak']); + expect((await stat(`${exePath}.bak`)).isDirectory()).toBe(true); + }); + + it('sweeps stale backups from earlier swaps on startup', async () => { + await writeFile(`${exePath}.bak`, 'stale-backup'); + await writeFile(`${exePath}.12345.bak`, 'stale-backup'); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + await expect(stat(`${exePath}.bak`)).rejects.toThrow(); + await expect(stat(`${exePath}.12345.bak`)).rejects.toThrow(); + }); + + it('leaves every artifact alone while another instance holds a fresh swap claim', async () => { + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + const claimPath = join(stagingDir, 'staged.json.swap-4242'); + await writeFile(claimPath, '{}\n', 'utf-8'); + await writeFile(`${exePath}.bak`, 'in-use-backup'); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // A mid-swap instance owns these: nothing is touched. + await expect(stat(claimPath)).resolves.toBeDefined(); + await expect(stat(`${exePath}.bak`)).resolves.toBeDefined(); + }); + + it('cleans up stale swap claims and their orphaned staged exe', async () => { + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + const exeFileName = stagedExeFileName(STAGED_VERSION, 'linux'); + const orphanedExe = join(stagingDir, exeFileName); + await writeFile(orphanedExe, Buffer.alloc(STAGED_EXE_SIZE, 1)); + const claimPath = join(stagingDir, 'staged.json.swap-4242'); + await writeFile( + claimPath, + `${JSON.stringify({ + version: STAGED_VERSION, + target: 'linux-x64', + exeFileName, + sha256: 'a'.repeat(64), + exeSize: STAGED_EXE_SIZE, + stagedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + }, null, 2)}\n`, + 'utf-8', + ); + // Crash residue: the claim is older than the stale window. + const past = new Date(Date.now() - 10 * 60 * 1000); + await utimes(claimPath, past, past); + + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + await expect(stat(claimPath)).rejects.toThrow(); + await expect(stat(orphanedExe)).rejects.toThrow(); + }); }); From e8284b6ab941ec6bf61cbc4fbca3ff5806ff2888 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 17:37:36 +0800 Subject: [PATCH 04/42] fix(kimi-code): address codex review on lock contention and swap crash window - The background native install no longer takes the outer install lock: the self-spawned downloader holds it for the whole download, and the parent's spawn-time lock raced the child into a false lastSuccess. - Smoke-check the staged exe before moving anything, so a bad staged binary is discarded with the install path never left empty; the remaining crash window is two adjacent atomic renames (documented, recoverable via the .bak or by re-running the install script). --- apps/kimi-code/src/cli/update/native-swap.ts | 28 +++++++++++--------- apps/kimi-code/src/cli/update/preflight.ts | 9 ++++++- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 2d2e7d6986..636d26b31e 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -250,8 +250,22 @@ export async function maybeRelaunchWithStagedNativeUpdate( const stagedExe = stagedExePath(deps.exePath, staged); - // 1. Pick a backup slot and move the running exe aside (rename of a running + // 1. Smoke-check the staged exe BEFORE touching the install path: a staged + // binary that cannot start (or lies about its version) is discarded with + // the running exe never moved — the safest possible failure shape. + if (!(await smokeCheck(stagedExe, staged, spawnImpl))) { + logSwap('smoke check failed, discarding staged update', { version: staged.version }); + await recordSwapFailure(staged.version); + return discard(); + } + + // 2. Pick a backup slot and move the running exe aside (rename of a running // exe is legal on Windows and POSIX alike; overwriting is not). + // + // Crash window: if the process dies between this rename and step 3, the + // install path is left empty and no CLI code can run to self-heal. Each + // rename is atomic, the window is two adjacent syscalls, and recovery is + // `mv .bak ` or re-running the install script. let bakPath = `${deps.exePath}.bak`; const oldBakCleared = await unlink(bakPath) .then(() => true) @@ -272,7 +286,7 @@ export async function maybeRelaunchWithStagedNativeUpdate( return false; } - // 2. Move the staged exe into place; roll back on failure. + // 3. Move the staged exe into place; roll back on failure. if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) { logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath }); await rollback(bakPath, deps.exePath); @@ -280,16 +294,6 @@ export async function maybeRelaunchWithStagedNativeUpdate( return discard(); } - // 3. Smoke-check the new exe; roll back when it cannot start or lies about - // its version. - if (!(await smokeCheck(deps.exePath, staged, spawnImpl))) { - logSwap('smoke check failed, rolling back', { version: staged.version }); - await unlink(deps.exePath).catch(() => {}); - await rollback(bakPath, deps.exePath); - await recordSwapFailure(staged.version); - return discard(); - } - // 4. Success: clean up and re-exec into the new binary. await unlink(claimedPath).catch(() => {}); await unlink(bakPath).catch(() => {}); diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index f3b028fb2b..0d5d8ae819 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -569,7 +569,14 @@ async function startBackgroundInstall( logger: UpdateLogger, rolloutTelemetry: RolloutTelemetry, ): Promise { - const lock = await tryAcquireUpdateInstallLock({ version: target.version }); + // The native self-spawned downloader holds the install lock itself for the + // whole download — taking it here too would race the child (it starts before + // this function's finally releases) into a false success. Package-manager + // installs keep the outer lock, which only guards against duplicate spawns. + const lock = + source === 'native' + ? { filePath: '', release: async (): Promise => {} } + : await tryAcquireUpdateInstallLock({ version: target.version }); if (lock === null) return; try { From 993dafa726154851f9b26471c01bdca19b7a41fa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 17:41:14 +0800 Subject: [PATCH 05/42] test(kimi-code): align swap test expectation with smoke-before-rename order The restore-on-failure case now observes the early smoke check's --version spawn; only the re-exec spawn must be absent. --- apps/kimi-code/test/cli/update/native-swap.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index c6608cc452..c99f6d93ae 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -269,7 +269,9 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); expect(relaunched).toBe(false); - expect(calls).toHaveLength(0); + // The smoke check runs before anything is moved; only the re-exec is absent. + expect(calls).toHaveLength(1); + expect(calls[0]?.args).toEqual(['--version']); // The staged update is restored, not dropped: a later launch retries the swap. const restored = await readStagedNativeUpdate(exePath); expect(restored).toMatchObject({ version: STAGED_VERSION }); From bec250b2974223f80b1a94f6cb9ef3d6a60ec5b6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 18:08:06 +0800 Subject: [PATCH 06/42] fix(kimi-code): stage the bare CDN binary instead of unzipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published per-release artifacts are the bare platform binaries (kimi-code-[.exe]), not zip archives — the staging flow now streams the download straight to the staged exe after the manifest sha256 check, and the zip reader is dropped. Verified end-to-end on macOS against the live CDN: download -> sha256 match -> swap -> re-exec into the real released binary. --- .../src/cli/update/native-manifest.ts | 7 +- apps/kimi-code/src/cli/update/native-stage.ts | 34 +++-- apps/kimi-code/src/cli/update/unzip.ts | 128 ---------------- apps/kimi-code/src/constant/app.ts | 3 +- .../test/cli/update/native-stage.test.ts | 115 +++++--------- apps/kimi-code/test/cli/update/unzip.test.ts | 142 ------------------ 6 files changed, 64 insertions(+), 365 deletions(-) delete mode 100644 apps/kimi-code/src/cli/update/unzip.ts delete mode 100644 apps/kimi-code/test/cli/update/unzip.test.ts diff --git a/apps/kimi-code/src/cli/update/native-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts index 077e34999c..1a0d095641 100644 --- a/apps/kimi-code/src/cli/update/native-manifest.ts +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -1,9 +1,10 @@ /** * Per-release native artifact manifest (`/binaries//manifest.json`). * - * Produced by `scripts/native/produce-manifest.mjs` and consumed by the - * install scripts; the staged updater reuses the same file so checksums and - * file names have a single source of truth. + * Published alongside the release and consumed by the install scripts; the + * staged updater reuses the same file so checksums and file names have a + * single source of truth. Entries point at the bare platform binary + * (`kimi-code-[.exe]`), not an archive. */ import { valid } from 'semver'; diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 08017dd43c..bb2da659cb 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -1,15 +1,15 @@ /** - * Native staged update: download + verify + unpack into `/.staging/`, + * Native staged update: download + verify into `/.staging/`, * without touching the running executable. The actual swap happens on the * next startup (see `native-swap.ts`). * - * Trust chain: the zip's sha256 comes from the per-release manifest (served - * over HTTPS), and the unpacked exe is re-checked against the zip entry's - * crc32, so a staged binary is byte-exact what the release pipeline produced. + * The CDN serves the bare platform binary (e.g. `kimi-code-win32-x64.exe`), + * whose sha256 comes from the per-release manifest over HTTPS — a staged + * binary is byte-exact what the release pipeline produced. */ import { createHash } from 'node:crypto'; -import { mkdir, open, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, open, readFile, rename, rm, rmdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { z } from 'zod'; @@ -22,7 +22,6 @@ import { nativeBinaryUrl, selectPlatformEntry, } from './native-manifest'; -import { unzipFirstFile } from './unzip'; const StagedNativeUpdateSchema = z .object({ @@ -30,7 +29,7 @@ const StagedNativeUpdateSchema = z target: z.string().min(1), /** Base name of the staged executable inside `.staging/`. */ exeFileName: z.string().min(1), - /** sha256 of the zip the exe was unpacked from. */ + /** sha256 of the staged binary (the manifest's checksum). */ sha256: z.string().regex(/^[a-f0-9]{64}$/), exeSize: z.number().int().min(1), stagedAt: z.string().min(1), @@ -117,16 +116,18 @@ async function downloadAndHash( partPath: string, expectedSha256: string, fetchImpl: typeof fetch, -): Promise { +): Promise { const response = await fetchImpl(url); if (!response.ok || response.body === null) { throw new Error(`native binary download returned HTTP ${response.status}`); } const hash = createHash('sha256'); + let size = 0; const file = await open(partPath, 'w'); try { for await (const chunk of response.body as AsyncIterable) { hash.update(chunk); + size += chunk.length; await file.write(chunk); } } finally { @@ -136,10 +137,11 @@ async function downloadAndHash( if (digest !== expectedSha256) { throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`); } + return size; } /** - * Download + verify + unpack `version` next to the running executable. + * Download + verify `version` next to the running executable. * * Short-circuits with `already-staged` when the same version is ready on * disk (repeat `kimi upgrade`, or foreground/background overlap). **Throws** @@ -177,24 +179,23 @@ export async function stageNativeUpdate( stagedAt: new Date().toISOString(), }; + const partPath = join(stagingDir, `${exeFileName}.part`); try { const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); const entry = selectPlatformEntry(manifest, platform, arch); - const partPath = join(stagingDir, `${entry.filename}.part`); options.stdout?.write(`Downloading Kimi Code ${options.version} (${target})…\n`); - await downloadAndHash( + const size = await downloadAndHash( nativeBinaryUrl(options.version, entry.filename), partPath, entry.checksum, fetchImpl, ); - options.stdout?.write('Verifying and unpacking…\n'); - const { data } = unzipFirstFile(await readFile(partPath)); - await writeFile(stagedExePath(options.exePath, staged), data, { mode: 0o755 }); - await rm(partPath, { force: true }); + // sha256 matched the manifest: promote the download to the staged exe. + await rename(partPath, stagedExePath(options.exePath, staged)); + await chmod(stagedExePath(options.exePath, staged), 0o755); staged.sha256 = entry.checksum; - staged.exeSize = data.length; + staged.exeSize = size; // Atomic write: staged.json only ever appears complete and consistent. await writeJsonFile( getNativeStagedStateFile(options.exePath), @@ -203,6 +204,7 @@ export async function stageNativeUpdate( ); return { status: 'staged', staged }; } catch (error) { + await rm(partPath, { force: true }).catch(() => {}); await removeStagedNativeUpdate(options.exePath); throw error; } diff --git a/apps/kimi-code/src/cli/update/unzip.ts b/apps/kimi-code/src/cli/update/unzip.ts deleted file mode 100644 index 3a8faab8df..0000000000 --- a/apps/kimi-code/src/cli/update/unzip.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Minimal single-file ZIP extraction for the native staged update. - * - * The release pipeline packages each native binary as a one-entry zip - * (`scripts/native/package.mjs`), so this parser deliberately supports only - * what that producer emits: store/deflate entries, no ZIP64, no encryption. - * Anything outside that envelope is rejected — a corrupt staged binary must - * fail here, before it can replace the running executable. - * - * No third-party dependency:Node >= 20.15 provides `zlib.crc32`. - */ - -import { crc32, inflateRawSync } from 'node:zlib'; - -const EOCD_SIGNATURE = 0x06054b50; -const CENTRAL_ENTRY_SIGNATURE = 0x02014b50; -const LOCAL_HEADER_SIGNATURE = 0x04034b50; - -const METHOD_STORE = 0; -const METHOD_DEFLATE = 8; - -/** A staged exe is a Node SEA binary (~150 MB today); cap far above that. */ -const MAX_ENTRIES = 16; -const MAX_UNCOMPRESSED_SIZE = 1024 * 1024 * 1024; // 1 GiB -/** EOCD record (22 B) + the largest legal comment (64 KiB). */ -const EOCD_SEARCH_WINDOW = 22 + 0xffff; - -const ZIP64_MARKER = 0xffffffff; - -function findEndOfCentralDirectory(buffer: Buffer): number { - const minOffset = Math.max(0, buffer.length - EOCD_SEARCH_WINDOW); - for (let offset = buffer.length - 22; offset >= minOffset; offset--) { - if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) { - return offset; - } - } - throw new Error('invalid zip: end of central directory not found'); -} - -export interface UnzipFirstFileOptions { - /** Drop directory entries while scanning for the first regular file. */ - readonly maxUncompressedSize?: number; -} - -/** - * Extract the first regular file entry. **Throws** on any structural or - * integrity problem (bad signature, ZIP64, unsupported method, multi-entry - * archive, size cap, crc mismatch) — callers must treat this as "the staged - * download is corrupt" and discard it. - */ -export function unzipFirstFile( - buffer: Buffer, - options: UnzipFirstFileOptions = {}, -): { readonly name: string; readonly data: Buffer } { - const maxUncompressedSize = options.maxUncompressedSize ?? MAX_UNCOMPRESSED_SIZE; - const eocdOffset = findEndOfCentralDirectory(buffer); - const entryCount = buffer.readUInt16LE(eocdOffset + 10); - const centralOffset = buffer.readUInt32LE(eocdOffset + 16); - if (entryCount < 1 || entryCount > MAX_ENTRIES) { - throw new Error(`invalid zip: unexpected entry count ${entryCount}`); - } - if (centralOffset >= eocdOffset) { - throw new Error('invalid zip: central directory out of bounds'); - } - - let offset = centralOffset; - for (let index = 0; index < entryCount; index++) { - if (offset + 46 > eocdOffset || buffer.readUInt32LE(offset) !== CENTRAL_ENTRY_SIGNATURE) { - throw new Error('invalid zip: malformed central directory entry'); - } - const method = buffer.readUInt16LE(offset + 10); - const expectedCrc = buffer.readUInt32LE(offset + 16); - const compressedSize = buffer.readUInt32LE(offset + 20); - const uncompressedSize = buffer.readUInt32LE(offset + 24); - const nameLength = buffer.readUInt16LE(offset + 28); - const extraLength = buffer.readUInt16LE(offset + 30); - const commentLength = buffer.readUInt16LE(offset + 32); - const localHeaderOffset = buffer.readUInt32LE(offset + 42); - const name = buffer.toString('utf-8', offset + 46, offset + 46 + nameLength); - offset += 46 + nameLength + extraLength + commentLength; - - // Directory entry. - if (name.endsWith('/')) continue; - - if ( - compressedSize === ZIP64_MARKER || - uncompressedSize === ZIP64_MARKER || - localHeaderOffset === ZIP64_MARKER - ) { - throw new Error('unsupported zip: ZIP64 entries are not handled'); - } - if (method !== METHOD_STORE && method !== METHOD_DEFLATE) { - throw new Error(`unsupported zip: compression method ${method}`); - } - if (uncompressedSize > maxUncompressedSize) { - throw new Error(`unsupported zip: entry too large (${uncompressedSize} bytes)`); - } - if (localHeaderOffset + 30 > centralOffset) { - throw new Error('invalid zip: local header out of bounds'); - } - if (buffer.readUInt32LE(localHeaderOffset) !== LOCAL_HEADER_SIGNATURE) { - throw new Error('invalid zip: malformed local header'); - } - const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26); - const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28); - const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength; - if (dataStart + compressedSize > centralOffset) { - throw new Error('invalid zip: entry data out of bounds'); - } - - const compressed = buffer.subarray(dataStart, dataStart + compressedSize); - const data = method === METHOD_STORE ? Buffer.from(compressed) : inflateRawSync(compressed); - if (data.length !== uncompressedSize) { - throw new Error( - `corrupt zip: size mismatch (expected ${uncompressedSize}, got ${data.length})`, - ); - } - const actualCrc = crc32(data); - if (actualCrc !== expectedCrc) { - throw new Error( - `corrupt zip: crc32 mismatch (expected ${expectedCrc.toString(16)}, got ${actualCrc.toString(16)})`, - ); - } - return { name, data }; - } - - throw new Error('invalid zip: no file entry found'); -} diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 84445aeb0a..d514d029bd 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -91,7 +91,8 @@ export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; // bodies, and the CDN install scripts read it for fresh installs. export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; // Per-release native artifacts: `/binaries//manifest.json` + -// `/binaries//kimi-code-.zip` (same layout install.ps1 uses). +// `/binaries//kimi-code-[.exe]` — the bare platform binary +// (same layout install.ps1 consumes). export const KIMI_CODE_CDN_BINARIES_BASE = `${KIMI_CODE_CDN_BASE}/binaries`; export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; // The marketplace catalog location constants live in the shared diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index d60a0defec..89ad5c6236 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -2,15 +2,10 @@ import { createHash } from 'node:crypto'; import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { crc32, deflateRawSync } from 'node:zlib'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - fetchNativeReleaseManifest, - nativeBinaryUrl, - nativeManifestUrl, -} from '#/cli/update/native-manifest'; +import { nativeBinaryUrl, nativeManifestUrl } from '#/cli/update/native-manifest'; import { readStagedNativeUpdate, removeStagedNativeUpdate, @@ -21,44 +16,8 @@ import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; const VERSION = '0.7.0'; const PAYLOAD = Buffer.from('fake-sea-binary-payload'); - -/** Same minimal zip builder as unzip.test.ts (single deflated entry). */ -function buildZip(name: string, data: Buffer): Buffer { - const nameBuf = Buffer.from(name, 'utf-8'); - const compressed = deflateRawSync(data); - const crc = crc32(data); - - const local = Buffer.alloc(30); - local.writeUInt32LE(0x04034b50, 0); - local.writeUInt16LE(20, 4); - local.writeUInt16LE(8, 8); - local.writeUInt32LE(crc, 14); - local.writeUInt32LE(compressed.length, 18); - local.writeUInt32LE(data.length, 22); - local.writeUInt16LE(nameBuf.length, 26); - local.writeUInt16LE(0, 28); - - const central = Buffer.alloc(46); - central.writeUInt32LE(0x02014b50, 0); - central.writeUInt16LE(20, 4); - central.writeUInt16LE(20, 6); - central.writeUInt16LE(8, 10); - central.writeUInt32LE(crc, 16); - central.writeUInt32LE(compressed.length, 20); - central.writeUInt32LE(data.length, 24); - central.writeUInt16LE(nameBuf.length, 28); - central.writeUInt32LE(0, 42); - - const centralBuf = Buffer.concat([central, nameBuf]); - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); - eocd.writeUInt16LE(1, 8); - eocd.writeUInt16LE(1, 10); - eocd.writeUInt32LE(centralBuf.length, 12); - eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16); - - return Buffer.concat([local, nameBuf, compressed, centralBuf, eocd]); -} +// The CDN serves the bare platform binary; the manifest checksum is its sha256. +const BINARY_FILENAME = 'kimi-code-linux-x64'; function sha256Hex(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); @@ -66,7 +25,7 @@ function sha256Hex(data: Buffer): string { interface MockCdnOptions { readonly version?: string; - readonly zip: Buffer; + readonly payload: Buffer; readonly checksum?: string; } @@ -77,8 +36,8 @@ function mockCdnFetch(options: MockCdnOptions): typeof fetch { tag: `v${version}`, platforms: { 'linux-x64': { - filename: 'kimi-code-linux-x64.zip', - checksum: options.checksum ?? sha256Hex(options.zip), + filename: BINARY_FILENAME, + checksum: options.checksum ?? sha256Hex(options.payload), }, }, }); @@ -87,8 +46,8 @@ function mockCdnFetch(options: MockCdnOptions): typeof fetch { if (url === nativeManifestUrl(version)) { return { ok: true, status: 200, text: async () => manifestBody, body: null }; } - if (url === nativeBinaryUrl(version, 'kimi-code-linux-x64.zip')) { - return { ok: true, status: 200, text: async () => '', body: [options.zip] }; + if (url === nativeBinaryUrl(version, BINARY_FILENAME)) { + return { ok: true, status: 200, text: async () => '', body: [options.payload] }; } return { ok: false, status: 404, text: async () => '', body: null }; }) as unknown as typeof fetch; @@ -107,14 +66,13 @@ describe('stageNativeUpdate', () => { await rm(workDir, { recursive: true, force: true }); }); - it('downloads, verifies, unpacks and records the staged metadata', async () => { - const zip = buildZip('kimi', PAYLOAD); + it('downloads, verifies and records the staged metadata', async () => { const result = await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); expect(result.status).toBe('staged'); @@ -122,7 +80,7 @@ describe('stageNativeUpdate', () => { version: VERSION, target: 'linux-x64', exeFileName: `kimi-${VERSION}`, - sha256: sha256Hex(zip), + sha256: sha256Hex(PAYLOAD), exeSize: PAYLOAD.length, }); @@ -130,16 +88,29 @@ describe('stageNativeUpdate', () => { expect(stagedOnDisk).toEqual(result.staged); const exeBytes = await readFile(stagedExePath(exePath, result.staged)); expect(exeBytes.equals(PAYLOAD)).toBe(true); - // The zip intermediate is gone once unpacking succeeded. - await expect(stat(join(getNativeStagingDir(exePath), 'kimi-code-linux-x64.zip.part'))).rejects.toThrow(); + // The .part intermediate is gone once the download was promoted. + await expect( + stat(join(getNativeStagingDir(exePath), `kimi-${VERSION}.part`)), + ).rejects.toThrow(); + }); + + it('marks the staged exe executable', async () => { + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + const info = await stat(stagedExePath(exePath, result.staged)); + expect(info.mode & 0o111).not.toBe(0); }); it('short-circuits when the same version is already staged', async () => { - const zip = buildZip('kimi', PAYLOAD); - const firstFetch = mockCdnFetch({ zip }); + const firstFetch = mockCdnFetch({ payload: PAYLOAD }); await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', fetchImpl: firstFetch }); - const secondFetch = mockCdnFetch({ zip }); + const secondFetch = mockCdnFetch({ payload: PAYLOAD }); const result = await stageNativeUpdate({ version: VERSION, exePath, @@ -153,13 +124,12 @@ describe('stageNativeUpdate', () => { }); it('re-stages when the staged exe went missing', async () => { - const zip = buildZip('kimi', PAYLOAD); const first = await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); // The metadata stays but the exe is deleted → not trustworthy, re-stage. await rm(stagedExePath(exePath, first.staged)); @@ -170,57 +140,55 @@ describe('stageNativeUpdate', () => { exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); expect(second.status).toBe('staged'); }); it('throws on a checksum mismatch and cleans up leftovers', async () => { - const zip = buildZip('kimi', PAYLOAD); await expect( stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip, checksum: 'f'.repeat(64) }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD, checksum: 'f'.repeat(64) }), }), ).rejects.toThrow(/sha256 mismatch/); expect(await readStagedNativeUpdate(exePath)).toBeNull(); + // Both the staged metadata and the .part download are gone. await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); }); it('throws when the platform is missing from the manifest', async () => { - const zip = buildZip('kimi', PAYLOAD); await expect( stageNativeUpdate({ version: VERSION, exePath, platform: 'win32', arch: 'arm64', - fetchImpl: mockCdnFetch({ zip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }), ).rejects.toThrow(/win32-arm64 not found/); }); it('supersedes a staged older version', async () => { - const oldZip = buildZip('kimi', Buffer.from('old-payload')); await stageNativeUpdate({ version: '0.6.0', exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ version: '0.6.0', zip: oldZip }), + fetchImpl: mockCdnFetch({ version: '0.6.0', payload: Buffer.from('old-payload') }), }); - const newZip = buildZip('kimi', PAYLOAD); const result = await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip: newZip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); expect(result.status).toBe('staged'); @@ -245,35 +213,32 @@ describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { }); it('returns null for malformed staged.json content', async () => { - const stagingDir = getNativeStagingDir(exePath); - await rm(stagingDir, { recursive: true, force: true }); const { mkdir } = await import('node:fs/promises'); + const stagingDir = getNativeStagingDir(exePath); await mkdir(stagingDir, { recursive: true }); await writeFile(getNativeStagedStateFile(exePath), '{not json', 'utf-8'); expect(await readStagedNativeUpdate(exePath)).toBeNull(); }); it('returns null when the exe size drifted from the metadata', async () => { - const zip = buildZip('kimi', PAYLOAD); const { staged } = await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); await writeFile(stagedExePath(exePath, staged), Buffer.alloc(PAYLOAD.length + 1)); expect(await readStagedNativeUpdate(exePath)).toBeNull(); }); it('removes staged artifacts', async () => { - const zip = buildZip('kimi', PAYLOAD); await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', - fetchImpl: mockCdnFetch({ zip }), + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); await removeStagedNativeUpdate(exePath); expect(await readStagedNativeUpdate(exePath)).toBeNull(); diff --git a/apps/kimi-code/test/cli/update/unzip.test.ts b/apps/kimi-code/test/cli/update/unzip.test.ts deleted file mode 100644 index b19586af76..0000000000 --- a/apps/kimi-code/test/cli/update/unzip.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { crc32, deflateRawSync } from 'node:zlib'; - -import { describe, expect, it } from 'vitest'; - -import { unzipFirstFile } from '#/cli/update/unzip'; - -interface TestEntry { - readonly name: string; - readonly data: Buffer; - readonly method: 0 | 8; -} - -/** Build a minimal well-formed zip in memory (store/deflate, no ZIP64). */ -function buildZip(entries: readonly TestEntry[]): Buffer { - const locals: Buffer[] = []; - const centrals: Buffer[] = []; - let offset = 0; - - for (const entry of entries) { - const name = Buffer.from(entry.name, 'utf-8'); - const compressed = - entry.method === 8 ? deflateRawSync(entry.data) : Buffer.from(entry.data); - const crc = crc32(entry.data); - - const local = Buffer.alloc(30); - local.writeUInt32LE(0x04034b50, 0); - local.writeUInt16LE(20, 4); - local.writeUInt16LE(0, 6); // flags - local.writeUInt16LE(entry.method, 8); - local.writeUInt32LE(crc, 14); - local.writeUInt32LE(compressed.length, 18); - local.writeUInt32LE(entry.data.length, 22); - local.writeUInt16LE(name.length, 26); - local.writeUInt16LE(0, 28); // extra length - locals.push(local, name, compressed); - - const central = Buffer.alloc(46); - central.writeUInt32LE(0x02014b50, 0); - central.writeUInt16LE(20, 4); - central.writeUInt16LE(20, 6); - central.writeUInt16LE(0, 8); // flags - central.writeUInt16LE(entry.method, 10); - central.writeUInt32LE(crc, 16); - central.writeUInt32LE(compressed.length, 20); - central.writeUInt32LE(entry.data.length, 24); - central.writeUInt16LE(name.length, 28); - central.writeUInt32LE(offset, 42); - centrals.push(central, Buffer.from(name)); - - offset += 30 + name.length + compressed.length; - } - - const centralStart = offset; - const centralBuf = Buffer.concat(centrals); - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); - eocd.writeUInt16LE(entries.length, 8); - eocd.writeUInt16LE(entries.length, 10); - eocd.writeUInt32LE(centralBuf.length, 12); - eocd.writeUInt32LE(centralStart, 16); - - return Buffer.concat([...locals, centralBuf, eocd]); -} - -describe('unzipFirstFile', () => { - it('extracts a stored entry', () => { - const zip = buildZip([{ name: 'kimi', data: Buffer.from('hello store'), method: 0 }]); - expect(unzipFirstFile(zip)).toEqual({ name: 'kimi', data: Buffer.from('hello store') }); - }); - - it('extracts a deflated entry', () => { - const payload = Buffer.alloc(64 * 1024, 7); - const zip = buildZip([{ name: 'kimi.exe', data: payload, method: 8 }]); - const out = unzipFirstFile(zip); - expect(out.name).toBe('kimi.exe'); - expect(out.data.equals(payload)).toBe(true); - }); - - it('skips directory entries and returns the first file', () => { - const zip = buildZip([ - { name: 'bin/', data: Buffer.alloc(0), method: 0 }, - { name: 'bin/kimi', data: Buffer.from('x'), method: 0 }, - ]); - expect(unzipFirstFile(zip).name).toBe('bin/kimi'); - }); - - it('rejects a buffer without an EOCD record', () => { - expect(() => unzipFirstFile(Buffer.from('not a zip'))).toThrow( - /end of central directory not found/, - ); - }); - - it('rejects an unsupported compression method', () => { - const zip = buildZip([{ name: 'kimi', data: Buffer.from('data'), method: 0 }]); - // Central directory starts at local header (30) + name (4) + data (4) = 38; - // its method field sits at +10. Patch it to 9 (unsupported). - zip.writeUInt16LE(9, 38 + 10); - expect(() => unzipFirstFile(zip)).toThrow(/compression method 9/); - }); - - it('rejects when the central directory claims an implausible entry count', () => { - const zip = buildZip([{ name: 'kimi', data: Buffer.from('data'), method: 0 }]); - const eocdOffset = zip.length - 22; - zip.writeUInt16LE(17, eocdOffset + 10); // beyond MAX_ENTRIES - expect(() => unzipFirstFile(zip)).toThrow(/unexpected entry count/); - }); - - it('rejects a corrupt payload (crc32 mismatch)', () => { - const zip = buildZip([{ name: 'kimi', data: Buffer.from('original payload'), method: 0 }]); - // Flip a byte inside the stored data (right after the 30-byte local header + name). - const dataOffset = 30 + 4 + 1; - zip.writeUInt8(zip.readUInt8(dataOffset) ^ 0xff, dataOffset); - expect(() => unzipFirstFile(zip)).toThrow(/crc32 mismatch/); - }); - - it('rejects a corrupt deflated payload', () => { - const payload = Buffer.alloc(4096, 1); - const zip = buildZip([{ name: 'kimi', data: payload, method: 8 }]); - // Corrupt one byte of the deflate stream. - const dataOffset = 30 + 4 + 10; - zip.writeUInt8(zip.readUInt8(dataOffset) ^ 0xff, dataOffset); - expect(() => unzipFirstFile(zip)).toThrow(); - }); - - it('rejects entries above the uncompressed size cap', () => { - const zip = buildZip([{ name: 'kimi', data: Buffer.from('payload'), method: 0 }]); - expect(() => unzipFirstFile(zip, { maxUncompressedSize: 4 })).toThrow(/entry too large/); - }); - - it('rejects ZIP64 markers', () => { - const zip = buildZip([{ name: 'kimi', data: Buffer.from('payload'), method: 0 }]); - // Patch the central entry's uncompressed size to the ZIP64 sentinel. - // Layout: local header (30) + name (4) + data (7) → central starts at 41; size field at +24. - zip.writeUInt32LE(0xffffffff, 41 + 24); - expect(() => unzipFirstFile(zip)).toThrow(/ZIP64/); - }); - - it('rejects an archive with no file entries', () => { - const zip = buildZip([{ name: 'bin/', data: Buffer.alloc(0), method: 0 }]); - expect(() => unzipFirstFile(zip)).toThrow(/no file entry/); - }); -}); From f04b4c0574c3ddc1463f2ce4dcc276507830cf80 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 18:20:52 +0800 Subject: [PATCH 07/42] fix(kimi-code): address second codex review round - re-exec: forward 128 + signo when the swapped-in child dies by signal instead of reporting exit 0 - __update_download: only exit 0 without staging when the lock holder is staging the SAME version; a different in-flight version (or a vanished lock) no longer surfaces as a successful foreground upgrade - staging: sweep orphaned .part downloads and unreferenced staged exes before downloading, preserving live swap claims and their payloads --- apps/kimi-code/src/cli/sub/update-download.ts | 27 +++++++++--- apps/kimi-code/src/cli/update/install-lock.ts | 24 ++++++++++ apps/kimi-code/src/cli/update/native-stage.ts | 44 ++++++++++++++++++- apps/kimi-code/src/cli/update/native-swap.ts | 13 +++++- .../test/cli/update-download.test.ts | 27 +++++++++++- .../test/cli/update/native-stage.test.ts | 31 +++++++++++++ .../test/cli/update/native-swap.test.ts | 28 ++++++++++-- 7 files changed, 180 insertions(+), 14 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index 191d958029..0690f8d07f 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -8,7 +8,10 @@ import { log } from '@moonshot-ai/kimi-code-sdk'; -import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; +import { + readUpdateInstallLockVersion, + tryAcquireUpdateInstallLock, +} from '#/cli/update/install-lock'; import { stageNativeUpdate } from '#/cli/update/native-stage'; import { detectNativeInstall } from '#/cli/update/source'; @@ -17,10 +20,24 @@ export async function runUpdateDownloadCommand(version: string): Promise process.stderr.write('error: update download is only available in the native build\n'); return 1; } - // Another instance is already staging this version (30-min stale window - // covers crashed downloaders): the outcome is equivalent, exit quietly. - const lock = await tryAcquireUpdateInstallLock({ version }); - if (lock === null) return 0; + let lock = await tryAcquireUpdateInstallLock({ version }); + if (lock === null) { + // Another instance holds the lock. Same target version → its outcome is + // ours, exit quietly. A different version (or a lock that vanished + // between acquire and read) must not surface as a successful download. + const holderVersion = await readUpdateInstallLockVersion(); + if (holderVersion === version) return 0; + if (holderVersion === undefined) { + // The lock was released between the two reads — retry the acquire once. + lock = await tryAcquireUpdateInstallLock({ version }); + } + if (lock === null) { + process.stderr.write( + `error: another update (${holderVersion ?? 'unknown version'}) is already downloading\n`, + ); + return 1; + } + } try { await stageNativeUpdate({ version, exePath: process.execPath, stdout: process.stdout }); return 0; diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 0b6f3834c3..6a51320167 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -93,3 +93,27 @@ export async function tryAcquireUpdateInstallLock( throw error; } } + +/** + * Return the version recorded in the held lock file, or undefined when the + * lock is gone or unreadable. Lets a downloader that failed to acquire the + * lock distinguish "another instance is staging the SAME version" (its + * outcome is ours — report success) from "a different version is in flight" + * (must not be reported as success to a foreground `kimi upgrade`). + */ +export async function readUpdateInstallLockVersion( + filePath: string = getUpdateInstallLockFile(), +): Promise { + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch { + return undefined; + } + try { + const version: unknown = (JSON.parse(raw) as { version?: unknown }).version; + return typeof version === 'string' && version.length > 0 ? version : undefined; + } catch { + return undefined; + } +} diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index bb2da659cb..7d1c15145e 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -9,11 +9,12 @@ */ import { createHash } from 'node:crypto'; -import { chmod, mkdir, open, readFile, rename, rm, rmdir, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; import { z } from 'zod'; +import { KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME } from '#/constant/app'; import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; import { writeJsonFile } from '#/utils/persistence'; @@ -94,6 +95,43 @@ export async function removeStagedNativeUpdate( await rmdir(stagingDir).catch(() => {}); } +/** + * Remove files in `.staging/` that nothing references: interrupted downloads + * (`.part`), and staged exes whose `staged.json` never landed (downloader + * killed between the two writes) — each such orphan is ~180 MB and would + * otherwise accumulate forever. Swap claim files (`staged.json.swap-*`) and + * the exes they reference are preserved: another instance may be mid-swap. + */ +async function cleanupStagingOrphans(stagingDir: string): Promise { + let entries: string[]; + try { + entries = await readdir(stagingDir); + } catch { + return; + } + const keep = new Set([KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME]); + for (const entry of entries) { + if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; + keep.add(entry); + const raw = await readFile(join(stagingDir, entry), 'utf-8').catch(() => null); + if (raw === null) continue; + try { + const exeFileName: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName; + if (typeof exeFileName === 'string' && exeFileName.length > 0) { + // basename(): the metadata contract is a plain file name — never let + // a hand-crafted path escape the staging dir. + keep.add(basename(exeFileName)); + } + } catch { + // Unparseable claim: keep the claim file itself, touch nothing else. + } + } + for (const entry of entries) { + if (keep.has(entry)) continue; + await rm(join(stagingDir, entry), { force: true, recursive: true }).catch(() => {}); + } +} + export interface StageNativeUpdateOptions { readonly version: string; /** Path of the installed executable the staged binary will later replace. */ @@ -169,6 +207,8 @@ export async function stageNativeUpdate( } const stagingDir = getNativeStagingDir(options.exePath); await mkdir(stagingDir, { recursive: true }); + // Drop orphans from interrupted earlier runs before writing ours. + await cleanupStagingOrphans(stagingDir); const staged: StagedNativeUpdate = { version: options.version, diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index f4f08d6aa0..932075fc17 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -18,6 +18,7 @@ import { spawn } from 'node:child_process'; import { readdir, readFile, rename, rmdir, stat, unlink } from 'node:fs/promises'; +import { constants as osConstants } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { gt } from 'semver'; @@ -285,10 +286,18 @@ function reexec( logSwap('re-exec spawn failed', { error: error.message }); resolve(false); }); - child.once('exit', (code, _signal) => { + child.once('exit', (code, signal) => { resolve(true); const exitImpl = deps.exitImpl ?? ((exitCode: number) => process.exit(exitCode)); - exitImpl(code ?? 0); + if (code !== null) { + exitImpl(code); + return; + } + // Terminated by a signal (OOM kill, external SIGKILL, …): mirror the + // shell's 128 + signo convention so the wrapper never reports a killed + // run as a successful CLI invocation. + const signo = signal !== null ? (osConstants.signals[signal] ?? 0) : 0; + exitImpl(signo > 0 ? 128 + signo : 1); }); }); } diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index af17d13b6e..fbe872b565 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -5,6 +5,7 @@ import { runUpdateDownloadCommand } from '#/cli/sub/update-download'; const mocks = vi.hoisted(() => ({ detectNativeInstall: vi.fn(() => true), tryAcquireUpdateInstallLock: vi.fn(), + readUpdateInstallLockVersion: vi.fn(), stageNativeUpdate: vi.fn(), })); @@ -14,6 +15,7 @@ vi.mock('#/cli/update/source', () => ({ vi.mock('#/cli/update/install-lock', () => ({ tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, + readUpdateInstallLockVersion: mocks.readUpdateInstallLockVersion, })); vi.mock('#/cli/update/native-stage', () => ({ @@ -53,12 +55,35 @@ describe('runUpdateDownloadCommand', () => { expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('native build')); }); - it('exits quietly when another instance holds the install lock', async () => { + it('exits quietly when another instance is staging the same version', async () => { mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); }); + it('fails instead of a false success when the lock holder stages another version', async () => { + mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + mocks.readUpdateInstallLockVersion.mockResolvedValue('0.8.0'); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(1); + expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('0.8.0')); + }); + + it('retries the acquire when the lock vanished between the two reads', async () => { + const release = vi.fn(async () => {}); + mocks.tryAcquireUpdateInstallLock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ filePath: '/tmp/install.lock', release }); + mocks.readUpdateInstallLockVersion.mockResolvedValue(undefined); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); + expect(mocks.stageNativeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ version: '0.7.0' }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + it('stages against the running exe and releases the lock', async () => { const release = vi.fn(async () => {}); mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ filePath: '/tmp/install.lock', release }); diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 89ad5c6236..091975e969 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -197,6 +197,37 @@ describe('stageNativeUpdate', () => { stat(join(getNativeStagingDir(exePath), 'kimi-0.6.0')), ).rejects.toThrow(); }); + + it('cleans orphaned staging files before downloading, preserving live swap claims', async () => { + const stagingDir = getNativeStagingDir(exePath); + const { mkdir } = await import('node:fs/promises'); + await mkdir(stagingDir, { recursive: true }); + // Orphans from interrupted earlier runs: a referenced-by-nothing exe and + // a stale .part download. + await writeFile(join(stagingDir, 'kimi-9.9.9'), Buffer.from('orphan-exe')); + await writeFile(join(stagingDir, 'kimi-9.9.9.part'), Buffer.from('partial')); + // A live swap claim referencing its own staged exe must survive. + const claimExe = 'kimi-8.8.8'; + await writeFile(join(stagingDir, claimExe), Buffer.from('swap-in-progress')); + await writeFile( + join(stagingDir, 'staged.json.swap-1234'), + JSON.stringify({ exeFileName: claimExe }), + ); + + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + expect(result.status).toBe('staged'); + + await expect(stat(join(stagingDir, 'kimi-9.9.9'))).rejects.toThrow(); + await expect(stat(join(stagingDir, 'kimi-9.9.9.part'))).rejects.toThrow(); + await expect(stat(join(stagingDir, 'staged.json.swap-1234'))).resolves.toBeDefined(); + await expect(stat(join(stagingDir, claimExe))).resolves.toBeDefined(); + }); }); describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index c99f6d93ae..6821e606f4 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -23,9 +23,10 @@ interface FakeChildHandlers { } function fakeChild(options: { - readonly code?: number; + readonly code?: number | null; readonly stdout?: string; readonly error?: Error; + readonly signal?: NodeJS.Signals | null; }): FakeChildHandlers { const listeners = new Map void>(); const stdoutChunks: string[] = []; @@ -49,9 +50,11 @@ function fakeChild(options: { if (options.stdout !== undefined) { for (const cb of stdoutListeners) cb(Buffer.from(options.stdout)); } + const code = options.code === undefined ? 0 : options.code; + const signal = options.signal ?? null; // The smoke check listens on 'close', the re-exec waiter on 'exit'. - listeners.get('close')?.(options.code ?? 0, null); - listeners.get('exit')?.(options.code ?? 0, null); + listeners.get('close')?.(code, signal); + listeners.get('exit')?.(code, signal); }); void stdoutChunks; return { onEvent: () => {}, child }; @@ -68,6 +71,7 @@ function createSpawnMock(routes: { readonly smokeStdout?: string; readonly reexecCode?: number; readonly reexecError?: Error; + readonly reexecSignal?: NodeJS.Signals; }): { readonly calls: SpawnCall[]; readonly spawnImpl: NativeSwapDeps['spawnImpl'] } { const calls: SpawnCall[] = []; const spawnImpl = ((cmd: string, args: readonly string[], options: Record) => { @@ -78,7 +82,11 @@ function createSpawnMock(routes: { stdout: routes.smokeStdout ?? `${STAGED_VERSION}\n`, }).child; } - return fakeChild({ code: routes.reexecCode ?? 0, error: routes.reexecError }).child; + return fakeChild({ + code: routes.reexecSignal !== undefined ? null : (routes.reexecCode ?? 0), + error: routes.reexecError, + signal: routes.reexecSignal ?? null, + }).child; }) as unknown as NativeSwapDeps['spawnImpl']; return { calls, spawnImpl }; } @@ -261,6 +269,18 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); }); + it('forwards a signal-derived nonzero exit code when the re-exec child is killed', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { spawnImpl } = createSpawnMock({ reexecSignal: 'SIGKILL' }); + const exitImpl = vi.fn(); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl, exitImpl }), + ); + expect(relaunched).toBe(true); + // 128 + 9 (SIGKILL), never a success-looking 0. + expect(exitImpl).toHaveBeenCalledWith(137); + }); + it('restores the staged metadata when the exe cannot be moved aside', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // rename(exe → bak) fails when the in-service exe is gone. From 844f55871fbf6e8ca7922b59fce258eeb27c0d3b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 18:51:40 +0800 Subject: [PATCH 08/42] feat(kimi-code): show download progress for native updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreground 'kimi upgrade' path streamed 180 MB with a single static 'Downloading…' line. Render progress instead: a throttled in-place percentage line on a TTY, one line per 32 MB when piped, and plain MB counts when Content-Length is unknown. --- apps/kimi-code/src/cli/sub/update-download.ts | 54 +++++++++++++++- apps/kimi-code/src/cli/update/native-stage.ts | 10 ++- .../test/cli/update-download.test.ts | 61 ++++++++++++++++++- .../test/cli/update/native-stage.test.ts | 27 +++++++- 4 files changed, 147 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index 0690f8d07f..83d38f69a7 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -38,10 +38,22 @@ export async function runUpdateDownloadCommand(version: string): Promise return 1; } } + const out = process.stdout; + const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`; + const onProgress = createDownloadProgress(out, label); try { - await stageNativeUpdate({ version, exePath: process.execPath, stdout: process.stdout }); + const result = await stageNativeUpdate({ + version, + exePath: process.execPath, + onProgress, + }); + if (out.isTTY) out.write('\n'); + if (result.status === 'already-staged') { + out.write(`Kimi Code ${version} is already downloaded; it applies on the next start.\n`); + } return 0; } catch (error) { + if (out.isTTY) out.write('\n'); const message = error instanceof Error ? error.message : String(error); process.stderr.write(`error: failed to download update ${version}: ${message}\n`); log.warn('native update download failed', { version, error: message }); @@ -50,3 +62,43 @@ export async function runUpdateDownloadCommand(version: string): Promise await lock.release().catch(() => {}); } } + +const PROGRESS_FRAME_INTERVAL_MS = 100; +const PROGRESS_LINE_INTERVAL_BYTES = 32 * 1024 * 1024; + +function formatDownloadProgress(label: string, downloaded: number, total: number | null): string { + const mb = Math.floor(downloaded / (1024 * 1024)); + if (total === null || total <= 0) return `${label} ${mb} MB`; + const totalMb = Math.max(1, Math.round(total / (1024 * 1024))); + const percent = Math.min(100, Math.floor((downloaded / total) * 100)); + return `${label} ${percent}% (${mb}/${totalMb} MB)`; +} + +/** + * Download progress renderer for the (foreground) downloader: a single + * in-place line on a TTY (`\r` + clear-line, throttled to 10 fps, final frame + * always rendered), or one line per 32 MB when piped to a file. The caller + * owns the trailing newline. + */ +export function createDownloadProgress( + out: NodeJS.WriteStream, + label: string, +): (downloadedBytes: number, totalBytes: number | null) => void { + const isTTY = out.isTTY; + let lastFrameAt = 0; + let lastLineAt = 0; + if (!isTTY) out.write(`${label}\n`); + return (downloaded, total) => { + const done = total !== null && downloaded >= total; + if (isTTY) { + const now = Date.now(); + if (!done && now - lastFrameAt < PROGRESS_FRAME_INTERVAL_MS) return; + lastFrameAt = now; + out.write(`\r\u001B[K${formatDownloadProgress(label, downloaded, total)}`); + return; + } + if (!done && downloaded - lastLineAt < PROGRESS_LINE_INTERVAL_BYTES) return; + lastLineAt = downloaded; + out.write(`${formatDownloadProgress(label, downloaded, total)}\n`); + }; +} diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 7d1c15145e..769288062e 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -139,7 +139,8 @@ export interface StageNativeUpdateOptions { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly fetchImpl?: typeof fetch; - readonly stdout?: { write(chunk: string): boolean }; + /** Download progress (bytes so far, Content-Length total when known). */ + readonly onProgress?: (downloadedBytes: number, totalBytes: number | null) => void; } export type StageNativeUpdateStatus = 'already-staged' | 'staged'; @@ -154,11 +155,15 @@ async function downloadAndHash( partPath: string, expectedSha256: string, fetchImpl: typeof fetch, + onProgress?: (downloadedBytes: number, totalBytes: number | null) => void, ): Promise { const response = await fetchImpl(url); if (!response.ok || response.body === null) { throw new Error(`native binary download returned HTTP ${response.status}`); } + const contentLength = response.headers.get('content-length'); + const total = + contentLength !== null && /^\d+$/.test(contentLength) ? Number(contentLength) : null; const hash = createHash('sha256'); let size = 0; const file = await open(partPath, 'w'); @@ -167,6 +172,7 @@ async function downloadAndHash( hash.update(chunk); size += chunk.length; await file.write(chunk); + onProgress?.(size, total); } } finally { await file.close(); @@ -223,12 +229,12 @@ export async function stageNativeUpdate( try { const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); const entry = selectPlatformEntry(manifest, platform, arch); - options.stdout?.write(`Downloading Kimi Code ${options.version} (${target})…\n`); const size = await downloadAndHash( nativeBinaryUrl(options.version, entry.filename), partPath, entry.checksum, fetchImpl, + options.onProgress, ); // sha256 matched the manifest: promote the download to the staged exe. await rename(partPath, stagedExePath(options.exePath, staged)); diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index fbe872b565..910c6550e6 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runUpdateDownloadCommand } from '#/cli/sub/update-download'; +import { createDownloadProgress, runUpdateDownloadCommand } from '#/cli/sub/update-download'; const mocks = vi.hoisted(() => ({ detectNativeInstall: vi.fn(() => true), @@ -32,6 +32,65 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async () => { }; }); +function fakeOut(isTTY: boolean): { readonly out: NodeJS.WriteStream; readonly chunks: string[] } { + const chunks: string[] = []; + const out = { + isTTY, + write(chunk: string) { + chunks.push(chunk); + return true; + }, + } as unknown as NodeJS.WriteStream; + return { out, chunks }; +} + +describe('createDownloadProgress', () => { + it('renders a throttled in-place line on a TTY, with the final frame always shown', () => { + const { out, chunks } = fakeOut(true); + const progress = createDownloadProgress(out, 'Downloading…'); + const total = 100 * 1024 * 1024; + + const nowSpy = vi.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_000); + progress(10 * 1024 * 1024, total); + nowSpy.mockReturnValue(1_050); // inside the 100 ms throttle window → skipped + progress(20 * 1024 * 1024, total); + nowSpy.mockReturnValue(1_200); + progress(30 * 1024 * 1024, total); + progress(total, total); // final frame is never throttled + + expect(chunks).toEqual([ + '\r\u001B[KDownloading… 10% (10/100 MB)', + '\r\u001B[KDownloading… 30% (30/100 MB)', + '\r\u001B[KDownloading… 100% (100/100 MB)', + ]); + nowSpy.mockRestore(); + }); + + it('prints the label up front and one line per 32 MB when piped', () => { + const { out, chunks } = fakeOut(false); + const progress = createDownloadProgress(out, 'Downloading…'); + const total = 100 * 1024 * 1024; + + progress(10 * 1024 * 1024, total); // below the 32 MB line interval → skipped + progress(40 * 1024 * 1024, total); + progress(total, total); + + expect(chunks).toEqual([ + 'Downloading…\n', + 'Downloading… 40% (40/100 MB)\n', + 'Downloading… 100% (100/100 MB)\n', + ]); + }); + + it('degrades to plain MB counts when Content-Length is unknown', () => { + const { out, chunks } = fakeOut(true); + const progress = createDownloadProgress(out, 'Downloading…'); + progress(5 * 1024 * 1024, null); + expect(chunks).toEqual(['\r\u001B[KDownloading… 5 MB']); + }); +}); + describe('runUpdateDownloadCommand', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 091975e969..0dc3b581af 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -47,7 +47,16 @@ function mockCdnFetch(options: MockCdnOptions): typeof fetch { return { ok: true, status: 200, text: async () => manifestBody, body: null }; } if (url === nativeBinaryUrl(version, BINARY_FILENAME)) { - return { ok: true, status: 200, text: async () => '', body: [options.payload] }; + return { + ok: true, + status: 200, + text: async (): Promise => '', + headers: { + get: (name: string): string | null => + name === 'content-length' ? String(options.payload.length) : null, + }, + body: [options.payload], + }; } return { ok: false, status: 404, text: async () => '', body: null }; }) as unknown as typeof fetch; @@ -106,6 +115,22 @@ describe('stageNativeUpdate', () => { expect(info.mode & 0o111).not.toBe(0); }); + it('reports download progress with the Content-Length total', async () => { + const progress: Array = []; + await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + onProgress: (downloaded, total) => { + progress.push([downloaded, total]); + }, + }); + // One frame per chunk; the mock stream delivers the payload in one piece. + expect(progress).toEqual([[PAYLOAD.length, PAYLOAD.length]]); + }); + it('short-circuits when the same version is already staged', async () => { const firstFetch = mockCdnFetch({ payload: PAYLOAD }); await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', fetchImpl: firstFetch }); From 3cb0cc093a5fce5207acfdf23a78673fcd27857a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 21:15:25 +0800 Subject: [PATCH 09/42] fix(kimi-code): bound native update downloads with an idle timeout Codex review: the manifest fetch cleared its timer once headers arrived, so a stalled response body hung the worker forever, and the binary download had no abort at all. The manifest timeout now covers body consumption, and the binary stream aborts after 30 s without a chunk (total duration stays unbounded for slow networks). The idle timeout is injectable for tests. --- .../src/cli/update/native-manifest.ts | 14 +++--- apps/kimi-code/src/cli/update/native-stage.ts | 31 +++++++++++- .../test/cli/update/native-manifest.test.ts | 23 +++++++++ .../test/cli/update/native-stage.test.ts | 50 +++++++++++++++++++ 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts index 1a0d095641..391983d070 100644 --- a/apps/kimi-code/src/cli/update/native-manifest.ts +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -60,16 +60,18 @@ export async function fetchNativeReleaseManifest( const timeout = setTimeout(() => { controller.abort(); }, MANIFEST_FETCH_TIMEOUT_MS); - let response: Response; + // The timeout must stay armed until the BODY is fully consumed: a CDN or + // proxy can deliver headers within the limit and then stall mid-body, and + // resolving `fetch()` alone would clear the timer and hang the worker. try { - response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal }); + const response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal }); + if (!response.ok) { + throw new Error(`native manifest for ${version} returned HTTP ${response.status}`); + } + return NativeReleaseManifestSchema.parse(JSON.parse(await response.text())); } finally { clearTimeout(timeout); } - if (!response.ok) { - throw new Error(`native manifest for ${version} returned HTTP ${response.status}`); - } - return NativeReleaseManifestSchema.parse(JSON.parse(await response.text())); } /** diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 769288062e..4960dded44 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -141,6 +141,8 @@ export interface StageNativeUpdateOptions { readonly fetchImpl?: typeof fetch; /** Download progress (bytes so far, Content-Length total when known). */ readonly onProgress?: (downloadedBytes: number, totalBytes: number | null) => void; + /** Test hook: override the download idle timeout (default 30 s). */ + readonly idleTimeoutMs?: number; } export type StageNativeUpdateStatus = 'already-staged' | 'staged'; @@ -150,15 +152,39 @@ export interface StageNativeUpdateResult { readonly staged: StagedNativeUpdate; } +/** + * Idle timeout for the binary stream: any 30 s without a arriving chunk + * aborts the download. Total duration is intentionally unbounded — slow + * networks may take as long as they need as long as bytes keep flowing. + */ +const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; + async function downloadAndHash( url: string, partPath: string, expectedSha256: string, fetchImpl: typeof fetch, onProgress?: (downloadedBytes: number, totalBytes: number | null) => void, + idleTimeoutMs: number = DOWNLOAD_IDLE_TIMEOUT_MS, ): Promise { - const response = await fetchImpl(url); + const controller = new AbortController(); + let idleTimeout: ReturnType | undefined; + const armIdleTimeout = (): void => { + if (idleTimeout !== undefined) clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => { + controller.abort(new Error(`download stalled: no data for ${idleTimeoutMs}ms`)); + }, idleTimeoutMs); + }; + armIdleTimeout(); + let response: Response; + try { + response = await fetchImpl(url, { signal: controller.signal }); + } catch (error) { + clearTimeout(idleTimeout); + throw error; + } if (!response.ok || response.body === null) { + clearTimeout(idleTimeout); throw new Error(`native binary download returned HTTP ${response.status}`); } const contentLength = response.headers.get('content-length'); @@ -169,12 +195,14 @@ async function downloadAndHash( const file = await open(partPath, 'w'); try { for await (const chunk of response.body as AsyncIterable) { + armIdleTimeout(); hash.update(chunk); size += chunk.length; await file.write(chunk); onProgress?.(size, total); } } finally { + clearTimeout(idleTimeout); await file.close(); } const digest = hash.digest('hex'); @@ -235,6 +263,7 @@ export async function stageNativeUpdate( entry.checksum, fetchImpl, options.onProgress, + options.idleTimeoutMs, ); // sha256 matched the manifest: promote the download to the staged exe. await rename(partPath, stagedExePath(options.exePath, staged)); diff --git a/apps/kimi-code/test/cli/update/native-manifest.test.ts b/apps/kimi-code/test/cli/update/native-manifest.test.ts index 5a3e31da31..b9ccf31648 100644 --- a/apps/kimi-code/test/cli/update/native-manifest.test.ts +++ b/apps/kimi-code/test/cli/update/native-manifest.test.ts @@ -87,6 +87,29 @@ describe('fetchNativeReleaseManifest', () => { }) as unknown as typeof fetch; await expect(fetchNativeReleaseManifest(VERSION, f)).rejects.toThrow(/network down/); }); + + it('rejects when the response body stalls past the request timeout', async () => { + vi.useFakeTimers(); + try { + const f = vi.fn(async (_input: string | URL, init?: RequestInit) => ({ + ok: true, + status: 200, + // Headers arrive, then the body stalls; only the timeout can end this. + text: async () => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }), + })) as unknown as typeof fetch; + const promise = fetchNativeReleaseManifest(VERSION, f); + const assertion = expect(promise).rejects.toThrow(/aborted/); + await vi.advanceTimersByTimeAsync(11_000); + await assertion; + } finally { + vi.useRealTimers(); + } + }); }); describe('selectPlatformEntry', () => { diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 0dc3b581af..b0d3dd1dd6 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -131,6 +131,56 @@ describe('stageNativeUpdate', () => { expect(progress).toEqual([[PAYLOAD.length, PAYLOAD.length]]); }); + it('aborts a stalled download after the idle timeout', async () => { + const manifestBody = JSON.stringify({ + version: VERSION, + platforms: { + 'linux-x64': { filename: BINARY_FILENAME, checksum: 'a'.repeat(64) }, + }, + }); + const fetchImpl = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url === nativeManifestUrl(VERSION)) { + return { ok: true, status: 200, text: async () => manifestBody, body: null }; + } + if (url === nativeBinaryUrl(VERSION, BINARY_FILENAME)) { + const signal = init?.signal; + const body = (async function* (): AsyncGenerator { + yield Buffer.from('first-chunk'); + // Stall forever — only the idle timeout's abort can end this. + await new Promise((_, reject) => { + signal?.addEventListener('abort', () => { + reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')); + }, { once: true }); + }); + })(); + return { + ok: true, + status: 200, + text: async (): Promise => '', + headers: { get: (): string | null => null }, + body, + }; + } + return { ok: false, status: 404, text: async (): Promise => '', body: null }; + }) as unknown as typeof fetch; + + // Real timers with a 50 ms test override — fake timers interact badly + // with async-generator suspension, so the idle timeout is injectable. + await expect( + stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl, + idleTimeoutMs: 50, + }), + ).rejects.toThrow(/stalled/); + // The failed attempt cleans up after itself. + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + }); + it('short-circuits when the same version is already staged', async () => { const firstFetch = mockCdnFetch({ payload: PAYLOAD }); await stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', fetchImpl: firstFetch }); From ef97ea38bf7aa831c26b38a0623eed40b167545b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 21:28:30 +0800 Subject: [PATCH 10/42] fix(kimi-code): retry native updates blocked by an orphaned active record Windows real-machine verification surfaced that a parent exiting before the downloader's exit event leaves a fresh-looking 'active' record that silently blocks every background retry for the 6 h TTL. For native installs, lock liveness is the truth past a 60 s spawn grace window: a held lock means a download is running, a free lock means the record is an orphan and a new attempt may start. Package-manager sources keep the TTL behavior (no lock to prove liveness). --- apps/kimi-code/src/cli/update/preflight.ts | 42 +++++++++++- .../test/cli/update/preflight.test.ts | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 0d5d8ae819..2e6093bd39 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -384,6 +384,44 @@ function hasFreshActiveInstall(state: UpdateInstallState, target: UpdateTarget): return Date.now() - startedAt < AUTO_INSTALL_ACTIVE_TTL_MS; } +/** + * A fresh-looking `active` record is not proof of work for native installs: + * the parent that wrote it may have exited before the spawned downloader's + * exit event (or the downloader died before doing anything), and the 6 h TTL + * would then silently block every retry. Past the spawn grace window — the + * worker needs a moment to self-acquire the lock — lock liveness IS the + * truth: held ⇒ a download is running; free ⇒ the record is an orphan and + * the caller may start a new attempt. Package-manager sources have no such + * liveness signal and keep the TTL behavior above. + */ +const NATIVE_INSTALL_SPAWN_GRACE_MS = 60_000; + +async function hasNativeInstallInFlight( + state: UpdateInstallState, + target: UpdateTarget, +): Promise { + const active = state.active; + if (active === null || active.version !== target.version) return false; + const startedAt = Date.parse(active.startedAt); + if (Number.isFinite(startedAt) && Date.now() - startedAt < NATIVE_INSTALL_SPAWN_GRACE_MS) { + return true; + } + const probe = await tryAcquireUpdateInstallLock({ version: target.version }); + if (probe === null) return true; + await probe.release().catch(() => {}); + return false; +} + +async function hasInstallInFlight( + source: InstallSource, + state: UpdateInstallState, + target: UpdateTarget, +): Promise { + return source === 'native' + ? hasNativeInstallInFlight(state, target) + : hasFreshActiveInstall(state, target); +} + async function showPendingBackgroundInstallNotice( state: UpdateInstallState, currentVersion: string, @@ -582,7 +620,7 @@ async function startBackgroundInstall( try { const freshState = await readUpdateInstallState().catch(() => state); if ( - hasFreshActiveInstall(freshState, target) || + (await hasInstallInFlight(source, freshState, target)) || failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD ) { return; @@ -701,7 +739,7 @@ async function tryStartAutomaticBackgroundInstall( if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { return false; } - if (!hasFreshActiveInstall(installState, target)) { + if (!(await hasInstallInFlight(source, installState, target))) { await startBackgroundInstall( installState, currentVersion, diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 90d19131cd..46673b9059 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -700,6 +700,71 @@ describe('runUpdatePreflight', () => { } }); + it('native: retries the background install when an old active record has no live lock', async () => { + // Orphaned `active`: older than the spawn grace window and the lock is + // free (beforeEach default) ⇒ the previous downloader is gone; retry. + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'native', + startedAt: new Date(Date.now() - 120_000).toISOString(), + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('native'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + ['__update_download', '0.5.0'], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + }); + + it('native: does not re-spawn while the install lock is genuinely held', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'native', + startedAt: new Date(Date.now() - 120_000).toISOString(), + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('native'); + // Lock probe fails ⇒ a downloader is actually in flight; trust it. + mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('native: trusts a fresh active record within the spawn grace window', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'native', + startedAt: new Date().toISOString(), + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('native'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).not.toHaveBeenCalled(); + // Inside the grace window the lock is never probed — the freshly spawned + // worker may simply not have reached its self-acquire yet. + expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + }); + it('tracks and logs successful background update installs', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); From 11a913bf524ef4848900b6163fa6742c14bb805c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 22:30:08 +0800 Subject: [PATCH 11/42] fix: skip staged swap while another instance holds a fresh claim sweepStaleNativeUpdateArtifacts already detected an in-progress swap in a concurrent instance, but the result stayed inside the cleanup helper: startup still claimed a newly published staged.json and ran a second swap, so the two launchers could rename the install path and delete each other's rollback backup. Propagate the in-progress signal and skip claiming until the existing claim is released or goes stale. --- apps/kimi-code/src/cli/update/native-swap.ts | 19 +++++++++++++++--- .../test/cli/update/native-swap.test.ts | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 932075fc17..906193be82 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -247,18 +247,22 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { * launch. The swap itself can never fully clean up after its own run — the * old process still holds its renamed image (`.bak`) on Windows — so later * launches sweep what the previous run could not. + * + * Returns true when another instance holds a fresh swap claim: every + * artifact is then left alone and the caller must not start a second swap. */ -async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise { +async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise { try { if (await cleanupStaleSwapClaims(exePath)) { // Another instance is mid-swap: leave every artifact alone — the `.bak` // next to the exe is its rollback source. - return; + return true; } await cleanupBackups(exePath); } catch { // Hygiene must never affect startup. } + return false; } /** @@ -313,13 +317,22 @@ export async function maybeRelaunchWithStagedNativeUpdate( deps: NativeSwapDeps, ): Promise { if (!deps.isNative) return false; - await sweepStaleNativeUpdateArtifacts(deps.exePath); + const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath); if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) { // Read-once guard: drop it so this session's children (and any nested // kimi launches from them) do not inherit the swap skip. delete deps.env[KIMI_CODE_UPDATE_REEXEC_ENV]; return false; } + if (swapInProgress) { + // Another instance holds a fresh swap claim and finishes (or rolls back) + // on its own. Starting a second swap here would rename the install path + // from under it and let each launcher delete the `.bak` the other may + // still need for rollback. Its re-exec — or our next launch — lands the + // update, so this session simply runs the current exe. + logSwap('another instance is mid-swap, skipping', { exePath: deps.exePath }); + return false; + } const claimed = await claimStagedUpdate(deps.exePath); if (claimed === null) return false; diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 6821e606f4..eb5769e2b3 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -346,6 +346,26 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { await expect(stat(`${exePath}.bak`)).resolves.toBeDefined(); }); + it('does not claim a newly staged update while another instance is mid-swap', async () => { + // Instance A holds a fresh claim; a downloader has since published a new + // staged.json. Claiming it here would start a second concurrent swap. + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + const claimPath = join(stagingDir, 'staged.json.swap-4242'); + await writeFile(claimPath, '{}\n', 'utf-8'); + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // The staged update and the claim stay put; the launch after the + // in-flight swap ends picks the update up. + await expect(stat(getNativeStagedStateFile(exePath))).resolves.toBeDefined(); + await expect(stat(claimPath)).resolves.toBeDefined(); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + it('cleans up stale swap claims and their orphaned staged exe', async () => { const stagingDir = getNativeStagingDir(exePath); await mkdir(stagingDir, { recursive: true }); From bc747e075dfd7af7c43520cbdb37859929f631a6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 22:59:18 +0800 Subject: [PATCH 12/42] fix: keep the install lock while its holder process is alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install lock went stale purely by age (30 min), but the native downloader is idle-bounded, not duration-bounded: a slow link can legitimately take longer. Another startup would then sweep the lock and spawn a second downloader, and both would write and clean the same .staging paths. Past the age threshold, fall back to a pid liveness probe (signal 0) — the lock is stale only when the holder is gone. --- apps/kimi-code/src/cli/update/install-lock.ts | 25 +++++++++++-- .../test/cli/update/install-lock.test.ts | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 6a51320167..a12be87a78 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -27,16 +27,37 @@ function isAlreadyExists(error: unknown): boolean { ); } +/** + * Liveness probe for the lock holder. Signal 0 delivers nothing; ESRCH means + * the process is gone, EPERM means it exists but may not be signalled — which + * still counts as alive. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + async function isStaleLock(filePath: string, now: Date): Promise { try { const raw = await readFile(filePath, 'utf-8'); const parsed = JSON.parse(raw) as unknown; if (typeof parsed !== 'object' || parsed === null) return true; - const lock = parsed as { readonly startedAt?: unknown }; + const lock = parsed as { readonly startedAt?: unknown; readonly pid?: unknown }; if (typeof lock.startedAt !== 'string') return true; const startedAt = Date.parse(lock.startedAt); if (!Number.isFinite(startedAt)) return true; - return now.getTime() - startedAt > UPDATE_INSTALL_LOCK_STALE_MS; + if (now.getTime() - startedAt <= UPDATE_INSTALL_LOCK_STALE_MS) return false; + // Past the age threshold the holder's liveness decides. A native download + // is idle-bounded but intentionally not duration-bounded, so a slow link + // legitimately exceeds it; sweeping that lock would let a second + // downloader write the same `.staging` paths concurrently. (A pid reused + // by an unrelated process can pin the lock until that process exits — a + // delayed update, never a corrupt one.) + return typeof lock.pid === 'number' ? !isProcessAlive(lock.pid) : true; } catch (error) { if (isNotFound(error)) return true; if (error instanceof SyntaxError) return true; diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index fd7b568f80..968c9c4030 100644 --- a/apps/kimi-code/test/cli/update/install-lock.test.ts +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -47,4 +48,39 @@ describe('update install lock', () => { expect(lock).not.toBeNull(); await lock?.release(); }); + + function writeAgedLock(pid: number): void { + const filePath = getUpdateInstallLockFile(); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + `${JSON.stringify({ + version: '0.5.0', + pid, + startedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + })}\n`, + 'utf-8', + ); + } + + it('does not treat an aged lock as stale while its holder process is alive', async () => { + // The holder is this very test process — guaranteed alive. A long native + // download must survive past the 30-minute age threshold. + writeAgedLock(process.pid); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).toBeNull(); + }); + + it('sweeps an aged lock whose holder process is gone', async () => { + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + await new Promise((resolve) => child.once('exit', resolve)); + writeAgedLock(child.pid ?? -1); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); }); From e630b0bd3e5694f81d59c7823bcf5c846f587c4d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 17 Aug 2026 23:35:56 +0800 Subject: [PATCH 13/42] fix: keep recovery artifacts on rollback failure and wait out same-version downloads Two robustness fixes from review: - native-swap: when moving the staged exe into place fails AND the rollback rename fails too (transient lock, AV), the install path is left absent and no next launch can start. Discarding the staged payload and claim on top of that removes the second recovery copy. rollback() now reports its result; on a double failure the swap keeps the .bak (which IS the old exe), the staged exe and the claim so manual recovery or a re-install still works. - update-download: a foreground `kimi upgrade` racing a background downloader of the same version exited 0 immediately, so the CLI printed a success message for a download that could still fail. The worker now waits while the same-version holder is in flight, adopts the verified staged result (staged.json lands before the lock is released), and takes over the download when the holder finished without staging. --- apps/kimi-code/src/cli/sub/update-download.ts | 45 +++++++++++++++--- apps/kimi-code/src/cli/update/native-swap.ts | 23 ++++++++-- .../test/cli/update-download.test.ts | 24 +++++++++- .../test/cli/update/native-swap.test.ts | 46 +++++++++++++++++++ 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index 83d38f69a7..4626d0f788 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -12,22 +12,54 @@ import { readUpdateInstallLockVersion, tryAcquireUpdateInstallLock, } from '#/cli/update/install-lock'; -import { stageNativeUpdate } from '#/cli/update/native-stage'; +import { readStagedNativeUpdate, stageNativeUpdate } from '#/cli/update/native-stage'; import { detectNativeInstall } from '#/cli/update/source'; +const LOCK_HELD_POLL_INTERVAL_MS = 2_000; + +/** + * Another worker holds the install lock for the SAME version. Returning right + * away would report a success that has not happened yet — the in-flight + * download may still fail — so wait for it: resolves true once its staged + * update is verified on disk, false when the holder finished without staging + * (the caller then takes over the download itself). + */ +async function waitForStagedUpdate(version: string, exePath: string): Promise { + for (;;) { + const staged = await readStagedNativeUpdate(exePath); + if (staged !== null && staged.version === version) return true; + // staged.json lands before the holder releases its lock, so a lock that + // is gone (or changed hands) with nothing staged means the holder failed. + const holderVersion = await readUpdateInstallLockVersion(); + if (holderVersion !== version) return false; + await new Promise((resolve) => { + setTimeout(resolve, LOCK_HELD_POLL_INTERVAL_MS); + }); + } +} + export async function runUpdateDownloadCommand(version: string): Promise { if (!detectNativeInstall()) { process.stderr.write('error: update download is only available in the native build\n'); return 1; } + const out = process.stdout; let lock = await tryAcquireUpdateInstallLock({ version }); if (lock === null) { - // Another instance holds the lock. Same target version → its outcome is - // ours, exit quietly. A different version (or a lock that vanished - // between acquire and read) must not surface as a successful download. const holderVersion = await readUpdateInstallLockVersion(); - if (holderVersion === version) return 0; - if (holderVersion === undefined) { + if (holderVersion === version) { + // Another worker is already downloading this exact version: wait for it + // and adopt its verified result instead of exiting on a maybe. + out.write( + `A download of Kimi Code ${version} is already in progress; waiting for it to finish…\n`, + ); + if (await waitForStagedUpdate(version, process.execPath)) { + out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`); + return 0; + } + // The holder finished without staging (failed or crashed): take over. + lock = await tryAcquireUpdateInstallLock({ version }); + } else if (holderVersion === undefined) { // The lock was released between the two reads — retry the acquire once. lock = await tryAcquireUpdateInstallLock({ version }); } @@ -38,7 +70,6 @@ export async function runUpdateDownloadCommand(version: string): Promise return 1; } } - const out = process.stdout; const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`; const onProgress = createDownloadProgress(out, label); try { diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 906193be82..1418c788ee 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -174,8 +174,13 @@ async function claimStagedUpdate(exePath: string): Promise return { staged, claimedPath }; } -async function rollback(bakPath: string, exePath: string): Promise { - await rename(bakPath, exePath).catch(() => {}); +async function rollback(bakPath: string, exePath: string): Promise { + try { + await rename(bakPath, exePath); + return true; + } catch { + return false; + } } /** @@ -402,7 +407,19 @@ export async function maybeRelaunchWithStagedNativeUpdate( // 3. Move the staged exe into place; roll back on failure. if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) { logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath }); - await rollback(bakPath, deps.exePath); + if (!(await rollback(bakPath, deps.exePath))) { + // Rollback failed too (transient file lock, AV, …): the install path is + // now absent and no next launch can start. Keep every artifact instead + // of discarding — the `.bak` IS the old exe and the staged payload is a + // second recovery copy, so `mv .bak ` or re-running the + // installer still recovers. + logSwap('rollback failed, keeping recovery artifacts', { + exePath: deps.exePath, + bakPath, + }); + await recordSwapFailure(staged.version); + return false; + } await recordSwapFailure(staged.version); return discard(); } diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index 910c6550e6..e860a64ad0 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ tryAcquireUpdateInstallLock: vi.fn(), readUpdateInstallLockVersion: vi.fn(), stageNativeUpdate: vi.fn(), + readStagedNativeUpdate: vi.fn(), })); vi.mock('#/cli/update/source', () => ({ @@ -20,6 +21,7 @@ vi.mock('#/cli/update/install-lock', () => ({ vi.mock('#/cli/update/native-stage', () => ({ stageNativeUpdate: mocks.stageNativeUpdate, + readStagedNativeUpdate: mocks.readStagedNativeUpdate, })); vi.mock('@moonshot-ai/kimi-code-sdk', async () => { @@ -114,11 +116,31 @@ describe('runUpdateDownloadCommand', () => { expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('native build')); }); - it('exits quietly when another instance is staging the same version', async () => { + it('waits for and adopts the result when another instance downloads the same version', async () => { mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); + // The other worker's staged update is verified on disk on the first poll. + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0' }); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('already in progress')); + }); + + it('takes over when the same-version holder finishes without staging', async () => { + const release = vi.fn(async () => {}); + mocks.tryAcquireUpdateInstallLock + .mockResolvedValueOnce(null) // held by the other worker… + .mockResolvedValueOnce({ filePath: '/tmp/install.lock', release }); // …then ours + mocks.readUpdateInstallLockVersion + .mockResolvedValueOnce('0.7.0') // the initial holder check + .mockResolvedValueOnce(undefined); // inside the wait: lock released, nothing staged + mocks.readStagedNativeUpdate.mockResolvedValue(null); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); + expect(mocks.stageNativeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ version: '0.7.0' }), + ); + expect(release).toHaveBeenCalledTimes(1); }); it('fails instead of a false success when the lock holder stages another version', async () => { diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index eb5769e2b3..c7ae9bab41 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -13,6 +13,27 @@ import { import { KIMI_CODE_UPDATE_REEXEC_ENV } from '#/constant/app'; import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; +const fsMocks = vi.hoisted(() => ({ + /** When set, renames matching the predicate fail with an injected error. */ + renameBlocker: null as null | ((src: string, dst: string) => boolean), +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: async ( + src: Parameters[0], + dst: Parameters[1], + ) => { + if (fsMocks.renameBlocker?.(String(src), String(dst)) === true) { + throw new Error('injected rename failure'); + } + return actual.rename(src, dst); + }, + }; +}); + const CURRENT_VERSION = '0.6.0'; const STAGED_VERSION = '0.7.0'; const STAGED_EXE_SIZE = 42; @@ -139,6 +160,7 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { await mkdir(join(workDir, 'bin'), { recursive: true }); await writeFile(exePath, 'old-binary'); vi.stubEnv('KIMI_CODE_HOME', homeDir); + fsMocks.renameBlocker = null; }); afterEach(async () => { @@ -397,4 +419,28 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { await expect(stat(claimPath)).rejects.toThrow(); await expect(stat(orphanedExe)).rejects.toThrow(); }); + + it('keeps recovery artifacts when both the swap-in rename and the rollback fail', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // Every rename INTO the install path fails: the staged exe cannot move + // in, and the backup cannot move back (transient lock, AV, …). + fsMocks.renameBlocker = (_src, dst) => dst === exePath; + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(1); // smoke check only, no re-exec + // The install path stays absent, but both recovery copies survive: the + // `.bak` IS the old exe, and the staged payload plus its claim are not + // discarded. + await expect(stat(exePath)).rejects.toThrow(); + expect(await readFile(`${exePath}.bak`, 'utf-8')).toBe('old-binary'); + const stagingDir = getNativeStagingDir(exePath); + await expect( + stat(join(stagingDir, stagedExeFileName(STAGED_VERSION, 'linux'))), + ).resolves.toBeDefined(); + await expect( + stat(join(stagingDir, `staged.json.swap-${process.pid}`)), + ).resolves.toBeDefined(); + }); }); From 07b52bdda2675ef1fcd2fef75f565150505c1d23 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:20:43 +0800 Subject: [PATCH 14/42] fix: stamp the swap claim with a fresh mtime when claiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rename() preserves the staged metadata's mtime, which can be arbitrarily old — the background download often finishes hours before the next launch claims it. A concurrent launch's sweep would then classify the live claim as crash residue (older than the 5-minute window) and delete the claim, the staged exe, and eventually the first swap's rollback backup. Stamp the claim file with the claim time so the staleness check measures the swap's liveness, not the download's age. --- apps/kimi-code/src/cli/update/native-swap.ts | 7 +- .../test/cli/update/native-swap.test.ts | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 1418c788ee..c22e2ea178 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -17,7 +17,7 @@ */ import { spawn } from 'node:child_process'; -import { readdir, readFile, rename, rmdir, stat, unlink } from 'node:fs/promises'; +import { readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; import { constants as osConstants } from 'node:os'; import { basename, dirname, join } from 'node:path'; @@ -171,6 +171,11 @@ async function claimStagedUpdate(exePath: string): Promise } catch { return null; } + // rename preserves the metadata's mtime, which can be arbitrarily old — the + // download may have finished hours before this launch. Stamp the claim so a + // concurrent launch's sweep does not misread this live claim as crash + // residue and delete the staged exe (and this swap's rollback backup). + await utimes(claimedPath, new Date(), new Date()).catch(() => {}); return { staged, claimedPath }; } diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index c7ae9bab41..16d8239047 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -443,4 +444,71 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { stat(join(stagingDir, `staged.json.swap-${process.pid}`)), ).resolves.toBeDefined(); }); + + it('stamps the claim with a fresh mtime so a concurrent launch does not misread it as stale', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // The metadata may have been staged long before this launch (background + // download finished hours ago); rename alone would keep that old mtime. + const longAgo = new Date(Date.now() - 10 * 60 * 1000); + await utimes(getNativeStagedStateFile(exePath), longAgo, longAgo); + + // Instance A: park inside the smoke check, holding the claim mid-swap. + let releaseSmoke!: () => void; + const smokeGate = new Promise((resolve) => { + releaseSmoke = resolve; + }); + const spawnImplA = ((cmd: string, args: readonly string[]) => { + if (args[0] !== '--version') return fakeChild({ code: 0 }).child; // re-exec + const listeners = new Map void>(); + const stdoutListeners: Array<(chunk: Buffer) => void> = []; + const child = { + once(event: string, cb: (...args: unknown[]) => void) { + listeners.set(event, cb); + }, + stdout: { + on(_event: 'data', cb: (chunk: Buffer) => void) { + stdoutListeners.push(cb); + }, + }, + kill: vi.fn(), + }; + const emitSmokeSuccess = (): void => { + for (const cb of stdoutListeners) cb(Buffer.from(`${STAGED_VERSION}\n`)); + listeners.get('close')?.(0, null); + listeners.get('exit')?.(0, null); + }; + queueMicrotask(() => { + void smokeGate.then(emitSmokeSuccess); + }); + return child; + }) as unknown as NativeSwapDeps['spawnImpl']; + const promiseA = maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl: spawnImplA })); + + // Wait until A holds the claim. + const stagingDir = getNativeStagingDir(exePath); + const claimPath = join(stagingDir, `staged.json.swap-${process.pid}`); + await vi.waitFor(() => { + expect(existsSync(claimPath)).toBe(true); + }); + // The claim carries the claim time, not the staged file's old mtime. + expect((await stat(claimPath)).mtimeMs).toBeGreaterThan(Date.now() - 60_000); + + // Instance B: its sweep must treat A's claim as live and touch nothing. + const { calls: callsB, spawnImpl: spawnImplB } = createSpawnMock({}); + const relaunchedB = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { spawnImpl: spawnImplB }), + ); + expect(relaunchedB).toBe(false); + expect(callsB).toHaveLength(0); + await expect(stat(claimPath)).resolves.toBeDefined(); + await expect( + stat(join(stagingDir, stagedExeFileName(STAGED_VERSION, 'linux'))), + ).resolves.toBeDefined(); + + // A finishes the swap unharmed. + releaseSmoke(); + await expect(promiseA).resolves.toBe(true); + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + }); }); From 4019f20f42bcd978ada0bc7c480fa7a038b675c7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:31:43 +0800 Subject: [PATCH 15/42] fix: stamp the claim before the rename so it is born fresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamping after the rename left a window: a concurrent launch could inspect the claim between the two syscalls, see the staged metadata's old mtime, and delete the staged executable mid-swap. utimes the state file first so the claim carries a fresh timestamp from the instant it is published — no fresh-looking-later intermediate state exists. --- apps/kimi-code/src/cli/update/native-swap.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index c22e2ea178..2b994c9351 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -167,15 +167,17 @@ async function claimStagedUpdate(exePath: string): Promise const claimedPath = `${stateFile}.swap-${process.pid}`; try { + // The metadata's mtime can be arbitrarily old — the download may have + // finished hours before this launch. Stamp it BEFORE the rename so the + // claim is born fresh: a concurrent launch's sweep never observes a live + // claim that looks like crash residue (and would delete the staged exe + // plus this swap's rollback backup). Stamping the state file itself is + // harmless — nothing reads its mtime. + await utimes(stateFile, new Date(), new Date()).catch(() => {}); await rename(stateFile, claimedPath); } catch { return null; } - // rename preserves the metadata's mtime, which can be arbitrarily old — the - // download may have finished hours before this launch. Stamp the claim so a - // concurrent launch's sweep does not misread this live claim as crash - // residue and delete the staged exe (and this swap's rollback backup). - await utimes(claimedPath, new Date(), new Date()).catch(() => {}); return { staged, claimedPath }; } From 40fbbcc44fe73b08def81d333cda84fdefe4d104 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 00:49:13 +0800 Subject: [PATCH 16/42] fix: chmod the staged download before publishing it at its final name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swap claims only the staged METADATA; the staged exe stays in .staging/. A concurrent same-version downloader (possible because swaps do not hold the install lock) then re-downloads and renames its .part over that path. If the swap moves the file into the install path between the downloader's rename and its post-publish chmod, the chmod lands on a path that is already gone and the installation is left non-executable — every future launch fails. Apply the executable mode to the private .part file before the publishing rename so the staged exe is executable from the instant it appears. --- apps/kimi-code/src/cli/update/native-stage.ts | 8 ++- .../test/cli/update/native-stage.test.ts | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 4960dded44..7e8ad09e2f 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -265,9 +265,13 @@ export async function stageNativeUpdate( options.onProgress, options.idleTimeoutMs, ); - // sha256 matched the manifest: promote the download to the staged exe. + // sha256 matched the manifest. Make the private .part file executable + // BEFORE publishing it: a concurrent swap may move the staged exe into + // the install path the instant it appears at its published name, so a + // post-publish chmod could land on a path that is already gone — leaving + // a non-executable installation behind. + await chmod(partPath, 0o755); await rename(partPath, stagedExePath(options.exePath, staged)); - await chmod(stagedExePath(options.exePath, staged), 0o755); staged.sha256 = entry.checksum; staged.exeSize = size; diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index b0d3dd1dd6..989ee650c8 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -14,6 +14,32 @@ import { } from '#/cli/update/native-stage'; import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; +const fsMocks = vi.hoisted(() => ({ + /** Records chmod/rename calls (path-based) so tests can assert ordering. */ + calls: [] as Array<{ readonly op: 'chmod' | 'rename'; readonly path: string; readonly dst?: string }>, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + chmod: async ( + path: Parameters[0], + mode: Parameters[1], + ) => { + fsMocks.calls.push({ op: 'chmod', path: String(path) }); + return actual.chmod(path, mode); + }, + rename: async ( + src: Parameters[0], + dst: Parameters[1], + ) => { + fsMocks.calls.push({ op: 'rename', path: String(src), dst: String(dst) }); + return actual.rename(src, dst); + }, + }; +}); + const VERSION = '0.7.0'; const PAYLOAD = Buffer.from('fake-sea-binary-payload'); // The CDN serves the bare platform binary; the manifest checksum is its sha256. @@ -69,6 +95,7 @@ describe('stageNativeUpdate', () => { beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), 'kimi-stage-test-')); exePath = join(workDir, 'bin', 'kimi'); + fsMocks.calls.length = 0; }); afterEach(async () => { @@ -115,6 +142,30 @@ describe('stageNativeUpdate', () => { expect(info.mode & 0o111).not.toBe(0); }); + it('makes the download executable before publishing it at the staged name', async () => { + // A concurrent swap may move the staged exe into place the instant it + // appears at its published name, so the chmod must land on the private + // .part file first — a later chmod could hit an already-moved path. + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + const stagedExe = stagedExePath(exePath, result.staged); + const partPath = `${stagedExe}.part`; + const chmodIndex = fsMocks.calls.findIndex( + (call) => call.op === 'chmod' && call.path === partPath, + ); + const publishIndex = fsMocks.calls.findIndex( + (call) => call.op === 'rename' && call.path === partPath && call.dst === stagedExe, + ); + expect(chmodIndex).toBeGreaterThanOrEqual(0); + expect(publishIndex).toBeGreaterThanOrEqual(0); + expect(chmodIndex).toBeLessThan(publishIndex); + }); + it('reports download progress with the Content-Length total', async () => { const progress: Array = []; await stageNativeUpdate({ From d4dcccd770ed886424269b2b9bcd6f7d8146a131 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 01:02:54 +0800 Subject: [PATCH 17/42] fix: publish the install lock atomically via hard link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'wx' open exposed a momentarily empty lock file before its contents were written. A concurrent acquirer reading in that window got a SyntaxError, treated the lock as stale, swept it and also won — two "holders" then ran stageNativeUpdate against the same .staging paths. Write the lock contents to a unique temp file and hard-link it into place: link() fails when the destination exists (same exclusivity as 'wx') and the lock path only ever appears fully written. --- apps/kimi-code/src/cli/update/install-lock.ts | 27 +++++++++++++------ .../test/cli/update/install-lock.test.ts | 18 ++++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index a12be87a78..18453de847 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -1,10 +1,13 @@ -import { mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { link, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; +/** Uniquifies the publish-temp path across concurrent in-process acquirers. */ +let lockTempCounter = 0; + export interface UpdateInstallLockRequest { readonly version: string; readonly now?: Date; @@ -70,15 +73,23 @@ async function createLockFile( request: UpdateInstallLockRequest, ): Promise { const now = request.now ?? new Date(); - const file = await open(filePath, 'wx', 0o600); + const content = `${JSON.stringify({ + version: request.version, + pid: process.pid, + startedAt: now.toISOString(), + }, null, 2)}\n`; + // Publish atomically: hard-link a fully-written temp file into place (link + // fails when the destination already exists, same exclusivity as 'wx'). A + // plain 'wx' open would expose a momentarily EMPTY lock file, and a + // concurrent acquirer could misread it as corrupt, sweep it, and also win — + // two "holders" then write the same `.staging` paths. + const tempPath = `${filePath}.${process.pid}.${lockTempCounter}.tmp`; + lockTempCounter += 1; + await writeFile(tempPath, content, { encoding: 'utf-8', mode: 0o600 }); try { - await file.writeFile(`${JSON.stringify({ - version: request.version, - pid: process.pid, - startedAt: now.toISOString(), - }, null, 2)}\n`, 'utf-8'); + await link(tempPath, filePath); } finally { - await file.close(); + await unlink(tempPath).catch(() => {}); } return { diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index 968c9c4030..e44eb33c90 100644 --- a/apps/kimi-code/test/cli/update/install-lock.test.ts +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -38,6 +38,22 @@ describe('update install lock', () => { await third?.release(); }); + it('grants the lock to exactly one of many concurrent acquirers', async () => { + // The lock file must never be observable in an empty/partial state: + // losers of the create race used to sweep the just-created (still empty) + // lock as "corrupt" and also win, breaking exclusivity. + const attempts = await Promise.all( + Array.from({ length: 20 }, () => tryAcquireUpdateInstallLock({ version: '0.5.0' })), + ); + const winners = attempts.filter((handle) => handle !== null); + expect(winners).toHaveLength(1); + const held = JSON.parse(readFileSync(getUpdateInstallLockFile(), 'utf-8')) as { + version: string; + }; + expect(held.version).toBe('0.5.0'); + await winners[0]?.release(); + }); + it('recovers from a corrupt lock file', async () => { const filePath = getUpdateInstallLockFile(); mkdirSync(dirname(filePath), { recursive: true }); From 21d32be2d098d6bea407283f4e013988f696b13b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 01:26:50 +0800 Subject: [PATCH 18/42] fix: serialize stale-lock takeover through a secondary lock A pathname-level delete can never be conditioned on the file still being the inspected stale instance, so a plain compare-and-delete still loses exclusivity: two workers classifying the same stale lock could interleave unlink and publish such that both won (proven by a 20-way contention test). Takeovers now go through a secondary create-if-absent lock (install.lock.takeover): the delete+publish section only ever runs in one process, staleness is re-validated inside it, and a fast-path creator that wins the briefly-free path simply beats the takeover. The takeover lock itself is age-swept (a live section lasts microseconds), and handles only release the lock instance they own. --- apps/kimi-code/src/cli/update/install-lock.ts | 127 ++++++++++++++---- .../test/cli/update/install-lock.test.ts | 16 +++ 2 files changed, 116 insertions(+), 27 deletions(-) diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 18453de847..1d4e4328af 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -1,10 +1,16 @@ -import { link, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { link, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; +/** + * A takeover's critical section is a few syscalls (microseconds), so a + * takeover lock older than this is crash residue and may be swept freely. + */ +const TAKEOVER_LOCK_STALE_MS = 60_000; + /** Uniquifies the publish-temp path across concurrent in-process acquirers. */ let lockTempCounter = 0; @@ -15,6 +21,8 @@ export interface UpdateInstallLockRequest { export interface UpdateInstallLockHandle { readonly filePath: string; + /** The exact contents this handle published — its ownership identity. */ + readonly content: string; release(): Promise; } @@ -44,28 +52,29 @@ function isProcessAlive(pid: number): boolean { } } -async function isStaleLock(filePath: string, now: Date): Promise { +/** + * Staleness check over the lock file's CONTENTS. Unparseable or shapeless + * content counts as stale (crash residue). Past the age threshold the + * holder's liveness decides: a native download is idle-bounded but + * intentionally not duration-bounded, so a slow link legitimately exceeds it; + * sweeping that lock would let a second downloader write the same `.staging` + * paths concurrently. (A pid reused by an unrelated process can pin the lock + * until that process exits — a delayed update, never a corrupt one.) + */ +function isStaleLockContent(raw: string, now: Date): boolean { + let parsed: unknown; try { - const raw = await readFile(filePath, 'utf-8'); - const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== 'object' || parsed === null) return true; - const lock = parsed as { readonly startedAt?: unknown; readonly pid?: unknown }; - if (typeof lock.startedAt !== 'string') return true; - const startedAt = Date.parse(lock.startedAt); - if (!Number.isFinite(startedAt)) return true; - if (now.getTime() - startedAt <= UPDATE_INSTALL_LOCK_STALE_MS) return false; - // Past the age threshold the holder's liveness decides. A native download - // is idle-bounded but intentionally not duration-bounded, so a slow link - // legitimately exceeds it; sweeping that lock would let a second - // downloader write the same `.staging` paths concurrently. (A pid reused - // by an unrelated process can pin the lock until that process exits — a - // delayed update, never a corrupt one.) - return typeof lock.pid === 'number' ? !isProcessAlive(lock.pid) : true; - } catch (error) { - if (isNotFound(error)) return true; - if (error instanceof SyntaxError) return true; - return false; + parsed = JSON.parse(raw); + } catch { + return true; } + if (typeof parsed !== 'object' || parsed === null) return true; + const lock = parsed as { readonly startedAt?: unknown; readonly pid?: unknown }; + if (typeof lock.startedAt !== 'string') return true; + const startedAt = Date.parse(lock.startedAt); + if (!Number.isFinite(startedAt)) return true; + if (now.getTime() - startedAt <= UPDATE_INSTALL_LOCK_STALE_MS) return false; + return typeof lock.pid === 'number' ? !isProcessAlive(lock.pid) : true; } async function createLockFile( @@ -94,7 +103,12 @@ async function createLockFile( return { filePath, + content, release: async (): Promise => { + // Release only the lock instance we own: a stale takeover may have + // replaced the file since we published it. + const current = await readFile(filePath, 'utf-8').catch(() => null); + if (current !== content) return; await unlink(filePath).catch((error: unknown) => { if (!isNotFound(error)) throw error; }); @@ -113,16 +127,75 @@ export async function tryAcquireUpdateInstallLock( if (!isAlreadyExists(error)) throw error; } - if (!(await isStaleLock(filePath, request.now ?? new Date()))) return null; - await unlink(filePath).catch((error: unknown) => { - if (!isNotFound(error)) throw error; - }); + // A lock file exists. Inspect it once to decide whether it is stale. + const inspected = await readFile(filePath, 'utf-8').catch(() => null); + if (inspected !== null && !isStaleLockContent(inspected, request.now ?? new Date())) { + return null; + } + if (inspected === null) { + // Vanished between create and read — retry the create once. + try { + return await createLockFile(filePath, request); + } catch (error) { + if (isAlreadyExists(error)) return null; + throw error; + } + } + // Stale lock. A pathname-level delete can never be conditioned on the file + // still being the inspected instance, so delete+publish MUST NOT run + // concurrently: serialize takeovers through a secondary create-if-absent + // lock and re-validate staleness inside that section. + const takeoverPath = `${filePath}.takeover`; + if (!(await acquireTakeoverLock(takeoverPath))) return null; try { - return await createLockFile(filePath, request); + const current = await readFile(filePath, 'utf-8').catch(() => null); + if (current !== null && !isStaleLockContent(current, request.now ?? new Date())) { + // A fresh lock appeared while we waited for the takeover section. + return null; + } + if (current !== null) { + await unlink(filePath).catch(() => {}); + } + try { + // A fast-path creator may still win the briefly-free path — its lock is + // legitimate (the path really was free), we simply lose. + return await createLockFile(filePath, request); + } catch (error) { + if (isAlreadyExists(error)) return null; + throw error; + } + } finally { + await unlink(takeoverPath).catch(() => {}); + } +} + +/** + * The takeover lock serializes stale-lock recovery. create-if-absent via hard + * link; an ancient holder is crash residue (a live section lasts microseconds) + * and is swept, then retried once. + */ +async function acquireTakeoverLock(takeoverPath: string): Promise { + if (await linkLockFile(takeoverPath)) return true; + const info = await stat(takeoverPath).catch(() => null); + if (info !== null && Date.now() - info.mtimeMs <= TAKEOVER_LOCK_STALE_MS) return false; + await unlink(takeoverPath).catch(() => {}); + return linkLockFile(takeoverPath); +} + +/** Create-if-absent publish of a small lock marker file. */ +async function linkLockFile(target: string): Promise { + const tempPath = `${target}.${process.pid}.${lockTempCounter}.tmp`; + lockTempCounter += 1; + await writeFile(tempPath, String(process.pid), { encoding: 'utf-8', mode: 0o600 }); + try { + await link(tempPath, target); + return true; } catch (error) { - if (isAlreadyExists(error)) return null; + if (isAlreadyExists(error)) return false; throw error; + } finally { + await unlink(tempPath).catch(() => {}); } } diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index e44eb33c90..3ce7e2b47e 100644 --- a/apps/kimi-code/test/cli/update/install-lock.test.ts +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -54,6 +54,22 @@ describe('update install lock', () => { await winners[0]?.release(); }); + it('grants exactly one winner when racing to take over a stale lock', async () => { + // A dead holder's aged lock: every contender classifies it as stale and + // tries to take it over. Compare-and-delete plus post-publish + // verification must leave exactly one survivor. + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + await new Promise((resolve) => child.once('exit', resolve)); + writeAgedLock(child.pid ?? -1); + + const attempts = await Promise.all( + Array.from({ length: 20 }, () => tryAcquireUpdateInstallLock({ version: '0.5.0' })), + ); + const winners = attempts.filter((handle) => handle !== null); + expect(winners).toHaveLength(1); + await winners[0]?.release(); + }); + it('recovers from a corrupt lock file', async () => { const filePath = getUpdateInstallLockFile(); mkdirSync(dirname(filePath), { recursive: true }); From 32f0e33ec5f1748a3691e0ca101453277e669553 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 01:49:00 +0800 Subject: [PATCH 19/42] fix: verify lock ownership after publish and preserve freshly staged exes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more race fixes from review: - install-lock: the stale-marker sweep repeats the inspect-then-delete race one level up — two contenders sweeping the same aged takeover marker could both win and enter the main-lock section together. Pathname APIs offer no conditional delete, so both the takeover marker and the main lock now verify ownership after publishing (unique marker content, read-back compare): a racing sweep converts to a single survivor instead of two holders. The irreducible residual (a delete landing in the microsecond link-to-verify window) degrades to a wasted download cycle, never a corrupt install — swap claims guard the exe independently. - native-swap: sweeping a stale swap claim deleted the exe it referenced even when a FRESH staged.json referenced the same version-derived name (a downloader re-staged the version after the swap crashed), throwing away a verified ~180 MB stage. The sweep now preserves any exe the current staged metadata still references. --- apps/kimi-code/src/cli/update/install-lock.ts | 21 +++++++++--- apps/kimi-code/src/cli/update/native-swap.ts | 11 ++++++- .../test/cli/update/native-swap.test.ts | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 1d4e4328af..0ddd5a9c8b 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -80,7 +80,7 @@ function isStaleLockContent(raw: string, now: Date): boolean { async function createLockFile( filePath: string, request: UpdateInstallLockRequest, -): Promise { +): Promise { const now = request.now ?? new Date(); const content = `${JSON.stringify({ version: request.version, @@ -100,6 +100,10 @@ async function createLockFile( } finally { await unlink(tempPath).catch(() => {}); } + // A racing stale-takeover may have removed our just-published lock and + // published its own; only the survivor may proceed. + const published = await readFile(filePath, 'utf-8').catch(() => null); + if (published !== content) return null; return { filePath, @@ -185,18 +189,27 @@ async function acquireTakeoverLock(takeoverPath: string): Promise { /** Create-if-absent publish of a small lock marker file. */ async function linkLockFile(target: string): Promise { - const tempPath = `${target}.${process.pid}.${lockTempCounter}.tmp`; + // Unique marker content doubles as the ownership identity below. + const marker = `${process.pid}.${lockTempCounter}`; + const tempPath = `${target}.${marker}.tmp`; lockTempCounter += 1; - await writeFile(tempPath, String(process.pid), { encoding: 'utf-8', mode: 0o600 }); + await writeFile(tempPath, marker, { encoding: 'utf-8', mode: 0o600 }); try { await link(tempPath, target); - return true; } catch (error) { if (isAlreadyExists(error)) return false; throw error; } finally { await unlink(tempPath).catch(() => {}); } + // The stale-marker sweep races this publish: it may unlink our fresh marker + // and link its own. Verify ownership so only the survivor of that race + // proceeds. (A delete landing after this read is the irreducible residual + // of pathname-only locking — there is no conditional-delete syscall; its + // worst case is a duplicated download cycle, never a corrupt install, + // because swap claims guard the executable independently.) + const published = await readFile(target, 'utf-8').catch(() => null); + return published === marker; } /** diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 2b994c9351..db7d5302f2 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -226,6 +226,12 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { } catch { return false; } + // A stale claim's referenced exe may have been REPLACED by a fresh staged + // download of the same version — downloaders deliberately coexist with + // claims, and the exe name is version-derived, so the names collide. The + // file the CURRENT staged metadata references belongs to that fresh stage, + // not to the crashed swap: preserve it. + const currentStaged = await readStagedNativeUpdate(exePath).catch(() => null); let swapInProgress = false; for (const entry of entries) { if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; @@ -243,7 +249,10 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { if (typeof exeFileName === 'string' && exeFileName.length > 0) { // basename(): the metadata contract is a plain file name — never // let a hand-crafted path escape the staging dir. - await unlink(join(stagingDir, basename(exeFileName))).catch(() => {}); + const referenced = basename(exeFileName); + if (referenced !== currentStaged?.exeFileName) { + await unlink(join(stagingDir, referenced)).catch(() => {}); + } } } catch { // Unparseable claim file — remove it anyway. diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 16d8239047..31b85f7f83 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -445,6 +445,39 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { ).resolves.toBeDefined(); }); + it('keeps the exe a fresh staged.json references when sweeping a stale claim', async () => { + // A swap crashed after claiming V (stale claim residue), and a downloader + // has since re-staged V: both records reference the same version-derived + // exe name. Sweeping the claim must not delete the freshly staged exe. + await seedStagedUpdate(exePath, STAGED_VERSION); + const stagingDir = getNativeStagingDir(exePath); + const claimPath = join(stagingDir, 'staged.json.swap-4242'); + await writeFile( + claimPath, + `${JSON.stringify({ + version: STAGED_VERSION, + target: 'linux-x64', + exeFileName: stagedExeFileName(STAGED_VERSION, 'linux'), + sha256: 'a'.repeat(64), + exeSize: STAGED_EXE_SIZE, + stagedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + })}\n`, + 'utf-8', + ); + const past = new Date(Date.now() - 10 * 60 * 1000); + await utimes(claimPath, past, past); + + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + // The stale claim is swept, the fresh stage survives and is swapped in. + await expect(stat(claimPath)).rejects.toThrow(); + expect(relaunched).toBe(true); + expect(calls).toHaveLength(2); // smoke check + re-exec + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + }); + it('stamps the claim with a fresh mtime so a concurrent launch does not misread it as stale', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // The metadata may have been staged long before this launch (background From 41b3c0abd42cd2a7ff970c5d16846f64764b76c2 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 10:48:07 +0800 Subject: [PATCH 20/42] fix: reject mismatched manifests, take over from dead holders, unique .part names Three robustness fixes from review: - native-manifest: the per-release endpoint can answer with ANOTHER release's manifest (stale cache, mispublish); its checksums would then be applied to this version's binary and fail verification on every attempt. Compare the parsed manifest version with the requested one. - install-lock/update-download: a killed lock holder skips its finally and never releases, stranding a waiting foreground `kimi upgrade` forever. A lock whose recorded pid is dead is now stale at any age (the atomic publish guarantees the pid was alive when written), and the same-version wait loop polls the acquisition itself, so a dead holder's lock is taken over within one poll instead of never. Package-manager spawns are unaffected: they hold the lock only around the spawn, and the active-record bookkeeping guards that layer. - native-stage: the download intermediate is now unique per worker (`.part` carries pid + counter), so overlapping same-version workers can no longer interleave writes into the same file. --- apps/kimi-code/src/cli/sub/update-download.ts | 38 +++++++++++++------ apps/kimi-code/src/cli/update/install-lock.ts | 17 +++++---- .../src/cli/update/native-manifest.ts | 9 ++++- apps/kimi-code/src/cli/update/native-stage.ts | 14 ++++++- .../test/cli/update-download.test.ts | 6 +-- .../test/cli/update/install-lock.test.ts | 23 +++++++++++ .../test/cli/update/native-manifest.test.ts | 9 +++++ .../test/cli/update/native-stage.test.ts | 29 ++++++++------ 8 files changed, 108 insertions(+), 37 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index 4626d0f788..fb87e1bc3e 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -11,27 +11,38 @@ import { log } from '@moonshot-ai/kimi-code-sdk'; import { readUpdateInstallLockVersion, tryAcquireUpdateInstallLock, + type UpdateInstallLockHandle, } from '#/cli/update/install-lock'; import { readStagedNativeUpdate, stageNativeUpdate } from '#/cli/update/native-stage'; import { detectNativeInstall } from '#/cli/update/source'; const LOCK_HELD_POLL_INTERVAL_MS = 2_000; +type StagedUpdateWait = + | { readonly status: 'staged' } + | { readonly status: 'takeover'; readonly lock: UpdateInstallLockHandle | null }; + /** * Another worker holds the install lock for the SAME version. Returning right * away would report a success that has not happened yet — the in-flight - * download may still fail — so wait for it: resolves true once its staged - * update is verified on disk, false when the holder finished without staging - * (the caller then takes over the download itself). + * download may still fail — so wait for it: 'staged' once its staged update is + * verified on disk; 'takeover' once the lock becomes acquirable, with the lock + * already held for the caller. The lock goes stale the moment its holder dies + * (see install-lock), so a killed downloader cannot strand a foreground + * `kimi upgrade` in this loop. */ -async function waitForStagedUpdate(version: string, exePath: string): Promise { +async function waitForStagedUpdate( + version: string, + exePath: string, +): Promise { for (;;) { const staged = await readStagedNativeUpdate(exePath); - if (staged !== null && staged.version === version) return true; - // staged.json lands before the holder releases its lock, so a lock that - // is gone (or changed hands) with nothing staged means the holder failed. - const holderVersion = await readUpdateInstallLockVersion(); - if (holderVersion !== version) return false; + if (staged !== null && staged.version === version) return { status: 'staged' }; + // Poll the acquisition itself: while the holder lives its lock stays + // fresh and this returns null without side effects; when the holder + // finishes (or dies) without staging, the takeover happens right here. + const lock = await tryAcquireUpdateInstallLock({ version }); + if (lock !== null) return { status: 'takeover', lock }; await new Promise((resolve) => { setTimeout(resolve, LOCK_HELD_POLL_INTERVAL_MS); }); @@ -53,12 +64,15 @@ export async function runUpdateDownloadCommand(version: string): Promise out.write( `A download of Kimi Code ${version} is already in progress; waiting for it to finish…\n`, ); - if (await waitForStagedUpdate(version, process.execPath)) { + const wait = await waitForStagedUpdate(version, process.execPath); + if (wait.status === 'staged') { out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`); return 0; } - // The holder finished without staging (failed or crashed): take over. - lock = await tryAcquireUpdateInstallLock({ version }); + // The holder finished without staging (failed or died): take over. The + // lock may already be held by another winner of the takeover race — + // the null check below reports that as held. + lock = wait.lock; } else if (holderVersion === undefined) { // The lock was released between the two reads — retry the acquire once. lock = await tryAcquireUpdateInstallLock({ version }); diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 0ddd5a9c8b..36fc49157b 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -54,12 +54,14 @@ function isProcessAlive(pid: number): boolean { /** * Staleness check over the lock file's CONTENTS. Unparseable or shapeless - * content counts as stale (crash residue). Past the age threshold the - * holder's liveness decides: a native download is idle-bounded but - * intentionally not duration-bounded, so a slow link legitimately exceeds it; - * sweeping that lock would let a second downloader write the same `.staging` - * paths concurrently. (A pid reused by an unrelated process can pin the lock - * until that process exits — a delayed update, never a corrupt one.) + * content counts as stale (crash residue). A holder that is gone can never + * release its lock (a killed process skips its finally) nor make progress — + * stale at ANY age; the atomic publish guarantees the pid was written + * complete by a then-live process, so a dead pid means the holder died + * afterwards. Past the age threshold a LIVE holder still survives: a native + * download is idle-bounded but intentionally not duration-bounded, so a slow + * link legitimately exceeds it. (A pid reused by an unrelated process can pin + * the lock until that process exits — a delayed update, never a corrupt one.) */ function isStaleLockContent(raw: string, now: Date): boolean { let parsed: unknown; @@ -73,8 +75,9 @@ function isStaleLockContent(raw: string, now: Date): boolean { if (typeof lock.startedAt !== 'string') return true; const startedAt = Date.parse(lock.startedAt); if (!Number.isFinite(startedAt)) return true; + if (typeof lock.pid === 'number' && !isProcessAlive(lock.pid)) return true; if (now.getTime() - startedAt <= UPDATE_INSTALL_LOCK_STALE_MS) return false; - return typeof lock.pid === 'number' ? !isProcessAlive(lock.pid) : true; + return typeof lock.pid !== 'number'; } async function createLockFile( diff --git a/apps/kimi-code/src/cli/update/native-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts index 391983d070..06c7c5bfc5 100644 --- a/apps/kimi-code/src/cli/update/native-manifest.ts +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -68,7 +68,14 @@ export async function fetchNativeReleaseManifest( if (!response.ok) { throw new Error(`native manifest for ${version} returned HTTP ${response.status}`); } - return NativeReleaseManifestSchema.parse(JSON.parse(await response.text())); + const manifest = NativeReleaseManifestSchema.parse(JSON.parse(await response.text())); + // A stale or mispublished endpoint can answer with ANOTHER release's + // manifest: its checksums would then be applied to this version's binary + // and every download would fail verification. Reject the mismatch here. + if (manifest.version !== version) { + throw new Error(`manifest for ${version} served content for ${manifest.version}`); + } + return manifest; } finally { clearTimeout(timeout); } diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 7e8ad09e2f..c85f1ccd5e 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -159,6 +159,9 @@ export interface StageNativeUpdateResult { */ const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; +/** Uniquifies the .part path across concurrent in-process workers. */ +let stageTempCounter = 0; + async function downloadAndHash( url: string, partPath: string, @@ -253,7 +256,16 @@ export async function stageNativeUpdate( stagedAt: new Date().toISOString(), }; - const partPath = join(stagingDir, `${exeFileName}.part`); + // Unique .part name per worker: a same-version downloader may overlap a + // swap claim (and, in the irreducible residual of pathname-level locking, a + // second lock holder) — a shared .part path would interleave writes into + // garbage that fails verification, with each side's cleanup deleting the + // other's payload. + const partPath = join( + stagingDir, + `${exeFileName}.${process.pid}.${stageTempCounter}.part`, + ); + stageTempCounter += 1; try { const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); const entry = selectPlatformEntry(manifest, platform, arch); diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index e860a64ad0..ea9ff23b49 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -131,10 +131,8 @@ describe('runUpdateDownloadCommand', () => { const release = vi.fn(async () => {}); mocks.tryAcquireUpdateInstallLock .mockResolvedValueOnce(null) // held by the other worker… - .mockResolvedValueOnce({ filePath: '/tmp/install.lock', release }); // …then ours - mocks.readUpdateInstallLockVersion - .mockResolvedValueOnce('0.7.0') // the initial holder check - .mockResolvedValueOnce(undefined); // inside the wait: lock released, nothing staged + .mockResolvedValueOnce({ filePath: '/tmp/install.lock', release }); // …won inside the wait loop + mocks.readUpdateInstallLockVersion.mockResolvedValueOnce('0.7.0'); // the initial holder check mocks.readStagedNativeUpdate.mockResolvedValue(null); await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); expect(mocks.stageNativeUpdate).toHaveBeenCalledWith( diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index 3ce7e2b47e..335e4f0873 100644 --- a/apps/kimi-code/test/cli/update/install-lock.test.ts +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -115,4 +115,27 @@ describe('update install lock', () => { expect(lock).not.toBeNull(); await lock?.release(); }); + + it('sweeps a young lock whose holder process is gone', async () => { + // A killed holder skips its finally and never releases: the dead pid must + // make the lock stale immediately, not after the 30-minute threshold. + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + await new Promise((resolve) => child.once('exit', resolve)); + const filePath = getUpdateInstallLockFile(); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + `${JSON.stringify({ + version: '0.5.0', + pid: child.pid ?? -1, + startedAt: new Date().toISOString(), + })}\n`, + 'utf-8', + ); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); }); diff --git a/apps/kimi-code/test/cli/update/native-manifest.test.ts b/apps/kimi-code/test/cli/update/native-manifest.test.ts index b9ccf31648..32a930db1c 100644 --- a/apps/kimi-code/test/cli/update/native-manifest.test.ts +++ b/apps/kimi-code/test/cli/update/native-manifest.test.ts @@ -65,6 +65,15 @@ describe('fetchNativeReleaseManifest', () => { expect(f).not.toHaveBeenCalled(); }); + it('rejects a manifest served for a different release', async () => { + // A stale/mispublished endpoint answering with another version's manifest + // must not apply that release's checksums to this version's binary. + const body = JSON.stringify({ version: '0.9.9', platforms: {} }); + await expect( + fetchNativeReleaseManifest(VERSION, mockFetch({ ok: true, status: 200, body })), + ).rejects.toThrow(/0\.9\.9/); + }); + it('throws on non-2xx', async () => { await expect( fetchNativeReleaseManifest(VERSION, mockFetch({ ok: false, status: 404 })), diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 989ee650c8..0db9b2472d 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -125,9 +125,10 @@ describe('stageNativeUpdate', () => { const exeBytes = await readFile(stagedExePath(exePath, result.staged)); expect(exeBytes.equals(PAYLOAD)).toBe(true); // The .part intermediate is gone once the download was promoted. - await expect( - stat(join(getNativeStagingDir(exePath), `kimi-${VERSION}.part`)), - ).rejects.toThrow(); + const leftovers = (await readdir(getNativeStagingDir(exePath))).filter((entry) => + entry.endsWith('.part'), + ); + expect(leftovers).toEqual([]); }); it('marks the staged exe executable', async () => { @@ -154,16 +155,20 @@ describe('stageNativeUpdate', () => { fetchImpl: mockCdnFetch({ payload: PAYLOAD }), }); const stagedExe = stagedExePath(exePath, result.staged); - const partPath = `${stagedExe}.part`; - const chmodIndex = fsMocks.calls.findIndex( - (call) => call.op === 'chmod' && call.path === partPath, + const chmodCall = fsMocks.calls.find( + (call) => call.op === 'chmod' && call.path.endsWith('.part'), ); - const publishIndex = fsMocks.calls.findIndex( - (call) => call.op === 'rename' && call.path === partPath && call.dst === stagedExe, + const publishCall = fsMocks.calls.find( + (call) => call.op === 'rename' && call.dst === stagedExe, + ); + if (chmodCall === undefined || publishCall === undefined) { + throw new Error('expected chmod(.part) and rename(.part → staged) calls'); + } + // The chmod lands on the very .part file that gets published, before it. + expect(publishCall.path).toBe(chmodCall.path); + expect(fsMocks.calls.indexOf(chmodCall)).toBeLessThan( + fsMocks.calls.indexOf(publishCall), ); - expect(chmodIndex).toBeGreaterThanOrEqual(0); - expect(publishIndex).toBeGreaterThanOrEqual(0); - expect(chmodIndex).toBeLessThan(publishIndex); }); it('reports download progress with the Content-Length total', async () => { From c8dbe15cf5e8158198f2928ae599c4361beed4b8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 11:04:37 +0800 Subject: [PATCH 21/42] fix: restrict staging cleanup to updater-owned names and retry short writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cleanupStagingOrphans recursively deleted anything it did not recognize; the staging dir sits next to the exe and can contain files belonging to the user or another tool. Deletion now requires a positive match on updater-owned artifact names (staged exes and .part intermediates) and only ever unlinks files. - FileHandle.write may persist fewer bytes than requested (short write, e.g. near disk exhaustion) while the running hash and size already accounted for the whole chunk — publishing a truncated binary under a valid checksum. The chunk write now loops until fully persisted. --- apps/kimi-code/src/cli/update/native-stage.ts | 31 +++++++- .../test/cli/update/native-stage.test.ts | 70 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index c85f1ccd5e..b40ff8c187 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -9,7 +9,7 @@ */ import { createHash } from 'node:crypto'; -import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'; +import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { z } from 'zod'; @@ -95,6 +95,15 @@ export async function removeStagedNativeUpdate( await rmdir(stagingDir).catch(() => {}); } +/** + * Entries the updater itself owns inside `.staging/`: staged executables + * (`kimi-[.exe]`) and download intermediates + * (`kimi-[.exe][..].part`). Cleanup must positively match + * this pattern — anything else in the directory belongs to the user or + * another tool and is left alone. + */ +const UPDATER_OWNED_STAGING_FILE = /^kimi-\d+\.\d+\.\d+(\.exe)?((\.\d+\.\d+)?\.part)?$/; + /** * Remove files in `.staging/` that nothing references: interrupted downloads * (`.part`), and staged exes whose `staged.json` never landed (downloader @@ -128,7 +137,11 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { } for (const entry of entries) { if (keep.has(entry)) continue; - await rm(join(stagingDir, entry), { force: true, recursive: true }).catch(() => {}); + // Only ever unlink updater-owned artifact names (files, never + // directories): the staging dir sits next to the exe and may contain + // data that is not ours. + if (!UPDATER_OWNED_STAGING_FILE.test(entry)) continue; + await unlink(join(stagingDir, entry)).catch(() => {}); } } @@ -201,7 +214,19 @@ async function downloadAndHash( armIdleTimeout(); hash.update(chunk); size += chunk.length; - await file.write(chunk); + // FileHandle.write may persist FEWER bytes than requested (a short + // write, e.g. near disk exhaustion) while the hash and size above + // already account for the whole chunk — an unretried short write would + // publish a truncated binary under a valid checksum. Loop until the + // chunk is fully on disk. + let offset = 0; + while (offset < chunk.length) { + const { bytesWritten } = await file.write(chunk, offset); + if (bytesWritten === 0) { + throw new Error('failed to write the native binary to disk (disk full?)'); + } + offset += bytesWritten; + } onProgress?.(size, total); } } finally { diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 0db9b2472d..d64891a139 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -17,6 +17,8 @@ import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; const fsMocks = vi.hoisted(() => ({ /** Records chmod/rename calls (path-based) so tests can assert ordering. */ calls: [] as Array<{ readonly op: 'chmod' | 'rename'; readonly path: string; readonly dst?: string }>, + /** When > 0, the next open() wraps its handle so the first write is short. */ + shortWriteBudget: 0, })); vi.mock('node:fs/promises', async (importOriginal) => { @@ -37,6 +39,34 @@ vi.mock('node:fs/promises', async (importOriginal) => { fsMocks.calls.push({ op: 'rename', path: String(src), dst: String(dst) }); return actual.rename(src, dst); }, + open: async ( + path: Parameters[0], + flags: Parameters[1], + mode: Parameters[2], + ) => { + const handle = await actual.open(path, flags, mode); + if (fsMocks.shortWriteBudget <= 0) return handle; + fsMocks.shortWriteBudget -= 1; + let truncated = false; + return { + // FileHandle methods live on the prototype, so delegate explicitly. + write: async ( + buffer: Buffer, + offset?: number | null, + length?: number | null, + position?: number | null, + ) => { + const off = offset ?? 0; + const len = length ?? buffer.length - off; + // The first write persists only half the requested bytes. + const effectiveLen = !truncated && len > 1 ? Math.floor(len / 2) : len; + truncated = true; + const result = await handle.write(buffer, off, effectiveLen, position ?? null); + return { bytesWritten: result.bytesWritten, buffer: result.buffer }; + }, + close: () => handle.close(), + }; + }, }; }); @@ -96,6 +126,7 @@ describe('stageNativeUpdate', () => { workDir = await mkdtemp(join(tmpdir(), 'kimi-stage-test-')); exePath = join(workDir, 'bin', 'kimi'); fsMocks.calls.length = 0; + fsMocks.shortWriteBudget = 0; }); afterEach(async () => { @@ -359,6 +390,45 @@ describe('stageNativeUpdate', () => { await expect(stat(join(stagingDir, 'staged.json.swap-1234'))).resolves.toBeDefined(); await expect(stat(join(stagingDir, claimExe))).resolves.toBeDefined(); }); + + it('retries short writes until each chunk is fully persisted', async () => { + // The first write to the .part file persists only half its bytes; the + // write loop must make up the remainder or the staged exe is truncated. + fsMocks.shortWriteBudget = 1; + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + expect(result.status).toBe('staged'); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + }); + + it('leaves foreign files in the staging directory alone', async () => { + const stagingDir = getNativeStagingDir(exePath); + const { mkdir } = await import('node:fs/promises'); + await mkdir(join(stagingDir, 'some-other-tool'), { recursive: true }); + await writeFile(join(stagingDir, 'user-notes.txt'), 'not ours', 'utf-8'); + await writeFile(join(stagingDir, 'some-other-tool', 'cache.bin'), 'not ours either'); + // A genuine updater-owned orphan to prove cleanup still works. + await writeFile(join(stagingDir, 'kimi-9.9.9'), Buffer.from('orphan-exe')); + + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + expect(result.status).toBe('staged'); + + await expect(stat(join(stagingDir, 'kimi-9.9.9'))).rejects.toThrow(); + await expect(stat(join(stagingDir, 'user-notes.txt'))).resolves.toBeDefined(); + await expect(stat(join(stagingDir, 'some-other-tool', 'cache.bin'))).resolves.toBeDefined(); + }); }); describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { From e5aa04be17eee234bc0ee3e5fda17e82245bbaa8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 11:22:22 +0800 Subject: [PATCH 22/42] fix: scope failure cleanup, recognize all semvers, reverify staged checksums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review: - native-stage failure cleanup deleted whatever staged update was currently published — including a concurrent worker's valid result that its caller had already reported as success. The catch path now removes only this attempt's own artifacts: its unique .part file and its staged exe name when the current metadata does not reference it. - The orphan-cleanup ownership check only matched stable x.y.z names; prerelease/build-metadata versions (1.2.3-rc.1, 1.2.3+build) would never be cleaned and accumulate ~180 MB each. Ownership now derives from the semver contract via the semver package's valid(). - The swap path trusted a staged exe whose size matched, though the metadata records the release checksum; post-download on-disk damage could pass the --version smoke check with corrupted bytes. claimStagedUpdate now re-verifies the staged exe's sha256 before claiming and discards the stage (for a later re-download) on mismatch — paid only when an update is actually pending. --- apps/kimi-code/src/cli/update/native-stage.ts | 55 ++++++++++++--- apps/kimi-code/src/cli/update/native-swap.ts | 15 ++++ .../test/cli/update/native-stage.test.ts | 70 +++++++++++++++++++ .../test/cli/update/native-swap.test.ts | 29 ++++++-- 4 files changed, 156 insertions(+), 13 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index b40ff8c187..f32d6f0928 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -9,9 +9,11 @@ */ import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises'; import { basename, join } from 'node:path'; +import { valid } from 'semver'; import { z } from 'zod'; import { KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME } from '#/constant/app'; @@ -95,14 +97,42 @@ export async function removeStagedNativeUpdate( await rmdir(stagingDir).catch(() => {}); } +/** Stream a file's sha256 as hex; null when the file cannot be read. */ +export async function hashFileSha256(filePath: string): Promise { + try { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); + } catch { + return null; + } +} + /** - * Entries the updater itself owns inside `.staging/`: staged executables - * (`kimi-[.exe]`) and download intermediates - * (`kimi-[.exe][..].part`). Cleanup must positively match - * this pattern — anything else in the directory belongs to the user or - * another tool and is left alone. + * Whether a `.staging/` entry is an updater-owned artifact: a staged + * executable (`kimi-[.exe]`) or a download intermediate + * (`kimi-[.exe][..].part`). Ownership derives from the + * semver/file-name contract (prerelease and build metadata included), so + * foreign files in the directory are never matched. */ -const UPDATER_OWNED_STAGING_FILE = /^kimi-\d+\.\d+\.\d+(\.exe)?((\.\d+\.\d+)?\.part)?$/; +function isUpdaterOwnedStagingFile(entry: string): boolean { + if (!entry.startsWith('kimi-')) return false; + let name = entry.slice('kimi-'.length); + const isPart = name.endsWith('.part'); + if (isPart) name = name.slice(0, -'.part'.length); + const candidates = isPart + ? // New-style intermediates carry a unique worker infix (..) + // after any .exe — try with and without stripping it (the infix is + // itself dot-numeric, which is ambiguous with prerelease suffixes). + [name, name.replace(/\.\d+\.\d+$/, '')] + : [name]; + return candidates.some((candidate) => { + const base = candidate.endsWith('.exe') ? candidate.slice(0, -'.exe'.length) : candidate; + return valid(base) !== null; + }); +} /** * Remove files in `.staging/` that nothing references: interrupted downloads @@ -140,7 +170,7 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { // Only ever unlink updater-owned artifact names (files, never // directories): the staging dir sits next to the exe and may contain // data that is not ours. - if (!UPDATER_OWNED_STAGING_FILE.test(entry)) continue; + if (!isUpdaterOwnedStagingFile(entry)) continue; await unlink(join(stagingDir, entry)).catch(() => {}); } } @@ -321,7 +351,16 @@ export async function stageNativeUpdate( return { status: 'staged', staged }; } catch (error) { await rm(partPath, { force: true }).catch(() => {}); - await removeStagedNativeUpdate(options.exePath); + // Remove only what THIS attempt owns. Another worker may have staged the + // same version while we were downloading — that result belongs to the + // current metadata, not to this failing attempt. + const current = await readStagedNativeUpdate(options.exePath).catch(() => null); + if (current?.exeFileName !== staged.exeFileName) { + await rm(stagedExePath(options.exePath, staged), { force: true }).catch(() => {}); + } + // Best effort: drop the staging dir itself when empty (a concurrent + // worker's files keep it around — rmdir only removes empty dirs). + await rmdir(getNativeStagingDir(options.exePath)).catch(() => {}); throw error; } } diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index db7d5302f2..c5c35b2aaa 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -32,6 +32,7 @@ import { import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; import { + hashFileSha256, readStagedNativeUpdate, removeStagedNativeUpdate, stagedExePath, @@ -164,6 +165,20 @@ async function claimStagedUpdate(exePath: string): Promise const stateFile = getNativeStagedStateFile(exePath); const staged = await readStagedNativeUpdate(exePath, stateFile); if (staged === null) return null; + // Re-verify the staged bytes against the recorded checksum before claiming: + // the exe could have been damaged on disk after the download verified it + // (corruption, a non-durable interrupted write), and the `--version` smoke + // check alone would not catch every such case. Only paid when an update is + // actually pending. A mismatch discards the stage so a later cycle + // re-downloads it — this is not a swap failure. + const digest = await hashFileSha256(stagedExePath(exePath, staged)); + if (digest !== staged.sha256) { + logSwap('staged exe failed checksum verification, discarding', { + version: staged.version, + }); + await removeStagedNativeUpdate(exePath, staged); + return null; + } const claimedPath = `${stateFile}.swap-${process.pid}`; try { diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index d64891a139..11c9495bac 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -429,6 +429,76 @@ describe('stageNativeUpdate', () => { await expect(stat(join(stagingDir, 'user-notes.txt'))).resolves.toBeDefined(); await expect(stat(join(stagingDir, 'some-other-tool', 'cache.bin'))).resolves.toBeDefined(); }); + + it('cleans orphans with prerelease and build-metadata versions', async () => { + const stagingDir = getNativeStagingDir(exePath); + const { mkdir } = await import('node:fs/promises'); + await mkdir(stagingDir, { recursive: true }); + await writeFile(join(stagingDir, 'kimi-1.2.3-rc.1'), Buffer.from('orphan')); + await writeFile(join(stagingDir, 'kimi-1.2.3+build.5.exe'), Buffer.from('orphan')); + await writeFile(join(stagingDir, 'kimi-1.2.3-rc.1.123.0.part'), Buffer.from('partial')); + + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + expect(result.status).toBe('staged'); + + await expect(stat(join(stagingDir, 'kimi-1.2.3-rc.1'))).rejects.toThrow(); + await expect(stat(join(stagingDir, 'kimi-1.2.3+build.5.exe'))).rejects.toThrow(); + await expect(stat(join(stagingDir, 'kimi-1.2.3-rc.1.123.0.part'))).rejects.toThrow(); + }); + + it("preserves another worker's staged result when this attempt fails", async () => { + const stagingDir = getNativeStagingDir(exePath); + const exeFileName = `kimi-${VERSION}`; + const otherPayload = Buffer.from('other-worker-payload'); + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url === nativeManifestUrl(VERSION)) { + const manifestBody = JSON.stringify({ + version: VERSION, + platforms: { + 'linux-x64': { filename: BINARY_FILENAME, checksum: sha256Hex(PAYLOAD) }, + }, + }); + return { ok: true, status: 200, text: async () => manifestBody, body: null }; + } + if (url === nativeBinaryUrl(VERSION, BINARY_FILENAME)) { + // A concurrent worker publishes its valid stage mid-download… + const { mkdir } = await import('node:fs/promises'); + await mkdir(stagingDir, { recursive: true }); + await writeFile(join(stagingDir, exeFileName), otherPayload); + await writeFile( + getNativeStagedStateFile(exePath), + `${JSON.stringify({ + version: VERSION, + target: 'linux-x64', + exeFileName, + sha256: sha256Hex(otherPayload), + exeSize: otherPayload.length, + stagedAt: new Date().toISOString(), + })}\n`, + ); + // …then this attempt's download fails. + return { ok: false, status: 503, text: async () => '', body: null }; + } + return { ok: false, status: 404, text: async (): Promise => '', body: null }; + }) as unknown as typeof fetch; + + await expect( + stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', fetchImpl }), + ).rejects.toThrow(/503/); + + // The concurrent worker's stage survives this attempt's failure cleanup. + const staged = await readStagedNativeUpdate(exePath); + expect(staged?.version).toBe(VERSION); + const bytes = await readFile(join(stagingDir, exeFileName)); + expect(bytes.equals(otherPayload)).toBe(true); + }); }); describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 31b85f7f83..f0229049c5 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -116,17 +117,17 @@ function createSpawnMock(routes: { async function seedStagedUpdate(exePath: string, version: string): Promise { const stagingDir = getNativeStagingDir(exePath); await mkdir(stagingDir, { recursive: true }); - await writeFile( - join(stagingDir, stagedExeFileName(version, 'linux')), - Buffer.alloc(STAGED_EXE_SIZE, 1), - ); + const exeBytes = Buffer.alloc(STAGED_EXE_SIZE, 1); + await writeFile(join(stagingDir, stagedExeFileName(version, 'linux')), exeBytes); await writeFile( getNativeStagedStateFile(exePath), `${JSON.stringify({ version, target: 'linux-x64', exeFileName: stagedExeFileName(version, 'linux'), - sha256: 'a'.repeat(64), + // The swap re-verifies the staged bytes against this checksum, so the + // seed must record the payload's real sha256. + sha256: createHash('sha256').update(exeBytes).digest('hex'), exeSize: STAGED_EXE_SIZE, stagedAt: new Date().toISOString(), }, null, 2)}\n`, @@ -478,6 +479,24 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); }); + it('discards a staged update whose exe fails the recorded checksum', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // Same size, different bytes — post-download on-disk damage. + const stagingDir = getNativeStagingDir(exePath); + const stagedExe = join(stagingDir, stagedExeFileName(STAGED_VERSION, 'linux')); + await writeFile(stagedExe, Buffer.alloc(STAGED_EXE_SIZE, 2)); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // The corrupt stage is discarded so a later cycle re-downloads it; the + // running exe is never touched. + await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); + await expect(stat(stagedExe)).rejects.toThrow(); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + it('stamps the claim with a fresh mtime so a concurrent launch does not misread it as stale', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // The metadata may have been staged long before this launch (background From 37bbf4709e721aaf4945cece57837f937ce9e12e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 11:38:03 +0800 Subject: [PATCH 23/42] fix: validate versions before path derivation and honor the update opt-out in the swap - native-stage: stageNativeUpdate derived staging paths (including the cleanup rm targets) from the version before fetchNativeReleaseManifest rejected it; a traversal string like `x/../../kimi` would resolve the staged-exe cleanup onto the running installation. The semver check now happens before any path is derived, and the staged-metadata schema constrains exeFileName to a plain file name. - native-swap: the startup swap ran before the update preflight, so KIMI_CODE_NO_AUTO_UPDATE / KIMI_CLI_NO_AUTO_UPDATE stopped gating update behavior once a payload was pending. The swap now honors the same opt-out: the staged payload stays in place for a later launch without the variable, and the current exe starts. --- apps/kimi-code/src/cli/update/native-stage.ts | 11 +++++- apps/kimi-code/src/cli/update/native-swap.ts | 7 ++++ apps/kimi-code/src/cli/update/preflight.ts | 9 ++--- .../test/cli/update/native-stage.test.ts | 35 +++++++++++++++++++ .../test/cli/update/native-swap.test.ts | 18 ++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index f32d6f0928..b98fa125fc 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -31,7 +31,10 @@ const StagedNativeUpdateSchema = z version: z.string().min(1), target: z.string().min(1), /** Base name of the staged executable inside `.staging/`. */ - exeFileName: z.string().min(1), + exeFileName: z + .string() + .min(1) + .refine((value) => basename(value) === value, { error: 'must be a plain file name' }), /** sha256 of the staged binary (the manifest's checksum). */ sha256: z.string().regex(/^[a-f0-9]{64}$/), exeSize: z.number().int().min(1), @@ -283,6 +286,12 @@ export async function stageNativeUpdate( ): Promise { const platform = options.platform ?? process.platform; const arch = options.arch ?? process.arch; + // Validate BEFORE anything derives a filesystem path from the version: the + // hidden download command takes it from argv, and a non-semver could carry + // path traversal into the cleanup paths below. + if (valid(options.version) === null) { + throw new Error(`invalid semver for native staging: ${JSON.stringify(options.version)}`); + } const fetchImpl = options.fetchImpl ?? fetch; const target = `${platform}-${arch}`; const exeFileName = stagedExeFileName(options.version, platform); diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index c5c35b2aaa..4952bc8363 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -38,6 +38,7 @@ import { stagedExePath, type StagedNativeUpdate, } from './native-stage'; +import { isAutoUpdateDisabledByEnv } from './preflight'; import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; export interface NativeSwapDeps { @@ -354,6 +355,12 @@ export async function maybeRelaunchWithStagedNativeUpdate( ): Promise { if (!deps.isNative) return false; const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath); + if (isAutoUpdateDisabledByEnv(deps.env)) { + // The user opted out of automatic updates entirely: leave any staged + // payload in place (it still applies on a later launch without the + // opt-out) and start the current exe. + return false; + } if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) { // Read-once guard: drop it so this session's children (and any nested // kimi launches from them) do not inherit the swap skip. diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 2e6093bd39..543a6666b3 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -485,11 +485,12 @@ async function showPendingBackgroundInstallNotice( /** * `KIMI_CODE_NO_AUTO_UPDATE` (or the legacy `KIMI_CLI_NO_AUTO_UPDATE` alias) - * fully disables the update preflight — no check, no background install, no - * prompt. Migrated from kimi-cli, where the variable gated all auto-update - * behavior. Accepts the usual truthy values (`1`/`true`/`yes`/`on`). + * fully disables automatic update behavior — no check, no background install, + * no prompt, and no staged-swap at startup (see `native-swap.ts`). Migrated + * from kimi-cli, where the variable gated all auto-update behavior. Accepts + * the usual truthy values (`1`/`true`/`yes`/`on`). */ -function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { +export function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { const truthy = (value?: string): boolean => ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); return truthy(env['KIMI_CODE_NO_AUTO_UPDATE']) || truthy(env['KIMI_CLI_NO_AUTO_UPDATE']); diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 11c9495bac..3d1eadfc5f 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -336,6 +336,22 @@ describe('stageNativeUpdate', () => { ).rejects.toThrow(/win32-arm64 not found/); }); + it('rejects a traversal version before deriving any filesystem path', async () => { + const fetchImpl = mockCdnFetch({ payload: PAYLOAD }); + await expect( + stageNativeUpdate({ + version: 'x/../../kimi', + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl, + }), + ).rejects.toThrow(/invalid semver/); + expect(fetchImpl).not.toHaveBeenCalled(); + // Nothing was created anywhere. + await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); + }); + it('supersedes a staged older version', async () => { await stageNativeUpdate({ version: '0.6.0', @@ -522,6 +538,25 @@ describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { expect(await readStagedNativeUpdate(exePath)).toBeNull(); }); + it('returns null when exeFileName is not a plain file name', async () => { + const { mkdir } = await import('node:fs/promises'); + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + await writeFile( + getNativeStagedStateFile(exePath), + JSON.stringify({ + version: '0.7.0', + target: 'linux-x64', + exeFileName: '../../evil', + sha256: 'a'.repeat(64), + exeSize: 42, + stagedAt: new Date().toISOString(), + }), + 'utf-8', + ); + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + }); + it('returns null when the exe size drifted from the metadata', async () => { const { staged } = await stageNativeUpdate({ version: VERSION, diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index f0229049c5..e84a6d4111 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -497,6 +497,24 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); }); + it('leaves a staged update in place when automatic updates are disabled by env', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { + spawnImpl, + env: { PATH: '/usr/bin', KIMI_CODE_NO_AUTO_UPDATE: '1' }, + }), + ); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // The payload stays staged for a later launch without the opt-out; the + // running exe is untouched. + await expect(stat(getNativeStagedStateFile(exePath))).resolves.toBeDefined(); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + it('stamps the claim with a fresh mtime so a concurrent launch does not misread it as stale', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // The metadata may have been staged long before this launch (background From 8d8f3b96e75975a6bf3ae2dc2df9c195ac0c0189 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 11:49:14 +0800 Subject: [PATCH 24/42] fix: restrict backup cleanup to updater-owned .bak names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanupBackups treated every .*.bak sibling as swap residue, so a user's own backup like kimi.config.bak in a shared bin directory was silently deleted on startup. Only the exact .bak and the numeric PID fallback ..bak are updater-created — cleanup now positively matches those two formats. --- apps/kimi-code/src/cli/update/native-swap.ts | 9 +++++++-- .../kimi-code/test/cli/update/native-swap.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 4952bc8363..660002e798 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -208,8 +208,11 @@ async function rollback(bakPath: string, exePath: string): Promise { /** * Remove leftover `.bak` siblings of the exe from earlier swaps/installs. - * A `.bak` still mapped by a running old instance cannot be deleted on - * Windows — it is simply left for a later launch. + * Only names the updater itself creates are removed: the exact `.bak` + * and the numeric PID fallback `..bak` — anything else with the + * prefix (`kimi.config.bak`, …) belongs to the user. A `.bak` still mapped + * by a running old instance cannot be deleted on Windows — it is simply + * left for a later launch. */ async function cleanupBackups(exePath: string, keepPath?: string): Promise { const dir = dirname(exePath); @@ -222,6 +225,8 @@ async function cleanupBackups(exePath: string, keepPath?: string): Promise } for (const entry of entries) { if (!entry.startsWith(`${base}.`) || !entry.endsWith('.bak')) continue; + const middle = entry.slice(base.length + 1, -'.bak'.length); + if (middle !== '' && !/^\d+$/.test(middle)) continue; const full = join(dir, entry); if (full === keepPath) continue; await unlink(full).catch(() => {}); diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index e84a6d4111..d38266c8ef 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -354,6 +354,21 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { await expect(stat(`${exePath}.12345.bak`)).rejects.toThrow(); }); + it('leaves foreign .bak files alone during backup cleanup', async () => { + await writeFile(`${exePath}.bak`, 'stale-backup'); + await writeFile(`${exePath}.config.bak`, 'user-backup'); + await writeFile(`${exePath}.notes.bak`, 'user-backup'); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // Only the updater-owned exact backup is swept. + await expect(stat(`${exePath}.bak`)).rejects.toThrow(); + expect(await readFile(`${exePath}.config.bak`, 'utf-8')).toBe('user-backup'); + expect(await readFile(`${exePath}.notes.bak`, 'utf-8')).toBe('user-backup'); + }); + it('leaves every artifact alone while another instance holds a fresh swap claim', async () => { const stagingDir = getNativeStagingDir(exePath); await mkdir(stagingDir, { recursive: true }); From d199e98f6b864a86f92ef3eed855089bc5b5444f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 12:13:17 +0800 Subject: [PATCH 25/42] fix: claim staged metadata before validating it and let manual upgrades bypass the opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native-swap: claimStagedUpdate validated the metadata and hashed the staged exe BEFORE the atomic rename, so a concurrent downloader superseding staged.json in between could get its fresh metadata claimed under the older object — the smoke check then failed and discard() deleted the newly published stage, recording a failure for the wrong version. The claim (utimes + rename) now happens first and validation acts on exactly the claimed file; discards use a new discardClaimedUpdate that never removes anything a meanwhile-published stage references. - The auto-update env opt-out gated the startup swap unconditionally, so an explicit `kimi upgrade` with the variable set staged the version but no launch ever applied it. Stages now record `manual: true` when they answer a user-initiated install (`__update_download --manual`, threaded from installUpdate through the hidden sub-command), and the swap applies manual stages even when automatic updates are opted out. --- apps/kimi-code/src/cli/commands.ts | 10 ++- apps/kimi-code/src/cli/sub/update-download.ts | 6 +- apps/kimi-code/src/cli/update/native-stage.ts | 9 ++ apps/kimi-code/src/cli/update/native-swap.ts | 85 +++++++++++++------ apps/kimi-code/src/cli/update/preflight.ts | 9 +- apps/kimi-code/src/main.ts | 4 +- .../test/cli/update-download.test.ts | 11 +++ .../test/cli/update/native-stage.test.ts | 14 +++ .../test/cli/update/native-swap.test.ts | 25 +++++- .../test/cli/update/preflight.test.ts | 4 +- 10 files changed, 137 insertions(+), 40 deletions(-) diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index fd47538d3b..6b6c3aca07 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -15,7 +15,7 @@ export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; -export type UpdateDownloadHandler = (version: string) => void; +export type UpdateDownloadHandler = (version: string, manual: boolean) => void; export function createProgram( version: string, @@ -141,12 +141,14 @@ export function createProgram( }); // Self-spawned worker for native staged updates (detached background - // download, or foreground from `kimi upgrade`). Hidden: not user-facing. + // download, or foreground from `kimi upgrade` — `--manual` marks the + // latter's stage as user-requested). Hidden: not user-facing. program .command('__update_download', { hidden: true }) .argument('') - .action((targetVersion: string) => { - onUpdateDownload(targetVersion); + .option('--manual', 'the stage answers an explicit user-initiated upgrade') + .action((targetVersion: string, options: { manual?: boolean }) => { + onUpdateDownload(targetVersion, options.manual === true); }); program.argument('[args...]').action((args: string[]) => { diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index fb87e1bc3e..c34549ef6e 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -49,7 +49,10 @@ async function waitForStagedUpdate( } } -export async function runUpdateDownloadCommand(version: string): Promise { +export async function runUpdateDownloadCommand( + version: string, + manual: boolean = false, +): Promise { if (!detectNativeInstall()) { process.stderr.write('error: update download is only available in the native build\n'); return 1; @@ -91,6 +94,7 @@ export async function runUpdateDownloadCommand(version: string): Promise version, exePath: process.execPath, onProgress, + manual, }); if (out.isTTY) out.write('\n'); if (result.status === 'already-staged') { diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index b98fa125fc..e490310736 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -39,6 +39,12 @@ const StagedNativeUpdateSchema = z sha256: z.string().regex(/^[a-f0-9]{64}$/), exeSize: z.number().int().min(1), stagedAt: z.string().min(1), + /** + * True when the stage was produced by an explicit user-initiated + * `kimi upgrade` (vs the passive background downloader): manual stages + * still apply when automatic updates are opted out via env. + */ + manual: z.boolean().optional(), }) .strict(); @@ -189,6 +195,8 @@ export interface StageNativeUpdateOptions { readonly onProgress?: (downloadedBytes: number, totalBytes: number | null) => void; /** Test hook: override the download idle timeout (default 30 s). */ readonly idleTimeoutMs?: number; + /** True when the stage answers an explicit user-initiated `kimi upgrade`. */ + readonly manual?: boolean; } export type StageNativeUpdateStatus = 'already-staged' | 'staged'; @@ -318,6 +326,7 @@ export async function stageNativeUpdate( sha256: '', exeSize: 0, stagedAt: new Date().toISOString(), + manual: options.manual === true ? true : undefined, }; // Unique .part name per worker: a same-version downloader may overlap a diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 660002e798..7b0b1f48b6 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -34,7 +34,6 @@ import { readUpdateInstallState, writeUpdateInstallState } from './install-state import { hashFileSha256, readStagedNativeUpdate, - removeStagedNativeUpdate, stagedExePath, type StagedNativeUpdate, } from './native-stage'; @@ -162,25 +161,18 @@ interface ClaimedStaged { * Returns null when there is nothing staged, the file disappeared under us, * or the staged exe failed consistency checks. */ +/** + * Atomically claim the staged metadata file (rename is atomic on both NTFS + * and POSIX, so exactly one of several concurrently starting instances wins), + * THEN validate the claimed contents. Claim-first matters: a concurrent + * downloader may supersede `staged.json` at any moment, so validating before + * the rename could act on metadata this swap never claimed. + * + * Returns null when there is nothing staged, the file disappeared under us, + * or the staged exe failed consistency checks. + */ async function claimStagedUpdate(exePath: string): Promise { const stateFile = getNativeStagedStateFile(exePath); - const staged = await readStagedNativeUpdate(exePath, stateFile); - if (staged === null) return null; - // Re-verify the staged bytes against the recorded checksum before claiming: - // the exe could have been damaged on disk after the download verified it - // (corruption, a non-durable interrupted write), and the `--version` smoke - // check alone would not catch every such case. Only paid when an update is - // actually pending. A mismatch discards the stage so a later cycle - // re-downloads it — this is not a swap failure. - const digest = await hashFileSha256(stagedExePath(exePath, staged)); - if (digest !== staged.sha256) { - logSwap('staged exe failed checksum verification, discarding', { - version: staged.version, - }); - await removeStagedNativeUpdate(exePath, staged); - return null; - } - const claimedPath = `${stateFile}.swap-${process.pid}`; try { // The metadata's mtime can be arbitrarily old — the download may have @@ -194,9 +186,49 @@ async function claimStagedUpdate(exePath: string): Promise } catch { return null; } + // Validate exactly the metadata we claimed. + const staged = await readStagedNativeUpdate(exePath, claimedPath); + if (staged === null) { + await unlink(claimedPath).catch(() => {}); + return null; + } + // Re-verify the staged bytes against the recorded checksum: the exe could + // have been damaged on disk after the download verified it (corruption, a + // non-durable interrupted write), and the `--version` smoke check alone + // would not catch every such case. Only paid when an update is actually + // pending. A mismatch discards the stage so a later cycle re-downloads it — + // this is not a swap failure. + const digest = await hashFileSha256(stagedExePath(exePath, staged)); + if (digest !== staged.sha256) { + logSwap('staged exe failed checksum verification, discarding', { + version: staged.version, + }); + await discardClaimedUpdate(exePath, claimedPath, staged); + return null; + } return { staged, claimedPath }; } +/** + * Delete a claimed stage's artifacts — never anything published meanwhile: + * after our claim the state-file path is free, so a downloader may have + * published a NEWER stage there, and the exe our claim references may belong + * to it (same version re-staged under the same name). + */ +async function discardClaimedUpdate( + exePath: string, + claimedPath: string, + staged: StagedNativeUpdate, +): Promise { + await unlink(claimedPath).catch(() => {}); + const current = await readStagedNativeUpdate(exePath).catch(() => null); + if (current?.exeFileName !== staged.exeFileName) { + await unlink(stagedExePath(exePath, staged)).catch(() => {}); + } + // Best effort: drop the staging dir itself when empty. + await rmdir(getNativeStagingDir(exePath)).catch(() => {}); +} + async function rollback(bakPath: string, exePath: string): Promise { try { await rename(bakPath, exePath); @@ -361,10 +393,12 @@ export async function maybeRelaunchWithStagedNativeUpdate( if (!deps.isNative) return false; const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath); if (isAutoUpdateDisabledByEnv(deps.env)) { - // The user opted out of automatic updates entirely: leave any staged - // payload in place (it still applies on a later launch without the - // opt-out) and start the current exe. - return false; + // The opt-out targets AUTOMATIC updates. A payload staged by an explicit + // `kimi upgrade` still applies — the user asked for it; a + // background-staged one stays in place for a later launch without the + // opt-out. + const staged = await readStagedNativeUpdate(deps.exePath); + if (staged?.manual !== true) return false; } if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) { // Read-once guard: drop it so this session's children (and any nested @@ -388,12 +422,7 @@ export async function maybeRelaunchWithStagedNativeUpdate( const spawnImpl = deps.spawnImpl ?? spawn; const discard = async (): Promise => { - // claimedPath lives inside `.staging/` — remove it first so the - // best-effort rmdir in removeStagedNativeUpdate can actually succeed. - // The staged metadata must be passed along: claiming already renamed the - // state file away, so rediscovery would find nothing and leak the exe. - await unlink(claimedPath).catch(() => {}); - await removeStagedNativeUpdate(deps.exePath, staged); + await discardClaimedUpdate(deps.exePath, claimedPath, staged); return false; }; diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 543a6666b3..3c7d371a28 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -171,10 +171,13 @@ function resolveInstallSpawn( source: InstallSource, version: string, platform: NodeJS.Platform, + options?: { readonly manual?: boolean }, ): { readonly resolvedCmd: string; readonly args: readonly string[]; readonly shell: boolean } | undefined { const { cmd, args } = spawnForSource(source, version, platform); if (source === 'native') { - return { resolvedCmd: cmd, args, shell: false }; + // A user-confirmed install marks the stage as manual so the startup swap + // applies it even when automatic updates are opted out via env. + return { resolvedCmd: cmd, args: options?.manual === true ? [...args, '--manual'] : args, shell: false }; } const resolvedCmd = resolveSpawnCommand(cmd, platform); if (resolvedCmd === undefined) return undefined; @@ -570,7 +573,9 @@ export async function installUpdate( version: string, platform: NodeJS.Platform, ): Promise { - const spawnTarget = resolveInstallSpawn(source, version, platform); + // installUpdate only runs after an explicit user choice (the `upgrade` + // command or the interactive prompt) — mark the stage as manual. + const spawnTarget = resolveInstallSpawn(source, version, platform, { manual: true }); if (spawnTarget === undefined) { throw new Error( `${spawnForSource(source, version, platform).cmd} was not found in PATH; cannot install the update`, diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 104d0a4321..37ec0a8827 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -267,8 +267,8 @@ function bootstrap(): void { process.exit(1); }); }, - (targetVersion) => { - void runUpdateDownloadCommand(targetVersion).then( + (targetVersion, manual) => { + void runUpdateDownloadCommand(targetVersion, manual).then( (code) => { process.exit(code); }, diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index ea9ff23b49..1cd2b38379 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -173,6 +173,17 @@ describe('runUpdateDownloadCommand', () => { expect(release).toHaveBeenCalledTimes(1); }); + it('marks the stage as manual when the download answers an explicit upgrade', async () => { + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/install.lock', + release: vi.fn(async () => {}), + }); + await expect(runUpdateDownloadCommand('0.7.0', true)).resolves.toBe(0); + expect(mocks.stageNativeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ version: '0.7.0', manual: true }), + ); + }); + it('reports staging failures with a non-zero exit code', async () => { mocks.stageNativeUpdate.mockRejectedValue(new Error('sha256 mismatch')); const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 3d1eadfc5f..869f617070 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -174,6 +174,20 @@ describe('stageNativeUpdate', () => { expect(info.mode & 0o111).not.toBe(0); }); + it('records the manual marker when the stage answers an explicit upgrade', async () => { + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + manual: true, + }); + expect(result.staged.manual).toBe(true); + // And it round-trips through the on-disk metadata. + expect((await readStagedNativeUpdate(exePath))?.manual).toBe(true); + }); + it('makes the download executable before publishing it at the staged name', async () => { // A concurrent swap may move the staged exe into place the instant it // appears at its published name, so the chmod must land on the private diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index d38266c8ef..5b5744cae7 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -114,7 +114,11 @@ function createSpawnMock(routes: { return { calls, spawnImpl }; } -async function seedStagedUpdate(exePath: string, version: string): Promise { +async function seedStagedUpdate( + exePath: string, + version: string, + options?: { readonly manual?: boolean }, +): Promise { const stagingDir = getNativeStagingDir(exePath); await mkdir(stagingDir, { recursive: true }); const exeBytes = Buffer.alloc(STAGED_EXE_SIZE, 1); @@ -130,6 +134,7 @@ async function seedStagedUpdate(exePath: string, version: string): Promise sha256: createHash('sha256').update(exeBytes).digest('hex'), exeSize: STAGED_EXE_SIZE, stagedAt: new Date().toISOString(), + manual: options?.manual === true ? true : undefined, }, null, 2)}\n`, 'utf-8', ); @@ -530,6 +535,24 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); }); + it('applies a manually staged update even when automatic updates are disabled by env', async () => { + // The opt-out targets automatic updates; an explicit `kimi upgrade` + // stages with manual: true and must still apply. + await seedStagedUpdate(exePath, STAGED_VERSION, { manual: true }); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + makeDeps(exePath, { + spawnImpl, + env: { PATH: '/usr/bin', KIMI_CODE_NO_AUTO_UPDATE: '1' }, + }), + ); + + expect(relaunched).toBe(true); + expect(calls).toHaveLength(2); // smoke check + re-exec + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + }); + it('stamps the claim with a fresh mtime so a concurrent launch does not misread it as stale', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // The metadata may have been staged long before this launch (background diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 46673b9059..a37c889f41 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -513,7 +513,7 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); expect(mocks.spawn).toHaveBeenCalledWith( process.execPath, - ['__update_download', '0.5.0'], + ['__update_download', '0.5.0', '--manual'], expect.objectContaining({ stdio: 'inherit' }), ); expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); @@ -536,7 +536,7 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); expect(mocks.spawn).toHaveBeenCalledWith( process.execPath, - ['__update_download', '0.5.0'], + ['__update_download', '0.5.0', '--manual'], expect.objectContaining({ stdio: 'inherit' }), ); expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); From 849145e61b0f200de3fc7ac440a30aea32ddc95b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 12:33:57 +0800 Subject: [PATCH 26/42] fix: promote adopted stages to manual and preserve claim-referenced payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes from review: - An explicit `kimi upgrade` adopting an auto-staged payload (already on disk, or still downloading via the wait path) returned before the manual marker applied, so under the env opt-out the swap still skipped it despite the success message. Both adoption paths now promote the staged metadata to manual: true via a new promoteStagedUpdateToManual. - The download-failure cleanup checked only the current staged metadata, but a live swap holds the metadata renamed aside as its claim — a failing same-version downloader could delete the exe an active swap was about to move into place. The catch path now also preserves names referenced by any live swap claim. - Restoring a claimed stage after a failed exe move used rename, which on POSIX replaces a newer staged.json a downloader published during the smoke check. The restore is now a create-if-absent hard link: it only lands when the state-file path is still free, and the older claim is discarded when a newer stage has taken it. --- apps/kimi-code/src/cli/sub/update-download.ts | 9 ++- apps/kimi-code/src/cli/update/native-stage.ts | 51 +++++++++++++-- apps/kimi-code/src/cli/update/native-swap.ts | 15 ++++- .../test/cli/update-download.test.ts | 14 ++++ .../test/cli/update/native-stage.test.ts | 65 +++++++++++++++++++ .../test/cli/update/native-swap.test.ts | 42 +++++++++++- 6 files changed, 187 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index c34549ef6e..7a8a52fcae 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -13,7 +13,11 @@ import { tryAcquireUpdateInstallLock, type UpdateInstallLockHandle, } from '#/cli/update/install-lock'; -import { readStagedNativeUpdate, stageNativeUpdate } from '#/cli/update/native-stage'; +import { + promoteStagedUpdateToManual, + readStagedNativeUpdate, + stageNativeUpdate, +} from '#/cli/update/native-stage'; import { detectNativeInstall } from '#/cli/update/source'; const LOCK_HELD_POLL_INTERVAL_MS = 2_000; @@ -69,6 +73,9 @@ export async function runUpdateDownloadCommand( ); const wait = await waitForStagedUpdate(version, process.execPath); if (wait.status === 'staged') { + // An explicit upgrade adopting a background-staged payload promotes + // its marker, so the swap applies it under the env opt-out as well. + if (manual) await promoteStagedUpdateToManual(process.execPath); out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`); return 0; } diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index e490310736..0309750238 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -87,6 +87,22 @@ export async function readStagedNativeUpdate( return staged; } +/** + * Mark the currently staged update as manual. Used when an explicit + * `kimi upgrade` adopts a payload the passive downloader staged (already on + * disk, or still downloading): the marker lets the startup swap apply it even + * under the env opt-out. Best-effort promotion happens via the same atomic + * metadata write as staging. + */ +export async function promoteStagedUpdateToManual(exePath: string): Promise { + const staged = await readStagedNativeUpdate(exePath); + if (staged === null || staged.manual === true) return; + await writeJsonFile(getNativeStagedStateFile(exePath), StagedNativeUpdateSchema, { + ...staged, + manual: true, + }); +} + /** Remove staged.json + the staged exe; used on downgrade-guard discards and swap failures. */ export async function removeStagedNativeUpdate( exePath: string, @@ -184,6 +200,23 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { } } +/** True when any swap claim file references the given staged exe name. */ +async function isReferencedBySwapClaim(stagingDir: string, exeFileName: string): Promise { + const entries = await readdir(stagingDir).catch(() => [] as string[]); + for (const entry of entries) { + if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; + const raw = await readFile(join(stagingDir, entry), 'utf-8').catch(() => null); + if (raw === null) continue; + try { + const referenced: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName; + if (typeof referenced === 'string' && basename(referenced) === exeFileName) return true; + } catch { + // Unparseable claim — not a reference. + } + } + return false; +} + export interface StageNativeUpdateOptions { readonly version: string; /** Path of the installed executable the staged binary will later replace. */ @@ -306,6 +339,12 @@ export async function stageNativeUpdate( const existing = await readStagedNativeUpdate(options.exePath); if (existing !== null && existing.version === options.version) { + // An explicit upgrade adopts an auto-staged payload: promote the marker + // so the startup swap applies it under the env opt-out as well. + if (options.manual === true && existing.manual !== true) { + await promoteStagedUpdateToManual(options.exePath); + return { status: 'already-staged', staged: { ...existing, manual: true } }; + } return { status: 'already-staged', staged: existing }; } @@ -369,11 +408,15 @@ export async function stageNativeUpdate( return { status: 'staged', staged }; } catch (error) { await rm(partPath, { force: true }).catch(() => {}); - // Remove only what THIS attempt owns. Another worker may have staged the - // same version while we were downloading — that result belongs to the - // current metadata, not to this failing attempt. + // Remove only what THIS attempt owns. The staged exe name may belong to + // a concurrent worker's published stage (referenced by the current + // metadata) or to a live swap (referenced by its claim — the metadata is + // renamed away mid-swap, so the metadata check alone cannot see it). const current = await readStagedNativeUpdate(options.exePath).catch(() => null); - if (current?.exeFileName !== staged.exeFileName) { + const referenced = + current?.exeFileName === staged.exeFileName || + (await isReferencedBySwapClaim(getNativeStagingDir(options.exePath), staged.exeFileName)); + if (!referenced) { await rm(stagedExePath(options.exePath, staged), { force: true }).catch(() => {}); } // Best effort: drop the staging dir itself when empty (a concurrent diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 7b0b1f48b6..19632b9e51 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -17,7 +17,7 @@ */ import { spawn } from 'node:child_process'; -import { readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; +import { link, readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; import { constants as osConstants } from 'node:os'; import { basename, dirname, join } from 'node:path'; @@ -470,9 +470,18 @@ export async function maybeRelaunchWithStagedNativeUpdate( } catch (error) { // Nothing was moved: startup continues with the old exe. Restore the // claimed metadata so a later launch retries the swap (transient locks - // clear on reboot) instead of silently dropping the staged update. + // clear on reboot) — but only into a still-free state-file path: a + // downloader may have published a NEWER stage while we smoke-checked, + // and a rename restore would silently replace it. link() is + // create-if-absent, so the restore can never overwrite; when the path is + // taken, the newer stage wins and ours is discarded. logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); - await rename(claimedPath, getNativeStagedStateFile(deps.exePath)).catch(() => {}); + try { + await link(claimedPath, getNativeStagedStateFile(deps.exePath)); + await unlink(claimedPath).catch(() => {}); + } catch { + await discardClaimedUpdate(deps.exePath, claimedPath, staged); + } return false; } diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index 1cd2b38379..76daf31b50 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ readUpdateInstallLockVersion: vi.fn(), stageNativeUpdate: vi.fn(), readStagedNativeUpdate: vi.fn(), + promoteStagedUpdateToManual: vi.fn(async () => {}), })); vi.mock('#/cli/update/source', () => ({ @@ -22,6 +23,7 @@ vi.mock('#/cli/update/install-lock', () => ({ vi.mock('#/cli/update/native-stage', () => ({ stageNativeUpdate: mocks.stageNativeUpdate, readStagedNativeUpdate: mocks.readStagedNativeUpdate, + promoteStagedUpdateToManual: mocks.promoteStagedUpdateToManual, })); vi.mock('@moonshot-ai/kimi-code-sdk', async () => { @@ -125,6 +127,18 @@ describe('runUpdateDownloadCommand', () => { await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('already in progress')); + // A background waiter's adoption keeps the auto marker. + expect(mocks.promoteStagedUpdateToManual).not.toHaveBeenCalled(); + }); + + it('promotes the adopted stage to manual when an explicit upgrade waited for it', async () => { + mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0' }); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await expect(runUpdateDownloadCommand('0.7.0', true)).resolves.toBe(0); + expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + expect(mocks.promoteStagedUpdateToManual).toHaveBeenCalledTimes(1); }); it('takes over when the same-version holder finishes without staging', async () => { diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 869f617070..01307253df 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -299,6 +299,31 @@ describe('stageNativeUpdate', () => { expect(secondFetch).not.toHaveBeenCalled(); }); + it('promotes an auto-staged payload to manual when an explicit upgrade adopts it', async () => { + // The passive downloader staged the version first (no manual marker). + await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + manual: true, + }); + + expect(result.status).toBe('already-staged'); + expect(result.staged.manual).toBe(true); + // The promotion persisted to the on-disk metadata. + expect((await readStagedNativeUpdate(exePath))?.manual).toBe(true); + }); + it('re-stages when the staged exe went missing', async () => { const first = await stageNativeUpdate({ version: VERSION, @@ -529,6 +554,46 @@ describe('stageNativeUpdate', () => { const bytes = await readFile(join(stagingDir, exeFileName)); expect(bytes.equals(otherPayload)).toBe(true); }); + + it('preserves the staged exe a live swap claim references when this attempt fails', async () => { + const stagingDir = getNativeStagingDir(exePath); + const exeFileName = `kimi-${VERSION}`; + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url === nativeManifestUrl(VERSION)) { + const manifestBody = JSON.stringify({ + version: VERSION, + platforms: { + 'linux-x64': { filename: BINARY_FILENAME, checksum: sha256Hex(PAYLOAD) }, + }, + }); + return { ok: true, status: 200, text: async () => manifestBody, body: null }; + } + if (url === nativeBinaryUrl(VERSION, BINARY_FILENAME)) { + // A swap claims the stage mid-download: the metadata is renamed + // aside (invisible to the metadata check), the exe still referenced + // by the live claim. + const { mkdir } = await import('node:fs/promises'); + await mkdir(stagingDir, { recursive: true }); + await writeFile(join(stagingDir, exeFileName), PAYLOAD); + await writeFile( + join(stagingDir, 'staged.json.swap-4321'), + JSON.stringify({ exeFileName }), + ); + // …then this attempt's download fails. + return { ok: false, status: 503, text: async () => '', body: null }; + } + return { ok: false, status: 404, text: async (): Promise => '', body: null }; + }) as unknown as typeof fetch; + + await expect( + stageNativeUpdate({ version: VERSION, exePath, platform: 'linux', arch: 'x64', fetchImpl }), + ).rejects.toThrow(/503/); + + // The exe owned by the live swap survives this attempt's failure cleanup. + const bytes = await readFile(join(stagingDir, exeFileName)); + expect(bytes.equals(PAYLOAD)).toBe(true); + }); }); describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 5b5744cae7..8ae8694810 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { existsSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; import { mkdtemp, mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -553,6 +553,46 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); }); + it('does not overwrite a concurrently published stage when restoring the claim', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // The exe move (step 2) fails. + fsMocks.renameBlocker = (src) => src === exePath; + const v2 = '0.8.0'; + const spawnImpl = ((cmd: string, args: readonly string[]) => { + if (args[0] === '--version') { + // Mid-smoke: a downloader publishes a NEWER stage (the state-file + // path is free — we claimed the older one). + const stagingDir = getNativeStagingDir(exePath); + const v2Exe = stagedExeFileName(v2, 'linux'); + writeFileSync(join(stagingDir, v2Exe), 'newer-binary'); + writeFileSync( + getNativeStagedStateFile(exePath), + `${JSON.stringify({ + version: v2, + target: 'linux-x64', + exeFileName: v2Exe, + sha256: 'b'.repeat(64), + exeSize: Buffer.byteLength('newer-binary'), + stagedAt: new Date().toISOString(), + })}\n`, + ); + return fakeChild({ code: 0, stdout: `${STAGED_VERSION}\n` }).child; + } + return fakeChild({ code: 0 }).child; + }) as unknown as NativeSwapDeps['spawnImpl']; + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + // The newer stage survived; the older claim was discarded instead of + // clobbering it, and the running exe never moved. + const staged = await readStagedNativeUpdate(exePath); + expect(staged?.version).toBe(v2); + await expect( + stat(join(getNativeStagingDir(exePath), stagedExeFileName(STAGED_VERSION, 'linux'))), + ).rejects.toThrow(); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + it('stamps the claim with a fresh mtime so a concurrent launch does not misread it as stale', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // The metadata may have been staged long before this launch (background From 53dfffb3be7ae5d16d0fe78815e520ed91a2db97 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 12:46:35 +0800 Subject: [PATCH 27/42] fix: drop exe deletion from stale-claim cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-claim sweep deleted the referenced exe based on a metadata snapshot taken before the loop; a downloader republishing the same version between the read and the unlink would have its fresh payload deleted after reporting success. Publication can never be synchronized with a pathname-level snapshot, so the sweep now removes only the claim files themselves — genuinely unreferenced exes are reaped by the downloader's own orphan cleanup (keep-set aware) before its next stage. --- apps/kimi-code/src/cli/update/native-swap.ts | 33 ++++--------------- .../test/cli/update/native-swap.test.ts | 7 ++-- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 19632b9e51..70f2697334 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -17,7 +17,7 @@ */ import { spawn } from 'node:child_process'; -import { link, readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; +import { link, readdir, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; import { constants as osConstants } from 'node:os'; import { basename, dirname, join } from 'node:path'; @@ -267,9 +267,12 @@ async function cleanupBackups(exePath: string, keepPath?: string): Promise /** * Prune `staged.json.swap-` claim files left by instances that died - * mid-swap, together with the staged exe they reference (re-downloaded on the - * next update cycle if still wanted). Returns true when a FRESH claim file - * was seen — i.e. another instance is swapping right now. + * mid-swap. Only the claim files themselves are removed: their referenced + * exes may belong to a freshly published stage (a downloader can republish + * the same version while we sweep, and the metadata snapshot is stale the + * moment it is read), and genuinely unreferenced exes are reaped by the + * downloader's own orphan cleanup before its next stage. Returns true when a + * FRESH claim file was seen — i.e. another instance is swapping right now. */ async function cleanupStaleSwapClaims(exePath: string): Promise { const stagingDir = getNativeStagingDir(exePath); @@ -279,12 +282,6 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { } catch { return false; } - // A stale claim's referenced exe may have been REPLACED by a fresh staged - // download of the same version — downloaders deliberately coexist with - // claims, and the exe name is version-derived, so the names collide. The - // file the CURRENT staged metadata references belongs to that fresh stage, - // not to the crashed swap: preserve it. - const currentStaged = await readStagedNativeUpdate(exePath).catch(() => null); let swapInProgress = false; for (const entry of entries) { if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; @@ -295,22 +292,6 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { swapInProgress = true; continue; } - const raw = await readFile(full, 'utf-8').catch(() => null); - if (raw !== null) { - try { - const exeFileName: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName; - if (typeof exeFileName === 'string' && exeFileName.length > 0) { - // basename(): the metadata contract is a plain file name — never - // let a hand-crafted path escape the staging dir. - const referenced = basename(exeFileName); - if (referenced !== currentStaged?.exeFileName) { - await unlink(join(stagingDir, referenced)).catch(() => {}); - } - } - } catch { - // Unparseable claim file — remove it anyway. - } - } await unlink(full).catch(() => {}); } return swapInProgress; diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 8ae8694810..2c652d2355 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -410,7 +410,7 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); }); - it('cleans up stale swap claims and their orphaned staged exe', async () => { + it('cleans up stale swap claims without touching staged exes', async () => { const stagingDir = getNativeStagingDir(exePath); await mkdir(stagingDir, { recursive: true }); const exeFileName = stagedExeFileName(STAGED_VERSION, 'linux'); @@ -439,7 +439,10 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(relaunched).toBe(false); expect(calls).toHaveLength(0); await expect(stat(claimPath)).rejects.toThrow(); - await expect(stat(orphanedExe)).rejects.toThrow(); + // The exe the claim referenced is left in place: it may belong to a + // freshly republished stage, and the downloader's orphan cleanup reaps + // it if nothing references it. + await expect(stat(orphanedExe)).resolves.toBeDefined(); }); it('keeps recovery artifacts when both the swap-in rename and the rollback fail', async () => { From bdc956734e3abfc13027c9eb83e60006038993e2 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:01:50 +0800 Subject: [PATCH 28/42] fix: never delete the staged exe when discarding a claim The same publication race existed one level down: a same-version downloader can rename its fresh payload onto the shared exe path after the discard's metadata snapshot but before the unlink (payloads publish before their metadata), and the discard would delete a download whose caller then reports success with nothing behind it. discardClaimedUpdate now removes only the claimed metadata file; unreferenced exes are reaped by the downloader's own orphan cleanup before its next stage. --- apps/kimi-code/src/cli/update/native-swap.ts | 27 +++++++------------ .../test/cli/update/native-swap.test.ts | 23 ++++++++++------ 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 70f2697334..8f636e6f34 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -203,30 +203,21 @@ async function claimStagedUpdate(exePath: string): Promise logSwap('staged exe failed checksum verification, discarding', { version: staged.version, }); - await discardClaimedUpdate(exePath, claimedPath, staged); + await discardClaimedUpdate(claimedPath); return null; } return { staged, claimedPath }; } /** - * Delete a claimed stage's artifacts — never anything published meanwhile: - * after our claim the state-file path is free, so a downloader may have - * published a NEWER stage there, and the exe our claim references may belong - * to it (same version re-staged under the same name). + * Discard a claimed stage: only the claimed metadata file is removed — never + * the staged exe. A same-version downloader may have just renamed its fresh + * payload onto that path (payloads publish before their metadata), and + * genuinely unreferenced exes are reaped by the downloader's own orphan + * cleanup before its next stage. */ -async function discardClaimedUpdate( - exePath: string, - claimedPath: string, - staged: StagedNativeUpdate, -): Promise { +async function discardClaimedUpdate(claimedPath: string): Promise { await unlink(claimedPath).catch(() => {}); - const current = await readStagedNativeUpdate(exePath).catch(() => null); - if (current?.exeFileName !== staged.exeFileName) { - await unlink(stagedExePath(exePath, staged)).catch(() => {}); - } - // Best effort: drop the staging dir itself when empty. - await rmdir(getNativeStagingDir(exePath)).catch(() => {}); } async function rollback(bakPath: string, exePath: string): Promise { @@ -403,7 +394,7 @@ export async function maybeRelaunchWithStagedNativeUpdate( const spawnImpl = deps.spawnImpl ?? spawn; const discard = async (): Promise => { - await discardClaimedUpdate(deps.exePath, claimedPath, staged); + await discardClaimedUpdate(claimedPath); return false; }; @@ -461,7 +452,7 @@ export async function maybeRelaunchWithStagedNativeUpdate( await link(claimedPath, getNativeStagedStateFile(deps.exePath)); await unlink(claimedPath).catch(() => {}); } catch { - await discardClaimedUpdate(deps.exePath, claimedPath, staged); + await discardClaimedUpdate(claimedPath); } return false; } diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 2c652d2355..9a514be828 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -216,9 +216,13 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(relaunched).toBe(false); expect(calls).toHaveLength(0); expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); - // Staged artifacts are gone, so future launches do not retry the discard. + // The metadata is gone, so future launches do not retry the discard; the + // exe is left for the downloader's orphan cleanup (it may belong to a + // freshly republished stage). await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); - await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); + await expect( + stat(join(getNativeStagingDir(exePath), stagedExeFileName(CURRENT_VERSION, 'linux'))), + ).resolves.toBeDefined(); }); it('discards staged metadata whose exe is missing', async () => { @@ -270,7 +274,8 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(calls).toHaveLength(1); // smoke only, no re-exec expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); - await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); + // The exe is left for the downloader's orphan cleanup (see the + // not-newer discard test). const state = await readUpdateInstallState(); expect(state.lastFailure).toMatchObject({ version: STAGED_VERSION, attempts: 1 }); @@ -513,10 +518,11 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(relaunched).toBe(false); expect(calls).toHaveLength(0); - // The corrupt stage is discarded so a later cycle re-downloads it; the + // The corrupt stage's metadata is discarded so a later cycle re-stages + // it; the exe is left for the downloader's orphan cleanup, and the // running exe is never touched. await expect(stat(getNativeStagedStateFile(exePath))).rejects.toThrow(); - await expect(stat(stagedExe)).rejects.toThrow(); + await expect(stat(stagedExe)).resolves.toBeDefined(); expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); }); @@ -586,13 +592,14 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); expect(relaunched).toBe(false); - // The newer stage survived; the older claim was discarded instead of - // clobbering it, and the running exe never moved. + // The newer stage survived; the older claim's metadata was discarded + // instead of clobbering it (its exe is left for the downloader's orphan + // cleanup), and the running exe never moved. const staged = await readStagedNativeUpdate(exePath); expect(staged?.version).toBe(v2); await expect( stat(join(getNativeStagingDir(exePath), stagedExeFileName(STAGED_VERSION, 'linux'))), - ).rejects.toThrow(); + ).resolves.toBeDefined(); expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); }); From f1af868236a7b631a8787e2158c90d469de24163 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:16:32 +0800 Subject: [PATCH 29/42] fix: only reap staging orphans old enough to be abandoned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orphan sweep could delete a concurrent worker's freshly renamed staged exe in the gap before its staged.json lands (payloads publish before their metadata), turning the admitted duplicate-worker race into a successful stage with no payload behind it. Unreferenced artifacts are now only deleted once older than a one-hour grace period — publication takes milliseconds, so unreferenced AND old means definitively abandoned. --- apps/kimi-code/src/cli/update/native-stage.ts | 16 +++++++++- .../test/cli/update/native-stage.test.ts | 31 +++++++++++++------ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 0309750238..579cf8f85f 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -159,6 +159,14 @@ function isUpdaterOwnedStagingFile(entry: string): boolean { }); } +/** + * An unreferenced artifact is only deleted once it is older than this. A + * concurrent worker's payload publishes BEFORE its metadata, so a freshly + * renamed staged exe can look like an orphan for a moment; publication takes + * milliseconds, so anything unreferenced AND old is definitively abandoned. + */ +const STAGING_ORPHAN_GRACE_MS = 60 * 60 * 1000; + /** * Remove files in `.staging/` that nothing references: interrupted downloads * (`.part`), and staged exes whose `staged.json` never landed (downloader @@ -196,7 +204,13 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { // directories): the staging dir sits next to the exe and may contain // data that is not ours. if (!isUpdaterOwnedStagingFile(entry)) continue; - await unlink(join(stagingDir, entry)).catch(() => {}); + const full = join(stagingDir, entry); + const info = await stat(full).catch(() => null); + if (info === null) continue; + // Too young to be abandoned — a concurrent worker may be about to + // publish its metadata. + if (Date.now() - info.mtimeMs < STAGING_ORPHAN_GRACE_MS) continue; + await unlink(full).catch(() => {}); } } diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 01307253df..1b022cab51 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -79,6 +79,13 @@ function sha256Hex(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } +/** Write a staging artifact old enough for the orphan sweep to reap it. */ +async function agedOrphan(path: string, content: string | Buffer): Promise { + await writeFile(path, content); + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + await utimes(path, old, old); +} + interface MockCdnOptions { readonly version?: string; readonly payload: Buffer; @@ -420,9 +427,9 @@ describe('stageNativeUpdate', () => { const { mkdir } = await import('node:fs/promises'); await mkdir(stagingDir, { recursive: true }); // Orphans from interrupted earlier runs: a referenced-by-nothing exe and - // a stale .part download. - await writeFile(join(stagingDir, 'kimi-9.9.9'), Buffer.from('orphan-exe')); - await writeFile(join(stagingDir, 'kimi-9.9.9.part'), Buffer.from('partial')); + // a stale .part download (aged past the orphan grace period). + await agedOrphan(join(stagingDir, 'kimi-9.9.9'), Buffer.from('orphan-exe')); + await agedOrphan(join(stagingDir, 'kimi-9.9.9.part'), Buffer.from('partial')); // A live swap claim referencing its own staged exe must survive. const claimExe = 'kimi-8.8.8'; await writeFile(join(stagingDir, claimExe), Buffer.from('swap-in-progress')); @@ -430,6 +437,10 @@ describe('stageNativeUpdate', () => { join(stagingDir, 'staged.json.swap-1234'), JSON.stringify({ exeFileName: claimExe }), ); + // A fresh unreferenced exe is too young to be reaped: a concurrent + // worker may be about to publish its metadata. + const youngExe = 'kimi-7.7.7'; + await writeFile(join(stagingDir, youngExe), Buffer.from('just-published')); const result = await stageNativeUpdate({ version: VERSION, @@ -444,6 +455,7 @@ describe('stageNativeUpdate', () => { await expect(stat(join(stagingDir, 'kimi-9.9.9.part'))).rejects.toThrow(); await expect(stat(join(stagingDir, 'staged.json.swap-1234'))).resolves.toBeDefined(); await expect(stat(join(stagingDir, claimExe))).resolves.toBeDefined(); + await expect(stat(join(stagingDir, youngExe))).resolves.toBeDefined(); }); it('retries short writes until each chunk is fully persisted', async () => { @@ -468,8 +480,9 @@ describe('stageNativeUpdate', () => { await mkdir(join(stagingDir, 'some-other-tool'), { recursive: true }); await writeFile(join(stagingDir, 'user-notes.txt'), 'not ours', 'utf-8'); await writeFile(join(stagingDir, 'some-other-tool', 'cache.bin'), 'not ours either'); - // A genuine updater-owned orphan to prove cleanup still works. - await writeFile(join(stagingDir, 'kimi-9.9.9'), Buffer.from('orphan-exe')); + // A genuine updater-owned orphan to prove cleanup still works (aged past + // the orphan grace period). + await agedOrphan(join(stagingDir, 'kimi-9.9.9'), Buffer.from('orphan-exe')); const result = await stageNativeUpdate({ version: VERSION, @@ -489,9 +502,9 @@ describe('stageNativeUpdate', () => { const stagingDir = getNativeStagingDir(exePath); const { mkdir } = await import('node:fs/promises'); await mkdir(stagingDir, { recursive: true }); - await writeFile(join(stagingDir, 'kimi-1.2.3-rc.1'), Buffer.from('orphan')); - await writeFile(join(stagingDir, 'kimi-1.2.3+build.5.exe'), Buffer.from('orphan')); - await writeFile(join(stagingDir, 'kimi-1.2.3-rc.1.123.0.part'), Buffer.from('partial')); + await agedOrphan(join(stagingDir, 'kimi-1.2.3-rc.1'), Buffer.from('orphan')); + await agedOrphan(join(stagingDir, 'kimi-1.2.3+build.5.exe'), Buffer.from('orphan')); + await agedOrphan(join(stagingDir, 'kimi-1.2.3-rc.1.123.0.part'), Buffer.from('partial')); const result = await stageNativeUpdate({ version: VERSION, From 763ebcffd1780b9462cca0eed0a32b9da6296e69 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:38:19 +0800 Subject: [PATCH 30/42] fix: honor the persisted auto-update preference in the swap and drop claim-unsafe deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The startup swap gated only on the env opt-out, so a payload staged automatically still installed after the user disabled automatic updates via [upgrade] auto_install = false. The swap now loads the persisted preference (only when an automatic stage is actually pending) and skips it, exactly like the env opt-out; manual stages still always apply. - Superseding a staged version deleted its exe through an uncoordinated read-then-remove that could pull the payload from a live swap. The supersede now removes only the old metadata record — the metadata write atomically replaces it, and an unreferenced exe is reaped by a later orphan cleanup. removeStagedNativeUpdate, left with no callers, is removed. - docs: the kimi upgrade reference (en + zh) no longer claims Windows native installations cannot upgrade automatically; native installs download and verify in the foreground and swap on the next start. --- apps/kimi-code/src/cli/update/native-stage.ts | 26 ++++--------------- apps/kimi-code/src/cli/update/native-swap.ts | 18 +++++++------ apps/kimi-code/src/cli/update/preflight.ts | 7 ++++- .../test/cli/update/native-stage.test.ts | 20 +++----------- .../test/cli/update/native-swap.test.ts | 26 +++++++++++++++++++ docs/en/reference/kimi-command.md | 2 +- docs/zh/reference/kimi-command.md | 2 +- 7 files changed, 53 insertions(+), 48 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 579cf8f85f..89b21024d1 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -103,25 +103,6 @@ export async function promoteStagedUpdateToManual(exePath: string): Promise { - const stagingDir = getNativeStagingDir(exePath); - // The swap flow claims staged.json by renaming it away first, so callers - // there must pass the already-read metadata — discovering it from the - // (now missing) state file would find nothing and leak the staged exe. - const staged = knownStaged ?? (await readStagedNativeUpdate(exePath).catch(() => null)); - if (staged !== null) { - await rm(stagedExePath(exePath, staged), { force: true }).catch(() => {}); - } - await rm(getNativeStagedStateFile(exePath), { force: true }).catch(() => {}); - // Best effort: drop the staging dir itself when empty (leftover `.part` - // files keep it around; the downloader truncates those on the next run). - await rmdir(stagingDir).catch(() => {}); -} - /** Stream a file's sha256 as hex; null when the file cannot be read. */ export async function hashFileSha256(filePath: string): Promise { try { @@ -363,9 +344,12 @@ export async function stageNativeUpdate( } // A different version was staged earlier and never swapped (skipped - // rollout, user stayed offline, …): supersede it before writing ours. + // rollout, user stayed offline, …). Only its metadata record is removed — + // the exe stays: deleting it could pull the payload from a live swap that + // claimed the old stage, and an unreferenced exe is reaped by a later + // orphan cleanup. The metadata write below atomically replaces the record. if (existing !== null) { - await removeStagedNativeUpdate(options.exePath); + await rm(getNativeStagedStateFile(options.exePath), { force: true }).catch(() => {}); } const stagingDir = getNativeStagingDir(options.exePath); await mkdir(stagingDir, { recursive: true }); diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 8f636e6f34..5841d20d88 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -37,7 +37,7 @@ import { stagedExePath, type StagedNativeUpdate, } from './native-stage'; -import { isAutoUpdateDisabledByEnv } from './preflight'; +import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from './preflight'; import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; export interface NativeSwapDeps { @@ -364,13 +364,15 @@ export async function maybeRelaunchWithStagedNativeUpdate( ): Promise { if (!deps.isNative) return false; const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath); - if (isAutoUpdateDisabledByEnv(deps.env)) { - // The opt-out targets AUTOMATIC updates. A payload staged by an explicit - // `kimi upgrade` still applies — the user asked for it; a - // background-staged one stays in place for a later launch without the - // opt-out. - const staged = await readStagedNativeUpdate(deps.exePath); - if (staged?.manual !== true) return false; + // An automatically staged payload applies only while automatic updates are + // enabled — both the env opt-out and the persisted `[upgrade] + // auto_install = false` preference gate it. A stage produced by an explicit + // `kimi upgrade` (manual) always applies. The pending read happens once + // here; the claim below re-reads. + const pending = await readStagedNativeUpdate(deps.exePath); + if (pending !== null && pending.manual !== true) { + if (isAutoUpdateDisabledByEnv(deps.env)) return false; + if (!(await shouldAutoInstallUpdates())) return false; } if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) { // Read-once guard: drop it so this session's children (and any nested diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 3c7d371a28..f54ff9b22a 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -499,7 +499,12 @@ export function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): return truthy(env['KIMI_CODE_NO_AUTO_UPDATE']) || truthy(env['KIMI_CLI_NO_AUTO_UPDATE']); } -async function shouldAutoInstallUpdates(): Promise { +/** + * The persisted `[upgrade].auto_install` preference (defaults to true when + * the config cannot be read). Gates the passive background install — and the + * startup swap of automatically staged payloads (see `native-swap.ts`). + */ +export async function shouldAutoInstallUpdates(): Promise { try { const config = await loadTuiConfig(); return config.upgrade.autoInstall; diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 1b022cab51..5521607804 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nativeBinaryUrl, nativeManifestUrl } from '#/cli/update/native-manifest'; import { readStagedNativeUpdate, - removeStagedNativeUpdate, stagedExePath, stageNativeUpdate, } from '#/cli/update/native-stage'; @@ -417,9 +416,11 @@ describe('stageNativeUpdate', () => { expect(result.status).toBe('staged'); expect(result.staged.version).toBe(VERSION); + // The older stage's exe is left in place (it may be claim-held by a live + // swap); an unreferenced one is reaped by a later orphan cleanup. await expect( stat(join(getNativeStagingDir(exePath), 'kimi-0.6.0')), - ).rejects.toThrow(); + ).resolves.toBeDefined(); }); it('cleans orphaned staging files before downloading, preserving live swap claims', async () => { @@ -609,7 +610,7 @@ describe('stageNativeUpdate', () => { }); }); -describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { +describe('readStagedNativeUpdate', () => { let workDir: string; let exePath: string; @@ -660,17 +661,4 @@ describe('readStagedNativeUpdate / removeStagedNativeUpdate', () => { await writeFile(stagedExePath(exePath, staged), Buffer.alloc(PAYLOAD.length + 1)); expect(await readStagedNativeUpdate(exePath)).toBeNull(); }); - - it('removes staged artifacts', async () => { - await stageNativeUpdate({ - version: VERSION, - exePath, - platform: 'linux', - arch: 'x64', - fetchImpl: mockCdnFetch({ payload: PAYLOAD }), - }); - await removeStagedNativeUpdate(exePath); - expect(await readStagedNativeUpdate(exePath)).toBeNull(); - await expect(stat(getNativeStagingDir(exePath))).rejects.toThrow(); - }); }); diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 9a514be828..b1c0753956 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -562,6 +562,32 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); }); + it('leaves an automatic stage in place when auto_install is disabled in the tui config', async () => { + await mkdir(homeDir, { recursive: true }); + await writeFile(join(homeDir, 'tui.toml'), '[upgrade]\nauto_install = false\n', 'utf-8'); + await seedStagedUpdate(exePath, STAGED_VERSION); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + await expect(stat(getNativeStagedStateFile(exePath))).resolves.toBeDefined(); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + + it('applies a manual stage even when auto_install is disabled in the tui config', async () => { + await mkdir(homeDir, { recursive: true }); + await writeFile(join(homeDir, 'tui.toml'), '[upgrade]\nauto_install = false\n', 'utf-8'); + await seedStagedUpdate(exePath, STAGED_VERSION, { manual: true }); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(true); + expect(calls).toHaveLength(2); // smoke check + re-exec + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + }); + it('does not overwrite a concurrently published stage when restoring the claim', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // The exe move (step 2) fails. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 238d741fbd..912f251dbb 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -268,7 +268,7 @@ Immediately check for the latest version and display an update prompt; exits aft kimi upgrade ``` -For global npm, pnpm, yarn, bun, and macOS / Linux native installations, `kimi upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. When the current installation method cannot be upgraded automatically (e.g., Windows native installation), the manual update command is printed instead. +For global npm, pnpm, yarn, and bun installations, `kimi upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start. When the current installation method cannot be upgraded automatically, the manual update command is printed instead. ### `kimi vis` diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 345e74d10b..6ae9e4f600 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -268,7 +268,7 @@ kimi migrate kimi upgrade ``` -对全局 npm、pnpm、yarn、bun 以及 macOS / Linux native 安装,`kimi upgrade` 会展示更新选项;选择 `Install update now` 后运行对应的前台安装命令。当前安装方式无法自动升级时(如 Windows native 安装),改为打印手动更新命令。 +对全局 npm、pnpm、yarn、bun 安装,`kimi upgrade` 会展示更新选项;选择 `Install update now` 后运行对应的前台安装命令。对 native 安装(含 Windows),会在前台下载并校验新二进制,并在下次启动时替换生效。当前安装方式无法自动升级时,改为打印手动更新命令。 ### `kimi vis` From b9344660a910337c2d2eb65dbe45210d74642222 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 13:56:09 +0800 Subject: [PATCH 31/42] fix: gate on claimed metadata, stop shared-path deletes on failure, exact smoke match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The opt-out gate evaluated a pre-claim snapshot of the staged metadata, but the claim could pick up a different (automatic) stage a downloader published in between — smuggling it past the gate. The env/preference check now runs on the CLAIMED metadata; when disabled, the claim is restored via create-if-absent link so a newer stage is never overwritten and a later launch can still apply it. The checksum re-verify moves after the gates so opted-out launches stop paying for the hash. - The download-failure cleanup still deleted the shared staged-exe path based on snapshot reference checks — the same publication race as the paths already fixed. It now removes only the attempt's privately owned .part file; the shared exe is left for the age-gated orphan cleanup. - The smoke check accepted the staged version as a substring of the --version output, so a mispublished 1.2.30 binary would satisfy a 1.2.3 target with a matching manifest checksum. It now requires the trimmed output to equal the staged version exactly. --- apps/kimi-code/src/cli/update/native-stage.ts | 34 ++----- apps/kimi-code/src/cli/update/native-swap.ts | 96 +++++++++++-------- .../test/cli/update/native-swap.test.ts | 10 ++ 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 89b21024d1..1979bb51f7 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -195,23 +195,6 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { } } -/** True when any swap claim file references the given staged exe name. */ -async function isReferencedBySwapClaim(stagingDir: string, exeFileName: string): Promise { - const entries = await readdir(stagingDir).catch(() => [] as string[]); - for (const entry of entries) { - if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; - const raw = await readFile(join(stagingDir, entry), 'utf-8').catch(() => null); - if (raw === null) continue; - try { - const referenced: unknown = (JSON.parse(raw) as { exeFileName?: unknown }).exeFileName; - if (typeof referenced === 'string' && basename(referenced) === exeFileName) return true; - } catch { - // Unparseable claim — not a reference. - } - } - return false; -} - export interface StageNativeUpdateOptions { readonly version: string; /** Path of the installed executable the staged binary will later replace. */ @@ -405,18 +388,13 @@ export async function stageNativeUpdate( ); return { status: 'staged', staged }; } catch (error) { + // Remove only what THIS attempt privately owns: its unique .part file. + // The shared staged-exe path is never deleted on failure — every + // reference check is a snapshot, and a concurrent worker may have just + // renamed its verified payload onto that path (payloads publish before + // their metadata). An unreferenced exe is reaped by the age-gated + // orphan cleanup. await rm(partPath, { force: true }).catch(() => {}); - // Remove only what THIS attempt owns. The staged exe name may belong to - // a concurrent worker's published stage (referenced by the current - // metadata) or to a live swap (referenced by its claim — the metadata is - // renamed away mid-swap, so the metadata check alone cannot see it). - const current = await readStagedNativeUpdate(options.exePath).catch(() => null); - const referenced = - current?.exeFileName === staged.exeFileName || - (await isReferencedBySwapClaim(getNativeStagingDir(options.exePath), staged.exeFileName)); - if (!referenced) { - await rm(stagedExePath(options.exePath, staged), { force: true }).catch(() => {}); - } // Best effort: drop the staging dir itself when empty (a concurrent // worker's files keep it around — rmdir only removes empty dirs). await rmdir(getNativeStagingDir(options.exePath)).catch(() => {}); diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 5841d20d88..50f0d1052d 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -102,9 +102,11 @@ async function recordSwapFailure(version: string): Promise { } /** - * Run `exe --version` as a smoke check: exit code 0 and the staged version in - * the output. A swapped binary that cannot even print its version must not - * replace the known-good exe. + * Run `exe --version` as a smoke check: exit code 0 and the EXACT staged + * version as the output (commander prints `\n`). A substring check + * would let a mispublished binary satisfy the wrong target (`1.2.30` + * contains `1.2.3`) — and the manifest checksum cannot catch that case when + * it also describes the wrong artifact. */ function smokeCheck( exePath: string, @@ -145,7 +147,7 @@ function smokeCheck( // the check needs the complete version output. child.once('close', (code) => { clearTimeout(timeout); - finish(code === 0 && stdout.includes(staged.version)); + finish(code === 0 && stdout.trim() === staged.version); }); }); } @@ -155,21 +157,15 @@ interface ClaimedStaged { readonly claimedPath: string; } -/** - * Atomically claim the staged metadata file (rename is atomic on both NTFS - * and POSIX, so exactly one of several concurrently starting instances wins). - * Returns null when there is nothing staged, the file disappeared under us, - * or the staged exe failed consistency checks. - */ /** * Atomically claim the staged metadata file (rename is atomic on both NTFS * and POSIX, so exactly one of several concurrently starting instances wins), - * THEN validate the claimed contents. Claim-first matters: a concurrent + * THEN parse the claimed contents. Claim-first matters: a concurrent * downloader may supersede `staged.json` at any moment, so validating before * the rename could act on metadata this swap never claimed. * * Returns null when there is nothing staged, the file disappeared under us, - * or the staged exe failed consistency checks. + * or the claimed metadata failed consistency checks. */ async function claimStagedUpdate(exePath: string): Promise { const stateFile = getNativeStagedStateFile(exePath); @@ -186,27 +182,29 @@ async function claimStagedUpdate(exePath: string): Promise } catch { return null; } - // Validate exactly the metadata we claimed. + // Parse exactly the metadata we claimed. const staged = await readStagedNativeUpdate(exePath, claimedPath); if (staged === null) { await unlink(claimedPath).catch(() => {}); return null; } - // Re-verify the staged bytes against the recorded checksum: the exe could - // have been damaged on disk after the download verified it (corruption, a - // non-durable interrupted write), and the `--version` smoke check alone - // would not catch every such case. Only paid when an update is actually - // pending. A mismatch discards the stage so a later cycle re-downloads it — - // this is not a swap failure. - const digest = await hashFileSha256(stagedExePath(exePath, staged)); - if (digest !== staged.sha256) { - logSwap('staged exe failed checksum verification, discarding', { - version: staged.version, - }); + return { staged, claimedPath }; +} + +/** + * Put a claimed stage's metadata back so a later launch can retry — but only + * into a still-free state-file path: a downloader may have published a NEWER + * stage meanwhile, and a rename restore would silently replace it. link() is + * create-if-absent, so the restore never overwrites; when the path is taken, + * the newer stage wins and ours is discarded. + */ +async function restoreClaimedUpdate(exePath: string, claimedPath: string): Promise { + try { + await link(claimedPath, getNativeStagedStateFile(exePath)); + await unlink(claimedPath).catch(() => {}); + } catch { await discardClaimedUpdate(claimedPath); - return null; } - return { staged, claimedPath }; } /** @@ -364,16 +362,6 @@ export async function maybeRelaunchWithStagedNativeUpdate( ): Promise { if (!deps.isNative) return false; const swapInProgress = await sweepStaleNativeUpdateArtifacts(deps.exePath); - // An automatically staged payload applies only while automatic updates are - // enabled — both the env opt-out and the persisted `[upgrade] - // auto_install = false` preference gate it. A stage produced by an explicit - // `kimi upgrade` (manual) always applies. The pending read happens once - // here; the claim below re-reads. - const pending = await readStagedNativeUpdate(deps.exePath); - if (pending !== null && pending.manual !== true) { - if (isAutoUpdateDisabledByEnv(deps.env)) return false; - if (!(await shouldAutoInstallUpdates())) return false; - } if (isTruthy(deps.env[KIMI_CODE_UPDATE_REEXEC_ENV])) { // Read-once guard: drop it so this session's children (and any nested // kimi launches from them) do not inherit the swap skip. @@ -410,6 +398,35 @@ export async function maybeRelaunchWithStagedNativeUpdate( return discard(); } + // Automatic stages apply only while automatic updates are enabled — both + // the env opt-out and the persisted `[upgrade] auto_install = false` + // preference gate them. Evaluated on the CLAIMED metadata: a pre-claim + // snapshot could be replaced by a downloader before the claim, smuggling an + // automatic payload past the gate. A manually requested stage always + // applies. When disabled, restore the claim (never overwriting a newer + // stage) so a later launch without the opt-out can still apply it. + if ( + staged.manual !== true && + (isAutoUpdateDisabledByEnv(deps.env) || !(await shouldAutoInstallUpdates())) + ) { + await restoreClaimedUpdate(deps.exePath, claimedPath); + return false; + } + + // Re-verify the staged bytes against the recorded checksum: the exe could + // have been damaged on disk after the download verified it (corruption, a + // non-durable interrupted write), and the `--version` smoke check alone + // would not catch every such case. Only paid once the swap actually + // proceeds. A mismatch discards the stage so a later cycle re-downloads + // it — this is not a swap failure. + const digest = await hashFileSha256(stagedExePath(deps.exePath, staged)); + if (digest !== staged.sha256) { + logSwap('staged exe failed checksum verification, discarding', { + version: staged.version, + }); + return discard(); + } + const stagedExe = stagedExePath(deps.exePath, staged); // 1. Smoke-check the staged exe BEFORE touching the install path: a staged @@ -450,12 +467,7 @@ export async function maybeRelaunchWithStagedNativeUpdate( // create-if-absent, so the restore can never overwrite; when the path is // taken, the newer stage wins and ours is discarded. logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); - try { - await link(claimedPath, getNativeStagedStateFile(deps.exePath)); - await unlink(claimedPath).catch(() => {}); - } catch { - await discardClaimedUpdate(claimedPath); - } + await restoreClaimedUpdate(deps.exePath, claimedPath); return false; } diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index b1c0753956..47bb10e16e 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -289,6 +289,16 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); }); + it('rolls back when the smoke output merely contains the staged version as a substring', async () => { + // `0.7.01` contains `0.7.0` but is a different release — a mispublished + // endpoint could serve exactly that with a matching checksum. + await seedStagedUpdate(exePath, STAGED_VERSION); + const { spawnImpl } = createSpawnMock({ smokeStdout: `${STAGED_VERSION}1\n` }); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + expect(relaunched).toBe(false); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + it('continues startup with the old in-memory code when the re-exec spawn fails', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); const { spawnImpl } = createSpawnMock({ reexecError: new Error('spawn EACCES') }); From 8168d5e110cd2a0c2238167c34bc7f95f42bfdd5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 14:13:05 +0800 Subject: [PATCH 32/42] fix: confirm the manual marker before reporting stage adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promoteStagedUpdateToManual silently no-oped when a startup swap had claimed the state file, while the adoption paths still reported success with manual: true synthesized — under the env opt-out the restored automatic metadata would then be skipped on every later launch despite the upgrade's success message. The helper now verifies the marker with a confirming read (one retry) and returns whether it persisted; the already-staged branch falls through to a fresh stage when it does not, and the same-version wait loop only adopts after a confirmed promotion. --- apps/kimi-code/src/cli/sub/update-download.ts | 31 +++++++---- apps/kimi-code/src/cli/update/native-stage.ts | 53 +++++++++++++------ .../test/cli/update-download.test.ts | 18 ++++++- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index 7a8a52fcae..46938869b9 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -34,19 +34,33 @@ type StagedUpdateWait = * already held for the caller. The lock goes stale the moment its holder dies * (see install-lock), so a killed downloader cannot strand a foreground * `kimi upgrade` in this loop. + * + * A manual (explicit-upgrade) waiter adopts only after CONFIRMING the manual + * marker landed on the stage — a concurrent startup swap may be claiming and + * restoring the metadata right now, and reporting adoption for a promotion + * that never persisted would strand the update under the env opt-out. */ async function waitForStagedUpdate( version: string, exePath: string, + manual: boolean, ): Promise { for (;;) { const staged = await readStagedNativeUpdate(exePath); - if (staged !== null && staged.version === version) return { status: 'staged' }; - // Poll the acquisition itself: while the holder lives its lock stays - // fresh and this returns null without side effects; when the holder - // finishes (or dies) without staging, the takeover happens right here. - const lock = await tryAcquireUpdateInstallLock({ version }); - if (lock !== null) return { status: 'takeover', lock }; + if (staged !== null && staged.version === version) { + if (!manual || (await promoteStagedUpdateToManual(exePath))) { + return { status: 'staged' }; + } + // The stage is being claimed/restored by a concurrent swap — the next + // poll either promotes the restored stage or takes over once it is + // gone. + } else { + // Poll the acquisition itself: while the holder lives its lock stays + // fresh and this returns null without side effects; when the holder + // finishes (or dies) without staging, the takeover happens right here. + const lock = await tryAcquireUpdateInstallLock({ version }); + if (lock !== null) return { status: 'takeover', lock }; + } await new Promise((resolve) => { setTimeout(resolve, LOCK_HELD_POLL_INTERVAL_MS); }); @@ -71,11 +85,8 @@ export async function runUpdateDownloadCommand( out.write( `A download of Kimi Code ${version} is already in progress; waiting for it to finish…\n`, ); - const wait = await waitForStagedUpdate(version, process.execPath); + const wait = await waitForStagedUpdate(version, process.execPath, manual); if (wait.status === 'staged') { - // An explicit upgrade adopting a background-staged payload promotes - // its marker, so the swap applies it under the env opt-out as well. - if (manual) await promoteStagedUpdateToManual(process.execPath); out.write(`Kimi Code ${version} is downloaded; it applies on the next start.\n`); return 0; } diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 1979bb51f7..c3b5f89705 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -88,19 +88,34 @@ export async function readStagedNativeUpdate( } /** - * Mark the currently staged update as manual. Used when an explicit - * `kimi upgrade` adopts a payload the passive downloader staged (already on - * disk, or still downloading): the marker lets the startup swap apply it even - * under the env opt-out. Best-effort promotion happens via the same atomic - * metadata write as staging. + * Mark the currently staged update as manual, confirming the marker actually + * persisted. Used when an explicit `kimi upgrade` adopts a payload the + * passive downloader staged (already on disk, or still downloading): the + * marker lets the startup swap apply it even under the env opt-out. + * + * Returns false when the stage is concurrently claimed by a startup swap + * (nothing to promote) or a confirming read never sees the marker — callers + * must NOT report adoption for a promotion that never landed. The write and + * the confirmation use the same atomic metadata path as staging; a swap that + * claims the PROMOTED file proceeds with the marker, which is the desired + * outcome anyway. */ -export async function promoteStagedUpdateToManual(exePath: string): Promise { - const staged = await readStagedNativeUpdate(exePath); - if (staged === null || staged.manual === true) return; - await writeJsonFile(getNativeStagedStateFile(exePath), StagedNativeUpdateSchema, { - ...staged, - manual: true, - }); +export async function promoteStagedUpdateToManual(exePath: string): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + const staged = await readStagedNativeUpdate(exePath); + if (staged === null) return false; + if (staged.manual === true) return true; + await writeJsonFile(getNativeStagedStateFile(exePath), StagedNativeUpdateSchema, { + ...staged, + manual: true, + }); + // Confirm: a concurrent claim/restore cycle could leave unpromoted + // content behind (the restore never overwrites, so a confirmed marker + // cannot be displaced afterwards). + const confirmed = await readStagedNativeUpdate(exePath); + if (confirmed?.manual === true) return true; + } + return false; } /** Stream a file's sha256 as hex; null when the file cannot be read. */ @@ -317,13 +332,17 @@ export async function stageNativeUpdate( const existing = await readStagedNativeUpdate(options.exePath); if (existing !== null && existing.version === options.version) { - // An explicit upgrade adopts an auto-staged payload: promote the marker - // so the startup swap applies it under the env opt-out as well. + // An explicit upgrade adopts an auto-staged payload — but only report + // the adoption once the manual marker is confirmed persisted. A stage + // currently being claimed by a startup swap cannot be promoted here; + // fall through and stage afresh instead. if (options.manual === true && existing.manual !== true) { - await promoteStagedUpdateToManual(options.exePath); - return { status: 'already-staged', staged: { ...existing, manual: true } }; + if (await promoteStagedUpdateToManual(options.exePath)) { + return { status: 'already-staged', staged: { ...existing, manual: true } }; + } + } else { + return { status: 'already-staged', staged: existing }; } - return { status: 'already-staged', staged: existing }; } // A different version was staged earlier and never swapped (skipped diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index 76daf31b50..3965f587b0 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ readUpdateInstallLockVersion: vi.fn(), stageNativeUpdate: vi.fn(), readStagedNativeUpdate: vi.fn(), - promoteStagedUpdateToManual: vi.fn(async () => {}), + promoteStagedUpdateToManual: vi.fn(async () => true), })); vi.mock('#/cli/update/source', () => ({ @@ -141,6 +141,22 @@ describe('runUpdateDownloadCommand', () => { expect(mocks.promoteStagedUpdateToManual).toHaveBeenCalledTimes(1); }); + it('keeps waiting until the manual promotion is confirmed persisted', async () => { + // The first promotion attempt loses a race with a concurrent swap's + // claim/restore cycle; the loop must not report adoption until the + // marker is confirmed. + mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0' }); + mocks.promoteStagedUpdateToManual + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await expect(runUpdateDownloadCommand('0.7.0', true)).resolves.toBe(0); + expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + expect(mocks.promoteStagedUpdateToManual).toHaveBeenCalledTimes(2); + }); + it('takes over when the same-version holder finishes without staging', async () => { const release = vi.fn(async () => {}); mocks.tryAcquireUpdateInstallLock From 7d2d52f862e9671b7b3ede70cc6b75ced4bf384a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 14:28:47 +0800 Subject: [PATCH 33/42] fix(cli): verify the staged payload digest before adopting it as already-staged readStagedNativeUpdate checks only the recorded size, so a same-size corruption after the download was adopted and reported as success, only for the startup swap's claim-time re-verify to reject and discard it. Compare the actual sha256 before returning already-staged; a mismatch falls through and re-stages from the CDN. --- apps/kimi-code/src/cli/update/native-stage.ts | 31 +++++++++++++------ .../test/cli/update/native-stage.test.ts | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index c3b5f89705..4a16731e18 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -332,21 +332,32 @@ export async function stageNativeUpdate( const existing = await readStagedNativeUpdate(options.exePath); if (existing !== null && existing.version === options.version) { - // An explicit upgrade adopts an auto-staged payload — but only report - // the adoption once the manual marker is confirmed persisted. A stage - // currently being claimed by a startup swap cannot be promoted here; - // fall through and stage afresh instead. - if (options.manual === true && existing.manual !== true) { - if (await promoteStagedUpdateToManual(options.exePath)) { - return { status: 'already-staged', staged: { ...existing, manual: true } }; + // readStagedNativeUpdate checks only the recorded size — a same-size + // corruption after the download (disk damage, a non-durable write) + // would still be adopted here and reported as success, only for the + // startup swap's claim-time re-verify to reject and discard it. Compare + // the actual digest before adopting; a mismatch falls through and + // re-stages from the CDN (the publishing rename replaces the damaged + // exe atomically). + const digest = await hashFileSha256(stagedExePath(options.exePath, existing)); + if (digest === existing.sha256) { + // An explicit upgrade adopts an auto-staged payload — but only report + // the adoption once the manual marker is confirmed persisted. A stage + // currently being claimed by a startup swap cannot be promoted here; + // fall through and stage afresh instead. + if (options.manual === true && existing.manual !== true) { + if (await promoteStagedUpdateToManual(options.exePath)) { + return { status: 'already-staged', staged: { ...existing, manual: true } }; + } + } else { + return { status: 'already-staged', staged: existing }; } - } else { - return { status: 'already-staged', staged: existing }; } } // A different version was staged earlier and never swapped (skipped - // rollout, user stayed offline, …). Only its metadata record is removed — + // rollout, user stayed offline, …), or the same version's payload failed + // the integrity check above. Only its metadata record is removed — // the exe stays: deleting it could pull the payload from a live swap that // claimed the old stage, and an unreferenced exe is reaped by a later // orphan cleanup. The metadata write below atomically replaces the record. diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 5521607804..cef1844903 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -330,6 +330,37 @@ describe('stageNativeUpdate', () => { expect((await readStagedNativeUpdate(exePath))?.manual).toBe(true); }); + it('re-stages when the staged exe is corrupted at the same size', async () => { + const first = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + // Same-size corruption after the download: the metadata still validates + // (size matches), but the bytes no longer hash to the recorded checksum. + await writeFile(stagedExePath(exePath, first.staged), Buffer.alloc(PAYLOAD.length)); + // Size-only readers still see the stage as valid… + expect(await readStagedNativeUpdate(exePath)).not.toBeNull(); + + const secondFetch = mockCdnFetch({ payload: PAYLOAD }); + const second = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: secondFetch, + }); + + // …but adoption re-verifies the digest, so the payload is re-downloaded + // and the damaged exe is replaced. + expect(second.status).toBe('staged'); + expect(secondFetch).toHaveBeenCalled(); + const repaired = await readFile(stagedExePath(exePath, second.staged)); + expect(repaired.equals(PAYLOAD)).toBe(true); + }); + it('re-stages when the staged exe went missing', async () => { const first = await stageNativeUpdate({ version: VERSION, From bd9010095be63d0959d90db5eec10f4a2e305532 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 14:47:23 +0800 Subject: [PATCH 34/42] fix(cli): keep staged metadata until its replacement is ready Two related races around staged.json, both reported against the duplicate-downloader residual: - stageNativeUpdate deleted the previous record before downloading its replacement; a pathname-only delete can remove a concurrent worker's freshly published record, orphaning a payload whose worker already reported success. The old record now stays until the final atomic metadata write replaces it. - promoteStagedUpdateToManual wrote the marker unconditionally onto whichever generation owned staged.json. It now takes the adopted record and promotes only while the on-disk metadata still matches it, and the post-write confirmation requires the promoted candidate itself. --- apps/kimi-code/src/cli/sub/update-download.ts | 2 +- apps/kimi-code/src/cli/update/native-stage.ts | 67 ++++++++++----- .../test/cli/update/native-stage.test.ts | 83 +++++++++++++++++++ 3 files changed, 132 insertions(+), 20 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index 46938869b9..c6aef5d75e 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -48,7 +48,7 @@ async function waitForStagedUpdate( for (;;) { const staged = await readStagedNativeUpdate(exePath); if (staged !== null && staged.version === version) { - if (!manual || (await promoteStagedUpdateToManual(exePath))) { + if (!manual || (await promoteStagedUpdateToManual(exePath, staged))) { return { status: 'staged' }; } // The stage is being claimed/restored by a concurrent swap — the next diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 4a16731e18..53b53f16a8 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -88,22 +88,51 @@ export async function readStagedNativeUpdate( } /** - * Mark the currently staged update as manual, confirming the marker actually + * Two staged records are the same generation when every field matches — + * ignoring only the `manual` marker that promotion flips. Used to make sure + * a read-modify-write still acts on the record it read. + */ +function isSameStagedRecord(a: StagedNativeUpdate, b: StagedNativeUpdate): boolean { + return ( + a.version === b.version && + a.target === b.target && + a.exeFileName === b.exeFileName && + a.sha256 === b.sha256 && + a.exeSize === b.exeSize && + a.stagedAt === b.stagedAt + ); +} + +/** + * Mark the adopted staged update as manual, confirming the marker actually * persisted. Used when an explicit `kimi upgrade` adopts a payload the * passive downloader staged (already on disk, or still downloading): the * marker lets the startup swap apply it even under the env opt-out. * - * Returns false when the stage is concurrently claimed by a startup swap - * (nothing to promote) or a confirming read never sees the marker — callers - * must NOT report adoption for a promotion that never landed. The write and - * the confirmation use the same atomic metadata path as staging; a swap that - * claims the PROMOTED file proceeds with the marker, which is the desired - * outcome anyway. + * `expected` is the record the caller read and decided to adopt. The promote + * write only happens while the on-disk metadata still IS that record — a + * concurrent downloader may have published a different stage meanwhile, and + * overwriting its record would orphan a payload whose worker already + * reported success. (Pathname-only writes cannot compare-and-swap, so a + * residual publish-between-check-and-write window remains; the identity + * re-read narrows it to that gap.) + * + * Returns false when the record changed / is concurrently claimed by a + * startup swap (nothing to promote) or a confirming read never sees the + * promoted record — callers must NOT report adoption for a promotion that + * never landed. The write and the confirmation use the same atomic metadata + * path as staging; a swap that claims the PROMOTED file proceeds with the + * marker, which is the desired outcome anyway. */ -export async function promoteStagedUpdateToManual(exePath: string): Promise { +export async function promoteStagedUpdateToManual( + exePath: string, + expected: StagedNativeUpdate, +): Promise { + if (expected.manual === true) return true; for (let attempt = 0; attempt < 2; attempt += 1) { const staged = await readStagedNativeUpdate(exePath); - if (staged === null) return false; + if (staged === null || !isSameStagedRecord(staged, expected)) return false; + // Another promoter already marked this exact record — our work is done. if (staged.manual === true) return true; await writeJsonFile(getNativeStagedStateFile(exePath), StagedNativeUpdateSchema, { ...staged, @@ -111,9 +140,10 @@ export async function promoteStagedUpdateToManual(exePath: string): Promise {}); - } + // the integrity check above. The old record is LEFT IN PLACE until the + // atomic metadata write below replaces it: a pathname-level delete could + // remove a concurrent worker's freshly published record (orphaning a + // payload whose worker already reported success), and a swap claiming the + // old stage meanwhile applies a still-valid update. The old exe stays too + // — an unreferenced one is reaped by the age-gated orphan cleanup. const stagingDir = getNativeStagingDir(options.exePath); await mkdir(stagingDir, { recursive: true }); // Drop orphans from interrupted earlier runs before writing ours. diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index cef1844903..abe180adb6 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nativeBinaryUrl, nativeManifestUrl } from '#/cli/update/native-manifest'; import { + promoteStagedUpdateToManual, readStagedNativeUpdate, stagedExePath, stageNativeUpdate, @@ -383,6 +384,32 @@ describe('stageNativeUpdate', () => { expect(second.status).toBe('staged'); }); + it('keeps the previous staged record when the superseding download fails', async () => { + await stageNativeUpdate({ + version: '0.6.0', + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ version: '0.6.0', payload: Buffer.from('old-payload') }), + }); + + // The superseding download fails verification. The old record must + // survive: deleting it before the replacement is ready could remove a + // concurrent worker's freshly published record, and here it would lose + // a still-valid staged update. + await expect( + stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD, checksum: 'f'.repeat(64) }), + }), + ).rejects.toThrow(/sha256 mismatch/); + + expect((await readStagedNativeUpdate(exePath))?.version).toBe('0.6.0'); + }); + it('throws on a checksum mismatch and cleans up leftovers', async () => { await expect( stageNativeUpdate({ @@ -641,6 +668,62 @@ describe('stageNativeUpdate', () => { }); }); +describe('promoteStagedUpdateToManual', () => { + let workDir: string; + let exePath: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'kimi-promote-test-')); + exePath = join(workDir, 'bin', 'kimi'); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('promotes the adopted record to manual', async () => { + await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + const adopted = await readStagedNativeUpdate(exePath); + if (adopted === null) throw new Error('expected a staged record'); + + await expect(promoteStagedUpdateToManual(exePath, adopted)).resolves.toBe(true); + expect((await readStagedNativeUpdate(exePath))?.manual).toBe(true); + }); + + it('refuses to promote a record the staged metadata no longer matches', async () => { + await stageNativeUpdate({ + version: '0.6.0', + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ version: '0.6.0', payload: Buffer.from('old-payload') }), + }); + const adopted = await readStagedNativeUpdate(exePath); + if (adopted === null) throw new Error('expected a staged record'); + + // A newer stage is published before the explicit upgrade's promote + // lands: the stale record must not overwrite it. + await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + + await expect(promoteStagedUpdateToManual(exePath, adopted)).resolves.toBe(false); + const current = await readStagedNativeUpdate(exePath); + expect(current?.version).toBe(VERSION); + expect(current?.manual).toBeUndefined(); + }); +}); + describe('readStagedNativeUpdate', () => { let workDir: string; let exePath: string; From 0de8d01e414961490098006e503a086838d9a445 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 15:00:20 +0800 Subject: [PATCH 35/42] fix(cli): preserve the exe referenced by the current staged record during orphan cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the supersede path now keeps the previous staged.json until the final atomic write replaces it, an aged staged exe is still the applicable update while its replacement downloads — but cleanupStagingOrphans only pinned exes referenced by swap claim files, so a payload older than the grace period was unlinked out from under its own record. Read staged.json itself in the pinning pass so the current record's exe is preserved like any live claim's. --- apps/kimi-code/src/cli/update/native-stage.ts | 17 ++++++++--- .../test/cli/update/native-stage.test.ts | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 53b53f16a8..a4aadab67e 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -197,8 +197,11 @@ const STAGING_ORPHAN_GRACE_MS = 60 * 60 * 1000; * Remove files in `.staging/` that nothing references: interrupted downloads * (`.part`), and staged exes whose `staged.json` never landed (downloader * killed between the two writes) — each such orphan is ~180 MB and would - * otherwise accumulate forever. Swap claim files (`staged.json.swap-*`) and - * the exes they reference are preserved: another instance may be mid-swap. + * otherwise accumulate forever. The exe referenced by the CURRENT + * `staged.json` is preserved (a superseded record is only replaced by the + * final atomic write, so its payload is still the applicable update while + * this run downloads), and so are swap claim files (`staged.json.swap-*`) + * with the exes they reference: another instance may be mid-swap. */ async function cleanupStagingOrphans(stagingDir: string): Promise { let entries: string[]; @@ -209,7 +212,13 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { } const keep = new Set([KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME]); for (const entry of entries) { - if (!entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`)) continue; + // The current record and every swap claim pin the exe they reference. + if ( + entry !== KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME && + !entry.startsWith(`${KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME}.swap-`) + ) { + continue; + } keep.add(entry); const raw = await readFile(join(stagingDir, entry), 'utf-8').catch(() => null); if (raw === null) continue; @@ -221,7 +230,7 @@ async function cleanupStagingOrphans(stagingDir: string): Promise { keep.add(basename(exeFileName)); } } catch { - // Unparseable claim: keep the claim file itself, touch nothing else. + // Unparseable record/claim: keep the file itself, touch nothing else. } } for (const entry of entries) { diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index abe180adb6..02db976123 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -481,6 +481,35 @@ describe('stageNativeUpdate', () => { ).resolves.toBeDefined(); }); + it('preserves the exe referenced by the current record during orphan cleanup', async () => { + const first = await stageNativeUpdate({ + version: '0.6.0', + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ version: '0.6.0', payload: Buffer.from('old-payload') }), + }); + // Age the staged exe past the orphan grace period: it is still the + // applicable update (staged.json references it until the final atomic + // write replaces the record), so the cleanup must not reap it. + const oldExe = stagedExePath(exePath, first.staged); + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + await utimes(oldExe, old, old); + + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + expect(result.status).toBe('staged'); + + // Referenced at cleanup time → survives this run (a later cleanup reaps + // it once the new record has replaced the old one). + await expect(stat(oldExe)).resolves.toBeDefined(); + }); + it('cleans orphaned staging files before downloading, preserving live swap claims', async () => { const stagingDir = getNativeStagingDir(exePath); const { mkdir } = await import('node:fs/promises'); From e539748e7937e99ee2978d2e7c057bd326560bb6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 15:12:07 +0800 Subject: [PATCH 36/42] chore(kimi-code): reword the native auto-update changeset --- .changeset/native-staged-auto-update.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/native-staged-auto-update.md b/.changeset/native-staged-auto-update.md index 97628bd34c..e5658c56b8 100644 --- a/.changeset/native-staged-auto-update.md +++ b/.changeset/native-staged-auto-update.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Support automatic updates for native (single-binary) installations, including Windows: new versions download in the background, verify against the release checksum, and swap in on the next launch. Run `kimi upgrade` to update now, or let the background updater handle it. +The Windows native (single-binary) CLI now supports automatic updates: new versions download in the background, verify against the release checksum, and swap in on the next launch. Run `kimi upgrade` to update immediately. From c01433dd01e9d0066cfaeca8246e7ea4ad033f4a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 15:14:50 +0800 Subject: [PATCH 37/42] chore(kimi-code): trim the native auto-update changeset --- .changeset/native-staged-auto-update.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/native-staged-auto-update.md b/.changeset/native-staged-auto-update.md index e5658c56b8..7626729453 100644 --- a/.changeset/native-staged-auto-update.md +++ b/.changeset/native-staged-auto-update.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -The Windows native (single-binary) CLI now supports automatic updates: new versions download in the background, verify against the release checksum, and swap in on the next launch. Run `kimi upgrade` to update immediately. +The Windows native (single-binary) CLI now supports automatic updates. From eb710531676a56883628987fe58dc1956ebd5d2e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 15:37:55 +0800 Subject: [PATCH 38/42] fix(cli): support update locking on filesystems without hard links link() fails with ENOTSUP/ENOSYS/EPERM on FAT/exFAT and some network mounts, which aborted every native update before the download. Add a shared createFileIfAbsent primitive (hard-link a fully written temp file, falling back to an exclusive create + write) and use it for the install lock, its takeover marker, and the swap's claim restore. The fallback's create->write gap is observable, so the lock inspection now grants young unparseable content a publish grace before sweeping it as crash residue. --- apps/kimi-code/src/cli/update/install-lock.ts | 106 ++++++++++-------- apps/kimi-code/src/cli/update/native-swap.ts | 25 +++-- apps/kimi-code/src/utils/persistence.ts | 37 +++++- .../test/cli/update/install-lock.test.ts | 81 ++++++++++++- .../test/cli/update/native-swap.test.ts | 29 +++++ 5 files changed, 217 insertions(+), 61 deletions(-) diff --git a/apps/kimi-code/src/cli/update/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 36fc49157b..f42042ac3a 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -1,7 +1,8 @@ -import { link, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, stat, unlink } from 'node:fs/promises'; import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; +import { createFileIfAbsent } from '#/utils/persistence'; const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; @@ -11,8 +12,14 @@ const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; */ const TAKEOVER_LOCK_STALE_MS = 60_000; -/** Uniquifies the publish-temp path across concurrent in-process acquirers. */ -let lockTempCounter = 0; +/** + * On filesystems without hard links the lock is published by an exclusive + * create + write (see createFileIfAbsent), which IS observable between create + * and write. A young unparseable lock is almost always that publish window, + * not corruption — only an unparseable lock older than this is swept as + * crash residue. + */ +const LOCK_PUBLISH_GRACE_MS = 60_000; export interface UpdateInstallLockRequest { readonly version: string; @@ -52,23 +59,41 @@ function isProcessAlive(pid: number): boolean { } } +interface LockInspection { + readonly content: string; + readonly mtimeMs: number; +} + +/** Read the lock file's content and mtime; null when it is gone/unreadable. */ +async function inspectLockFile(filePath: string): Promise { + const content = await readFile(filePath, 'utf-8').catch(() => null); + if (content === null) return null; + const info = await stat(filePath).catch(() => null); + if (info === null) return null; + return { content, mtimeMs: info.mtimeMs }; +} + /** - * Staleness check over the lock file's CONTENTS. Unparseable or shapeless - * content counts as stale (crash residue). A holder that is gone can never - * release its lock (a killed process skips its finally) nor make progress — - * stale at ANY age; the atomic publish guarantees the pid was written - * complete by a then-live process, so a dead pid means the holder died - * afterwards. Past the age threshold a LIVE holder still survives: a native - * download is idle-bounded but intentionally not duration-bounded, so a slow - * link legitimately exceeds it. (A pid reused by an unrelated process can pin - * the lock until that process exits — a delayed update, never a corrupt one.) + * Staleness check over the lock file's CONTENTS. Shapeless content counts as + * stale (crash residue). Unparseable content is also crash residue — but only + * once it is older than the publish grace: on filesystems without hard links + * a fallback publish is observable mid-write (see LOCK_PUBLISH_GRACE_MS), and + * sweeping that window would break exclusivity. A holder that is gone can + * never release its lock (a killed process skips its finally) nor make + * progress — stale at ANY age; the atomic publish guarantees the pid was + * written complete by a then-live process, so a dead pid means the holder + * died afterwards. Past the age threshold a LIVE holder still survives: a + * native download is idle-bounded but intentionally not duration-bounded, so + * a slow link legitimately exceeds it. (A pid reused by an unrelated process + * can pin the lock until that process exits — a delayed update, never a + * corrupt one.) */ -function isStaleLockContent(raw: string, now: Date): boolean { +function isStaleLock(inspection: LockInspection, now: Date): boolean { let parsed: unknown; try { - parsed = JSON.parse(raw); + parsed = JSON.parse(inspection.content); } catch { - return true; + return now.getTime() - inspection.mtimeMs > LOCK_PUBLISH_GRACE_MS; } if (typeof parsed !== 'object' || parsed === null) return true; const lock = parsed as { readonly startedAt?: unknown; readonly pid?: unknown }; @@ -90,19 +115,12 @@ async function createLockFile( pid: process.pid, startedAt: now.toISOString(), }, null, 2)}\n`; - // Publish atomically: hard-link a fully-written temp file into place (link - // fails when the destination already exists, same exclusivity as 'wx'). A - // plain 'wx' open would expose a momentarily EMPTY lock file, and a - // concurrent acquirer could misread it as corrupt, sweep it, and also win — - // two "holders" then write the same `.staging` paths. - const tempPath = `${filePath}.${process.pid}.${lockTempCounter}.tmp`; - lockTempCounter += 1; - await writeFile(tempPath, content, { encoding: 'utf-8', mode: 0o600 }); - try { - await link(tempPath, filePath); - } finally { - await unlink(tempPath).catch(() => {}); - } + // Publish atomically and only into a still-free path (EEXIST propagates to + // the caller's inspection flow). The lock file is never observable empty + // on filesystems with hard links; elsewhere the exclusive-create fallback + // leaves a brief publish window, which the inspection side covers with + // LOCK_PUBLISH_GRACE_MS. + await createFileIfAbsent(filePath, content); // A racing stale-takeover may have removed our just-published lock and // published its own; only the survivor may proceed. const published = await readFile(filePath, 'utf-8').catch(() => null); @@ -135,8 +153,8 @@ export async function tryAcquireUpdateInstallLock( } // A lock file exists. Inspect it once to decide whether it is stale. - const inspected = await readFile(filePath, 'utf-8').catch(() => null); - if (inspected !== null && !isStaleLockContent(inspected, request.now ?? new Date())) { + const inspected = await inspectLockFile(filePath); + if (inspected !== null && !isStaleLock(inspected, request.now ?? new Date())) { return null; } if (inspected === null) { @@ -156,8 +174,8 @@ export async function tryAcquireUpdateInstallLock( const takeoverPath = `${filePath}.takeover`; if (!(await acquireTakeoverLock(takeoverPath))) return null; try { - const current = await readFile(filePath, 'utf-8').catch(() => null); - if (current !== null && !isStaleLockContent(current, request.now ?? new Date())) { + const current = await inspectLockFile(filePath); + if (current !== null && !isStaleLock(current, request.now ?? new Date())) { // A fresh lock appeared while we waited for the takeover section. return null; } @@ -178,35 +196,31 @@ export async function tryAcquireUpdateInstallLock( } /** - * The takeover lock serializes stale-lock recovery. create-if-absent via hard - * link; an ancient holder is crash residue (a live section lasts microseconds) - * and is swept, then retried once. + * The takeover lock serializes stale-lock recovery. create-if-absent via the + * shared primitive (hard link, or an exclusive create where unsupported); an + * ancient holder is crash residue (a live section lasts microseconds) and is + * swept, then retried once. */ async function acquireTakeoverLock(takeoverPath: string): Promise { - if (await linkLockFile(takeoverPath)) return true; + if (await publishTakeoverMarker(takeoverPath)) return true; const info = await stat(takeoverPath).catch(() => null); if (info !== null && Date.now() - info.mtimeMs <= TAKEOVER_LOCK_STALE_MS) return false; await unlink(takeoverPath).catch(() => {}); - return linkLockFile(takeoverPath); + return publishTakeoverMarker(takeoverPath); } /** Create-if-absent publish of a small lock marker file. */ -async function linkLockFile(target: string): Promise { +async function publishTakeoverMarker(target: string): Promise { // Unique marker content doubles as the ownership identity below. - const marker = `${process.pid}.${lockTempCounter}`; - const tempPath = `${target}.${marker}.tmp`; - lockTempCounter += 1; - await writeFile(tempPath, marker, { encoding: 'utf-8', mode: 0o600 }); + const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; try { - await link(tempPath, target); + await createFileIfAbsent(target, marker); } catch (error) { if (isAlreadyExists(error)) return false; throw error; - } finally { - await unlink(tempPath).catch(() => {}); } // The stale-marker sweep races this publish: it may unlink our fresh marker - // and link its own. Verify ownership so only the survivor of that race + // and publish its own. Verify ownership so only the survivor of that race // proceeds. (A delete landing after this read is the irreducible residual // of pathname-only locking — there is no conditional-delete syscall; its // worst case is a duplicated download cycle, never a corrupt install, diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 50f0d1052d..99dc065591 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -17,7 +17,7 @@ */ import { spawn } from 'node:child_process'; -import { link, readdir, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; +import { readdir, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; import { constants as osConstants } from 'node:os'; import { basename, dirname, join } from 'node:path'; @@ -39,6 +39,7 @@ import { } from './native-stage'; import { isAutoUpdateDisabledByEnv, shouldAutoInstallUpdates } from './preflight'; import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; +import { createFileIfAbsent } from '#/utils/persistence'; export interface NativeSwapDeps { readonly exePath: string; @@ -194,17 +195,17 @@ async function claimStagedUpdate(exePath: string): Promise /** * Put a claimed stage's metadata back so a later launch can retry — but only * into a still-free state-file path: a downloader may have published a NEWER - * stage meanwhile, and a rename restore would silently replace it. link() is - * create-if-absent, so the restore never overwrites; when the path is taken, - * the newer stage wins and ours is discarded. + * stage meanwhile, and an unconditional restore would silently replace it. + * The publish is create-if-absent (hard link, or an exclusive create on + * filesystems without hard-link support), so the restore never overwrites; + * when the path is taken, the newer stage wins and ours is discarded. */ async function restoreClaimedUpdate(exePath: string, claimedPath: string): Promise { - try { - await link(claimedPath, getNativeStagedStateFile(exePath)); - await unlink(claimedPath).catch(() => {}); - } catch { - await discardClaimedUpdate(claimedPath); + const content = await readFile(claimedPath, 'utf-8').catch(() => null); + if (content !== null) { + await createFileIfAbsent(getNativeStagedStateFile(exePath), content).catch(() => {}); } + await unlink(claimedPath).catch(() => {}); } /** @@ -463,9 +464,9 @@ export async function maybeRelaunchWithStagedNativeUpdate( // claimed metadata so a later launch retries the swap (transient locks // clear on reboot) — but only into a still-free state-file path: a // downloader may have published a NEWER stage while we smoke-checked, - // and a rename restore would silently replace it. link() is - // create-if-absent, so the restore can never overwrite; when the path is - // taken, the newer stage wins and ours is discarded. + // and an unconditional restore would silently replace it. The restore is + // create-if-absent, so it can never overwrite; when the path is taken, + // the newer stage wins and ours is discarded. logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); await restoreClaimedUpdate(deps.exePath, claimedPath); return false; diff --git a/apps/kimi-code/src/utils/persistence.ts b/apps/kimi-code/src/utils/persistence.ts index a458ae02ab..0b60e5109c 100644 --- a/apps/kimi-code/src/utils/persistence.ts +++ b/apps/kimi-code/src/utils/persistence.ts @@ -6,7 +6,7 @@ * these helpers. */ -import { appendFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { appendFile, link, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import type { z } from 'zod'; @@ -17,6 +17,15 @@ function isNotFound(error: unknown): boolean { ); } +/** + * Hard links need filesystem support: FAT/exFAT (and some network mounts) + * answer link() with ENOTSUP/ENOSYS/EPERM instead. + */ +function isHardLinkUnsupported(error: unknown): boolean { + const code = (error as { code?: string } | null)?.code; + return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EPERM'; +} + function assertNonConfigWrite(filePath: string): void { if (basename(filePath) === 'config.toml') { throw new Error( @@ -66,6 +75,32 @@ export async function writeJsonFile( } } +/** + * Create `filePath` with `content` only while the path is still free — + * atomically, and throwing EEXIST when it is already taken. + * + * Primary primitive: hard-link a fully written temp file into place, so the + * destination is never observable in an empty/partial state. Filesystems + * without hard-link support (FAT/exFAT, some network mounts) fall back to an + * exclusive create + write — whose create→write gap IS observable, so readers + * of such files must grant young unparseable content a publish grace before + * treating it as corrupt (see the update install lock for an example). + */ +export async function createFileIfAbsent(filePath: string, content: string): Promise { + assertNonConfigWrite(filePath); + await mkdir(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + await writeFile(tmpPath, content, { encoding: 'utf-8', mode: 0o600 }); + try { + await link(tmpPath, filePath); + } catch (error) { + if (!isHardLinkUnsupported(error)) throw error; + await writeFile(filePath, content, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }); + } finally { + await unlink(tmpPath).catch(() => {}); + } +} + export async function readJsonlFile( filePath: string, lineSchema: z.ZodType, diff --git a/apps/kimi-code/test/cli/update/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index 335e4f0873..63bfbc783f 100644 --- a/apps/kimi-code/test/cli/update/install-lock.test.ts +++ b/apps/kimi-code/test/cli/update/install-lock.test.ts @@ -1,13 +1,36 @@ import { spawn } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; import { getUpdateInstallLockFile } from '#/utils/paths'; +const fsMocks = vi.hoisted(() => ({ + /** When set, link() throws an error with this code (no hard-link support). */ + linkError: null as string | null, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + link: async ( + src: Parameters[0], + dst: Parameters[1], + ) => { + if (fsMocks.linkError !== null) { + throw Object.assign(new Error('link() is not supported (mocked)'), { + code: fsMocks.linkError, + }); + } + return actual.link(src, dst); + }, + }; +}); + const originalEnv = { ...process.env }; let dir: string; @@ -15,6 +38,7 @@ let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'kimi-update-install-lock-')); process.env['KIMI_CODE_HOME'] = dir; + fsMocks.linkError = null; }); afterEach(() => { @@ -74,6 +98,59 @@ describe('update install lock', () => { const filePath = getUpdateInstallLockFile(); mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, '{', 'utf-8'); + // Crash residue is old; a YOUNG unparseable file is treated as a publish + // still in progress (see the publish grace), so age it past the grace. + const old = new Date(Date.now() - 2 * 60 * 1000); + utimesSync(filePath, old, old); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('treats a young unparseable lock as a publish in progress', async () => { + // The exclusive-create fallback (filesystems without hard links) is + // observable between create and write; sweeping that window would break + // exclusivity, so young unparseable content is NOT stale. + const filePath = getUpdateInstallLockFile(); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, '{', 'utf-8'); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).toBeNull(); + }); + + it('acquires, excludes and releases on filesystems without hard-link support', async () => { + fsMocks.linkError = 'ENOTSUP'; + + const first = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(first).not.toBeNull(); + expect(await tryAcquireUpdateInstallLock({ version: '0.5.0' })).toBeNull(); + + await first?.release(); + const again = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + expect(again).not.toBeNull(); + await again?.release(); + }); + + it('grants exactly one winner under concurrent exclusive-create publishes', async () => { + fsMocks.linkError = 'ENOTSUP'; + + const attempts = await Promise.all( + Array.from({ length: 20 }, () => tryAcquireUpdateInstallLock({ version: '0.5.0' })), + ); + const winners = attempts.filter((handle) => handle !== null); + expect(winners).toHaveLength(1); + await winners[0]?.release(); + }); + + it('takes over a stale lock without hard-link support', async () => { + fsMocks.linkError = 'ENOTSUP'; + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + await new Promise((resolve) => child.once('exit', resolve)); + writeAgedLock(child.pid ?? -1); const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 47bb10e16e..a48c5ff8a3 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -18,6 +18,8 @@ import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; const fsMocks = vi.hoisted(() => ({ /** When set, renames matching the predicate fail with an injected error. */ renameBlocker: null as null | ((src: string, dst: string) => boolean), + /** When set, link() throws an error with this code (no hard-link support). */ + linkError: null as string | null, })); vi.mock('node:fs/promises', async (importOriginal) => { @@ -33,6 +35,17 @@ vi.mock('node:fs/promises', async (importOriginal) => { } return actual.rename(src, dst); }, + link: async ( + src: Parameters[0], + dst: Parameters[1], + ) => { + if (fsMocks.linkError !== null) { + throw Object.assign(new Error('link() is not supported (mocked)'), { + code: fsMocks.linkError, + }); + } + return actual.link(src, dst); + }, }; }); @@ -168,6 +181,7 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { await writeFile(exePath, 'old-binary'); vi.stubEnv('KIMI_CODE_HOME', homeDir); fsMocks.renameBlocker = null; + fsMocks.linkError = null; }); afterEach(async () => { @@ -344,6 +358,21 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { ).resolves.toBeDefined(); }); + it('restores the staged metadata without hard-link support', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // The restore publishes create-if-absent; on filesystems without hard + // links it must fall back to an exclusive create, not drop the stage. + fsMocks.linkError = 'ENOTSUP'; + // rename(exe → bak) fails when the in-service exe is gone. + await rm(exePath); + const { spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + const restored = await readStagedNativeUpdate(exePath); + expect(restored).toMatchObject({ version: STAGED_VERSION }); + }); + it('falls back to a pid-named backup when the plain .bak cannot be removed', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // A directory at `${exePath}.bak` cannot be removed via unlink → pid fallback. From 9c4cf49cc88169798e796d8d9ed4fdb5bc246fa7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 16:06:56 +0800 Subject: [PATCH 39/42] fix(cli): publish staged exes under unique names and recover orphaned claims Two related robustness fixes in the staged swap flow: - A staged executable is now published under a unique per-worker name (kimi-...[.exe]) and never replaced; the atomic metadata write retargets the pointer. The pathname a swap validates at claim time can no longer be exchanged by a concurrent same-version publisher between validation and install. - restoreClaimedUpdate only drops the claim when the restore landed or a newer stage holds the state-file path; transient failures retain it. The stale-claim sweep now restores aged claims (create-if-absent) instead of deleting them, so a stage orphaned by a dead swap or a transient restore failure is retried on a later launch. --- apps/kimi-code/src/cli/update/native-stage.ts | 80 +++++++++++-------- apps/kimi-code/src/cli/update/native-swap.ts | 46 ++++++++--- .../test/cli/update/native-stage.test.ts | 16 +++- .../test/cli/update/native-swap.test.ts | 40 +++++++++- 4 files changed, 130 insertions(+), 52 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index a4aadab67e..56bde64d09 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -54,6 +54,23 @@ export function stagedExeFileName(version: string, platform: NodeJS.Platform): s return platform === 'win32' ? `kimi-${version}.exe` : `kimi-${version}`; } +/** Uniquifies the published staged-exe name across concurrent in-process workers. */ +let stageTempCounter = 0; + +/** + * The name a stage is published under: the base name plus a unique per-worker + * infix (`kimi-...[.exe]`). Once published, a + * staged executable is NEVER replaced — a same-version re-download publishes + * a new generation and the atomic metadata write retargets the pointer — so + * the pathname a swap validates at claim time is stable: no concurrent + * publisher can exchange the bytes between validation and install. + */ +function uniqueStagedExeFileName(version: string, platform: NodeJS.Platform): string { + const infix = `.${process.pid}.${Date.now()}.${stageTempCounter}`; + stageTempCounter += 1; + return platform === 'win32' ? `kimi-${version}${infix}.exe` : `kimi-${version}${infix}`; +} + export function stagedExePath(exePath: string, staged: StagedNativeUpdate): string { return join(getNativeStagingDir(exePath), staged.exeFileName); } @@ -163,26 +180,26 @@ export async function hashFileSha256(filePath: string): Promise { /** * Whether a `.staging/` entry is an updater-owned artifact: a staged - * executable (`kimi-[.exe]`) or a download intermediate - * (`kimi-[.exe][..].part`). Ownership derives from the + * executable (`kimi-[...][.exe]`) or a download + * intermediate (the same plus `.part`). Ownership derives from the * semver/file-name contract (prerelease and build metadata included), so * foreign files in the directory are never matched. */ function isUpdaterOwnedStagingFile(entry: string): boolean { if (!entry.startsWith('kimi-')) return false; let name = entry.slice('kimi-'.length); - const isPart = name.endsWith('.part'); - if (isPart) name = name.slice(0, -'.part'.length); - const candidates = isPart - ? // New-style intermediates carry a unique worker infix (..) - // after any .exe — try with and without stripping it (the infix is - // itself dot-numeric, which is ambiguous with prerelease suffixes). - [name, name.replace(/\.\d+\.\d+$/, '')] - : [name]; - return candidates.some((candidate) => { - const base = candidate.endsWith('.exe') ? candidate.slice(0, -'.exe'.length) : candidate; - return valid(base) !== null; - }); + if (name.endsWith('.part')) name = name.slice(0, -'.part'.length); + if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length); + // Published artifacts may carry a unique per-worker infix after the + // version (..., or the older ..) — try with and + // without stripping it (the infix is dot-numeric, which is ambiguous with + // prerelease suffixes, so every candidate is checked). + const candidates = [ + name, + name.replace(/\.\d+\.\d+$/, ''), + name.replace(/\.\d+\.\d+\.\d+$/, ''), + ]; + return candidates.some((candidate) => valid(candidate) !== null); } /** @@ -278,9 +295,6 @@ export interface StageNativeUpdateResult { */ const DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; -/** Uniquifies the .part path across concurrent in-process workers. */ -let stageTempCounter = 0; - async function downloadAndHash( url: string, partPath: string, @@ -367,7 +381,10 @@ export async function stageNativeUpdate( } const fetchImpl = options.fetchImpl ?? fetch; const target = `${platform}-${arch}`; - const exeFileName = stagedExeFileName(options.version, platform); + // Unique per-worker publish name — see uniqueStagedExeFileName: a staged + // exe is never replaced once published, so the pathname a swap validates + // at claim time cannot be exchanged by a concurrent publisher. + const exeFileName = uniqueStagedExeFileName(options.version, platform); const existing = await readStagedNativeUpdate(options.exePath); if (existing !== null && existing.version === options.version) { @@ -376,8 +393,8 @@ export async function stageNativeUpdate( // would still be adopted here and reported as success, only for the // startup swap's claim-time re-verify to reject and discard it. Compare // the actual digest before adopting; a mismatch falls through and - // re-stages from the CDN (the publishing rename replaces the damaged - // exe atomically). + // re-stages from the CDN (published under a new generation name — the + // damaged exe is left for the age-gated orphan cleanup). const digest = await hashFileSha256(stagedExePath(options.exePath, existing)); if (digest === existing.sha256) { // An explicit upgrade adopts an auto-staged payload — but only report @@ -417,16 +434,10 @@ export async function stageNativeUpdate( manual: options.manual === true ? true : undefined, }; - // Unique .part name per worker: a same-version downloader may overlap a - // swap claim (and, in the irreducible residual of pathname-level locking, a - // second lock holder) — a shared .part path would interleave writes into - // garbage that fails verification, with each side's cleanup deleting the - // other's payload. - const partPath = join( - stagingDir, - `${exeFileName}.${process.pid}.${stageTempCounter}.part`, - ); - stageTempCounter += 1; + // The .part intermediate is just the publish name plus the suffix — the + // name already carries this worker's unique infix, so concurrent workers + // never interleave writes into a shared path. + const partPath = join(stagingDir, `${exeFileName}.part`); try { const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); const entry = selectPlatformEntry(manifest, platform, arch); @@ -457,11 +468,10 @@ export async function stageNativeUpdate( return { status: 'staged', staged }; } catch (error) { // Remove only what THIS attempt privately owns: its unique .part file. - // The shared staged-exe path is never deleted on failure — every - // reference check is a snapshot, and a concurrent worker may have just - // renamed its verified payload onto that path (payloads publish before - // their metadata). An unreferenced exe is reaped by the age-gated - // orphan cleanup. + // If the failure landed after the publishing rename, this attempt's exe + // is already at its unique name with no metadata pointing at it — left + // in place (a just-published exe may belong to a concurrent metadata + // write) and reaped by the age-gated orphan cleanup. await rm(partPath, { force: true }).catch(() => {}); // Best effort: drop the staging dir itself when empty (a concurrent // worker's files keep it around — rmdir only removes empty dirs). diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 99dc065591..8b16982a1f 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -67,6 +67,12 @@ function isNotFound(error: unknown): boolean { ); } +function isAlreadyExists(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'EEXIST' + ); +} + /** * A `staged.json.swap-` claim file younger than this marks a swap in * progress in another instance; older ones are crash residue. The bound @@ -197,13 +203,26 @@ async function claimStagedUpdate(exePath: string): Promise * into a still-free state-file path: a downloader may have published a NEWER * stage meanwhile, and an unconditional restore would silently replace it. * The publish is create-if-absent (hard link, or an exclusive create on - * filesystems without hard-link support), so the restore never overwrites; - * when the path is taken, the newer stage wins and ours is discarded. + * filesystems without hard-link support), so the restore never overwrites. + * + * The claim file is removed only when the restore landed or the path was + * taken by a newer stage (ours is superseded either way). A transient + * failure (ENOSPC, EACCES, …) RETAINS the claim: discarding it would orphan + * the staged exe with no newer stage to show for it, and the stale-claim + * sweep retries the restore on a later launch. */ async function restoreClaimedUpdate(exePath: string, claimedPath: string): Promise { const content = await readFile(claimedPath, 'utf-8').catch(() => null); - if (content !== null) { - await createFileIfAbsent(getNativeStagedStateFile(exePath), content).catch(() => {}); + if (content === null) { + // Nothing readable to restore — drop the residue. + await unlink(claimedPath).catch(() => {}); + return; + } + try { + await createFileIfAbsent(getNativeStagedStateFile(exePath), content); + } catch (error) { + if (!isAlreadyExists(error)) return; + // EEXIST: a concurrently published newer stage won the path. } await unlink(claimedPath).catch(() => {}); } @@ -256,13 +275,16 @@ async function cleanupBackups(exePath: string, keepPath?: string): Promise } /** - * Prune `staged.json.swap-` claim files left by instances that died - * mid-swap. Only the claim files themselves are removed: their referenced - * exes may belong to a freshly published stage (a downloader can republish - * the same version while we sweep, and the metadata snapshot is stale the - * moment it is read), and genuinely unreferenced exes are reaped by the - * downloader's own orphan cleanup before its next stage. Returns true when a - * FRESH claim file was seen — i.e. another instance is swapping right now. + * Recover `staged.json.swap-` claim files left by instances that died + * mid-swap (or kept by a restore that hit a transient error). An AGED claim + * is restored back onto the state-file path — create-if-absent, so a newer + * published stage is never overwritten — and this very launch can then claim + * and retry the swap; the claim file is dropped once the record is restored + * or superseded, and retained on transient errors. The referenced exes are + * never touched here: they may belong to a freshly published stage, and + * genuinely unreferenced ones are reaped by the downloader's own orphan + * cleanup before its next stage. Returns true when a FRESH claim file was + * seen — i.e. another instance is swapping right now. */ async function cleanupStaleSwapClaims(exePath: string): Promise { const stagingDir = getNativeStagingDir(exePath); @@ -282,7 +304,7 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { swapInProgress = true; continue; } - await unlink(full).catch(() => {}); + await restoreClaimedUpdate(exePath, full); } return swapInProgress; } diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 02db976123..97641fc66b 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -153,10 +153,13 @@ describe('stageNativeUpdate', () => { expect(result.staged).toMatchObject({ version: VERSION, target: 'linux-x64', - exeFileName: `kimi-${VERSION}`, sha256: sha256Hex(PAYLOAD), exeSize: PAYLOAD.length, }); + // The published exe name carries a unique per-worker infix: a staged + // executable is never replaced once published, so the pathname a swap + // validates at claim time is stable. + expect(result.staged.exeFileName).toMatch(/^kimi-0\.7\.0\.\d+\.\d+\.\d+$/); const stagedOnDisk = await readStagedNativeUpdate(exePath); expect(stagedOnDisk).toEqual(result.staged); @@ -355,9 +358,11 @@ describe('stageNativeUpdate', () => { }); // …but adoption re-verifies the digest, so the payload is re-downloaded - // and the damaged exe is replaced. + // and published under a NEW generation name (a published exe is never + // replaced — the damaged one is left for the orphan cleanup). expect(second.status).toBe('staged'); expect(secondFetch).toHaveBeenCalled(); + expect(second.staged.exeFileName).not.toBe(first.staged.exeFileName); const repaired = await readFile(stagedExePath(exePath, second.staged)); expect(repaired.equals(PAYLOAD)).toBe(true); }); @@ -456,7 +461,7 @@ describe('stageNativeUpdate', () => { }); it('supersedes a staged older version', async () => { - await stageNativeUpdate({ + const first = await stageNativeUpdate({ version: '0.6.0', exePath, platform: 'linux', @@ -477,7 +482,7 @@ describe('stageNativeUpdate', () => { // The older stage's exe is left in place (it may be claim-held by a live // swap); an unreferenced one is reaped by a later orphan cleanup. await expect( - stat(join(getNativeStagingDir(exePath), 'kimi-0.6.0')), + stat(join(getNativeStagingDir(exePath), first.staged.exeFileName)), ).resolves.toBeDefined(); }); @@ -593,6 +598,8 @@ describe('stageNativeUpdate', () => { await agedOrphan(join(stagingDir, 'kimi-1.2.3-rc.1'), Buffer.from('orphan')); await agedOrphan(join(stagingDir, 'kimi-1.2.3+build.5.exe'), Buffer.from('orphan')); await agedOrphan(join(stagingDir, 'kimi-1.2.3-rc.1.123.0.part'), Buffer.from('partial')); + // New-style published name with the unique per-worker infix. + await agedOrphan(join(stagingDir, 'kimi-4.5.6.1234.1700000000000.0'), Buffer.from('orphan')); const result = await stageNativeUpdate({ version: VERSION, @@ -606,6 +613,7 @@ describe('stageNativeUpdate', () => { await expect(stat(join(stagingDir, 'kimi-1.2.3-rc.1'))).rejects.toThrow(); await expect(stat(join(stagingDir, 'kimi-1.2.3+build.5.exe'))).rejects.toThrow(); await expect(stat(join(stagingDir, 'kimi-1.2.3-rc.1.123.0.part'))).rejects.toThrow(); + await expect(stat(join(stagingDir, 'kimi-4.5.6.1234.1700000000000.0'))).rejects.toThrow(); }); it("preserves another worker's staged result when this attempt fails", async () => { diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index a48c5ff8a3..4f22f98de8 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync, writeFileSync } from 'node:fs'; -import { mkdtemp, mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readdir, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -373,6 +373,44 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(restored).toMatchObject({ version: STAGED_VERSION }); }); + it('retains the claim when the restore hits a transient error', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // rename(exe → bak) fails when the in-service exe is gone. + await rm(exePath); + // ENOSPC is not a hard-link-support error: the restore's create-if-absent + // publish fails transiently, and the claim must be RETAINED for a later + // launch's sweep — dropping it would orphan the staged exe with no newer + // stage to show for it. + fsMocks.linkError = 'ENOSPC'; + const { spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + // The state file was not published, and the claim is still there. + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + const names = await readdir(getNativeStagingDir(exePath)); + expect(names.some((name) => name.startsWith('staged.json.swap-'))).toBe(true); + }); + + it('restores an aged orphaned claim and swaps it on that very launch', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // Simulate a claim left by a dead swap: the record renamed aside and aged + // past the claim-stale threshold. + const claimPath = join(getNativeStagingDir(exePath), 'staged.json.swap-99999'); + await rename(getNativeStagedStateFile(exePath), claimPath); + const old = new Date(Date.now() - 10 * 60 * 1000); + await utimes(claimPath, old, old); + + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + // The sweep restored the claim, and this launch swapped the update in. + expect(relaunched).toBe(true); + expect(calls).toHaveLength(2); + const newExe = await readFile(exePath); + expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); + }); + it('falls back to a pid-named backup when the plain .bak cannot be removed', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // A directory at `${exePath}.bak` cannot be removed via unlink → pid fallback. From cf5e0c8c01a8823555a440e0593db40864ba4553 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 16:25:25 +0800 Subject: [PATCH 40/42] fix(cli): verify the staged payload digest in the lock-wait adoption path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForStagedUpdate relied on readStagedNativeUpdate, which checks only the recorded size: while a holder re-stages a same-size-corrupted payload (its metadata is replaced only when the repaired generation publishes), a waiter could promote and report the corrupt stage as downloaded, and startup would later reject its checksum. Apply the same integrity bar as stageNativeUpdate's already-staged path — adopt only a payload that hashes to its recorded checksum; a mismatch falls through to the lock poll, which takes over once the holder finishes without repairing it. --- apps/kimi-code/src/cli/sub/update-download.ts | 18 +++++++- .../test/cli/update-download.test.ts | 44 +++++++++++++++++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/update-download.ts b/apps/kimi-code/src/cli/sub/update-download.ts index c6aef5d75e..efc582ecb9 100644 --- a/apps/kimi-code/src/cli/sub/update-download.ts +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -14,8 +14,10 @@ import { type UpdateInstallLockHandle, } from '#/cli/update/install-lock'; import { + hashFileSha256, promoteStagedUpdateToManual, readStagedNativeUpdate, + stagedExePath, stageNativeUpdate, } from '#/cli/update/native-stage'; import { detectNativeInstall } from '#/cli/update/source'; @@ -35,6 +37,13 @@ type StagedUpdateWait = * (see install-lock), so a killed downloader cannot strand a foreground * `kimi upgrade` in this loop. * + * Adoption applies the same integrity bar as stageNativeUpdate's + * already-staged path: the recorded size proves nothing, and the holder may + * still be RE-STAGING a same-size-corrupted payload (its metadata is only + * replaced when the new generation publishes). A recorded stage whose payload + * fails the checksum is treated as not-yet-staged — the lock poll below takes + * over once the holder finishes without repairing it. + * * A manual (explicit-upgrade) waiter adopts only after CONFIRMING the manual * marker landed on the stage — a concurrent startup swap may be claiming and * restoring the metadata right now, and reporting adoption for a promotion @@ -47,7 +56,11 @@ async function waitForStagedUpdate( ): Promise { for (;;) { const staged = await readStagedNativeUpdate(exePath); - if (staged !== null && staged.version === version) { + const digest = + staged !== null && staged.version === version + ? await hashFileSha256(stagedExePath(exePath, staged)) + : null; + if (staged !== null && digest === staged.sha256) { if (!manual || (await promoteStagedUpdateToManual(exePath, staged))) { return { status: 'staged' }; } @@ -57,7 +70,8 @@ async function waitForStagedUpdate( } else { // Poll the acquisition itself: while the holder lives its lock stays // fresh and this returns null without side effects; when the holder - // finishes (or dies) without staging, the takeover happens right here. + // finishes (or dies) without staging a VERIFIED payload, the takeover + // happens right here. const lock = await tryAcquireUpdateInstallLock({ version }); if (lock !== null) return { status: 'takeover', lock }; } diff --git a/apps/kimi-code/test/cli/update-download.test.ts b/apps/kimi-code/test/cli/update-download.test.ts index 3965f587b0..991e250c49 100644 --- a/apps/kimi-code/test/cli/update-download.test.ts +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ stageNativeUpdate: vi.fn(), readStagedNativeUpdate: vi.fn(), promoteStagedUpdateToManual: vi.fn(async () => true), + hashFileSha256: vi.fn(), + stagedExePath: vi.fn(() => '/tmp/staged-exe'), })); vi.mock('#/cli/update/source', () => ({ @@ -24,6 +26,8 @@ vi.mock('#/cli/update/native-stage', () => ({ stageNativeUpdate: mocks.stageNativeUpdate, readStagedNativeUpdate: mocks.readStagedNativeUpdate, promoteStagedUpdateToManual: mocks.promoteStagedUpdateToManual, + hashFileSha256: mocks.hashFileSha256, + stagedExePath: mocks.stagedExePath, })); vi.mock('@moonshot-ai/kimi-code-sdk', async () => { @@ -96,6 +100,8 @@ describe('createDownloadProgress', () => { }); describe('runUpdateDownloadCommand', () => { + const STAGED_HASH = 'a'.repeat(64); + beforeEach(() => { vi.clearAllMocks(); mocks.detectNativeInstall.mockReturnValue(true); @@ -104,6 +110,7 @@ describe('runUpdateDownloadCommand', () => { release: vi.fn(async () => {}), }); mocks.stageNativeUpdate.mockResolvedValue({ status: 'staged', staged: {} }); + mocks.hashFileSha256.mockResolvedValue(STAGED_HASH); }); afterEach(() => { @@ -122,7 +129,7 @@ describe('runUpdateDownloadCommand', () => { mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); // The other worker's staged update is verified on disk on the first poll. - mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0' }); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0', sha256: STAGED_HASH }); const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); @@ -134,7 +141,7 @@ describe('runUpdateDownloadCommand', () => { it('promotes the adopted stage to manual when an explicit upgrade waited for it', async () => { mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); - mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0' }); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0', sha256: STAGED_HASH }); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await expect(runUpdateDownloadCommand('0.7.0', true)).resolves.toBe(0); expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); @@ -147,7 +154,7 @@ describe('runUpdateDownloadCommand', () => { // marker is confirmed. mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); - mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0' }); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0', sha256: STAGED_HASH }); mocks.promoteStagedUpdateToManual .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); @@ -157,6 +164,37 @@ describe('runUpdateDownloadCommand', () => { expect(mocks.promoteStagedUpdateToManual).toHaveBeenCalledTimes(2); }); + it('waits instead of adopting when the recorded payload fails the checksum', async () => { + mocks.tryAcquireUpdateInstallLock.mockResolvedValue(null); + mocks.readUpdateInstallLockVersion.mockResolvedValue('0.7.0'); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0', sha256: STAGED_HASH }); + // First poll: the recorded payload is corrupt (the holder is re-staging + // it — its metadata is only replaced when the repaired generation + // publishes); second poll: the repaired generation verifies. + mocks.hashFileSha256.mockResolvedValueOnce('corrupt').mockResolvedValue(STAGED_HASH); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); + expect(mocks.stageNativeUpdate).not.toHaveBeenCalled(); + expect(mocks.hashFileSha256).toHaveBeenCalledTimes(2); + }); + + it('takes over when the holder dies leaving a corrupt stage behind', async () => { + const release = vi.fn(async () => {}); + mocks.tryAcquireUpdateInstallLock + .mockResolvedValueOnce(null) // initial acquire: held + .mockResolvedValue({ filePath: '/tmp/install.lock', release }); // in-loop takeover + mocks.readUpdateInstallLockVersion.mockResolvedValueOnce('0.7.0'); + mocks.readStagedNativeUpdate.mockResolvedValue({ version: '0.7.0', sha256: STAGED_HASH }); + // The recorded payload never verifies: the lock poll takes over and + // stageNativeUpdate's own adoption check re-stages it. + mocks.hashFileSha256.mockResolvedValue('corrupt'); + await expect(runUpdateDownloadCommand('0.7.0')).resolves.toBe(0); + expect(mocks.stageNativeUpdate).toHaveBeenCalledWith( + expect.objectContaining({ version: '0.7.0' }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + it('takes over when the same-version holder finishes without staging', async () => { const release = vi.fn(async () => {}); mocks.tryAcquireUpdateInstallLock From fbc61f6fcf8ec2aeab7ae447a6cd4357041a8397 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 16:55:50 +0800 Subject: [PATCH 41/42] fix(cli): serialize swap critical sections and preserve in-flight publishes - The fresh-claim sweep is only a directory snapshot: two processes could both pass it before either claimed, then rename the same installed exe concurrently and delete each other's rollback backup. A create-if-absent swap mutex (swap.lock, age-gated like the takeover marker) now serializes the executable-renaming section; the loser restores its claim and defers. The mutex is released as soon as the new exe is in place, before the re-exec, so it is never held for the child session's lifetime. - claimStagedUpdate no longer destroys a claimed record that is unparseable but was young at claim time: on filesystems without hard links the exclusive-create publish is observable mid-write, and discarding it would orphan the staged exe while the writer reports success. Such a record is put back with the same inode so the writer completes it; aged corrupt residue and well-formed records with a missing/changed exe are still discarded. --- apps/kimi-code/src/cli/update/native-stage.ts | 23 +- apps/kimi-code/src/cli/update/native-swap.ts | 229 +++++++++++++----- .../test/cli/update/native-swap.test.ts | 58 +++++ 3 files changed, 246 insertions(+), 64 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index 56bde64d09..f85b86d7c8 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -75,6 +75,18 @@ export function stagedExePath(exePath: string, staged: StagedNativeUpdate): stri return join(getNativeStagingDir(exePath), staged.exeFileName); } +/** Parse staged-update metadata from raw text; null when malformed. */ +export function parseStagedNativeUpdate(raw: string): StagedNativeUpdate | null { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + const parsed = StagedNativeUpdateSchema.safeParse(json); + return parsed.success ? parsed.data : null; +} + /** * Read the staged-update metadata, returning null when anything is off: * missing/corrupt `staged.json`, or the staged exe went away / changed size. @@ -90,15 +102,8 @@ export async function readStagedNativeUpdate( } catch { return null; } - let json: unknown; - try { - json = JSON.parse(raw); - } catch { - return null; - } - const parsed = StagedNativeUpdateSchema.safeParse(json); - if (!parsed.success) return null; - const staged = parsed.data; + const staged = parseStagedNativeUpdate(raw); + if (staged === null) return null; const info = await stat(stagedExePath(exePath, staged)).catch(() => null); if (info === null || info.size !== staged.exeSize) return null; return staged; diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index 8b16982a1f..b160e3be9e 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -33,6 +33,7 @@ import { import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; import { hashFileSha256, + parseStagedNativeUpdate, readStagedNativeUpdate, stagedExePath, type StagedNativeUpdate, @@ -80,6 +81,20 @@ function isAlreadyExists(error: unknown): boolean { */ const SWAP_CLAIM_STALE_MS = 5 * 60 * 1000; +/** + * The swap's executable-renaming critical section is a few filesystem ops + * (well under a second), so a swap mutex older than this is crash residue. + */ +const SWAP_MUTEX_STALE_MS = 60_000; + +/** + * A young unparseable `staged.json` may be an in-flight exclusive-create + * publish (observable mid-write on filesystems without hard links — see + * createFileIfAbsent), not corruption. The publish gap is microscopic, so + * only records younger than this get the benefit of the doubt. + */ +const STAGED_PUBLISH_GRACE_MS = 60_000; + // First launch of a fresh ~150 MB unsigned exe can sit in an antivirus scan; // give Windows extra headroom so a slow scan is not misread as a broken binary. const SMOKE_CHECK_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 15_000; @@ -172,11 +187,20 @@ interface ClaimedStaged { * the rename could act on metadata this swap never claimed. * * Returns null when there is nothing staged, the file disappeared under us, - * or the claimed metadata failed consistency checks. + * or the claimed metadata failed consistency checks. A claimed record that is + * UNPARSEABLE but was young at claim time may be an in-flight + * exclusive-create publish (observable mid-write where hard links are + * unsupported): it is put back with the same inode so the writer completes + * it, never destroyed. Aged corrupt residue and well-formed records whose exe + * is gone/changed are deterministically dead and discarded. */ async function claimStagedUpdate(exePath: string): Promise { const stateFile = getNativeStagedStateFile(exePath); const claimedPath = `${stateFile}.swap-${process.pid}`; + // Capture the record's age BEFORE the stamp below rewrites it. + const before = await stat(stateFile).catch(() => null); + const youngAtClaim = + before === null || Date.now() - before.mtimeMs <= STAGED_PUBLISH_GRACE_MS; try { // The metadata's mtime can be arbitrarily old — the download may have // finished hours before this launch. Stamp it BEFORE the rename so the @@ -192,6 +216,17 @@ async function claimStagedUpdate(exePath: string): Promise // Parse exactly the metadata we claimed. const staged = await readStagedNativeUpdate(exePath, claimedPath); if (staged === null) { + const raw = await readFile(claimedPath, 'utf-8').catch(() => null); + const wellFormed = raw !== null && parseStagedNativeUpdate(raw) !== null; + if (!wellFormed && youngAtClaim) { + // Possible in-flight publish: put the SAME inode back so the writer's + // pending write completes it. rename can overwrite a concurrently + // published newer record — bounded to this parse-failure window, and + // the loser is a newer stage that simply re-downloads, never a corrupt + // install. + await rename(claimedPath, stateFile).catch(() => {}); + return null; + } await unlink(claimedPath).catch(() => {}); return null; } @@ -247,6 +282,54 @@ async function rollback(bakPath: string, exePath: string): Promise { } } +export interface SwapMutexHandle { + release(): Promise; +} + +/** + * Serialize the swap's executable-renaming critical section across CLI + * processes. The fresh-claim sweep is only a directory SNAPSHOT: two + * processes can both pass it before either claims, then claim different + * stage generations and rename the same installed exe concurrently — + * deleting or replacing each other's `.bak` rollback source. The mutex is + * create-if-absent (via createFileIfAbsent); an aged holder is crash residue + * (the section lasts well under a second) and is swept, then retried once. + */ +async function acquireSwapMutex(stagingDir: string): Promise { + const mutexPath = join(stagingDir, 'swap.lock'); + const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await createFileIfAbsent(mutexPath, marker); + } catch (error) { + if (!isAlreadyExists(error)) { + // Transient IO failure (ENOSPC, EACCES, …): defer the swap rather + // than abort it — the caller restores the claim for a later launch. + return null; + } + if (attempt === 1) return null; + // Held — or crash residue: only an AGED mutex may be swept. + const info = await stat(mutexPath).catch(() => null); + if (info !== null && Date.now() - info.mtimeMs <= SWAP_MUTEX_STALE_MS) return null; + await unlink(mutexPath).catch(() => {}); + continue; + } + // The stale sweep races this publish; only the survivor proceeds (same + // irreducible residual as the install lock's takeover marker). + const published = await readFile(mutexPath, 'utf-8').catch(() => null); + if (published !== marker) return null; + return { + release: async (): Promise => { + // Release only the mutex instance we own. + const current = await readFile(mutexPath, 'utf-8').catch(() => null); + if (current !== marker) return; + await unlink(mutexPath).catch(() => {}); + }, + }; + } + return null; +} + /** * Remove leftover `.bak` siblings of the exe from earlier swaps/installs. * Only names the updater itself creates are removed: the exact `.bak` @@ -315,8 +398,9 @@ async function cleanupStaleSwapClaims(exePath: string): Promise { * old process still holds its renamed image (`.bak`) on Windows — so later * launches sweep what the previous run could not. * - * Returns true when another instance holds a fresh swap claim: every - * artifact is then left alone and the caller must not start a second swap. + * Returns true when another instance holds a fresh swap claim or swap mutex: + * every artifact is then left alone and the caller must not start a second + * swap. */ async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise { try { @@ -325,6 +409,15 @@ async function sweepStaleNativeUpdateArtifacts(exePath: string): Promise null, + ); + if (mutexInfo !== null && Date.now() - mutexInfo.mtimeMs <= SWAP_MUTEX_STALE_MS) { + return true; + } await cleanupBackups(exePath); } catch { // Hygiene must never affect startup. @@ -461,64 +554,90 @@ export async function maybeRelaunchWithStagedNativeUpdate( return discard(); } - // 2. Pick a backup slot and move the running exe aside (rename of a running - // exe is legal on Windows and POSIX alike; overwriting is not). - // - // Crash window: if the process dies between this rename and step 3, the - // install path is left empty and no CLI code can run to self-heal. Each - // rename is atomic, the window is two adjacent syscalls, and recovery is - // `mv .bak ` or re-running the install script. - let bakPath = `${deps.exePath}.bak`; - try { - await unlink(bakPath); - } catch (error) { - if (!isNotFound(error)) { - // The leftover `.bak` is locked by a still-running old instance (or - // undeletable for another reason) — take a unique backup name, the same - // fallback install.ps1 uses. It is best-effort cleaned up on later runs. - bakPath = `${deps.exePath}.${process.pid}.bak`; - } - } - try { - await rename(deps.exePath, bakPath); - } catch (error) { - // Nothing was moved: startup continues with the old exe. Restore the - // claimed metadata so a later launch retries the swap (transient locks - // clear on reboot) — but only into a still-free state-file path: a - // downloader may have published a NEWER stage while we smoke-checked, - // and an unconditional restore would silently replace it. The restore is - // create-if-absent, so it can never overwrite; when the path is taken, - // the newer stage wins and ours is discarded. - logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); + // The fresh-claim sweep at startup is only a directory snapshot — another + // instance may have begun its swap after our sweep ran. Take the swap + // mutex before touching the install path so two swaps never rename the + // same exe concurrently (each would delete the other's `.bak` rollback + // source). The staged payload is immutable (unique generation name), so + // nothing validated above can change while we contend here. + const swapMutex = await acquireSwapMutex(getNativeStagingDir(deps.exePath)); + if (swapMutex === null) { + logSwap('another instance is in its swap critical section, deferring', { + exePath: deps.exePath, + }); await restoreClaimedUpdate(deps.exePath, claimedPath); return false; } + try { + // 2. Pick a backup slot and move the running exe aside (rename of a running + // exe is legal on Windows and POSIX alike; overwriting is not). + // + // Crash window: if the process dies between this rename and step 3, the + // install path is left empty and no CLI code can run to self-heal. Each + // rename is atomic, the window is two adjacent syscalls, and recovery is + // `mv .bak ` or re-running the install script. + let bakPath = `${deps.exePath}.bak`; + try { + await unlink(bakPath); + } catch (error) { + if (!isNotFound(error)) { + // The leftover `.bak` is locked by a still-running old instance (or + // undeletable for another reason) — take a unique backup name, the same + // fallback install.ps1 uses. It is best-effort cleaned up on later runs. + bakPath = `${deps.exePath}.${process.pid}.bak`; + } + } + try { + await rename(deps.exePath, bakPath); + } catch (error) { + // Nothing was moved: startup continues with the old exe. Restore the + // claimed metadata so a later launch retries the swap (transient locks + // clear on reboot) — but only into a still-free state-file path: a + // downloader may have published a NEWER stage while we smoke-checked, + // and an unconditional restore would silently replace it. The restore is + // create-if-absent, so it can never overwrite; when the path is taken, + // the newer stage wins and ours is discarded. + logSwap('failed to move exe aside', { exePath: deps.exePath, error: String(error) }); + await restoreClaimedUpdate(deps.exePath, claimedPath); + return false; + } - // 3. Move the staged exe into place; roll back on failure. - if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) { - logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath }); - if (!(await rollback(bakPath, deps.exePath))) { - // Rollback failed too (transient file lock, AV, …): the install path is - // now absent and no next launch can start. Keep every artifact instead - // of discarding — the `.bak` IS the old exe and the staged payload is a - // second recovery copy, so `mv .bak ` or re-running the - // installer still recovers. - logSwap('rollback failed, keeping recovery artifacts', { - exePath: deps.exePath, - bakPath, - }); + // 3. Move the staged exe into place; roll back on failure. + if ((await rename(stagedExe, deps.exePath).catch(() => null)) === null) { + logSwap('failed to move staged exe into place, rolling back', { exePath: deps.exePath }); + if (!(await rollback(bakPath, deps.exePath))) { + // Rollback failed too (transient file lock, AV, …): the install path is + // now absent and no next launch can start. Keep every artifact instead + // of discarding — the `.bak` IS the old exe and the staged payload is a + // second recovery copy, so `mv .bak ` or re-running the + // installer still recovers. + logSwap('rollback failed, keeping recovery artifacts', { + exePath: deps.exePath, + bakPath, + }); + await recordSwapFailure(staged.version); + return false; + } await recordSwapFailure(staged.version); - return false; + return await discard(); } - await recordSwapFailure(staged.version); - return discard(); - } - // 4. Success: clean up and re-exec into the new binary. - await unlink(claimedPath).catch(() => {}); - await unlink(bakPath).catch(() => {}); - await cleanupBackups(deps.exePath, bakPath); - await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {}); - logSwap('swap succeeded, re-launching', { version: staged.version }); + // The rollback-critical section ends here: the new exe is in place and + // the `.bak` is no longer needed. Release before the cosmetic cleanup so + // the (now deletable) staging dir does not linger behind the mutex. + await swapMutex.release(); + + // 4. Success: clean up and re-exec into the new binary. + await unlink(claimedPath).catch(() => {}); + await unlink(bakPath).catch(() => {}); + await cleanupBackups(deps.exePath, bakPath); + await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {}); + logSwap('swap succeeded, re-launching', { version: staged.version }); + } finally { + await swapMutex.release(); + } + // Re-exec OUTSIDE the critical section: the child runs the user session, + // so awaiting it inside the try would hold the mutex for its whole + // lifetime. return reexec({ ...deps, spawnImpl }); } diff --git a/apps/kimi-code/test/cli/update/native-swap.test.ts b/apps/kimi-code/test/cli/update/native-swap.test.ts index 4f22f98de8..ec105abc48 100644 --- a/apps/kimi-code/test/cli/update/native-swap.test.ts +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -411,6 +411,64 @@ describe('maybeRelaunchWithStagedNativeUpdate', () => { expect(newExe.equals(Buffer.alloc(STAGED_EXE_SIZE, 1))).toBe(true); }); + it('defers the swap while another instance holds the swap mutex', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + // A fresh swap.lock = another instance in its rename critical section. + await writeFile(join(getNativeStagingDir(exePath), 'swap.lock'), 'other-instance'); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(calls).toHaveLength(0); + // The stage is untouched for a later launch; the exe is untouched. + expect(await readStagedNativeUpdate(exePath)).toMatchObject({ version: STAGED_VERSION }); + expect(await readFile(exePath, 'utf-8')).toBe('old-binary'); + }); + + it('sweeps an aged swap mutex and proceeds with the swap', async () => { + await seedStagedUpdate(exePath, STAGED_VERSION); + const mutexPath = join(getNativeStagingDir(exePath), 'swap.lock'); + await writeFile(mutexPath, 'crash-residue'); + const old = new Date(Date.now() - 10 * 60 * 1000); + await utimes(mutexPath, old, old); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(true); + expect(calls).toHaveLength(2); + // The mutex was released after the swap. + await expect(stat(mutexPath)).rejects.toThrow(); + }); + + it('puts a young unparseable staged record back instead of destroying it', async () => { + // An in-flight exclusive-create publish (filesystems without hard links) + // is observable mid-write; claiming and discarding it would orphan the + // staged exe while the writer still reports success. + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + const stateFile = getNativeStagedStateFile(exePath); + await writeFile(stateFile, '{', 'utf-8'); + const { spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + expect(await readFile(stateFile, 'utf-8')).toBe('{'); + }); + + it('discards an aged unparseable staged record as crash residue', async () => { + const stagingDir = getNativeStagingDir(exePath); + await mkdir(stagingDir, { recursive: true }); + const stateFile = getNativeStagedStateFile(exePath); + await writeFile(stateFile, '{', 'utf-8'); + const old = new Date(Date.now() - 10 * 60 * 1000); + await utimes(stateFile, old, old); + const { spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + await expect(stat(stateFile)).rejects.toThrow(); + }); + it('falls back to a pid-named backup when the plain .bak cannot be removed', async () => { await seedStagedUpdate(exePath, STAGED_VERSION); // A directory at `${exePath}.bak` cannot be removed via unlink → pid fallback. From cfc6717e52d5e409e263423238f9ff59d97d364c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 18 Aug 2026 17:04:47 +0800 Subject: [PATCH 42/42] fix(cli): keep backup cleanup inside the swap mutex The early release let a subsequent swap rename the just-installed exe to the shared .bak path while the previous swap's cleanup was still about to unlink that same path, destroying the second swap's rollback source. The mutex now covers the backup cleanup; the cosmetic staging-dir rmdir and the re-exec stay outside it. --- apps/kimi-code/src/cli/update/native-swap.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/cli/update/native-swap.ts b/apps/kimi-code/src/cli/update/native-swap.ts index b160e3be9e..9b477efaba 100644 --- a/apps/kimi-code/src/cli/update/native-swap.ts +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -622,22 +622,21 @@ export async function maybeRelaunchWithStagedNativeUpdate( return await discard(); } - // The rollback-critical section ends here: the new exe is in place and - // the `.bak` is no longer needed. Release before the cosmetic cleanup so - // the (now deletable) staging dir does not linger behind the mutex. - await swapMutex.release(); - - // 4. Success: clean up and re-exec into the new binary. + // 4. Success: clean up, STILL INSIDE the mutex — a swap that acquires it + // the instant we release could rename the exe we just installed to the + // shared `.bak` path, and this cleanup would delete that rollback + // source. Then re-exec into the new binary. await unlink(claimedPath).catch(() => {}); await unlink(bakPath).catch(() => {}); await cleanupBackups(deps.exePath, bakPath); - await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {}); logSwap('swap succeeded, re-launching', { version: staged.version }); } finally { await swapMutex.release(); } - // Re-exec OUTSIDE the critical section: the child runs the user session, - // so awaiting it inside the try would hold the mutex for its whole - // lifetime. + // Cosmetic, now that the release removed our mutex file: drop the staging + // dir when empty. And re-exec OUTSIDE the critical section: the child runs + // the user session, so awaiting it inside the try would hold the mutex for + // its whole lifetime. + await rmdir(getNativeStagingDir(deps.exePath)).catch(() => {}); return reexec({ ...deps, spawnImpl }); }