合并远端 master 到 codex/agc-agent-plugins

合并远端 master 的 AGC 启动、进程复用与项目写锁回收修复
合并远端 master 的开发脚本、运维文档与项目记忆更新
保留本分支的通用插件宿主、插件宿主服务与 AGC Plugin SDK 改动
This commit is contained in:
2026-09-10 16:33:42 +08:00
14 changed files with 1859 additions and 240 deletions
@@ -1714,10 +1714,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',
@@ -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,
};
+219 -78
View File
@@ -2005,8 +2005,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::<Vec<_>>();
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::<Vec<_>>();
// 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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
std::env::var_os("APPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
fn platform_config_root() -> Option<PathBuf> {
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<PathBuf> {
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<Option<PathBuf>>);
impl StartupLogSlot {
fn new(path: Option<PathBuf>) -> 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<PathBuf> {
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)]
@@ -2333,8 +2461,13 @@ fn main() {
}
let mut tauri_context = tauri::generate_context!();
let startup_log: Option<PathBuf> = 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())
@@ -2346,37 +2479,27 @@ fn main() {
.manage(PluginHost::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
})?;
if let Err(error) = app.state::<PluginHost>().initialize(&config_dir) {
@@ -2388,20 +2511,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(
@@ -2409,20 +2525,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(
@@ -2432,23 +2541,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(
@@ -2456,12 +2558,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![
@@ -2635,19 +2733,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);
@@ -2677,6 +2769,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!(
@@ -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<bool> {
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<bool> {
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<bool> {
None
}
fn project_write_lock_owner_pid(path: &Path) -> Option<u64> {
fs::read_to_string(path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&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<u64> {
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::<FileTime>() };
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
// 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<u64> {
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::<u64>()
.ok()?;
let boot_time = fs::read_to_string("/proc/stat")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("btime "))?
.trim()
.parse::<u64>()
.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<u64> {
None
}
/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照:
/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息
/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。
#[derive(Debug, Clone)]
pub(crate) struct ProjectWriteLockSnapshot {
content: Vec<u8>,
pid: Option<u64>,
created_at: Option<u64>,
process_started_at: Option<u64>,
}
impl ProjectWriteLockSnapshot {
pub(crate) fn read(path: &Path) -> Option<Self> {
let content = fs::read(path).ok()?;
let payload = serde_json::from_slice::<serde_json::Value>(&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::<serde_json::Value>(&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<u64> {
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<u64>,
now: u64,
) -> Option<u64> {
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<u64>,
now: u64,
process_is_alive: impl Fn(u64) -> Option<bool>,
process_started_at: impl Fn(u64) -> Option<u64>,
) -> 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<ProjectWriteLockSnapshot> {
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::<serde_json::Value>(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<bool, String> {
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) => {
@@ -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;
@@ -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<u8> {
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();
}
@@ -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 游戏创作启动子进程生命周期', () => {