From 0180dce7fb54c8e703d2427c8444d417170450ae Mon Sep 17 00:00:00 2001 From: xingyu Date: Sat, 15 Aug 2026 22:27:21 +0800 Subject: [PATCH 1/3] fix(runtime): avoid Windows process owner scan timeout (#1173) --- .../runtime/kun-serve-process-cleanup.test.ts | 153 +++++++++++++++--- src/main/runtime/kun-serve-process-cleanup.ts | 85 ++++++++-- 2 files changed, 204 insertions(+), 34 deletions(-) diff --git a/src/main/runtime/kun-serve-process-cleanup.test.ts b/src/main/runtime/kun-serve-process-cleanup.test.ts index ace70874a..0b44eabfc 100644 --- a/src/main/runtime/kun-serve-process-cleanup.test.ts +++ b/src/main/runtime/kun-serve-process-cleanup.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import { - WINDOWS_CURRENT_USER_PROCESS_SCRIPT, + PROCESS_TABLE_TIMEOUT_MS, + WINDOWS_PROCESS_CANDIDATE_SCRIPT, clearHistoricalKunServeProcesses, + inspectCurrentUserProcess, listCurrentUserProcesses, looksLikeKunServeCommand, looksLikeKunServeProcess, parseUnixProcessSnapshot, parseWindowsProcessSnapshot, + windowsCurrentUserProcessScript, type KunServeProcessSnapshot } from './kun-serve-process-cleanup' @@ -48,7 +51,7 @@ describe('Kun serve process snapshot parsing', () => { ]))).toEqual([{ pid: 203, parentPid: 10, command: 'kun-runtime' }]) }) - it('uses UID-filtered ps on Unix and candidate-filtered CIM on Windows', async () => { + it('uses UID-filtered ps on Unix and owner-free candidate CIM on Windows', async () => { const unixRun = vi.fn(async () => ({ stdout: '' })) await listCurrentUserProcesses({ platform: 'linux', @@ -58,25 +61,130 @@ describe('Kun serve process snapshot parsing', () => { expect(unixRun).toHaveBeenCalledWith( 'ps', ['-axww', '-o', 'pid=', '-o', 'ppid=', '-o', 'uid=', '-o', 'command='], - expect.objectContaining({ windowsHide: true }) + expect.objectContaining({ windowsHide: true, timeout: 1_800_000 }) ) const windowsRun = vi.fn(async () => ({ stdout: '[]' })) await listCurrentUserProcesses({ platform: 'win32', run: windowsRun }) expect(windowsRun).toHaveBeenCalledWith( 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_CURRENT_USER_PROCESS_SCRIPT], - expect.objectContaining({ windowsHide: true }) + ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROCESS_CANDIDATE_SCRIPT], + expect.objectContaining({ windowsHide: true, timeout: 1_800_000 }) ) - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).toContain('-Filter') - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).toContain("Name = 'node.exe'") - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).toContain("Name = 'electron.exe'") - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).toContain("Name LIKE 'kun%.exe'") - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).not.toContain( - 'Get-CimInstance Win32_Process | ForEach-Object' + expect(PROCESS_TABLE_TIMEOUT_MS).toBe(1_800_000) + expect(WINDOWS_PROCESS_CANDIDATE_SCRIPT).toContain('-Filter') + expect(WINDOWS_PROCESS_CANDIDATE_SCRIPT).toContain("Name = 'node.exe'") + expect(WINDOWS_PROCESS_CANDIDATE_SCRIPT).toContain("Name = 'electron.exe'") + expect(WINDOWS_PROCESS_CANDIDATE_SCRIPT).toContain("Name LIKE 'kun%.exe'") + expect(WINDOWS_PROCESS_CANDIDATE_SCRIPT).not.toContain('GetOwnerSid') + expect(WINDOWS_PROCESS_CANDIDATE_SCRIPT).not.toContain('$currentSid') + }) + + it('verifies current-user ownership through an exact Windows PID query', async () => { + const snapshot = { + ProcessId: 204, + ParentProcessId: 10, + ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe', + CommandLine: 'node C:\\Kun\\serve-entry.js serve' + } + const windowsRun = vi.fn(async () => ({ stdout: JSON.stringify(snapshot) })) + + await expect(inspectCurrentUserProcess(204, { + platform: 'win32', + run: windowsRun + })).resolves.toEqual({ + pid: 204, + parentPid: 10, + executable: 'C:\\Program Files\\nodejs\\node.exe', + command: 'node C:\\Kun\\serve-entry.js serve' + }) + + const script = windowsCurrentUserProcessScript(204) + expect(script).toContain('ProcessId = 204') + expect(script).toContain('GetOwnerSid') + expect(script).toContain('$currentSid') + expect(windowsRun).toHaveBeenCalledWith( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + expect.objectContaining({ timeout: 1_800_000 }) + ) + }) + + it('re-verifies an exact Unix PID with the current UID', async () => { + const unixRun = vi.fn(async () => ({ + stdout: ' 205 1 501 /usr/local/bin/node /Kun/serve-entry.js serve\n' + })) + + await expect(inspectCurrentUserProcess(205, { + platform: 'linux', + currentUid: 501, + run: unixRun + })).resolves.toEqual({ + pid: 205, + parentPid: 1, + command: '/usr/local/bin/node /Kun/serve-entry.js serve' + }) + expect(unixRun).toHaveBeenCalledWith( + 'ps', + ['-p', '205', '-o', 'pid=', '-o', 'ppid=', '-o', 'uid=', '-o', 'command='], + expect.objectContaining({ timeout: 1_800_000 }) ) - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).toContain('GetOwnerSid') - expect(WINDOWS_CURRENT_USER_PROCESS_SCRIPT).toContain('$currentSid') + }) + + it('does not query owners for unrelated processes in a Node-heavy Windows snapshot', async () => { + const unrelated = Array.from({ length: 37 }, (_, index) => ({ + ProcessId: 1_000 + index, + ParentProcessId: 10, + ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe', + CommandLine: `node C:\\tools\\mcp-${index}.js` + })) + const kun = { + ProcessId: 2_000, + ParentProcessId: 10, + ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe', + CommandLine: 'node C:\\Kun\\serve-entry.js serve --port 18899' + } + const windowsRun = vi.fn(async (_command: string, args: string[]) => { + const script = args[3] ?? '' + if (script === WINDOWS_PROCESS_CANDIDATE_SCRIPT) { + return { stdout: JSON.stringify([...unrelated, kun]) } + } + if (script.includes('ProcessId = 2000')) return { stdout: JSON.stringify(kun) } + throw new Error(`unexpected owner query: ${script}`) + }) + + await expect(listCurrentUserProcesses({ + platform: 'win32', + run: windowsRun + })).resolves.toEqual([{ + pid: 2_000, + parentPid: 10, + executable: 'C:\\Program Files\\nodejs\\node.exe', + command: 'node C:\\Kun\\serve-entry.js serve --port 18899' + }]) + + const ownerQueries = windowsRun.mock.calls + .map((call) => call[1][3] ?? '') + .filter((script) => script.includes('GetOwnerSid')) + expect(ownerQueries).toHaveLength(1) + expect(ownerQueries[0]).toContain('ProcessId = 2000') + }) + + it('drops a strict Windows candidate when exact-PID ownership cannot be verified', async () => { + const kun = { + ProcessId: 2_001, + ParentProcessId: 10, + ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe', + CommandLine: 'node C:\\Kun\\serve-entry.js serve' + } + const windowsRun = vi.fn(async (_command: string, args: string[]) => ({ + stdout: args[3] === WINDOWS_PROCESS_CANDIDATE_SCRIPT ? JSON.stringify(kun) : '' + })) + + await expect(listCurrentUserProcesses({ + platform: 'win32', + run: windowsRun + })).resolves.toEqual([]) }) }) @@ -145,13 +253,14 @@ describe('historical Kun serve cleanup', () => { }) it('fails closed when a matched PID changes identity before signaling', async () => { - let reads = 0 - const listProcesses = vi.fn(async () => { - reads += 1 - return reads === 1 - ? [{ pid: 401, parentPid: 1, command: 'kun-runtime' }] - : [{ pid: 401, parentPid: 1, command: '/usr/bin/node unrelated.js' }] - }) + const listProcesses = vi.fn(async () => [ + { pid: 401, parentPid: 1, command: 'kun-runtime' } + ]) + const inspectProcess = vi.fn(async () => ({ + pid: 401, + parentPid: 1, + command: '/usr/bin/node unrelated.js' + })) const waitForExit = vi.fn(async () => false) const terminate = vi.fn(async (_pid: number, verify: () => Promise) => { expect(await verify()).toBe(false) @@ -161,9 +270,13 @@ describe('historical Kun serve cleanup', () => { await expect(clearHistoricalKunServeProcesses({ currentPid: 999, listProcesses, + inspectProcess, waitForExit, terminate, log: vi.fn(async () => undefined) })).rejects.toThrow(/401.*replacement was not started/i) + + expect(terminate).toHaveBeenCalledOnce() + expect(inspectProcess).toHaveBeenCalledWith(401) }) }) diff --git a/src/main/runtime/kun-serve-process-cleanup.ts b/src/main/runtime/kun-serve-process-cleanup.ts index 124339cce..d81ea45b2 100644 --- a/src/main/runtime/kun-serve-process-cleanup.ts +++ b/src/main/runtime/kun-serve-process-cleanup.ts @@ -36,31 +36,49 @@ type ProcessListOptions = { type CleanupOptions = { currentPid?: number listProcesses?: () => Promise + inspectProcess?: (pid: number) => Promise terminate?: typeof terminateVerifiedPid waitForExit?: typeof waitForPidExit log?: (line: string) => Promise } -const PROCESS_TABLE_TIMEOUT_MS = 15_000 +export const PROCESS_TABLE_TIMEOUT_MS = 30 * 60_000 const PROCESS_TABLE_MAX_BUFFER = 16 * 1024 * 1024 -export const WINDOWS_CURRENT_USER_PROCESS_SCRIPT = [ - '$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value', +export const WINDOWS_PROCESS_CANDIDATE_SCRIPT = [ "$candidates = Get-CimInstance Win32_Process -Filter \"Name = 'node.exe' OR Name = 'electron.exe' OR Name LIKE 'kun%.exe'\"", '$items = $candidates | ForEach-Object {', - ' $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction SilentlyContinue', - ' if ($owner.Sid -eq $currentSid) {', - ' [pscustomobject]@{', - ' ProcessId = $_.ProcessId', - ' ParentProcessId = $_.ParentProcessId', - ' ExecutablePath = $_.ExecutablePath', - ' CommandLine = $_.CommandLine', - ' }', + ' [pscustomobject]@{', + ' ProcessId = $_.ProcessId', + ' ParentProcessId = $_.ParentProcessId', + ' ExecutablePath = $_.ExecutablePath', + ' CommandLine = $_.CommandLine', ' }', '}', '@($items) | ConvertTo-Json -Compress' ].join('\n') +export function windowsCurrentUserProcessScript(pid: number): string { + if (!validPid(pid)) throw new Error(`Invalid process ID: ${pid}`) + return [ + '$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value', + `$candidate = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"`, + '$items = @()', + 'if ($null -ne $candidate) {', + ' $owner = Invoke-CimMethod -InputObject $candidate -MethodName GetOwnerSid -ErrorAction SilentlyContinue', + ' if ($owner.Sid -eq $currentSid) {', + ' $items += [pscustomobject]@{', + ' ProcessId = $candidate.ProcessId', + ' ParentProcessId = $candidate.ParentProcessId', + ' ExecutablePath = $candidate.ExecutablePath', + ' CommandLine = $candidate.CommandLine', + ' }', + ' }', + '}', + '@($items) | ConvertTo-Json -Compress' + ].join('\n') +} + const defaultRun: ProcessTableRunner = async (command, args, options) => { const result = await execFileAsync(command, args, options) return { stdout: String(result.stdout ?? '') } @@ -74,10 +92,17 @@ export async function listCurrentUserProcesses( if (platform === 'win32') { const { stdout } = await run( 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_CURRENT_USER_PROCESS_SCRIPT], + ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROCESS_CANDIDATE_SCRIPT], processTableCommandOptions() ) - return parseWindowsProcessSnapshot(stdout) + const candidates = parseWindowsProcessSnapshot(stdout) + .filter((entry) => looksLikeKunServeProcess(entry)) + const verified: KunServeProcessSnapshot[] = [] + for (const candidate of candidates) { + const current = await inspectCurrentUserProcess(candidate.pid, { platform, run }) + if (current && looksLikeKunServeProcess(current)) verified.push(current) + } + return verified } const currentUid = options.currentUid ?? process.getuid?.() @@ -92,6 +117,35 @@ export async function listCurrentUserProcesses( return parseUnixProcessSnapshot(stdout, currentUid as number) } +export async function inspectCurrentUserProcess( + pid: number, + options: ProcessListOptions = {} +): Promise { + if (!validPid(pid)) return null + const platform = options.platform ?? process.platform + const run = options.run ?? defaultRun + if (platform === 'win32') { + const { stdout } = await run( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', windowsCurrentUserProcessScript(pid)], + processTableCommandOptions() + ) + return parseWindowsProcessSnapshot(stdout).find((entry) => entry.pid === pid) ?? null + } + + const currentUid = options.currentUid ?? process.getuid?.() + if (!Number.isInteger(currentUid) || (currentUid ?? -1) < 0) { + throw new Error('Cannot verify the current Unix user while inspecting a Kun serve process.') + } + const { stdout } = await run( + 'ps', + ['-p', String(pid), '-o', 'pid=', '-o', 'ppid=', '-o', 'uid=', '-o', 'command='], + processTableCommandOptions() + ) + return parseUnixProcessSnapshot(stdout, currentUid as number) + .find((entry) => entry.pid === pid) ?? null +} + function processTableCommandOptions(): { windowsHide: boolean timeout: number @@ -184,6 +238,9 @@ export async function clearHistoricalKunServeProcesses( ): Promise { const currentPid = options.currentPid ?? process.pid const listProcesses = options.listProcesses ?? (() => listCurrentUserProcesses()) + const inspectProcess = options.inspectProcess ?? (options.listProcesses + ? async (pid: number) => (await listProcesses()).find((entry) => entry.pid === pid) ?? null + : (pid: number) => inspectCurrentUserProcess(pid)) const terminate = options.terminate ?? terminateVerifiedPid const waitForExit = options.waitForExit ?? waitForPidExit const log = options.log ?? ((line) => appendManagedLogLine('kun', line)) @@ -210,7 +267,7 @@ export async function clearHistoricalKunServeProcesses( } await log(formatKunLogLine('lifecycle', match.pid, 'terminating historical kun serve process')) const terminated = await terminate(match.pid, async () => { - const current = (await listProcesses()).find((entry) => entry.pid === match.pid) + const current = await inspectProcess(match.pid) return Boolean(current && looksLikeKunServeProcess(current, currentPid)) }, waitForExit) if (terminated) { From b7bf5cc05aa8a00ad88a9eb339c941045c033de3 Mon Sep 17 00:00:00 2001 From: xingyu Date: Sat, 15 Aug 2026 23:39:55 +0800 Subject: [PATCH 2/3] fix(models): preserve custom provider display names (#1175) --- src/main/main-ready-ipc.ts | 11 +++++- src/main/upstream-models.test.ts | 58 ++++++++++++++++++++++++++++++++ src/main/upstream-models.ts | 11 ++++-- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/main/main-ready-ipc.ts b/src/main/main-ready-ipc.ts index 7b33272e4..a564ed545 100644 --- a/src/main/main-ready-ipc.ts +++ b/src/main/main-ready-ipc.ts @@ -211,9 +211,18 @@ export function registerMainIpc(services: MainServices): void { const shared = await runtimeRequest(settings, '/v1/model-connections', { method: 'GET' }) if (shared.ok) { try { + const providerSettings = getModelProviderSettings(settings) + const configuredProviderLabels = new Map( + providerSettings.providers.flatMap((provider) => { + const providerId = provider.id.trim().toLowerCase() + const label = provider.name.trim() + return providerId && label ? [[providerId, label]] : [] + }) + ) const live = modelListFromSharedConnections( JSON.parse(shared.body) as unknown, - getModelProviderSettings(settings).localGateway.name + providerSettings.localGateway.name, + configuredProviderLabels ) if (live) return live } catch { diff --git a/src/main/upstream-models.test.ts b/src/main/upstream-models.test.ts index 2dde0a95b..669eeea83 100644 --- a/src/main/upstream-models.test.ts +++ b/src/main/upstream-models.test.ts @@ -77,6 +77,64 @@ function settings(dataDir: string, model = 'settings-model'): AppSettingsV1 { } describe('upstream model picker list', () => { + it('uses the latest configured name for a live custom provider', () => { + const result = modelListFromSharedConnections({ + schemaVersion: 1, + providers: [{ + id: 'custom-provider-10', + name: 'custom-provider-10', + configured: true, + credentialStatus: 'ready', + models: ['custom-model'], + modelCapabilities: { + 'custom-model': { + inputModalities: ['text', 'image'], + outputModalities: ['text'], + supportsToolCalling: true, + messageParts: ['text', 'image_url'] + } + } + }] + }, 'Kun API', new Map([['custom-provider-10', 'My Gateway']])) + + expect(result).toMatchObject({ + ok: true, + modelGroups: [expect.objectContaining({ + providerId: 'custom-provider-10', + label: 'My Gateway', + modelIds: ['custom-model'], + modelProfiles: { + 'custom-model': expect.objectContaining({ + inputModalities: ['text', 'image'], + supportsToolCalling: true + }) + } + })] + }) + }) + + it('preserves the live label when no configured provider name matches', () => { + const result = modelListFromSharedConnections({ + schemaVersion: 1, + providers: [{ + id: 'runtime-only', + name: 'Runtime Only', + configured: true, + credentialStatus: 'ready', + models: ['runtime-model'] + }] + }, 'Kun API', new Map([['other-provider', 'Other Provider']])) + + expect(result).toMatchObject({ + ok: true, + modelGroups: [expect.objectContaining({ + providerId: 'runtime-only', + label: 'Runtime Only', + modelIds: ['runtime-model'] + })] + }) + }) + it('preserves Codex preset identity and Fast service-tier capability from the live registry', () => { const result = modelListFromSharedConnections({ schemaVersion: 1, diff --git a/src/main/upstream-models.ts b/src/main/upstream-models.ts index a0e8ce60a..6cabe7c4e 100644 --- a/src/main/upstream-models.ts +++ b/src/main/upstream-models.ts @@ -82,7 +82,8 @@ export async function fetchUpstreamModelIds( */ export function modelListFromSharedConnections( value: unknown, - localGatewayName = 'Kun API' + localGatewayName = 'Kun API', + configuredProviderLabels: ReadonlyMap = new Map() ): FetchUpstreamModelsResult | null { const root = objectValue(value) if (root.schemaVersion !== 1 || !Array.isArray(root.providers)) return null @@ -94,6 +95,7 @@ export function modelListFromSharedConnections( profile.configured !== true || credentialUnavailable || typeof profile.id !== 'string' || + !profile.id.trim() || !Array.isArray(profile.models) ) { return [] @@ -135,12 +137,15 @@ export function modelListFromSharedConnections( ...(capability.responsesMode === 'lite' ? { responsesMode: 'lite' as const } : {}) } satisfies ModelProviderModelProfileV1]] })) + const providerId = profile.id.trim() + const configuredLabel = configuredProviderLabels.get(providerId.toLowerCase())?.trim() return [{ - providerId: profile.id, + providerId, ...(typeof profile.presetSource === 'string' && profile.presetSource.trim() ? { presetSource: profile.presetSource.trim() } : {}), - label: typeof profile.name === 'string' && profile.name.trim() ? profile.name.trim() : profile.id, + label: configuredLabel || + (typeof profile.name === 'string' && profile.name.trim() ? profile.name.trim() : providerId), modelIds, modelProfiles, ...(typeof profile.accountId === 'string' && profile.accountId.trim() From c95e8f7dc6782bea03a7a1d1640d841980658345 Mon Sep 17 00:00:00 2001 From: xingyu Date: Sun, 16 Aug 2026 00:55:55 +0800 Subject: [PATCH 3/3] docs(release): add v0.3.3 release notes (#1176) --- release/release-v0.3.3.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 release/release-v0.3.3.md diff --git a/release/release-v0.3.3.md b/release/release-v0.3.3.md new file mode 100644 index 000000000..76288546e --- /dev/null +++ b/release/release-v0.3.3.md @@ -0,0 +1,25 @@ +# Kun v0.3.3 + +v0.3.3 是一个稳定性修复版本,重点解决 Windows 上进程较多时无法启动,以及自定义服务商名称在会话模型选择器中显示为默认 ID 的问题。 + +### Windows 启动与历史 Runtime 清理(#1172) + +- 修复 Windows 在启动时清理历史 `kun serve` 进程会逐个对所有同用户 `node.exe`、`electron.exe` 和 `kun*.exe` 查询进程所有者的问题。 +- 现在先快速获取候选进程的基础信息并做严格命令行识别,只会对真正匹配 Kun Runtime 的少量 PID 单独验证所有者;大量开发工具、MCP 服务或 Node 进程不再导致线性 WMI 查询延迟。 +- 进程表保护超时调整为 30 分钟,但正常启动仍走快速候选筛选和按 PID 验证,不会等待这一时限。 +- 终止前会再次验证 PID 的身份和所有者,避免 PID 复用时误清理无关进程。 + +### 自定义服务商显示名称(#1174) + +- 修复从 0.3.0 起,会话窗口的模型选择器优先读取运行中 Kun 注册表后,仍显示创建时自动生成的 `custom-provider-*` ID 而不是设置中已保存名称的问题。 +- 模型选择器现在保留运行时注册表提供的模型可用性、能力和凭据状态,同时以最新保存的同 ID 服务商名称作为展示标签。 +- 改名立即在下一次模型列表刷新时生效,无需重启 Kun;没有匹配设置项的运行时服务商仍显示其原有标签。 + +### 影响与升级 + +- Windows 用户无需为了启动 Kun 关闭 MCP 服务、Node 开发服务器或其他 Electron 应用;升级后重新启动即可生效。 +- 已配置的服务商地址、API Key、模型列表、路由和会话数据不需要迁移,也不会因本次更新而改变。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.3.2...v0.3.3