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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions release/release-v0.3.3.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion src/main/main-ready-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
153 changes: 133 additions & 20 deletions src/main/runtime/kun-serve-process-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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',
Expand All @@ -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([])
})
})

Expand Down Expand Up @@ -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<boolean>) => {
expect(await verify()).toBe(false)
Expand All @@ -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)
})
})
85 changes: 71 additions & 14 deletions src/main/runtime/kun-serve-process-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,31 +36,49 @@ type ProcessListOptions = {
type CleanupOptions = {
currentPid?: number
listProcesses?: () => Promise<KunServeProcessSnapshot[]>
inspectProcess?: (pid: number) => Promise<KunServeProcessSnapshot | null>
terminate?: typeof terminateVerifiedPid
waitForExit?: typeof waitForPidExit
log?: (line: string) => Promise<void>
}

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 ?? '') }
Expand All @@ -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?.()
Expand All @@ -92,6 +117,35 @@ export async function listCurrentUserProcesses(
return parseUnixProcessSnapshot(stdout, currentUid as number)
}

export async function inspectCurrentUserProcess(
pid: number,
options: ProcessListOptions = {}
): Promise<KunServeProcessSnapshot | null> {
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
Expand Down Expand Up @@ -184,6 +238,9 @@ export async function clearHistoricalKunServeProcesses(
): Promise<KunServeCleanupReport> {
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))
Expand All @@ -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) {
Expand Down
Loading
Loading