diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 93977e9c3..62aa2205e 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1706,10 +1706,16 @@ if ( runtimeConfigSetupStart === -1 || runtimeConfigSetupEnd === -1 || !runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') || - !runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') || + !runtimeConfigSetupSource.includes('setup_log.fail(') || !runtimeConfigSetupSource.includes( 'startup.appdata.configure.failed details={details}', - ) + ) || + !tauriHandlerSource.includes('impl StartupLogSlot {') || + !tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') || + !tauriHandlerSource.includes( + 'self.append(line);\n show_startup_error_dialog(self.path().as_deref());', + ) || + !tauriHandlerSource.includes('early_startup_log_path(') ) { throw new Error( 'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures', diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 64577d35f..8435bbfa9 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,10 +1,16 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + normalizeWindowsPath, + parseWindowsProcessSnapshot, + stopWindowsProcessTree, + stopWindowsWorktreeProcesses, +} from '../../../scripts/dev-windows-process.mjs'; import { agcVitePortEnvKey, readAgcDevEndpoint, @@ -15,6 +21,10 @@ import { const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); +const apiServerExePath = resolve( + repoRoot, + 'server-rs/target/debug/api-server.exe', +); const defaultApiTarget = process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; const backendDatabase = 'genarrative-game-creator-dev'; @@ -160,19 +170,184 @@ function readBackendServiceFailure( return null; } +function urlPort(url) { + try { + const port = Number(new URL(url).port); + return Number.isInteger(port) && port > 0 ? port : 0; + } catch { + return 0; + } +} + +// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少 +// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。 +function readWindowsPortOwnerIdentities( + ports, + { spawnImpl = spawnSync, env = process.env } = {}, +) { + const uniquePorts = [...new Set(ports.filter((port) => port > 0))]; + if (uniquePorts.length === 0) { + return null; + } + + const command = [ + '$ErrorActionPreference = "SilentlyContinue"', + '$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }', + '$result = @()', + 'foreach ($port in $ports) {', + ' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1', + ' if (-not $connection) { continue }', + ' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue', + ' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }', + '}', + 'ConvertTo-Json -InputObject @($result) -Compress', + ].join('\n'); + + const result = spawnImpl( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + { + encoding: 'utf8', + env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') }, + maxBuffer: 8 * 1024 * 1024, + }, + ); + if (result?.error || result?.status !== 0) { + return null; + } + + const owners = new Map(); + for (const entry of parseWindowsProcessSnapshot(result.stdout)) { + const port = Number(entry?.port); + if (Number.isInteger(port) && port > 0) { + owners.set(port, entry); + } + } + return owners; +} + +function isWorktreeApiServerOwner( + owner, + { expectedExePath = apiServerExePath } = {}, +) { + if (!owner) { + return false; + } + const expected = normalizeWindowsPath(expectedExePath); + const actual = normalizeWindowsPath(owner.executablePath); + return Boolean(expected) && actual === expected; +} + +function isWorktreeSpacetimeOwner( + owner, + { expectedDataDir = backendSpacetimeDataDir } = {}, +) { + if (!owner) { + return false; + } + const expected = normalizeWindowsPath(expectedDataDir); + if (!expected) { + return false; + } + const name = String(owner.name ?? '').toLowerCase(); + if (!name.startsWith('spacetime')) { + return false; + } + return normalizeWindowsPath(owner.commandLine).includes(expected); +} + +// 端口健康不代表后端属于当前工作树:上个工作树 Ctrl+C 残留的 api-server 仍会 +// 应答 /healthz。复用前必须证明端口上的进程就是本工作树的可执行文件与数据目录。 +function verifyAgcBackendOwnership({ + apiUrl, + spacetimeUrl, + bgfilterWorkerUrl, + platform = process.platform, + expectedExePath = apiServerExePath, + expectedDataDir = backendSpacetimeDataDir, + readPortOwners = readWindowsPortOwnerIdentities, +} = {}) { + if (platform !== 'win32') { + return { ok: true, reason: 'platform-unsupported', owners: new Map() }; + } + + const ports = [ + urlPort(apiUrl), + urlPort(bgfilterWorkerUrl), + urlPort(spacetimeUrl), + ]; + const owners = readPortOwners(ports); + if (!owners) { + return { ok: true, reason: 'owner-probe-unavailable', owners: new Map() }; + } + + const apiOwner = owners.get(urlPort(apiUrl)); + if (!isWorktreeApiServerOwner(apiOwner, { expectedExePath })) { + return { ok: false, reason: 'api-server-owner-mismatch', owners, apiOwner }; + } + + const workerOwner = owners.get(urlPort(bgfilterWorkerUrl)); + if (!isWorktreeApiServerOwner(workerOwner, { expectedExePath })) { + return { + ok: false, + reason: 'bgfilter-worker-owner-mismatch', + owners, + workerOwner, + }; + } + + const spacetimeOwner = owners.get(urlPort(spacetimeUrl)); + if (!isWorktreeSpacetimeOwner(spacetimeOwner, { expectedDataDir })) { + return { + ok: false, + reason: 'spacetime-owner-mismatch', + owners, + spacetimeOwner, + }; + } + + return { ok: true, reason: 'owned', owners }; +} + +function formatOwnerLabel(owner) { + if (!owner) { + return '未知进程'; + } + const pid = Number(owner.processId); + const label = owner.executablePath || owner.commandLine || owner.name || ''; + return `${Number.isInteger(pid) ? `pid=${pid} ` : ''}${String(label).trim()}`.trim(); +} + async function isBackendReady({ state = readJson(devStackStatePath), isReady = isHttpReady, + verifyOwnership = verifyAgcBackendOwnership, + onOwnershipRejected = null, } = {}) { const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } = resolveBackendTargetsFromState(state, { requireAgcBackend: true, }); + if (!hasMatchingBackend || !apiUrl || !spacetimeUrl || !bgfilterWorkerUrl) { + return false; + } + + const ownership = await verifyOwnership({ + apiUrl, + spacetimeUrl, + bgfilterWorkerUrl, + }); + if (!ownership?.ok) { + onOwnershipRejected?.(ownership); + return false; + } + if (ownership.reason === 'owner-probe-unavailable') { + console.warn( + '[ai-game-creator-shell] 无法读取端口监听进程归属,本次按旧行为复用配套后端。', + ); + } + return ( - hasMatchingBackend && - Boolean(apiUrl) && - Boolean(spacetimeUrl) && - Boolean(bgfilterWorkerUrl) && (await isReady(`${apiUrl}/healthz`)) && (await isReady(`${spacetimeUrl}/v1/ping`)) && (await isReady(`${bgfilterWorkerUrl}/readyz`)) @@ -485,14 +660,18 @@ async function terminateChildTree( return { stopped: true, forced: false }; } const result = await taskkillImpl(child.pid); - return { - stopped: - !result?.timedOut && - !result?.error && - [0, 128].includes(result?.code ?? 0), - forced: true, - result, - }; + const taskkillStopped = + !result?.timedOut && + !result?.error && + [0, 128].includes(result?.code ?? 0); + if (taskkillStopped) { + return { stopped: true, forced: true, result }; + } + + // 包装层(cmd.exe / npm.cmd)先被 Ctrl+C 杀掉时 taskkill 拿不到活着的 PID, + // 这里继续按记录下来的根 PID 遍历,尽量收掉更深的后端进程。 + const treeStopped = stopWindowsProcessTree(child.pid); + return { stopped: treeStopped.length > 0, forced: true, result }; } const processGroupId = childLifecycles.get(child)?.processGroupId; @@ -542,15 +721,29 @@ async function waitForBackendReady( backendChild, timeoutMs = 600_000, { - checkBackendReady = isBackendReady, + checkBackendReady = (onOwnershipRejected) => + isBackendReady({ onOwnershipRejected }), readState = () => readJson(devStackStatePath), resolveTargets = readBackendTargets, } = {}, ) { const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const startedAt = Date.now(); + let lastOwnershipReason = ''; while (Date.now() - startedAt < timeoutMs) { - if (await checkBackendReady()) { + if ( + await checkBackendReady((ownership) => { + if (ownership.reason === lastOwnershipReason) { + return; + } + lastOwnershipReason = ownership.reason; + // 本次自己拉起的后端如果归属校验一直不通过,必须把原因打出来, + // 否则只会表现为等待 600 秒后超时。 + console.warn( + `[ai-game-creator-shell] 等待配套后端就绪时归属校验未通过(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)})。`, + ); + }) + ) { return resolveTargets(); } const state = readState(); @@ -576,7 +769,14 @@ async function waitForBackendReady( async function ensureBackend({ onBackendChild = () => {}, - checkBackendReady = isBackendReady, + checkBackendReady = () => + isBackendReady({ + onOwnershipRejected(ownership) { + console.warn( + `[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`, + ); + }, + }), resolveTargets = readBackendTargets, spawnBackend = () => spawnChild( @@ -656,15 +856,33 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) { async function main() { let backendChild = null; + let startedBackend = false; let viteChild = null; let shutdownSignal = ''; const signalHandlers = new Map(); + // 只有本次会话真正拉起过配套后端时才做兜底清扫:复用别人后端时不能连带 + // 杀掉对方的进程。dev.mjs 的清理依赖它的 shell 包装层仍然活着,而 Ctrl+C + // 往往先杀掉包装层,所以这里必须按本工作树 api-server.exe 的身份再收一次。 + const sweepStartedBackend = () => { + if (!startedBackend || process.platform !== 'win32') { + return; + } + const stopped = stopWindowsWorktreeProcesses({ apiServerExePath }); + if (stopped.length > 0) { + console.log( + `[ai-game-creator-shell] 已清理残留后端进程: ${stopped.join(', ')}`, + ); + } + }; + for (const signal of ['SIGINT', 'SIGTERM']) { const handler = () => { shutdownSignal = signal; stopChild(viteChild, signal); stopChild(backendChild, signal); + // 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。 + sweepStartedBackend(); }; signalHandlers.set(signal, handler); process.on(signal, handler); @@ -683,6 +901,7 @@ async function main() { }, }); backendChild = backend.backendChild; + startedBackend = Boolean(backendChild); if (shutdownSignal) { throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`); } @@ -716,6 +935,7 @@ async function main() { terminateChildTree(viteChild), terminateChildTree(backendChild), ]); + sweepStartedBackend(); for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } @@ -732,20 +952,25 @@ function isDirectModuleExecution() { export { ensureBackend, formatChildFailure, + formatOwnerLabel, isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, isProcessGroupAlive, + isWorktreeApiServerOwner, + isWorktreeSpacetimeOwner, preflightExistingVite, readBackendServiceFailure, readChildFailure, readExistingViteServer, readLinuxProcessGroupAlive, + readWindowsPortOwnerIdentities, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + verifyAgcBackendOwnership, waitForBackendReady, waitForChildTermination, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 91d0abf62..dab175a08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1998,8 +1998,136 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat sanitized.chars().take(2_048).collect() } -fn show_startup_error_dialog(log_path: &Path) { - app_log!("Genarrative startup failed; see {}", log_path.display()); +/// 启动阶段的致命失败必须让用户看得见:release 双击启动时 stderr 不可见,只写日志 +/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。日志路径尚未 +/// 确定时仍然要提示,只是不给路径。 +#[cfg(windows)] +fn show_startup_error_dialog(log_path: Option<&Path>) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, + }; + + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + let title = std::ffi::OsStr::new("Genarrative AI Game Creator") + .encode_wide() + .chain(Some(0)) + .collect::>(); + let message_text = match log_path { + Some(log_path) => format!( + "应用启动失败,请把下面的诊断日志发给开发人员:\n{}", + log_path.display() + ), + None => { + "应用启动失败,诊断日志路径尚未确定;请把这条提示和复现步骤发给开发人员。".to_string() + } + }; + let message = std::ffi::OsStr::new(&message_text) + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call. + unsafe { + MessageBoxW( + std::ptr::null_mut(), + message.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR | MB_SETFOREGROUND, + ); + } +} + +#[cfg(not(windows))] +fn show_startup_error_dialog(log_path: Option<&Path>) { + if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) { + return; + } + match log_path { + Some(log_path) => eprintln!( + "Genarrative AI Game Creator startup failed; see {}", + log_path.display() + ), + None => eprintln!( + "Genarrative AI Game Creator startup failed before the diagnostics log path was known" + ), + } +} + +/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` +/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 +/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 +fn early_startup_log_path(identifier: &str) -> Option { + let configured_dir = game_creator_runtime_config_dir(); + resolve_early_startup_log_path(configured_dir.as_deref(), identifier) +} + +fn resolve_early_startup_log_path( + configured_dir: Option<&Path>, + identifier: &str, +) -> Option { + match configured_dir { + Some(directory) => Some(directory.join("diagnostics/startup.log")), + None => { + platform_config_root().map(|root| root.join(identifier).join("diagnostics/startup.log")) + } + } +} + +#[cfg(windows)] +fn platform_config_root() -> Option { + std::env::var_os("APPDATA").map(PathBuf::from) +} + +#[cfg(target_os = "macos")] +fn platform_config_root() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Application Support")) +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn platform_config_root() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) +} + +/// 启动诊断日志槽位。`configure_game_creator_runtime_config_dir` 之前只能退回按 +/// 标识符推导的 APPDATA 路径,成功后再切换到真实配置目录,保证早期失败也有落点。 +#[derive(Debug, Default)] +struct StartupLogSlot(Mutex>); + +impl StartupLogSlot { + fn new(path: Option) -> Self { + Self(Mutex::new(path)) + } + + fn set(&self, path: PathBuf) { + match self.0.lock() { + Ok(mut guard) => *guard = Some(path), + Err(poisoned) => *poisoned.into_inner() = Some(path), + } + } + + fn path(&self) -> Option { + match self.0.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } + + fn append(&self, line: &str) { + if let Some(path) = self.path() { + let _ = append_bounded_diagnostic_line(&path, line); + } + } + + /// 启动阶段的致命失败:先落盘,再给出用户可见提示。日志路径未知时仍然要 + /// 提示,否则早期失败依旧表现为“双击没反应”。 + fn fail(&self, line: &str) { + self.append(line); + show_startup_error_dialog(self.path().as_deref()); + } } #[derive(Clone, Debug)] @@ -2326,8 +2454,13 @@ fn main() { } let mut tauri_context = tauri::generate_context!(); - let startup_log: Option = None; - let setup_log = startup_log.clone(); + // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 + // `--config-dir`),否则退到平台配置根,保证 + // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 + let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( + tauri_context.config().identifier.as_str(), + ))); + let setup_log = Arc::clone(&startup_log); let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -2338,37 +2471,27 @@ fn main() { .manage(ProjectResourcePreviewReadManager::default()) .setup(move |app| { error_report::initialize_notifications(app.handle()); - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.setup.begin"); - let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin"); - } + setup_log.append("startup.setup.begin"); + setup_log.append("startup.appdata.configure.begin"); configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.appdata.configure.failed details={details}"), - ); - show_startup_error_dialog(path); - } + // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 + let config_dir = game_creator_runtime_config_dir(); + let details = + sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); + setup_log.fail(&format!( + "startup.appdata.configure.failed details={details}" + )); })?; - let startup_log = game_creator_runtime_config_dir() - .map(|directory| directory.join("diagnostics/startup.log")); - if let Some(path) = startup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete"); + if let Some(directory) = game_creator_runtime_config_dir() { + setup_log.set(directory.join("diagnostics/startup.log")); } + setup_log.append("startup.appdata.configure.complete"); let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { let error = std::io::Error::new( std::io::ErrorKind::NotFound, "客户端 AppData 配置目录未初始化", ); - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line( - path, - "startup.appdata.resolve.failed details=config-dir-uninitialized", - ); - show_startup_error_dialog(path); - } + setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; load_platform_session_fixture_from_env(&config_dir).map_err(|error| { @@ -2377,20 +2500,13 @@ fn main() { format!("加载平台登录态测试 fixture 失败:{error}"), ) })?; - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin"); - } + setup_log.append("startup.runner.configure.begin"); configure_external_agent_runner(&config_dir) .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.configure.failed details={details}"), - ); - show_startup_error_dialog(path); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.configure.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2398,20 +2514,13 @@ fn main() { format!("配置 Agent Runner 失败:{error}"), ) })?; - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete"); - } + setup_log.append("startup.runner.configure.complete"); let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.owner-lock.failed details={details}"), - ); - show_startup_error_dialog(path); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.owner-lock.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2421,23 +2530,16 @@ fn main() { })?; let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); app.manage(gui_owner_lock); - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin"); - } + setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.attach-owner.failed details={details}"), - ); - show_startup_error_dialog(path); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.attach-owner.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2445,12 +2547,8 @@ fn main() { format!("绑定 Agent Runner GUI owner 失败:{error}"), ) })?; - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); - } - if let Some(path) = setup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.setup.complete"); - } + setup_log.append("startup.runner.start.complete"); + setup_log.append("startup.setup.complete"); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -2597,19 +2695,13 @@ fn main() { .build(tauri_context); let app = match app { Ok(app) => { - if let Some(path) = startup_log.as_deref() { - let _ = append_bounded_diagnostic_line(path, "startup.build.complete"); - } + startup_log.append("startup.build.complete"); app } Err(error) => { - if let Some(path) = startup_log.as_deref() { + if let Some(path) = startup_log.path() { let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.build.failed details={details}"), - ); - show_startup_error_dialog(path); + startup_log.fail(&format!("startup.build.failed details={details}")); } app_log!("failed to build Genarrative AI Game Creator shell: {error}"); std::process::exit(1); @@ -2639,6 +2731,55 @@ mod diagnostic_log_tests { assert!(previous.contains(&"x".repeat(32))); } + #[test] + fn startup_log_slot_keeps_early_failures_after_the_real_config_dir_is_known() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let path = directory.path().join("startup.log"); + let slot = StartupLogSlot::new(None); + + // 配置目录未知时不能凭空造出日志文件。 + slot.append("startup.setup.begin"); + assert!(!path.exists()); + + slot.set(path.clone()); + slot.append("startup.setup.begin"); + slot.append("startup.appdata.configure.complete"); + + let content = fs::read_to_string(&path).expect("read startup log"); + assert!(content.contains("startup.setup.begin")); + assert!(content.contains("startup.appdata.configure.complete")); + } + + #[test] + fn early_startup_log_path_prefers_the_already_applied_config_dir() { + let directory = tempfile::tempdir().expect("create config directory"); + assert_eq!( + resolve_early_startup_log_path(Some(directory.path()), "world.genarrative.test"), + Some(directory.path().join("diagnostics/startup.log")) + ); + + // 配置目录尚未生效时才退到平台配置根,且仍要按标识符分层。 + if let Some(fallback) = resolve_early_startup_log_path(None, "world.genarrative.test") { + assert!(fallback.ends_with("world.genarrative.test/diagnostics/startup.log")); + } + } + + #[test] + fn startup_log_slot_fail_without_path_still_reports_instead_of_going_silent() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let slot = StartupLogSlot::new(None); + + // 路径未知时 fail 不能静默:它必须仍然走到用户可见提示,同时不造日志文件。 + slot.fail("startup.runner.owner-lock.failed details=test"); + assert_eq!(fs::read_dir(directory.path()).expect("read dir").count(), 0); + + let path = directory.path().join("startup.log"); + let slot = StartupLogSlot::new(Some(path.clone())); + slot.fail("startup.runner.owner-lock.failed details=test"); + let content = fs::read_to_string(&path).expect("read startup log"); + assert!(content.contains("startup.runner.owner-lock.failed details=test")); + } + #[test] fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index ca1a45b15..02f3c83a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -6,6 +6,12 @@ pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; +/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 +/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 +/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 +const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; +/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 +const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; #[derive(Debug)] @@ -49,7 +55,11 @@ impl Drop for ProjectWriteLock { #[cfg(unix)] fn project_write_lock_process_is_alive(process_id: u64) -> Option { - let process_id = i32::try_from(process_id).ok().filter(|value| *value > 0)?; + // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 + // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; let result = unsafe { libc::kill(process_id, 0) }; if result == 0 { return Some(true); @@ -72,7 +82,11 @@ fn project_write_lock_process_is_alive(process_id: u64) -> Option { fn CloseHandle(handle: *mut c_void) -> i32; } - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 + // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; const STILL_ACTIVE: u32 = 259; // SAFETY: OpenProcess returns an owned kernel handle or null; it is @@ -103,53 +117,231 @@ fn project_write_lock_process_is_alive(_process_id: u64) -> Option { None } -fn project_write_lock_owner_pid(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)) +/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 +/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 +/// 保持原有的保守回收策略。 +#[cfg(windows)] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetProcessTimes( + process: *mut c_void, + creation_time: *mut FileTime, + exit_time: *mut FileTime, + kernel_time: *mut FileTime, + user_time: *mut FileTime, + ) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 + const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + // SAFETY: every FileTime is plain data filled by GetProcessTimes. + let mut creation = unsafe { std::mem::zeroed::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // SAFETY: `process` is a live handle and all four pointers are writable scalars. + let result = + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); + file_time + .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) + .map(|unix_100ns| unix_100ns / 10_000_000) +} + +#[cfg(target_os = "linux")] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. + let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clock_ticks <= 0 { + return None; + } + let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; + let start_ticks = stat + .rsplit_once(") ")? + .1 + .split_whitespace() + .nth(19)? + .parse::() + .ok()?; + let boot_time = fs::read_to_string("/proc/stat") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .trim() + .parse::() + .ok()?; + Some(boot_time + start_ticks / clock_ticks as u64) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { + None +} + +/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: +/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 +/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 +#[derive(Debug, Clone)] +pub(crate) struct ProjectWriteLockSnapshot { + content: Vec, + pid: Option, + created_at: Option, + process_started_at: Option, +} + +impl ProjectWriteLockSnapshot { + pub(crate) fn read(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let payload = serde_json::from_slice::(&content).ok(); + let number = |key: &str| { + payload + .as_ref() + .and_then(|payload| payload.get(key)) + .and_then(serde_json::Value::as_u64) + }; + Some(Self { + pid: number("pid"), + created_at: number("createdAt"), + process_started_at: number("processStartedAt"), + content, + }) + } } fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { - project_write_lock_owner_pid(path) == Some(u64::from(std::process::id())) + ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) + == Some(u64::from(std::process::id())) } -fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 { - let created_at = fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| payload.get("createdAt").and_then(serde_json::Value::as_u64)); - if let Some(created_at) = created_at { - return unix_timestamp().saturating_sub(created_at); - } +/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” +/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 +/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 +fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { metadata .modified() .ok() - .and_then(|modified| modified.elapsed().ok()) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or_default() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) } -fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; +/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 +/// 按“不回收”处理,不能退化成 0 或极大值。 +fn project_write_lock_age_seconds( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, +) -> Option { + if let Some(created_at) = snapshot.created_at { + return Some(now.saturating_sub(created_at)); + } + modified_at.map(|modified_at| now.saturating_sub(modified_at)) +} + +/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 +/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 +pub(crate) fn project_write_lock_reclaim_decision( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, + process_is_alive: impl Fn(u64) -> Option, + process_started_at: impl Fn(u64) -> Option, +) -> bool { + let Some(owner_pid) = snapshot.pid else { + // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 + return project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); }; + match process_is_alive(owner_pid) { + Some(false) => true, + Some(true) => { + // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 + match (snapshot.process_started_at, process_started_at(owner_pid)) { + // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 + (Some(stored), Some(actual)) => stored != actual, + // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; + // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 + (None, Some(actual)) => { + let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { + return false; + }; + actual + > lock_created_at + .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) + } + _ => false, + } + } + // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 + None => project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), + } +} + +/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 +fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) || !metadata.is_file() || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES { - return false; + return None; } - let content = fs::read_to_string(path).ok(); - let owner_pid = content - .as_deref() - .and_then(|content| serde_json::from_str::(content).ok()) - .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)); - if let Some(owner_alive) = owner_pid.and_then(project_write_lock_process_is_alive) { - return !owner_alive; + let snapshot = ProjectWriteLockSnapshot::read(path)?; + project_write_lock_reclaim_decision( + &snapshot, + project_write_lock_file_modified_seconds(&metadata), + unix_timestamp(), + project_write_lock_process_is_alive, + project_write_lock_process_start_time_seconds, + ) + .then_some(snapshot) +} + +/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 +/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 +/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 +pub(crate) fn project_write_lock_reclaim( + path: &Path, + snapshot: &ProjectWriteLockSnapshot, +) -> Result { + match fs::read(path) { + Ok(content) if content == snapshot.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); + } + } + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), } - project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS } fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { @@ -228,6 +420,10 @@ pub(crate) fn acquire_project_write_lock( let payload = serde_json::json!({ "commandId": command_id, "pid": std::process::id(), + // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 + "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( + std::process::id() + )), "createdAt": unix_timestamp(), "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), }); @@ -277,33 +473,29 @@ pub(crate) fn acquire_project_write_lock( bypassed_same_process: false, }); } - Err(error) - if project_write_lock_open_error_is_contention(&error) - && !retried_after_reclaim - && project_write_lock_can_be_reclaimed(&path) => - { - fs::remove_file(&path).map_err(|error| { - format!("清理失效项目写锁失败:{}: {error}", path.display()) - })?; - retried_after_reclaim = true; - } - Err(error) - if project_write_lock_open_error_is_contention(&error) - && crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) => - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. - return Ok(ProjectWriteLock { - path, - content: String::new(), - bypassed_same_process: true, - }); - } Err(error) if project_write_lock_open_error_is_contention(&error) => { + if !retried_after_reclaim { + if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) { + if project_write_lock_reclaim(&path, &snapshot)? { + retried_after_reclaim = true; + continue; + } + } + } + if crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } return Err(format!("项目正在被其他写操作占用:{}", path.display())); } Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 68c898339..e2c15a956 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -6069,6 +6069,7 @@ mod command_runtime; pub(crate) mod configuration; mod goal; mod project; +mod project_lock_recovery; mod project_tools; mod provider; mod response_stream; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs new file mode 100644 index 000000000..9f7dc6541 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs @@ -0,0 +1,358 @@ +use super::*; +use std::process::Stdio; + +// Issue #310 复现:异常退出后在项目里残留 `.agent/project.lock`,下一次打开 +// 项目时所有写操作都被拒绝。 +// +// 下面的用例是回归护栏:持有者确实不存在时残留锁必须被安全回收,同时不能抢走 +// 仍然活着持有者的锁。 + +const PROJECT_LOCK_RELATIVE_PATH: &str = ".agent/project.lock"; +/// 一个确定不会被占用的进程号:高于两个平台实际分配的进程号上限,因此 Windows +/// 的 OpenProcess 对它返回 ERROR_INVALID_PARAMETER,Unix 的 kill(pid, 0) 返回 +/// ESRCH。取值必须落在 Unix 有符号 32 位 pid 范围内,否则在 Unix 上会先命中 +/// “进程号不可表示”分支,而不是这条“死进程”分支。 +const DEAD_OWNER_PID: u64 = i32::MAX as u64 - 1; + +fn write_project_lock_fixture(root: &Path, content: &[u8]) { + fs::write(root.join(PROJECT_LOCK_RELATIVE_PATH), content).expect("写入项目写锁 fixture"); +} + +fn project_lock_fixture_payload(pid: u64, created_at: u64) -> Vec { + serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.crashed-writer", + "pid": pid, + "createdAt": created_at, + "nonce": 1, + })) + .expect("序列化项目写锁 fixture") +} + +fn backdate_project_lock_fixture(root: &Path, seconds: u64) { + let file = fs::OpenOptions::new() + .write(true) + .open(root.join(PROJECT_LOCK_RELATIVE_PATH)) + .expect("打开项目写锁 fixture"); + file.set_modified(SystemTime::now() - Duration::from_secs(seconds)) + .expect("回拨项目写锁 fixture mtime"); +} + +/// 写入锁 fixture 并读回同一份快照,供纯判据用例使用。 +fn project_lock_snapshot(root: &Path, content: &[u8]) -> ProjectWriteLockSnapshot { + write_project_lock_fixture(root, content); + ProjectWriteLockSnapshot::read(&root.join(PROJECT_LOCK_RELATIVE_PATH)) + .expect("读取项目写锁快照") +} + +#[cfg(windows)] +fn spawn_unrelated_live_process() -> std::process::Child { + std::process::Command::new("ping") + .args(["-n", "30", "127.0.0.1"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("启动无关的活进程") +} + +#[cfg(target_os = "linux")] +fn spawn_unrelated_live_process() -> std::process::Child { + std::process::Command::new("sleep") + .arg("30") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("启动无关的活进程") +} + +fn stop_unrelated_live_process(mut child: std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +/// 基线:记录着已死进程号的残留锁本来就应该被回收。 +#[test] +fn project_write_lock_reclaims_dead_owner_pid() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-dead-owner", "锁回收-死进程").expect("初始化项目"); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(DEAD_OWNER_PID, unix_timestamp()), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "死进程残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 锁文件被外部改写或截断损坏、PID 超出平台进程号空间(例如 u64::MAX)时,它不 +/// 可能属于任何活进程,必须直接回收而不是再等满 600 秒;Unix 的 pid_t 只有有符号 +/// 32 位,越界取值尤其容易在这里被漏掉。 +#[test] +fn project_write_lock_reclaims_unrepresentable_owner_pid() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-invalid-pid", "锁回收-非法进程号").expect("初始化项目"); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::MAX, unix_timestamp()), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "非法进程号的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 空锁超过 30 秒宽限期即回收:崩溃停在 `create_new` 与落盘 payload 之间时写入方 +/// 已经放弃,不能继续阻塞项目。 +#[test] +fn project_write_lock_reclaims_empty_body_after_short_grace() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-empty-body", "锁回收-空锁").expect("初始化项目"); + write_project_lock_fixture(&root, b""); + backdate_project_lock_fixture(&root, 120); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "空残留锁超过宽限期仍未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 旧格式锁(无 `processStartedAt`)记录的 PID 已被复用给另一个活进程时,按“进程 +/// 启动时间晚于锁创建时间”推断原持有者已退出并回收。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_reclaims_pid_reused_by_other_live_process() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-pid-reuse", "锁回收-PID复用").expect("初始化项目"); + + let unrelated = spawn_unrelated_live_process(); + // 锁是在一小时前被写下的,而记录里的 PID 现在属于刚刚才启动的另一个进程: + // 这只能是 PID 复用,原持有者早已退出。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(unrelated.id()), unix_timestamp() - 3_600), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + stop_unrelated_live_process(unrelated); + assert!( + acquired.is_ok(), + "PID 复用后的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 新格式锁自带进程启动身份:即使时间戳看起来“刚刚写过”,只要身份对不上就判定 +/// PID 复用并回收;这条路径不依赖系统时钟是否发生跳变。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_reclaims_pid_reused_identity_mismatch() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-pid-identity", "锁回收-身份不一致") + .expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.crashed-writer", + "pid": u64::from(unrelated.id()), + "processStartedAt": started_at + 1, + "createdAt": unix_timestamp(), + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + stop_unrelated_live_process(unrelated); + assert!( + acquired.is_ok(), + "启动身份不一致的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:PID 与启动身份都吻合说明持有者真的活着,绝不能抢锁。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_keeps_matching_process_identity() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-live-identity", "锁回收-身份一致").expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.live-writer", + "pid": u64::from(unrelated.id()), + "processStartedAt": started_at, + "createdAt": unix_timestamp(), + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent") + .expect_err("持有者仍然活着时不能回收"); + stop_unrelated_live_process(unrelated); + assert!( + error.contains("项目正在被其他写操作占用"), + "活持有者必须进入占用分支,实际错误:{error}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:刚创建的空锁可能只是写入方还没落盘,绝不能被别人抢走。 +#[test] +fn project_write_lock_keeps_fresh_empty_body() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-fresh-empty", "锁回收-新鲜空锁").expect("初始化项目"); + write_project_lock_fixture(&root, b""); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent") + .expect_err("刚创建的空锁必须保持占用"); + assert!( + error.contains("项目正在被其他写操作占用"), + "并发写必须进入占用分支,实际错误:{error}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:旧格式锁(有 `pid` 和 `createdAt`、没有 `processStartedAt`)的持有者确实 +/// 活着,且进程启动时间早于锁创建时间时,不能被当成 PID 复用抢走。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_keeps_live_old_format_holder_started_before_created_at() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-old-format-live", "锁回收-旧格式活持有者") + .expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + // 锁是在持有者启动之后才写下的,所以进程启动时间自然早于 createdAt。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(unrelated.id()), started_at + 60), + ); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent"); + stop_unrelated_live_process(unrelated); + assert!( + matches!(&error, Err(message) if message.contains("项目正在被其他写操作占用")), + "旧格式活持有者不能被当成 PID 复用,实际结果:{error:?}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:回收判定只是快照观察,删除前必须重新核对内容。判定之后被并发方替换成 +/// 自己的活锁时不能删除它;文件已经消失时也不算失败。 +#[test] +fn project_write_lock_reclaim_skips_replaced_or_removed_lock_file() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-reclaim-race", "锁回收-并发替换").expect("初始化项目"); + let lock_path = root.join(PROJECT_LOCK_RELATIVE_PATH); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(4_242, unix_timestamp()), + ); + let stale_snapshot = ProjectWriteLockSnapshot::read(&lock_path).expect("读取残留锁快照"); + + // 并发方回收了残留锁并装上了自己的活锁。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(std::process::id()), unix_timestamp()), + ); + assert!( + !project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对被替换的锁文件"), + "内容已经变化时必须放弃删除" + ); + assert!(lock_path.exists(), "并发方新装的活锁不能被删掉"); + + fs::remove_file(&lock_path).expect("删除锁文件"); + assert!( + !project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对已消失的锁文件"), + "锁文件已经消失时不算失败" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:存活状态无法判定(`None`)时保持保守——年轻锁必须保留,明显过期才回收。 +#[test] +fn project_write_lock_decision_keeps_young_lock_when_liveness_is_unknown() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-unknown-liveness", "锁回收-存活未知") + .expect("初始化项目"); + let now = unix_timestamp(); + let snapshot = project_lock_snapshot(&root, &project_lock_fixture_payload(4_242, now)); + + assert!( + !project_write_lock_reclaim_decision(&snapshot, Some(now), now, |_| None, |_| None), + "存活状态无法判定时,新鲜锁必须保持占用" + ); + assert!( + project_write_lock_reclaim_decision(&snapshot, Some(now), now + 601, |_| None, |_| None), + "存活状态无法判定且明显过期时必须回收" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:mtime 不可读(未知)时不能退化成“年龄极大”。未知年龄按不回收处理, +/// 未知创建时间也不能满足“启动时间晚于创建时间”的 PID 复用推断。 +#[test] +fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-unknown-mtime", "锁回收-mtime未知") + .expect("初始化项目"); + let now = unix_timestamp(); + + let empty = project_lock_snapshot(&root, b""); + assert!( + !project_write_lock_reclaim_decision(&empty, None, now + 601, |_| None, |_| None), + "mtime 未知时空锁必须保持占用" + ); + assert!( + project_write_lock_reclaim_decision(&empty, Some(now - 120), now, |_| None, |_| None), + "mtime 已知且超过宽限期的空锁必须回收" + ); + + let live_old_format = project_lock_snapshot( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.live-writer", + "pid": 4_242, + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + assert!( + !project_write_lock_reclaim_decision( + &live_old_format, + None, + now + 601, + |_| Some(true), + |_| Some(now + 3_600) + ), + "创建时间未知时不能按 PID 复用抢走活持有者" + ); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 4418da177..fb4534a86 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,22 +7,33 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, + formatOwnerLabel, isBackendReady, isProcessGroupAlive, + isWorktreeApiServerOwner, + isWorktreeSpacetimeOwner, preflightExistingVite, readBackendServiceFailure, readLinuxProcessGroupAlive, + readWindowsPortOwnerIdentities, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + verifyAgcBackendOwnership, waitForBackendReady, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); +const expectedExePath = resolve('server-rs/target/debug/api-server.exe'); +const ownedBackend = async () => ({ + ok: true, + reason: 'owned', + owners: new Map(), +}); function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { return { @@ -109,6 +120,7 @@ describe('AI 游戏创作配套后端复用门禁', () => { isBackendReady({ state: backendState(expectedDataDir, false), isReady, + verifyOwnership: ownedBackend, }), ).resolves.toBe(false); expect(isReady).not.toHaveBeenCalled(); @@ -118,6 +130,7 @@ describe('AI 游戏创作配套后端复用门禁', () => { isBackendReady({ state: backendState(expectedDataDir), isReady, + verifyOwnership: ownedBackend, }), ).resolves.toBe(false); expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); @@ -127,6 +140,7 @@ describe('AI 游戏创作配套后端复用门禁', () => { isBackendReady({ state: backendState(expectedDataDir), isReady, + verifyOwnership: ownedBackend, }), ).resolves.toBe(true); }); @@ -171,6 +185,232 @@ describe('AI 游戏创作配套后端复用门禁', () => { }), ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); }); + + test('端口上的后端不属于当前工作树时拒绝复用', async () => { + const isReady = vi.fn(async () => true); + const onOwnershipRejected = vi.fn(); + + await expect( + isBackendReady({ + state: backendState(expectedDataDir), + isReady, + verifyOwnership: async () => ({ + ok: false, + reason: 'api-server-owner-mismatch', + apiOwner: { processId: 4321, name: 'api-server.exe' }, + }), + onOwnershipRejected, + }), + ).resolves.toBe(false); + + expect(onOwnershipRejected).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'api-server-owner-mismatch' }), + ); + expect(isReady).not.toHaveBeenCalled(); + }); + + test('归属探测不可用时退化为旧行为而不是让本地启动失败', async () => { + const isReady = vi.fn(async () => true); + + await expect( + isBackendReady({ + state: backendState(expectedDataDir), + isReady, + verifyOwnership: async () => ({ + ok: true, + reason: 'owner-probe-unavailable', + owners: new Map(), + }), + }), + ).resolves.toBe(true); + expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); + }); +}); + +describe('AI 游戏创作配套后端归属校验', () => { + const urls = { + apiUrl: 'http://127.0.0.1:8082', + spacetimeUrl: 'http://127.0.0.1:3101', + bgfilterWorkerUrl: 'http://127.0.0.1:8083', + }; + + function ownerMap({ + apiExe = expectedExePath, + dataDir = expectedDataDir, + } = {}) { + return new Map([ + [ + 8082, + { + port: 8082, + processId: 11, + name: 'api-server.exe', + executablePath: apiExe, + commandLine: null, + }, + ], + [ + 8083, + { + port: 8083, + processId: 12, + name: 'api-server.exe', + executablePath: expectedExePath, + commandLine: null, + }, + ], + [ + 3101, + { + port: 3101, + processId: 13, + name: 'spacetimedb-standalone.exe', + executablePath: null, + commandLine: `spacetimedb-standalone.exe start --data-dir ${dataDir}`, + }, + ], + ]); + } + + test('api-server 可执行文件来自其它工作树时判定为不归属', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => + ownerMap({ + apiExe: resolve( + '.worktrees/other/server-rs/target/debug/api-server.exe', + ), + }), + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('api-server-owner-mismatch'); + }); + + test('SpacetimeDB 使用其它 data dir 时判定为不归属', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => + ownerMap({ dataDir: resolve('server-rs/.spacetimedb/local/data') }), + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('spacetime-owner-mismatch'); + }); + + test('可执行文件与 data dir 都匹配时允许复用', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => ownerMap(), + }); + + expect(result).toMatchObject({ ok: true, reason: 'owned' }); + }); + + test('归属探测不可用时不阻断本地启动', () => { + const result = verifyAgcBackendOwnership({ + ...urls, + platform: 'win32', + expectedExePath, + expectedDataDir, + readPortOwners: () => null, + }); + + expect(result).toMatchObject({ + ok: true, + reason: 'owner-probe-unavailable', + }); + }); + + test('非 Windows 平台保持原有复用行为', () => { + const result = verifyAgcBackendOwnership({ ...urls, platform: 'linux' }); + expect(result).toMatchObject({ ok: true, reason: 'platform-unsupported' }); + }); + + test('可执行文件路径与 data dir 归属判定忽略大小写和 \\\\?\\ 前缀', () => { + expect( + isWorktreeApiServerOwner( + { + processId: 1, + executablePath: `\\\\?\\${expectedExePath.toUpperCase()}`, + }, + { expectedExePath }, + ), + ).toBe(true); + expect( + isWorktreeSpacetimeOwner( + { + processId: 2, + name: 'spacetimedb-standalone.exe', + commandLine: `start --data-dir ${expectedDataDir.toUpperCase()}`, + }, + { expectedDataDir }, + ), + ).toBe(true); + expect( + isWorktreeSpacetimeOwner( + { processId: 3, name: 'node.exe', commandLine: expectedDataDir }, + { expectedDataDir }, + ), + ).toBe(false); + }); + + test('端口监听进程探测解析 PowerShell 输出', () => { + const spawnImpl = vi.fn(() => ({ + status: 0, + error: null, + stdout: JSON.stringify([ + { + port: 8082, + processId: 4321, + name: 'api-server.exe', + executablePath: expectedExePath, + commandLine: null, + }, + ]), + })); + + const owners = readWindowsPortOwnerIdentities([8082, 0], { + spawnImpl, + env: {}, + }); + + expect(owners?.get(8082)).toMatchObject({ processId: 4321 }); + expect(spawnImpl).toHaveBeenCalledWith( + 'powershell.exe', + expect.any(Array), + expect.objectContaining({ env: { GENARRATIVE_QUERY_PORTS: '8082' } }), + ); + }); + + test('探测失败时返回 null 以触发退化分支', () => { + expect( + readWindowsPortOwnerIdentities([8082], { + spawnImpl: () => ({ status: 1, error: null, stdout: '' }), + env: {}, + }), + ).toBeNull(); + expect(readWindowsPortOwnerIdentities([], { env: {} })).toBeNull(); + }); + + test('归属日志包含 pid 与进程标识', () => { + expect( + formatOwnerLabel({ + processId: 4321, + executablePath: 'C:\\a\\api-server.exe', + }), + ).toBe('pid=4321 C:\\a\\api-server.exe'); + expect(formatOwnerLabel(null)).toBe('未知进程'); + }); }); describe('AI 游戏创作启动子进程生命周期', () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0788ea087..9e04c9de5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8206,3 +8206,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 路由口径:`resolveProjectResourceEditCapability` 不再返回 `image-canvas`,PNG/JPEG/WebP 与其他图片类型一样走 `derive + image-reference`,不再要求先正规化为正式 manifest asset。 - 过渡项(带删除触发条件):`scripts/check-config.mjs` 的 native-only 白名单新增 `normalize_local_project_raster_resource`,因为原前端调用方随草稿画布一起删除。资源画布重建快速编辑后,该命令重新获得前端调用方,届时删除这条 allowlist 条目。 - 存量数据:`.agent/workbench/asset-canvas/` 不写清理代码,不做迁移;只对目录做一次性手工清理,`.agent/workbench/` 下 resource-edits、replacement-queue 等在役目录不得整体删除。 + +## 2026-09-09 项目写锁残留回收与启动诊断 + +- 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断,锁创建时间未知时不做该推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒,mtime 不可读时按未知年龄不回收。活持有者始终不回收。 +- 决策:回收判据与删除基于同一次读到的锁文件快照——payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 +- 决策:启动诊断日志用 `StartupLogSlot`,优先用已生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),配置目录就绪后再切换;`startup.*.failed` 与 `show_startup_error_dialog` 必须可达,日志路径未知时也必须给出用户可见提示;Windows 启动失败弹系统消息框,其它平台写 stderr。 +- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。 +- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。 +- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3aa858c58..86a25036e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -46,6 +46,18 @@ - 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。 - jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。 +## 2026-09-09 `npm run agc` 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查 + +- **现象**:`npm run agc` 按 Ctrl+C 后终端回到提示符,但上个工作树的 `api-server.exe` / SpacetimeDB 仍在监听 `8082` / `8083` / `3101`;切到另一个 worktree 再启动 AGC 时,前端仍然连到上个工作树的后端,在改过数据库 / schema 的工作树上会串库。 +- **原因**: + 1. Windows 下所有长驻服务都由 Node `shell: true` 经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`)。`scripts/dev.mjs` 的 `stopProcess` 见到直接子进程已退出就直接 `return`,`start-dev-stack.mjs` / `start-tauri-dev.mjs` 对已退出 PID 的 `taskkill /PID /T /F` 只会失败并返回 `stopped: false`,于是更深的 `cargo → api-server.exe` 没有任何人收。 + 2. 即使走到按根 PID 遍历进程树,遍历依赖快照里的父子链;中间层(包装层)先消失时链路断开,遍历只能拿到根 PID,深处的后端不可达。 + 3. 复用判据只看 `.app/dev-stack.json` 的 status 与 `/healthz`、`/readyz`、`/v1/ping`,从不校验端口上的进程属于哪个工作树;残留后端照样“健康”,因此被当成自己的后端复用。 +- **处理**:新增 `scripts/dev-windows-process.mjs`,同时提供按根 PID 遍历与按身份匹配(`server-rs/target/debug/api-server.exe` 绝对路径、SpacetimeDB `--data-dir`)两条独立清理路径。`dev.mjs` 在直接子进程已退出时也继续清理,并在退出时按身份兜底清扫本工作树后端(复用他人 standalone 时不清理)。`start-dev-stack.mjs` 在收到信号和 `finally` 各清扫一次本工作树 `api-server.exe`(仅限本次自己拉起后端的情况),复用前先校验端口监听进程归属,无法证明归属就不复用、改为启动自己的后端并允许端口漂移。 +- **排查顺序**:先看 `.app/dev-stack.json` 的 status 与实际监听端口是否一致,再用 `Get-CimInstance Win32_Process` 按本工作树 `server-rs\target\debug\api-server.exe` 路径与 SpacetimeDB `--data-dir` 核对残留进程;不要因为 `/healthz` 返回 200 就认定后端属于当前工作树。 +- **验证**:`node --check scripts/dev.mjs scripts/dev-windows-process.mjs apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`;`npx vitest run scripts/dev-windows-process.test.ts apps/ai-game-creator-shell/tests/start-dev-stack.test.ts scripts/dev.test.ts`;真机确认 Ctrl+C 后没有匹配本工作树 `api-server.exe` 路径的残留进程。 +- **关联**:`scripts/dev.mjs`、`scripts/dev-windows-process.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe - **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。 @@ -5003,3 +5015,19 @@ - 处理:框选命中判断使用 `Element.closest('.genarrative-image-canvas__world')`,并保留 viewport 自身命中路径;回归测试通过真实 `CanvasWorld` DOM 的 `pointerdown` 冒泡覆盖内层 world content。 - 验证:`src/components/image-editor/useImageCanvasStageInteractions.test.tsx` 覆盖内层 world content 命中,定向交互测试通过。 - 关联:`packages/image-canvas-react/src/useImageCanvasStageInteractions.ts`、`packages/image-canvas-react/src/CanvasWorld.tsx`。 + +## 跨平台“死进程 PID” fixture 必须落在 Unix 有符号 32 位范围内(2026-09-09) + +- 现象:`project_lock_recovery::project_write_lock_reclaims_dead_owner_pid` 在 Windows 本地通过,在 Linux CI 报“死进程残留锁未被回收,实际错误:项目正在被其他写操作占用”。 +- 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。 +- 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。 +- 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 + +## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09) + +- 现象:两个实例同时恢复同一个崩溃项目时,后判定的一方可能删掉另一方刚装上的活锁;`remove_file` 的 `NotFound` 还会被当成硬失败,直接报“清理失效项目写锁失败”。 +- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。 +- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。 +- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3414c5141..c9fb8ae55 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1288,3 +1288,10 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。 - Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。 + +## 2026-09-09 项目写锁残留回收与启动诊断 + +- `.agent/project.lock` 新增 `processStartedAt`(持有进程启动时间,Unix 秒)。PID 仍存活时必须先核对启动身份:身份不一致即判定 PID 复用,可直接回收;旧锁没有该字段时退回“进程启动时间晚于锁 `createdAt` 加 5 秒容差”的推断,锁创建时间未知时不做该推断。崩溃停在 `create_new` 与落盘 payload 之间的空锁 / 坏锁宽限期从 600 秒收紧到 30 秒;无法判定持有者是否存活、或 mtime 不可读时保持保守(前者 600 秒、后者不回收),活持有者仍然不回收。 +- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 +- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。 +- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bee46452e..a1bdae5e9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -62,9 +62,9 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 diff --git a/scripts/dev-windows-process.mjs b/scripts/dev-windows-process.mjs new file mode 100644 index 000000000..ec53bc774 --- /dev/null +++ b/scripts/dev-windows-process.mjs @@ -0,0 +1,242 @@ +import { spawnSync } from 'node:child_process'; + +// Windows 开发栈清理工具。 +// +// 背景:Windows 下所有长驻服务都经 `cmd.exe /d /s /c` 包装层启动(Node 的 +// `shell: true`),而 Ctrl+C 会先让包装层退出。一旦中间层退出,按父进程链 +// 遍历就再也到不了更深的服务进程,`taskkill /T` 也会因为 PID 已消失而失效。 +// 因此这里同时提供两种定位方式: +// 1. `selectProcessTreeIds`:按记录下来的根 PID 做父子链遍历(能处理根已退出、 +// 但中间层仍留在快照里的情况)。 +// 2. `selectWorktreeOwnedProcessIds`:按身份匹配(api-server.exe 的绝对路径、 +// SpacetimeDB 的 --data-dir),不依赖任何仍然存活的包装层。 +// 两者结合后,即使 `npm run agc` 的 Ctrl+C 只杀掉了 shell 包装层,也不会留下 +// 属于本工作树的后端进程。 + +function normalizeWindowsPath(value) { + const raw = String(value ?? '') + .trim() + .replace(/^\\\\\?\\/u, ''); + if (!raw) { + return ''; + } + return raw.replace(/[\\/]+$/u, '').toLowerCase(); +} + +function parseWindowsProcessSnapshot(rawText) { + const raw = String(rawText ?? '').trim(); + if (!raw) { + return []; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!parsed) { + return []; + } + return Array.isArray(parsed) ? parsed : [parsed]; +} + +// 一次退出流程里会多次清理(每个服务的进程树 + 最后的身份兜底清扫), +// PowerShell 全量进程快照约 1 秒,短时间内复用同一份快照即可,避免 Ctrl+C +// 后清理被拖成十几秒。只在默认实现下缓存,注入实现(测试)始终重新读取。 +const PROCESS_SNAPSHOT_TTL_MS = 1000; +let cachedProcessSnapshot = null; +let cachedProcessSnapshotAt = 0; + +function readWindowsProcessSnapshot({ + spawnSyncImpl = spawnSync, + env = process.env, + now = Date.now, + ttlMs = PROCESS_SNAPSHOT_TTL_MS, +} = {}) { + const cacheable = spawnSyncImpl === spawnSync && env === process.env; + if ( + cacheable && + cachedProcessSnapshot && + now() - cachedProcessSnapshotAt < ttlMs + ) { + return cachedProcessSnapshot; + } + + const command = [ + '$ErrorActionPreference = "SilentlyContinue"', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ].join('\n'); + const result = spawnSyncImpl( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + { + encoding: 'utf8', + env, + maxBuffer: 32 * 1024 * 1024, + }, + ); + if (result?.error || result?.status !== 0) { + return []; + } + + const snapshot = parseWindowsProcessSnapshot(result.stdout); + if (cacheable) { + cachedProcessSnapshot = snapshot; + cachedProcessSnapshotAt = now(); + } + return snapshot; +} + +function selectProcessTreeIds(processes, rootPid) { + if (!Number.isInteger(rootPid)) { + return []; + } + + const childrenByParent = new Map(); + for (const processEntry of processes ?? []) { + const parentId = Number(processEntry?.ParentProcessId); + const processId = Number(processEntry?.ProcessId); + if (!Number.isInteger(parentId) || !Number.isInteger(processId)) { + continue; + } + if (!childrenByParent.has(parentId)) { + childrenByParent.set(parentId, []); + } + childrenByParent.get(parentId).push(processId); + } + + const collected = new Set(); + const queue = [rootPid]; + while (queue.length > 0) { + const current = queue.shift(); + if (collected.has(current)) { + continue; + } + collected.add(current); + for (const childId of childrenByParent.get(current) ?? []) { + queue.push(childId); + } + } + return [...collected]; +} + +function selectWorktreeOwnedProcessIds( + processes, + { apiServerExePath = '', spacetimeDataDir = '', selfPid = process.pid } = {}, +) { + const expectedExePath = normalizeWindowsPath(apiServerExePath); + const expectedDataDir = normalizeWindowsPath(spacetimeDataDir); + if (!expectedExePath && !expectedDataDir) { + return []; + } + + const matched = []; + for (const processEntry of processes ?? []) { + const processId = Number(processEntry?.ProcessId); + if (!Number.isInteger(processId) || processId === selfPid) { + continue; + } + + const executablePath = normalizeWindowsPath(processEntry?.ExecutablePath); + if (expectedExePath && executablePath === expectedExePath) { + matched.push(processId); + continue; + } + + if (!expectedDataDir) { + continue; + } + const name = String(processEntry?.Name ?? '').toLowerCase(); + if (!name.startsWith('spacetime')) { + continue; + } + if ( + normalizeWindowsPath(processEntry?.CommandLine).includes(expectedDataDir) + ) { + matched.push(processId); + } + } + return matched; +} + +function stopWindowsProcessIds( + processIds, + { spawnSyncImpl = spawnSync, env = process.env, waitForExitMs = 0 } = {}, +) { + const uniqueIds = [ + ...new Set((processIds ?? []).filter((value) => Number.isInteger(value))), + ]; + if (uniqueIds.length === 0) { + return []; + } + + const command = [ + '$ErrorActionPreference = "SilentlyContinue"', + '$ids = $env:GENARRATIVE_STOP_PIDS -split ","', + 'foreach ($id in $ids) {', + ' if ($id) { Stop-Process -Id ([int]$id) -Force -ErrorAction SilentlyContinue }', + '}', + ...(waitForExitMs > 0 + ? [ + // 启动前清理旧 api-server 时必须等它真正退出,否则 Windows 仍占用 + // target\debug\api-server.exe,cargo 会报 failed to remove file。 + `Wait-Process -Id $ids -Timeout ${Math.ceil(waitForExitMs / 1000)} -ErrorAction SilentlyContinue`, + ] + : []), + ].join('\n'); + spawnSyncImpl( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + { + env: { ...env, GENARRATIVE_STOP_PIDS: uniqueIds.join(',') }, + stdio: 'ignore', + }, + ); + return uniqueIds; +} + +function stopWindowsProcessTree( + rootPid, + { snapshot = null, spawnSyncImpl = spawnSync, env = process.env } = {}, +) { + if (!Number.isInteger(rootPid)) { + return []; + } + + const processes = + snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env }); + return stopWindowsProcessIds(selectProcessTreeIds(processes, rootPid), { + spawnSyncImpl, + env, + }); +} + +function stopWindowsWorktreeProcesses({ + apiServerExePath = '', + spacetimeDataDir = '', + snapshot = null, + spawnSyncImpl = spawnSync, + env = process.env, +} = {}) { + const processes = + snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env }); + return stopWindowsProcessIds( + selectWorktreeOwnedProcessIds(processes, { + apiServerExePath, + spacetimeDataDir, + }), + { spawnSyncImpl, env }, + ); +} + +export { + normalizeWindowsPath, + parseWindowsProcessSnapshot, + readWindowsProcessSnapshot, + selectProcessTreeIds, + selectWorktreeOwnedProcessIds, + stopWindowsProcessIds, + stopWindowsProcessTree, + stopWindowsWorktreeProcesses, +}; diff --git a/scripts/dev-windows-process.test.ts b/scripts/dev-windows-process.test.ts new file mode 100644 index 000000000..57a5e11ca --- /dev/null +++ b/scripts/dev-windows-process.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'vitest'; + +import { + normalizeWindowsPath, + parseWindowsProcessSnapshot, + selectProcessTreeIds, + selectWorktreeOwnedProcessIds, +} from './dev-windows-process.mjs'; + +function processEntry(overrides) { + return { + ProcessId: 1, + ParentProcessId: 0, + Name: 'node.exe', + ExecutablePath: null, + CommandLine: null, + ...overrides, + }; +} + +describe('Windows 进程快照解析', () => { + test('单个进程对象也归一为数组', () => { + const single = parseWindowsProcessSnapshot( + JSON.stringify({ ProcessId: 42, ParentProcessId: 1 }), + ); + expect(single).toHaveLength(1); + expect(single[0].ProcessId).toBe(42); + }); + + test('空输出和非法 JSON 返回空数组', () => { + expect(parseWindowsProcessSnapshot('')).toEqual([]); + expect(parseWindowsProcessSnapshot('null')).toEqual([]); + expect(parseWindowsProcessSnapshot('not json')).toEqual([]); + }); + + test('路径归一化去掉 \\\\?\\ 前缀、尾部分隔符并忽略大小写', () => { + expect( + normalizeWindowsPath('\\\\?\\C:\\Repo\\target\\debug\\api-server.exe'), + ).toBe('c:\\repo\\target\\debug\\api-server.exe'); + expect(normalizeWindowsPath('C:\\Repo\\data\\')).toBe('c:\\repo\\data'); + expect(normalizeWindowsPath(null)).toBe(''); + }); +}); + +describe('按根 PID 遍历进程树', () => { + test('中间层进程已从快照消失时无法再到达更深的后代', () => { + // cmd(10) -> wrapper(20) -> api-server(30)。Ctrl+C 先杀掉 10 和 20, + // 快照里只剩 30(ParentProcessId 仍指向已消失的 20),父链断开后 + // 按根 PID 遍历只能拿到根自己,这正是后端被漏杀的原因。 + const snapshot = [ + processEntry({ + ProcessId: 30, + ParentProcessId: 20, + Name: 'api-server.exe', + }), + ]; + expect(selectProcessTreeIds(snapshot, 10)).toEqual([10]); + }); + + test('根已退出但中间层仍在快照里时仍可收全后代', () => { + const snapshot = [ + processEntry({ ProcessId: 20, ParentProcessId: 10, Name: 'cargo.exe' }), + processEntry({ + ProcessId: 30, + ParentProcessId: 20, + Name: 'api-server.exe', + }), + ]; + expect(selectProcessTreeIds(snapshot, 10).sort((a, b) => a - b)).toEqual([ + 10, 20, 30, + ]); + }); + + test('父链完整时能收全后代', () => { + const snapshot = [ + processEntry({ ProcessId: 10, ParentProcessId: 1, Name: 'cmd.exe' }), + processEntry({ ProcessId: 20, ParentProcessId: 10, Name: 'cargo.exe' }), + processEntry({ + ProcessId: 30, + ParentProcessId: 20, + Name: 'api-server.exe', + }), + processEntry({ ProcessId: 40, ParentProcessId: 1, Name: 'other.exe' }), + ]; + expect(selectProcessTreeIds(snapshot, 10).sort((a, b) => a - b)).toEqual([ + 10, 20, 30, + ]); + }); +}); + +describe('按身份匹配本工作树后端进程', () => { + const apiServerExePath = 'C:\\Repo\\server-rs\\target\\debug\\api-server.exe'; + const spacetimeDataDir = + 'C:\\Repo\\server-rs\\.spacetimedb\\ai-game-creator\\data'; + + test('只收本工作树的 api-server.exe 与同一 data-dir 的 SpacetimeDB', () => { + const snapshot = [ + processEntry({ + ProcessId: 100, + Name: 'api-server.exe', + ExecutablePath: apiServerExePath, + CommandLine: '"server-rs\\target\\debug\\api-server.exe"', + }), + processEntry({ + ProcessId: 101, + Name: 'api-server.exe', + ExecutablePath: 'C:\\Other\\server-rs\\target\\debug\\api-server.exe', + }), + processEntry({ + ProcessId: 102, + Name: 'spacetimedb-standalone.exe', + ExecutablePath: + 'C:\\Users\\me\\SpacetimeDB\\spacetimedb-standalone.exe', + CommandLine: `spacetimedb-standalone.exe start --data-dir ${spacetimeDataDir} --listen-addr 127.0.0.1:3101`, + }), + processEntry({ + ProcessId: 103, + Name: 'spacetimedb-standalone.exe', + CommandLine: + 'spacetimedb-standalone.exe start --data-dir C:\\Other\\data --listen-addr 127.0.0.1:3101', + }), + processEntry({ + ProcessId: 104, + Name: 'node.exe', + CommandLine: `node dev.mjs --spacetime-data-dir ${spacetimeDataDir}`, + }), + ]; + + expect( + selectWorktreeOwnedProcessIds(snapshot, { + apiServerExePath, + spacetimeDataDir, + }).sort((a, b) => a - b), + ).toEqual([100, 102]); + }); + + test('只给 api-server 路径时不会误伤 SpacetimeDB', () => { + const snapshot = [ + processEntry({ + ProcessId: 100, + Name: 'api-server.exe', + ExecutablePath: apiServerExePath, + }), + processEntry({ + ProcessId: 102, + Name: 'spacetimedb-standalone.exe', + CommandLine: `--data-dir ${spacetimeDataDir}`, + }), + ]; + + expect( + selectWorktreeOwnedProcessIds(snapshot, { apiServerExePath }), + ).toEqual([100]); + }); + + test('排除自身进程且路径大小写不敏感', () => { + const snapshot = [ + processEntry({ + ProcessId: 200, + Name: 'api-server.exe', + ExecutablePath: apiServerExePath.toUpperCase(), + }), + ]; + + expect( + selectWorktreeOwnedProcessIds(snapshot, { + apiServerExePath, + selfPid: 200, + }), + ).toEqual([]); + expect( + selectWorktreeOwnedProcessIds(snapshot, { apiServerExePath }), + ).toEqual([200]); + }); + + test('没有可匹配身份时返回空数组', () => { + const snapshot = [processEntry({ ProcessId: 100, Name: 'api-server.exe' })]; + expect(selectWorktreeOwnedProcessIds(snapshot, {})).toEqual([]); + }); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index ef5fc75ea..237a31f31 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -36,6 +36,13 @@ import { resolveApiServerLogFile, resolveClientHost, } from './dev-utils.mjs'; +import { + readWindowsProcessSnapshot, + selectWorktreeOwnedProcessIds, + stopWindowsProcessIds, + stopWindowsProcessTree as stopWindowsProcessTreeById, + stopWindowsWorktreeProcesses, +} from './dev-windows-process.mjs'; // Resolve the workspace from this script's location, not the caller's cwd. // AGC starts this scheduler through `npm --prefix` from its own package; using @@ -917,7 +924,17 @@ class DevService { } async function stopProcess(child, label) { - if (!child || child.exitCode != null || child.signalCode != null) { + if (!child) { + return; + } + + // Windows 下直接子进程是 `cmd.exe /d /s /c` 包装层,Ctrl+C 会先让它退出。 + // 包装层退出不代表 cargo / api-server / spacetime 已经退出,所以这里不能像 + // 以前那样直接 return,必须继续按记录下来的根 PID 清理后代。 + if (child.exitCode != null || child.signalCode != null) { + if (process.platform === 'win32' && Number.isInteger(child.pid)) { + stopWindowsProcessTree(child.pid, label); + } return; } @@ -938,7 +955,7 @@ async function stopProcess(child, label) { try { if (process.platform === 'win32') { - stopWindowsProcessTree(child.pid); + stopWindowsProcessTree(child.pid, label); } else { child.kill('SIGTERM'); } @@ -1135,51 +1152,46 @@ async function stopStaleLocalExternalGenerationWorkers({ return stopped; } -function stopWindowsProcessTree(pid) { - if (!pid) { - return; +function resolveWindowsApiServerExePath(repoRootPath = repoRoot) { + return resolve(repoRootPath, 'server-rs/target/debug/api-server.exe'); +} + +function stopWindowsProcessTree(pid, label = '') { + if (!Number.isInteger(pid)) { + return []; } - spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - [ - '$ErrorActionPreference = "SilentlyContinue"', - '$root = [int]$env:GENARRATIVE_STOP_PID', - '$all = Get-CimInstance Win32_Process', - '$childrenByParent = @{}', - 'foreach ($process in $all) {', - ' $parent = [int]$process.ParentProcessId', - ' if (-not $childrenByParent.ContainsKey($parent)) { $childrenByParent[$parent] = @() }', - ' $childrenByParent[$parent] += [int]$process.ProcessId', - '}', - '$toStop = New-Object System.Collections.Generic.List[int]', - '$queue = New-Object System.Collections.Generic.Queue[int]', - '$queue.Enqueue($root)', - 'while ($queue.Count -gt 0) {', - ' $current = $queue.Dequeue()', - ' $toStop.Add($current)', - ' if ($childrenByParent.ContainsKey($current)) {', - ' foreach ($child in $childrenByParent[$current]) { $queue.Enqueue($child) }', - ' }', - '}', - 'foreach ($id in ($toStop | Select-Object -Unique | Sort-Object -Descending)) {', - ' Stop-Process -Id $id -Force -ErrorAction SilentlyContinue', - '}', - ].join('\n'), - ], - { - env: { - ...process.env, - GENARRATIVE_STOP_PID: String(pid), - }, - stdio: 'ignore', - }, - ); + const stopped = stopWindowsProcessTreeById(pid); + if (stopped.length > 1) { + console.log( + `[dev${label ? `:${label}` : ''}] 已停止进程树: ${stopped.join(', ')}`, + ); + } + return stopped; +} + +// 兜底清扫:包装层(cmd.exe / cargo / npm)可能已经退出,父进程链断掉后按 PID +// 遍历再也找不到真正的服务进程,因此这里按身份再清一次本工作树的后端。 +function stopWindowsWorktreeBackendProcesses({ + spacetimeDataDir = '', + logStream = null, + snapshot = null, +} = {}) { + if (process.platform !== 'win32') { + return []; + } + + const stopped = stopWindowsWorktreeProcesses({ + apiServerExePath: resolveWindowsApiServerExePath(), + spacetimeDataDir, + snapshot, + }); + if (stopped.length > 0) { + const line = `[dev] 已清理本工作树残留后端进程: ${stopped.join(', ')}\n`; + process.stdout.write(line); + logStream?.write(line); + } + return stopped; } class DevRunner { @@ -2478,6 +2490,14 @@ class DevRunner { await this.services.get(serviceName)?.stop(); } + // 复用别人启动的 SpacetimeDB 时不能连带杀掉对方的 standalone;只有本进程 + // 自己拉起的 standalone 才属于本次退出的清理范围。 + stopWindowsWorktreeBackendProcesses({ + spacetimeDataDir: this.state.spacetimeReused + ? '' + : this.options.spacetimeDataDir, + }); + process.exit(code); } } @@ -2487,47 +2507,15 @@ function stopExistingWindowsApiServer(logStream) { return; } - const apiServerExePath = resolve( - repoRoot, - 'server-rs/target/debug/api-server.exe', - ); - const command = [ - '$ErrorActionPreference = "Continue"', - '$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)', - '$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {', - ' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)', - '}', - 'foreach ($process in $processes) {', - ' try {', - ' Stop-Process -Id $process.Id -Force -ErrorAction Stop', - ' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue', - ' Write-Output $process.Id', - ' } catch {', - ' Write-Error "[dev:api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', - ' }', - '}', - 'exit 0', - ].join('\n'); + const apiServerExePath = resolveWindowsApiServerExePath(); + const snapshot = readWindowsProcessSnapshot(); + const processIds = selectWorktreeOwnedProcessIds(snapshot, { + apiServerExePath, + }); + const stopped = stopWindowsProcessIds(processIds, { waitForExitMs: 5000 }); - const result = spawnSync( - 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], - { - encoding: 'utf8', - env: { - ...process.env, - GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath, - }, - }, - ); - - if (result.error) { - throw result.error; - } - - const output = String(result.stdout ?? '').trim(); - if (output) { - const line = `[dev:api-server] 已停止旧 api-server 进程: ${output}\n`; + if (stopped.length > 0) { + const line = `[dev:api-server] 已停止旧 api-server 进程: ${stopped.join(', ')}\n`; process.stdout.write(line); logStream?.write(line); } @@ -3505,8 +3493,10 @@ export { resolveDevStackStatePath, resolveLocalSpacetimeApiIdentityPath, resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath, + resolveWindowsApiServerExePath, shouldAcceptWatchEvent, shouldTrustExistingSpacetimeToken, + stopWindowsWorktreeBackendProcesses, }; async function main() {