fix: 修复 Windows 打包版后端重启失败问题 (#5009)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp
This commit is contained in:
エイカク
2026-02-10 21:33:06 +09:00
committed by GitHub
parent d35771f97d
commit 64de474139
11 changed files with 408 additions and 81 deletions
+63 -19
View File
@@ -44,6 +44,64 @@ class AstrBotUpdator(RepoZipUpdator):
except psutil.NoSuchProcess:
pass
@staticmethod
def _is_option_arg(arg: str) -> bool:
return arg.startswith("-")
@classmethod
def _collect_flag_values(cls, argv: list[str], flag: str) -> str | None:
try:
idx = argv.index(flag)
except ValueError:
return None
if idx + 1 >= len(argv):
return None
value_parts: list[str] = []
for arg in argv[idx + 1 :]:
if cls._is_option_arg(arg):
break
if arg:
value_parts.append(arg)
if not value_parts:
return None
return " ".join(value_parts).strip() or None
@classmethod
def _resolve_webui_dir_arg(cls, argv: list[str]) -> str | None:
return cls._collect_flag_values(argv, "--webui-dir")
def _build_frozen_reboot_args(self) -> list[str]:
argv = list(sys.argv[1:])
webui_dir = self._resolve_webui_dir_arg(argv)
if not webui_dir:
webui_dir = os.environ.get("ASTRBOT_WEBUI_DIR")
if webui_dir:
return ["--webui-dir", webui_dir]
return []
@staticmethod
def _reset_pyinstaller_environment() -> None:
if not getattr(sys, "frozen", False):
return
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
for key in list(os.environ.keys()):
if key.startswith("_PYI_"):
os.environ.pop(key, None)
def _build_reboot_argv(self, executable: str) -> list[str]:
if os.environ.get("ASTRBOT_CLI") == "1":
args = sys.argv[1:]
return [executable, "-m", "astrbot.cli.__main__", *args]
if getattr(sys, "frozen", False):
args = self._build_frozen_reboot_args()
return [executable, *args]
return [executable, *sys.argv]
def _reboot(self, delay: int = 3) -> None:
"""重启当前程序
在指定的延迟后,终止所有子进程并重新启动程序
@@ -51,28 +109,14 @@ class AstrBotUpdator(RepoZipUpdator):
"""
time.sleep(delay)
self.terminate_child_processes()
if os.name == "nt":
py = f'"{sys.executable}"'
else:
py = sys.executable
executable = sys.executable
try:
# 仅 CLI 模式走 `python -m astrbot.cli.__main__`
# 打包后的后端可执行文件需要直接 exec 自身。
if os.environ.get("ASTRBOT_CLI") == "1":
if os.name == "nt":
args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]]
else:
args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
else:
if getattr(sys, "frozen", False):
# Frozen executable should not receive argv[0] as a positional argument.
os.execl(sys.executable, py, *sys.argv[1:])
else:
os.execl(sys.executable, py, *sys.argv)
self._reset_pyinstaller_environment()
reboot_argv = self._build_reboot_argv(executable)
os.execv(executable, reboot_argv)
except Exception as e:
logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
logger.error(f"重启失败({executable}, {e}),请尝试手动重启。")
raise e
async def check_update(
@@ -371,6 +371,7 @@ import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useI18n } from '@/i18n/composables'
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
import WaitingForRestart from './WaitingForRestart.vue'
const { t } = useI18n()
@@ -948,12 +949,12 @@ const formatISODate = (isoString) => {
}
// 重启 AstrBot
const restartAstrBot = () => {
axios.post('/api/stat/restart-core').then(() => {
if (wfr.value) {
wfr.value.check()
}
})
const restartAstrBot = async () => {
try {
await restartAstrBotRuntime(wfr.value)
} catch (error) {
console.error(error)
}
}
// 重置所有状态
@@ -113,6 +113,7 @@
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useI18n } from '@/i18n/composables'
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
import ConsoleDisplayer from './ConsoleDisplayer.vue'
import WaitingForRestart from './WaitingForRestart.vue'
@@ -258,12 +259,12 @@ const getPlatformLabel = (platform) => {
}
// 重启 AstrBot
const restartAstrBot = () => {
axios.post('/api/stat/restart-core').then(() => {
if (wfr.value) {
wfr.value.check();
}
})
const restartAstrBot = async () => {
try {
await restartAstrBotRuntime(wfr.value)
} catch (error) {
console.error(error)
}
}
// 打开对话框的方法
@@ -47,6 +47,11 @@ export default {
this.timeoutInternal()
}, 1000)
},
stop() {
this.visible = false
this.cnt = 0
this.newStartTime = -1
},
timeoutInternal() {
console.log('wfr: timeoutInternal', this.newStartTime, this.startTime)
if (this.newStartTime === -1 && this.cnt < 60 && this.visible) {
+1 -1
View File
@@ -11,7 +11,7 @@ declare global {
restarting: boolean;
canManage: boolean;
}>;
restartBackend: () => Promise<{
restartBackend: (authToken?: string | null) => Promise<{
ok: boolean;
reason: string | null;
}>;
+36
View File
@@ -0,0 +1,36 @@
import axios from 'axios'
type WaitingForRestartRef = {
check: () => void | Promise<void>
stop?: () => void
}
async function triggerWaiting(waitingRef?: WaitingForRestartRef | null) {
if (!waitingRef) return
await waitingRef.check()
}
export async function restartAstrBot(
waitingRef?: WaitingForRestartRef | null
): Promise<void> {
const desktopBridge = window.astrbotDesktop
if (desktopBridge?.isElectron) {
const authToken = localStorage.getItem('token')
try {
const result = await desktopBridge.restartBackend(authToken)
if (!result.ok) {
waitingRef?.stop?.()
throw new Error(result.reason || 'Failed to restart backend.')
}
await triggerWaiting(waitingRef)
} catch (error) {
waitingRef?.stop?.()
throw error
}
return
}
await axios.post('/api/stat/restart-core')
await triggerWaiting(waitingRef)
}
+2 -3
View File
@@ -190,6 +190,7 @@ import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
@@ -375,9 +376,7 @@ export default {
this.save_message_success = "success";
if (this.isSystemConfig) {
axios.post('/api/stat/restart-core').then(() => {
this.$refs.wfr.check();
})
restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
}
} else {
this.save_message = res.data.message || this.messages.saveError;
+7 -5
View File
@@ -78,12 +78,12 @@
<script setup>
import { ref, watch } from 'vue';
import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
@@ -136,10 +136,12 @@ const wfr = ref(null);
const migrationDialog = ref(null);
const backupDialog = ref(null);
const restartAstrBot = () => {
axios.post('/api/stat/restart-core').then(() => {
wfr.value.check();
})
const restartAstrBot = async () => {
try {
await restartAstrBotRuntime(wfr.value);
} catch (error) {
console.error(error);
}
}
const startMigration = async () => {
+272 -32
View File
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require('child_process');
const { delay, ensureDir, normalizeUrl, waitForProcessExit } = require('./common');
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000;
function parseBackendTimeoutMs(app) {
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
@@ -177,6 +178,19 @@ class BackendManager {
return this.backendConfig;
}
getBackendPort() {
try {
const parsed = new URL(this.backendUrl);
if (parsed.port) {
const port = Number.parseInt(parsed.port, 10);
return Number.isFinite(port) ? port : null;
}
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
canManageBackend() {
return Boolean(this.getBackendConfig().cmd);
}
@@ -207,13 +221,124 @@ class BackendManager {
}
}
getEffectiveWaitMs(maxWaitMs = 0) {
if (maxWaitMs > 0) {
return maxWaitMs;
}
if (this.app.isPackaged) {
return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS;
}
return 0;
}
async requestBackendJson(pathname, options = {}) {
const timeoutMs = options.timeoutMs || 2000;
const method = options.method || 'GET';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const requestUrl = new URL(pathname, this.backendUrl);
requestUrl.searchParams.set('_ts', `${Date.now()}`);
const authToken =
typeof options.authToken === 'string' && options.authToken
? options.authToken
: null;
try {
const response = await fetch(requestUrl.toString(), {
method,
signal: controller.signal,
redirect: 'manual',
headers: {
Accept: 'application/json',
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...(options.headers || {}),
},
});
if (!response.ok) {
return { ok: false, data: null };
}
const data = await response.json();
return { ok: true, data };
} catch {
return { ok: false, data: null };
} finally {
clearTimeout(timeout);
}
}
async getBackendStartTime() {
const result = await this.requestBackendJson('/api/stat/start-time', {
timeoutMs: 1800,
method: 'GET',
});
if (!result.ok || !result.data) {
return null;
}
const rawStartTime = result.data?.data?.start_time;
const numericStartTime = Number(rawStartTime);
return Number.isFinite(numericStartTime) ? numericStartTime : null;
}
async requestGracefulRestart(authToken = null) {
const result = await this.requestBackendJson('/api/stat/restart-core', {
timeoutMs: 2500,
method: 'POST',
authToken,
headers: {
'Content-Type': 'application/json',
},
});
return result.ok;
}
async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) {
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const gracefulWaitMs =
effectiveMaxWaitMs > 0
? effectiveMaxWaitMs
: GRACEFUL_RESTART_WAIT_FALLBACK_MS;
const start = Date.now();
let sawBackendDown = false;
while (true) {
const reachable = await this.pingBackend(700);
if (!reachable) {
sawBackendDown = true;
} else {
const currentStartTime = await this.getBackendStartTime();
if (
previousStartTime !== null &&
currentStartTime !== null &&
currentStartTime !== previousStartTime
) {
return { ok: true, reason: null };
}
if (sawBackendDown && previousStartTime === null) {
return { ok: true, reason: null };
}
if (
sawBackendDown &&
previousStartTime !== null &&
currentStartTime === null
) {
return { ok: true, reason: null };
}
}
if (Date.now() - start >= gracefulWaitMs) {
return {
ok: false,
reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`,
};
}
await delay(350);
}
}
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
const effectiveMaxWaitMs =
maxWaitMs > 0
? maxWaitMs
: this.app.isPackaged
? PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS
: 0;
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
const start = Date.now();
while (true) {
if (await this.pingBackend()) {
@@ -258,6 +383,9 @@ class BackendManager {
if (this.app.isPackaged) {
env.ASTRBOT_ELECTRON_CLIENT = '1';
}
if (backendConfig.webuiDir) {
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
}
if (backendConfig.rootDir) {
env.ASTRBOT_ROOT = backendConfig.rootDir;
const logsDir = path.join(backendConfig.rootDir, 'logs');
@@ -344,6 +472,8 @@ class BackendManager {
if (process.platform === 'win32' && pid) {
try {
// Synchronous taskkill is acceptable here because stop/restart is
// already a control-path operation and not latency-sensitive.
const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
@@ -386,6 +516,115 @@ class BackendManager {
this.closeBackendLogFd();
}
findListeningPidsOnWindows(port) {
// Synchronous netstat parsing is acceptable here because this helper is
// used only during shutdown/restart cleanup paths.
const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
windowsHide: true,
});
if (result.status !== 0 || !result.stdout) {
return [];
}
const pids = new Set();
const lines = result.stdout.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) {
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length < 5) {
continue;
}
const localAddress = parts[1] || '';
const state = (parts[3] || '').toUpperCase();
const pid = parts[parts.length - 1];
if (!/^\d+$/.test(pid)) {
continue;
}
if (state !== 'LISTENING') {
continue;
}
const cleanedLocalAddress = localAddress.replace(/\]$/, '');
const segments = cleanedLocalAddress.split(':');
const portStr = segments[segments.length - 1];
const portNum = Number(portStr);
if (Number.isInteger(portNum) && portNum === Number(port)) {
pids.add(pid);
}
}
return Array.from(pids);
}
async stopUnmanagedBackendByPort() {
if (!this.app.isPackaged || process.platform !== 'win32') {
return false;
}
const port = this.getBackendPort();
if (!port) {
return false;
}
const pids = this.findListeningPidsOnWindows(port);
if (!pids.length) {
return false;
}
this.log(
`Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`,
);
for (const pid of pids) {
try {
// Synchronous taskkill is acceptable here because unmanaged cleanup
// is performed only during shutdown/restart control flows.
spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
await delay(500);
return !(await this.pingBackend(1200));
}
async stopAnyBackend() {
if (this.backendProcess) {
await this.stopManagedBackend();
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
} else {
const running = await this.pingBackend();
if (!running) {
return { ok: true, reason: null };
}
}
const cleaned = await this.stopUnmanagedBackendByPort();
if (cleaned) {
return { ok: true, reason: null };
}
return {
ok: false,
reason: 'Backend is running but not managed by Electron.',
};
}
async ensureBackend() {
this.backendStartupFailureReason = null;
@@ -415,7 +654,7 @@ class BackendManager {
};
}
async restartBackend() {
async restartBackend(authToken = null) {
if (!this.canManageBackend()) {
return {
ok: false,
@@ -431,6 +670,31 @@ class BackendManager {
this.backendRestarting = true;
try {
const backendRunning = await this.pingBackend(900);
if (backendRunning) {
const previousStartTime = await this.getBackendStartTime();
const gracefulRequested = await this.requestGracefulRestart(authToken);
if (gracefulRequested) {
const gracefulResult = await this.waitForGracefulRestart(
previousStartTime,
this.backendTimeoutMs,
);
if (gracefulResult.ok) {
return {
ok: true,
reason: null,
};
}
this.log(
`Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`,
);
} else {
this.log(
'Graceful restart request failed; falling back to managed restart.',
);
}
}
await this.stopManagedBackend();
const startResult = await this.startBackendAndWait(this.backendTimeoutMs);
if (!startResult.ok) {
@@ -468,31 +732,7 @@ class BackendManager {
}
try {
if (!this.backendProcess) {
const running = await this.pingBackend();
if (running) {
return {
ok: false,
reason: 'Backend is running but not managed by Electron.',
};
}
return {
ok: true,
reason: null,
};
}
await this.stopManagedBackend();
const running = await this.pingBackend();
if (running) {
return {
ok: false,
reason: 'Backend is still reachable after stop request.',
};
}
return {
ok: true,
reason: null,
};
return await this.stopAnyBackend();
} catch (error) {
return {
ok: false,
+6 -8
View File
@@ -245,8 +245,8 @@ function registerIpcHandlers() {
return backendManager.getState();
});
ipcMain.handle('astrbot-desktop:restart-backend', async () => {
return backendManager.restartBackend();
ipcMain.handle('astrbot-desktop:restart-backend', async (_event, authToken) => {
return backendManager.restartBackend(authToken);
});
ipcMain.handle('astrbot-desktop:stop-backend', async () => {
@@ -358,12 +358,10 @@ app.on('before-quit', (event) => {
.persistLocaleFromDashboard(mainWindow, backendManager.getBackendUrl())
.catch(() => {})
.then(() =>
backendManager.stopManagedBackend().catch((error) => {
logElectron(
`stopBackend failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
backendManager.stopAnyBackend().then((result) => {
if (!result.ok) {
logElectron(`stopBackend failed: ${result.reason || 'unknown reason'}`);
}
}),
)
.finally(() => {
+2 -1
View File
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('astrbotDesktop', {
isElectron: true,
isElectronRuntime: () => ipcRenderer.invoke('astrbot-desktop:is-electron-runtime'),
getBackendState: () => ipcRenderer.invoke('astrbot-desktop:get-backend-state'),
restartBackend: () => ipcRenderer.invoke('astrbot-desktop:restart-backend'),
restartBackend: (authToken) =>
ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken),
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
});