fix: 加固退出清理

退出应用时终止后台 进程
移除运行时动态安装 Python 依赖
This commit is contained in:
Wxw-Gu
2026-08-21 15:37:01 +08:00
parent 262b15db84
commit b324f480dd
6 changed files with 60 additions and 62 deletions
+1 -26
View File
@@ -9,7 +9,7 @@
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md. * onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
*/ */
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
const { execFileSync } = require('node:child_process') const { execFileSync } = require('node:child_process')
const fs = require('node:fs') const fs = require('node:fs')
const os = require('node:os') const os = require('node:os')
@@ -84,31 +84,6 @@ for (const required of [executable, script, config]) {
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`) if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
} }
const pythonPackages = path.join(outputDir, 'python')
const pilkPackage = path.join(pythonPackages, 'pilk')
if (!fs.existsSync(pilkPackage)) {
console.log('[wechat-personal] 安装项目内语音编码器 pilk')
fs.mkdirSync(pythonPackages, { recursive: true })
try {
execFileSync(
'python3',
[
'-m',
'pip',
'install',
'--disable-pip-version-check',
'--no-compile',
'--target',
pythonPackages,
'pilk==0.2.4'
],
{ stdio: 'inherit' }
)
} catch {
console.warn('[wechat-personal] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器')
}
}
function patchPerSendPayload(scriptPath) { function patchPerSendPayload(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8') let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('var activeTriggerX1Payload = ptr(0);')) return if (source.includes('var activeTriggerX1Payload = ptr(0);')) return
+4 -2
View File
@@ -1857,13 +1857,15 @@ app.on('before-quit', (event) => {
void (async () => { void (async () => {
agentHubService.stop() agentHubService.stop()
personalWechatSendService.stop()
flushBootstrapCacheWritesSync() flushBootstrapCacheWritesSync()
const [, nativeCallsDrained] = await Promise.all([ const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined), apiServer.stop().catch(() => undefined),
chat.closeChatDbForQuit().catch(() => false), chat.closeChatDbForQuit().catch(() => false),
voiceRecognition?.dispose().catch(() => undefined), voiceRecognition?.dispose().catch(() => undefined),
knowledgeSearchService?.dispose().catch(() => undefined) knowledgeSearchService?.dispose().catch(() => undefined),
personalWechatSendService.terminate().catch((error) => {
console.warn('[Shutdown] personal WeChat sender cleanup failed:', error)
})
]) ])
if (!nativeCallsDrained) { if (!nativeCallsDrained) {
console.warn('[Shutdown] WCDB async calls did not fully drain before quit') console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
@@ -1057,7 +1057,8 @@ export class AiSearchPipelineService {
return result.evidence.map((item): AiSearchPipelineEvidence => { return result.evidence.map((item): AiSearchPipelineEvidence => {
const rawConversationId = String(item.conversationId || '').trim() const rawConversationId = String(item.conversationId || '').trim()
const contact = const contact =
contactsById.get(rawConversationId) || contactsById.get(rawConversationId.toLocaleLowerCase()) contactsById.get(rawConversationId) ||
contactsById.get(rawConversationId.toLocaleLowerCase())
return { return {
...item, ...item,
conversationId: contact?.md5 || rawConversationId, conversationId: contact?.md5 || rawConversationId,
@@ -265,27 +265,6 @@ export class PersonalWechatRuntimeManager {
addModifiedWorkNotice(script) addModifiedWorkNotice(script)
await chmod(executable, 0o755) await chmod(executable, 0o755)
const pythonPackages = join(stagedDirectory, 'python')
await mkdir(pythonPackages, { recursive: true })
try {
await execFileAsync('/usr/bin/env', [
'python3',
'-m',
'pip',
'install',
'--disable-pip-version-check',
'--no-compile',
'--target',
pythonPackages,
'pilk==0.2.4'
])
} catch (error) {
console.warn(
'[PersonalWechatRuntime] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器:',
error
)
}
for (const required of [ for (const required of [
executable, executable,
script, script,
@@ -372,20 +372,29 @@ async function readOneBotProcessInfo(): Promise<OneBotProcessInfo | undefined> {
async function terminateOneBot(info: OneBotProcessInfo): Promise<void> { async function terminateOneBot(info: OneBotProcessInfo): Promise<void> {
if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return
await terminateProcess(info.pid)
}
async function terminateProcess(pid: number): Promise<void> {
try { try {
process.kill(info.pid, 'SIGTERM') process.kill(pid, 'SIGTERM')
} catch { } catch {
return return
} }
const startedAt = Date.now() const startedAt = Date.now()
while (Date.now() - startedAt < STOP_TIMEOUT_MS) { while (Date.now() - startedAt < STOP_TIMEOUT_MS) {
try { try {
process.kill(info.pid, 0) process.kill(pid, 0)
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
} catch { } catch {
return return
} }
} }
try {
process.kill(pid, 'SIGKILL')
} catch {
// The process exited between the last liveness check and the forced stop.
}
} }
async function requestWithTimeout( async function requestWithTimeout(
@@ -605,18 +614,11 @@ export class PersonalWechatSendService {
return status return status
} }
stop(): void {
const child = this.child
this.child = null
this.startPromise = null
// A second Attach to the same WeChat process is unstable. Leave OneBot alive;
// it monitors the WeChat PID and exits when that process ends.
if (child && child.exitCode === null) child.unref()
}
async terminate(): Promise<void> { async terminate(): Promise<void> {
const trackedPid = this.child?.pid
const oneBot = await readOneBotProcessInfo() const oneBot = await readOneBotProcessInfo()
if (oneBot) await terminateOneBot(oneBot) if (oneBot) await terminateOneBot(oneBot)
if (trackedPid && trackedPid !== oneBot?.pid) await terminateProcess(trackedPid)
this.child = null this.child = null
this.startPromise = null this.startPromise = null
this.lastError = '' this.lastError = ''
+40 -1
View File
@@ -1,6 +1,6 @@
import { mkdtempSync, readFileSync, rmSync } from 'fs' import { mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join } from 'path' import { join, resolve } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest' import { afterAll, describe, expect, it, vi } from 'vitest'
import { classifyStickerHttpFailure } from '../../src/shared/sticker' import { classifyStickerHttpFailure } from '../../src/shared/sticker'
@@ -51,3 +51,42 @@ describe('sticker HTTP failures', () => {
expect(classifyStickerHttpFailure(429, 'https://fixture.invalid/a').code).toBe('rate_limited') expect(classifyStickerHttpFailure(429, 'https://fixture.invalid/a').code).toBe('rate_limited')
}) })
}) })
describe('personal WeChat runtime security invariants', () => {
it('waits for sender termination during application shutdown', () => {
const mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8')
const shutdownStart = mainSource.indexOf("app.on('before-quit'")
const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart)
const shutdownSource = mainSource.slice(shutdownStart, shutdownEnd)
expect(shutdownStart).toBeGreaterThanOrEqual(0)
expect(shutdownEnd).toBeGreaterThan(shutdownStart)
expect(shutdownSource).toContain('await Promise.all([')
expect(shutdownSource).toContain('personalWechatSendService.terminate()')
expect(shutdownSource).not.toContain('personalWechatSendService.stop()')
const senderSource = readFileSync(
resolve('src/main/services/personal-wechat-send-service.ts'),
'utf8'
)
expect(senderSource).toContain("process.kill(pid, 'SIGTERM')")
expect(senderSource).toContain("process.kill(pid, 'SIGKILL')")
expect(senderSource).toContain('const trackedPid = this.child?.pid')
})
it('does not install Python packages while preparing the sender runtime', () => {
const runtimeManagerSource = readFileSync(
resolve('src/main/services/personal-wechat-runtime-manager.ts'),
'utf8'
)
const preparationScriptSource = readFileSync(
resolve('scripts/prepare-wechat-chatter-runtime.cjs'),
'utf8'
)
for (const source of [runtimeManagerSource, preparationScriptSource]) {
expect(source).not.toContain('pilk==')
expect(source).not.toMatch(/['"]pip['"]/)
}
})
})