diff --git a/.changeset/native-staged-auto-update.md b/.changeset/native-staged-auto-update.md new file mode 100644 index 0000000000..7626729453 --- /dev/null +++ b/.changeset/native-staged-auto-update.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +The Windows native (single-binary) CLI now supports automatic updates. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index a090df4d0f..6b6c3aca07 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, manual: boolean) => 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,17 @@ export function createProgram( onPluginNodeRunner(entry, args); }); + // Self-spawned worker for native staged updates (detached background + // 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('') + .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[]) => { 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..efc582ecb9 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/update-download.ts @@ -0,0 +1,185 @@ +/** + * 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 { + readUpdateInstallLockVersion, + tryAcquireUpdateInstallLock, + type UpdateInstallLockHandle, +} from '#/cli/update/install-lock'; +import { + hashFileSha256, + promoteStagedUpdateToManual, + readStagedNativeUpdate, + stagedExePath, + 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: '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. + * + * 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 + * 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); + 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' }; + } + // 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 a VERIFIED payload, 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); + }); + } +} + +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; + } + const out = process.stdout; + let lock = await tryAcquireUpdateInstallLock({ version }); + if (lock === null) { + const holderVersion = await readUpdateInstallLockVersion(); + 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`, + ); + const wait = await waitForStagedUpdate(version, process.execPath, manual); + 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 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 }); + } + if (lock === null) { + process.stderr.write( + `error: another update (${holderVersion ?? 'unknown version'}) is already downloading\n`, + ); + return 1; + } + } + const label = `Downloading Kimi Code ${version} (${process.platform}-${process.arch})…`; + const onProgress = createDownloadProgress(out, label); + try { + const result = await stageNativeUpdate({ + version, + exePath: process.execPath, + onProgress, + manual, + }); + 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 }); + return 1; + } finally { + 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/install-lock.ts b/apps/kimi-code/src/cli/update/install-lock.ts index 0b6f3834c3..f42042ac3a 100644 --- a/apps/kimi-code/src/cli/update/install-lock.ts +++ b/apps/kimi-code/src/cli/update/install-lock.ts @@ -1,10 +1,26 @@ -import { mkdir, open, readFile, unlink } 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; +/** + * 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; + +/** + * 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; readonly now?: Date; @@ -12,6 +28,8 @@ export interface UpdateInstallLockRequest { export interface UpdateInstallLockHandle { readonly filePath: string; + /** The exact contents this handle published — its ownership identity. */ + readonly content: string; release(): Promise; } @@ -27,42 +45,95 @@ function isAlreadyExists(error: unknown): boolean { ); } -async function isStaleLock(filePath: string, now: Date): Promise { +/** + * 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 { - 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 }; - 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; + process.kill(pid, 0); + return true; } catch (error) { - if (isNotFound(error)) return true; - if (error instanceof SyntaxError) return true; - return false; + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +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. 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 isStaleLock(inspection: LockInspection, now: Date): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(inspection.content); + } catch { + 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 }; + 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'; } async function createLockFile( filePath: string, request: UpdateInstallLockRequest, -): Promise { +): Promise { const now = request.now ?? new Date(); - const file = await open(filePath, 'wx', 0o600); - try { - await file.writeFile(`${JSON.stringify({ - version: request.version, - pid: process.pid, - startedAt: now.toISOString(), - }, null, 2)}\n`, 'utf-8'); - } finally { - await file.close(); - } + const content = `${JSON.stringify({ + version: request.version, + pid: process.pid, + startedAt: now.toISOString(), + }, null, 2)}\n`; + // 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); + if (published !== content) return null; 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; }); @@ -81,15 +152,103 @@ 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 inspectLockFile(filePath); + if (inspected !== null && !isStaleLock(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 inspectLockFile(filePath); + if (current !== null && !isStaleLock(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 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 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 publishTakeoverMarker(takeoverPath); +} + +/** Create-if-absent publish of a small lock marker file. */ +async function publishTakeoverMarker(target: string): Promise { + // Unique marker content doubles as the ownership identity below. + const marker = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + try { + await createFileIfAbsent(target, marker); } catch (error) { - if (isAlreadyExists(error)) return null; + if (isAlreadyExists(error)) return false; throw error; } + // The stale-marker sweep races this publish: it may unlink our fresh marker + // 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, + // because swap claims guard the executable independently.) + const published = await readFile(target, 'utf-8').catch(() => null); + return published === marker; +} + +/** + * 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-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts new file mode 100644 index 0000000000..06c7c5bfc5 --- /dev/null +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -0,0 +1,101 @@ +/** + * Per-release native artifact manifest (`/binaries//manifest.json`). + * + * 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'; +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); + // 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 { + const response = await fetchImpl(nativeManifestUrl(version), { signal: controller.signal }); + if (!response.ok) { + throw new Error(`native manifest for ${version} returned HTTP ${response.status}`); + } + 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); + } +} + +/** + * 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..f85b86d7c8 --- /dev/null +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -0,0 +1,486 @@ +/** + * Native staged update: download + verify into `/.staging/`, + * without touching the running executable. The actual swap happens on the + * next startup (see `native-swap.ts`). + * + * 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 { 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'; +import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths'; +import { writeJsonFile } from '#/utils/persistence'; + +import { + fetchNativeReleaseManifest, + nativeBinaryUrl, + selectPlatformEntry, +} from './native-manifest'; + +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) + .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), + 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(); + +export type StagedNativeUpdate = z.infer; + +export function stagedExeFileName(version: string, platform: NodeJS.Platform): string { + 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); +} + +/** 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. + * 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; + } + 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; +} + +/** + * 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. + * + * `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, + 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 || !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, + 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). The confirmation must see the + // promoted ADOPTION CANDIDATE itself, not just any manual record. + const confirmed = await readStagedNativeUpdate(exePath); + if (confirmed?.manual === true && isSameStagedRecord(confirmed, expected)) return true; + } + return false; +} + +/** 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; + } +} + +/** + * Whether a `.staging/` entry is an updater-owned artifact: a staged + * 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); + 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); +} + +/** + * 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 + * killed between the two writes) — each such orphan is ~180 MB and would + * 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[]; + try { + entries = await readdir(stagingDir); + } catch { + return; + } + const keep = new Set([KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME]); + for (const entry of entries) { + // 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; + 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 record/claim: keep the file itself, touch nothing else. + } + } + for (const entry of entries) { + if (keep.has(entry)) continue; + // 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 (!isUpdaterOwnedStagingFile(entry)) continue; + 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(() => {}); + } +} + +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; + /** 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; + /** True when the stage answers an explicit user-initiated `kimi upgrade`. */ + readonly manual?: boolean; +} + +export type StageNativeUpdateStatus = 'already-staged' | 'staged'; + +export interface StageNativeUpdateResult { + readonly status: StageNativeUpdateStatus; + 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 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'); + const total = + contentLength !== null && /^\d+$/.test(contentLength) ? Number(contentLength) : null; + const hash = createHash('sha256'); + let size = 0; + const file = await open(partPath, 'w'); + try { + for await (const chunk of response.body as AsyncIterable) { + armIdleTimeout(); + hash.update(chunk); + size += chunk.length; + // 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 { + clearTimeout(idleTimeout); + await file.close(); + } + const digest = hash.digest('hex'); + if (digest !== expectedSha256) { + throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`); + } + return size; +} + +/** + * 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** + * 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; + // 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}`; + // 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) { + // 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 (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 + // 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, existing)) { + return { status: 'already-staged', staged: { ...existing, manual: true } }; + } + } else { + return { status: 'already-staged', staged: existing }; + } + } + } + + // A different version was staged earlier and never swapped (skipped + // rollout, user stayed offline, …), or the same version's payload failed + // 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. + await cleanupStagingOrphans(stagingDir); + + const staged: StagedNativeUpdate = { + version: options.version, + target, + exeFileName, + sha256: '', + exeSize: 0, + stagedAt: new Date().toISOString(), + manual: options.manual === true ? true : undefined, + }; + + // 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); + const size = await downloadAndHash( + nativeBinaryUrl(options.version, entry.filename), + partPath, + entry.checksum, + fetchImpl, + options.onProgress, + options.idleTimeoutMs, + ); + // 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)); + + staged.sha256 = entry.checksum; + staged.exeSize = size; + // Atomic write: staged.json only ever appears complete and consistent. + await writeJsonFile( + getNativeStagedStateFile(options.exePath), + StagedNativeUpdateSchema, + staged, + ); + return { status: 'staged', staged }; + } catch (error) { + // Remove only what THIS attempt privately owns: its unique .part file. + // 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). + 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 new file mode 100644 index 0000000000..9b477efaba --- /dev/null +++ b/apps/kimi-code/src/cli/update/native-swap.ts @@ -0,0 +1,642 @@ +/** + * 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. 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, readFile, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'; +import { constants as osConstants } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import { gt } from 'semver'; + +import { log } from '@moonshot-ai/kimi-code-sdk'; + +import { + KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME, + KIMI_CODE_UPDATE_REEXEC_ENV, +} from '#/constant/app'; + +import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { + hashFileSha256, + parseStagedNativeUpdate, + readStagedNativeUpdate, + stagedExePath, + type StagedNativeUpdate, +} 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; + 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 isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' + ); +} + +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 + * comfortably exceeds the slowest swap (smoke-check timeout included). + */ +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; + +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 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, + 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); + }, SMOKE_CHECK_TIMEOUT_MS); + 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.trim() === 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), + * 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 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 + // 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; + } + // 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; + } + 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 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. + * + * 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) { + // 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(() => {}); +} + +/** + * 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(claimedPath: string): Promise { + await unlink(claimedPath).catch(() => {}); +} + +async function rollback(bakPath: string, exePath: string): Promise { + try { + await rename(bakPath, exePath); + return true; + } catch { + return false; + } +} + +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` + * 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); + 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 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(() => {}); + } +} + +/** + * 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); + 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; + } + await restoreClaimedUpdate(exePath, full); + } + 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. + * + * 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 { + if (await cleanupStaleSwapClaims(exePath)) { + // Another instance is mid-swap: leave every artifact alone — the `.bak` + // next to the exe is its rollback source. + return true; + } + // A live swap critical section holds the mutex: same deference. (Only a + // snapshot, but the swap re-checks the mutex after claiming, so a + // freshly-started swap is never entered concurrently.) + const mutexInfo = await stat(join(getNativeStagingDir(exePath), 'swap.lock')).catch( + () => null, + ); + if (mutexInfo !== null && Date.now() - mutexInfo.mtimeMs <= SWAP_MUTEX_STALE_MS) { + return true; + } + await cleanupBackups(exePath); + } catch { + // Hygiene must never affect startup. + } + return false; +} + +/** + * 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)); + 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); + }); + }); +} + +/** + * 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 (!deps.isNative) return false; + 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; + const { staged, claimedPath } = claimed; + const spawnImpl = deps.spawnImpl ?? spawn; + + const discard = async (): Promise => { + await discardClaimedUpdate(claimedPath); + 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(); + } + + // 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 + // 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(); + } + + // 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, + }); + await recordSwapFailure(staged.version); + return false; + } + await recordSwapFailure(staged.version); + return await discard(); + } + + // 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); + logSwap('swap succeeded, re-launching', { version: staged.version }); + } finally { + await swapMutex.release(); + } + // 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 }); +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 5bcad7c9bf..f54ff9b22a 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,31 @@ 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, + 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') { + // 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; + 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 +206,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.'; @@ -361,6 +387,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, @@ -424,17 +488,23 @@ 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']); } -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; @@ -508,19 +578,23 @@ 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`); + // 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`, + ); } 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 +603,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}`)); }); }); } @@ -544,13 +618,20 @@ 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 { const freshState = await readUpdateInstallState().catch(() => state); if ( - hasFreshActiveInstall(freshState, target) || + (await hasInstallInFlight(source, freshState, target)) || failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD ) { return; @@ -577,7 +658,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 +710,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. @@ -670,7 +750,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/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 19b4acc25c..d514d029bd 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,10 @@ 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-[.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 // 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..37ec0a8827 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, manual) => { + void runUpdateDownloadCommand(targetVersion, manual).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/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/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..991e250c49 --- /dev/null +++ b/apps/kimi-code/test/cli/update-download.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDownloadProgress, runUpdateDownloadCommand } from '#/cli/sub/update-download'; + +const mocks = vi.hoisted(() => ({ + detectNativeInstall: vi.fn(() => true), + tryAcquireUpdateInstallLock: vi.fn(), + readUpdateInstallLockVersion: vi.fn(), + 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', () => ({ + detectNativeInstall: mocks.detectNativeInstall, +})); + +vi.mock('#/cli/update/install-lock', () => ({ + tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, + readUpdateInstallLockVersion: mocks.readUpdateInstallLockVersion, +})); + +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 () => { + const actual = await vi.importActual( + '@moonshot-ai/kimi-code-sdk', + ); + return { + ...actual, + log: { ...actual.log, warn: vi.fn() }, + }; +}); + +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', () => { + const STAGED_HASH = 'a'.repeat(64); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.detectNativeInstall.mockReturnValue(true); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/install.lock', + release: vi.fn(async () => {}), + }); + mocks.stageNativeUpdate.mockResolvedValue({ status: 'staged', staged: {} }); + mocks.hashFileSha256.mockResolvedValue(STAGED_HASH); + }); + + 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('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', 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(); + 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', 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(); + 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', sha256: STAGED_HASH }); + 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('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 + .mockResolvedValueOnce(null) // held by the other worker… + .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( + 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 () => { + 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 }); + 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('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); + 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/install-lock.test.ts b/apps/kimi-code/test/cli/update/install-lock.test.ts index fd7b568f80..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,12 +1,36 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +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; @@ -14,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(() => { @@ -37,10 +62,153 @@ 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('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 }); 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' }); + + 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(); + }); + + 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' }); 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..32a930db1c --- /dev/null +++ b/apps/kimi-code/test/cli/update/native-manifest.test.ts @@ -0,0 +1,152 @@ +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('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 })), + ).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/); + }); + + 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', () => { + 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..97641fc66b --- /dev/null +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -0,0 +1,815 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, 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 { nativeBinaryUrl, nativeManifestUrl } from '#/cli/update/native-manifest'; +import { + promoteStagedUpdateToManual, + readStagedNativeUpdate, + stagedExePath, + stageNativeUpdate, +} 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 }>, + /** When > 0, the next open() wraps its handle so the first write is short. */ + shortWriteBudget: 0, +})); + +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); + }, + 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(), + }; + }, + }; +}); + +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. +const BINARY_FILENAME = 'kimi-code-linux-x64'; + +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; + 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: BINARY_FILENAME, + checksum: options.checksum ?? sha256Hex(options.payload), + }, + }, + }); + 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, BINARY_FILENAME)) { + 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; +} + +describe('stageNativeUpdate', () => { + let workDir: string; + let exePath: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'kimi-stage-test-')); + exePath = join(workDir, 'bin', 'kimi'); + fsMocks.calls.length = 0; + fsMocks.shortWriteBudget = 0; + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('downloads, verifies and records the staged metadata', async () => { + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + + expect(result.status).toBe('staged'); + expect(result.staged).toMatchObject({ + version: VERSION, + target: 'linux-x64', + 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); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + // The .part intermediate is gone once the download was promoted. + const leftovers = (await readdir(getNativeStagingDir(exePath))).filter((entry) => + entry.endsWith('.part'), + ); + expect(leftovers).toEqual([]); + }); + + 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('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 + // .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 chmodCall = fsMocks.calls.find( + (call) => call.op === 'chmod' && call.path.endsWith('.part'), + ); + 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), + ); + }); + + 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('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 }); + + const secondFetch = mockCdnFetch({ payload: PAYLOAD }); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: secondFetch, + }); + + expect(result.status).toBe('already-staged'); + 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 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 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); + }); + + it('re-stages when the staged exe went missing', async () => { + const first = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + // 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({ payload: PAYLOAD }), + }); + 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({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + 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 () => { + await expect( + stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'win32', + arch: 'arm64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }), + ).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 () => { + const first = await stageNativeUpdate({ + version: '0.6.0', + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ version: '0.6.0', payload: Buffer.from('old-payload') }), + }); + + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + + 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), first.staged.exeFileName)), + ).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'); + await mkdir(stagingDir, { recursive: true }); + // Orphans from interrupted earlier runs: a referenced-by-nothing exe and + // 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')); + await writeFile( + 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, + 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(); + await expect(stat(join(stagingDir, youngExe))).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 (aged past + // the orphan grace period). + await agedOrphan(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(); + }); + + 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 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, + 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(); + 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 () => { + 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); + }); + + 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('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; + + 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 { 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 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, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: PAYLOAD }), + }); + await writeFile(stagedExePath(exePath, staged), Buffer.alloc(PAYLOAD.length + 1)); + expect(await readStagedNativeUpdate(exePath)).toBeNull(); + }); +}); 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..ec105abc48 --- /dev/null +++ b/apps/kimi-code/test/cli/update/native-swap.test.ts @@ -0,0 +1,833 @@ +import { createHash } from 'node:crypto'; +import { existsSync, writeFileSync } from 'node:fs'; +import { mkdtemp, mkdir, readdir, readFile, rename, 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 { readStagedNativeUpdate, 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 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) => { + 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); + }, + 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 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 | null; + readonly stdout?: string; + readonly error?: Error; + readonly signal?: NodeJS.Signals | null; +}): 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)); + } + 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')?.(code, signal); + listeners.get('exit')?.(code, signal); + }); + 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 reexecSignal?: NodeJS.Signals; +}): { 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.reexecSignal !== undefined ? null : (routes.reexecCode ?? 0), + error: routes.reexecError, + signal: routes.reexecSignal ?? null, + }).child; + }) as unknown as NativeSwapDeps['spawnImpl']; + return { calls, spawnImpl }; +} + +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); + await writeFile(join(stagingDir, stagedExeFileName(version, 'linux')), exeBytes); + await writeFile( + getNativeStagedStateFile(exePath), + `${JSON.stringify({ + version, + target: 'linux-x64', + exeFileName: stagedExeFileName(version, 'linux'), + // 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(), + manual: options?.manual === true ? true : undefined, + }, 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); + fsMocks.renameBlocker = null; + fsMocks.linkError = null; + }); + + 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 env = { [KIMI_CODE_UPDATE_REEXEC_ENV]: '1' }; + const relaunched = await maybeRelaunchWithStagedNativeUpdate( + 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'); + }); + + 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'); + // 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(join(getNativeStagingDir(exePath), stagedExeFileName(CURRENT_VERSION, 'linux'))), + ).resolves.toBeDefined(); + }); + + 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(); + // 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 }); + }); + + 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('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') }); + 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); + }); + + 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. + await rm(exePath); + const { calls, spawnImpl } = createSpawnMock({}); + const relaunched = await maybeRelaunchWithStagedNativeUpdate(makeDeps(exePath, { spawnImpl })); + + expect(relaunched).toBe(false); + // 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 }); + await expect( + stat(join(getNativeStagingDir(exePath), stagedExeFileName(STAGED_VERSION, 'linux'))), + ).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('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('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. + 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 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 }); + 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('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 without touching staged exes', 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(); + // 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 () => { + 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(); + }); + + 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('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'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)).resolves.toBeDefined(); + 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('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('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. + 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'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'))), + ).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 + // 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); + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 3382d622eb..a37c889f41 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', '--manual'], + 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', '--manual'], + 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 }); } @@ -698,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()); @@ -1179,23 +1246,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/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`