修复Linux进程树探活与Runtime默认栈溢出
Project CI / Repository checks (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Successful in 3m32s
Project CI / Native shell tests (pull_request) Failing after 7m38s

Linux进程组探活忽略已终止但未回收的zombie成员

pending恢复与后续队列统一跨越装箱的Tokio任务边界

补充Tauri生命周期和Runtime默认栈回归测试与文档
This commit is contained in:
2026-08-03 13:34:25 +08:00
parent b95da30721
commit bda0d0d398
7 changed files with 215 additions and 37 deletions
@@ -1,8 +1,8 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -300,16 +300,100 @@ function stopChild(child, signal = 'SIGTERM') {
}
}
function isProcessGroupAlive(processGroupId, killImpl = process.kill) {
function parseLinuxProcessStat(value) {
const commandEnd = value.lastIndexOf(')');
if (commandEnd < 0) {
return null;
}
const fields = value
.slice(commandEnd + 1)
.trim()
.split(/\s+/);
if (fields.length < 3 || fields[0].length !== 1) {
return null;
}
const processGroupId = Number(fields[2]);
if (!Number.isInteger(processGroupId)) {
return null;
}
return { state: fields[0], processGroupId };
}
function readLinuxProcessGroupRunning(
processGroupId,
{
procRoot = '/proc',
readdirImpl = readdirSync,
readFileImpl = readFileSync,
} = {},
) {
let entries;
try {
entries = readdirImpl(procRoot, { withFileTypes: true });
} catch {
return null;
}
let inspectedProcess = false;
for (const entry of entries) {
const name = typeof entry === 'string' ? entry : entry.name;
if (!/^\d+$/.test(name)) {
continue;
}
if (typeof entry !== 'string' && !entry.isDirectory()) {
continue;
}
let stat;
try {
stat = readFileImpl(join(procRoot, name, 'stat'), 'utf8');
} catch (error) {
// 进程可能在枚举后立刻退出;继续检查同组的其它成员。
if (error?.code === 'ENOENT' || error?.code === 'ESRCH') {
continue;
}
return null;
}
const parsed = parseLinuxProcessStat(stat);
if (!parsed) {
return null;
}
inspectedProcess = true;
if (
parsed?.processGroupId === processGroupId &&
!['Z', 'X', 'x'].includes(parsed.state)
) {
return true;
}
}
return inspectedProcess ? false : null;
}
function isProcessGroupRunning(
processGroupId,
{
platform = process.platform,
killImpl = process.kill,
readLinuxProcessGroup = readLinuxProcessGroupRunning,
} = {},
) {
if (!Number.isInteger(processGroupId)) {
return false;
}
try {
killImpl(-processGroupId, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
if (platform !== 'linux') {
return true;
}
try {
// Linux 的 kill(-PGID, 0) 会把尚未被容器 PID 1 回收的 zombie 也视为
// 存在;zombie 已不能执行代码,不应让有界清理被误判为失败。
return readLinuxProcessGroup(processGroupId) ?? true;
} catch {
return true;
}
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
@@ -415,7 +499,7 @@ async function terminateChildTree(
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
gracefulTimeoutMs,
)
) {
@@ -430,7 +514,7 @@ async function terminateChildTree(
}
}
const stopped = await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
forceTimeoutMs,
);
return { stopped, forced: true };
@@ -600,8 +684,10 @@ export {
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
isProcessGroupRunning,
preflightExistingVite,
readChildFailure,
readLinuxProcessGroupRunning,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
@@ -1,5 +1,28 @@
use super::*;
async fn run_after_pending_stack_boundary<T>(
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
) -> T
where
T: Send + 'static,
{
// Debug builds give pending execution, the background main loop, and queue draining large
// poll frames. The boxed future keeps that large frame out of its caller before a joined child
// task gives it an independent poll boundary. JoinSet still aborts the child if its parent
// continuation is dropped.
let mut tasks = tokio::task::JoinSet::new();
tasks.spawn(future);
match tasks
.join_next()
.await
.expect("pending continuation task must exist")
{
Ok(output) => output,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => panic!("pending continuation task was cancelled: {error}"),
}
}
async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
root: PathBuf,
agent_id: String,
@@ -7,11 +30,7 @@ async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
runtime: AgentRuntimeState,
continuation: AgentRuntimeContinuationContext,
) -> AgentBackgroundTaskOutcome {
// Debug builds give both pending execution and the background main loop large poll frames.
// A joined child task prevents those frames from sharing one Tokio worker stack while
// JoinSet still aborts the child if its parent continuation is dropped.
let mut tasks = tokio::task::JoinSet::new();
tasks.spawn(async move {
run_after_pending_stack_boundary(Box::pin(async move {
run_game_creator_agent_background_task_with_context(
root,
agent_id,
@@ -20,19 +39,36 @@ async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
continuation,
)
.await
});
match tasks
.join_next()
.await
.expect("pending continuation task must exist")
{
Ok(outcome) => outcome,
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
Err(error) => panic!("pending continuation task was cancelled: {error}"),
}
}))
.await
}
async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root: PathBuf,
agent_id: String,
) {
run_after_pending_stack_boundary(Box::pin(async move {
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
}))
.await;
}
pub(crate) async fn continue_game_creator_agent_pending_tool_action(
root: PathBuf,
agent_id: String,
pending: AgentRuntimePendingToolAction,
runtime: AgentRuntimeState,
) {
run_after_pending_stack_boundary(Box::pin(async move {
continue_game_creator_agent_pending_tool_action_within_stack_boundary(
root, agent_id, pending, runtime,
)
.await;
}))
.await;
}
async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
root: PathBuf,
agent_id: String,
mut pending: AgentRuntimePendingToolAction,
@@ -102,7 +138,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
)
.await;
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root, agent_id,
)
.await;
}
return;
}
@@ -111,7 +150,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
if !has_persisted_terminal_observation
&& stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime)
{
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
.await;
return;
}
if let Err(error) = validate_agent_runtime_pending_context(&root, &runtime, &pending) {
@@ -373,14 +413,18 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
&pending,
&observation,
) {
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root, agent_id,
)
.await;
}
return;
}
if observation.is_waiting_for_confirmation()
&& stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime)
{
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
.await;
return;
}
if observation.is_waiting_for_confirmation() {
@@ -557,7 +601,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
}),
);
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
.await;
return;
}
if !auto_execution {
@@ -734,7 +779,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
runtime,
&format!("恢复 Agent Runtime context bundle 失败:{error}"),
);
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root, agent_id,
)
.await;
return;
}
};
@@ -785,7 +833,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
runtime,
&format!("持久化 Agent Runtime context bundle 失败:{error}"),
);
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
root, agent_id,
)
.await;
return;
}
};
@@ -828,7 +879,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
)
.await;
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
.await;
}
}
@@ -7,7 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
import {
ensureBackend,
isProcessGroupRunning,
preflightExistingVite,
readLinuxProcessGroupRunning,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
@@ -154,6 +156,40 @@ describe('AI 游戏创作启动子进程生命周期', () => {
}
});
test('Linux 进程组探活忽略已退出但尚未回收的 zombie', () => {
const stats = new Map([
['/proc/4822/stat', '4822 (node worker) Z 1 4821 4821'],
['/proc/7001/stat', '7001 (other) S 1 7001 7001'],
]);
const readLinuxGroup = () =>
readLinuxProcessGroupRunning(4821, {
readdirImpl: () => [
{ name: '4822', isDirectory: () => true },
{ name: '7001', isDirectory: () => true },
],
readFileImpl: (path) => stats.get(path),
});
expect(readLinuxGroup()).toBe(false);
expect(
isProcessGroupRunning(4821, {
platform: 'linux',
killImpl: vi.fn(),
readLinuxProcessGroup: readLinuxGroup,
}),
).toBe(false);
expect(
isProcessGroupRunning(4821, {
platform: 'linux',
killImpl: vi.fn(),
readLinuxProcessGroup: () => null,
}),
).toBe(true);
stats.set('/proc/4822/stat', '4822 (node worker) R 1 4821 4821');
expect(readLinuxGroup()).toBe(true);
});
test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => {
const child = Object.assign(new EventEmitter(), {
exitCode: null,
@@ -5,7 +5,11 @@ import { join } from 'node:path';
import { describe, expect, test, vi } from 'vitest';
import { spawnChild, terminateChildTree } from '../scripts/start-dev-stack.mjs';
import {
isProcessGroupRunning,
spawnChild,
terminateChildTree,
} from '../scripts/start-dev-stack.mjs';
import {
buildTauriArguments,
runTauriDev,
@@ -200,7 +204,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
});
expect(result).toBe(42);
expect(() => process.kill(-cliChild.pid, 0)).toThrow();
expect(isProcessGroupRunning(cliChild.pid)).toBe(false);
} finally {
if (Number.isInteger(cliChild?.pid)) {
try {
@@ -3990,8 +3990,8 @@
- 现象:Supervisor collaboration durable isolated spawn 恢复测试在默认 Tokio worker 栈下稳定 `stack overflow`;单独运行同样失败,提高 `RUST_MIN_STACK` 后通过。
- 原因:不是业务递归。debug 构建中 pending action continuation、后台 task queue 和 Agent 主循环各自形成大型 async poll frame;恢复路径在同一次 poll 调用链直接进入下一层状态机,累计超过 worker 默认栈。
- 处理: pending continuation 与后台主循环之间建立独立 Tokio task 轮询边界,使 pending poll 先退栈后再轮询主循环。边界必须保留结构化取消语义;当前使用 `JoinSet`,父 continuation 被丢弃时同步 abort 子任务。不得只增大 CI 的 `RUST_MIN_STACK`,否则生产默认栈仍可能崩溃。
- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖 policy batch 全组 pending/cancellation 回归,证明恢复不重复生成 isolated spawn、父任务取消不遗留后台子任务。
- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。边界必须保留结构化取消语义;当前使用 boxed future 与 `JoinSet`,父 continuation 被丢弃时同步 abort 子任务。不得只增大 CI 的 `RUST_MIN_STACK`,否则生产默认栈仍可能崩溃。
- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖 policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`
## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离
@@ -4027,8 +4027,8 @@
- 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。
- 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。
- 处理:`dev``game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILLWindows 固定调用 `taskkill /PID <pid> /T /F``start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。
- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
- 处理:`dev``game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILLWindows 固定调用 `taskkill /PID <pid> /T /F`Linux 容器的 PID 1 可能不及时回收已退出的孤儿后代,`kill(-PGID, 0)` 会继续命中 zombie;Linux 探活必须扫描 `/proc/<pid>/stat`,只把同 PGID 的非 zombie 成员视为仍在运行,`/proc` 不可读时继续失败关闭。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。
- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL、Linux 同组只剩 zombie 时视为已停止,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts``apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`
## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03
@@ -765,7 +765,7 @@ game-project/
- 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。
- 2026-07-17 起,同一 Runtime 文档的“V1.32 Runtime 强制 Supervisor 协作合同”作为 mixed swarm 可靠性事实源。项目可用 `.agent/collaboration-policy.json` 约束首波 static/isolated 模式、数量和 required static AgentRuntime 在任何 child 副作用前整批校验并把合同指纹固化进 Provider batch v2。当前父 run 一旦形成 delivery/group,正式 `project-supervisor` 默认只负责编排、状态认领和验证,不再直接执行项目 mutation;专业 Agent/isolated child 权限与唯一 Supervisor 最终回复边界保持不变。
- 2026-07-17 V1.32 最终代码已完成独立真实 Provider PASS:首批 mixed batch、三 isolated child、Runner 强杀恢复、专业返工、宿主验证、唯一最终回复与零重复/残留/泄漏同时成立。真实报告计数、隔离重试配置和仍待收敛的 tool-plan repair 成本统一以 Runtime 文档 V1.32 章节与共享决策记录为准。
- 2026-08-03 恢复执行补充约束:pending action continuation 进入后台主循环必须跨越独立 Tokio task 轮询边界,不能让 pending executor、task queue 与 Agent 主循环的大型 async poll frame 在同一 worker 调用栈连续嵌套。边界必须随父 continuation 取消子任务并保持 durable action、batch、run/session 身份及恢复防重语义当前使用 `JoinSet` 承担结构化取消。CI 和生产均使用默认 worker 栈验证,不以提高 `RUST_MIN_STACK` 代替代码边界。
- 2026-08-03 恢复执行补充约束:整个 pending action continuation、它进入后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,不能让 pending executor、task queue 与 Agent 主循环的大型 async poll frame 在同一 worker 调用栈连续嵌套。边界输入必须先装箱,避免泛型 helper 在真正 spawn 前仍把大型 future 保留在调用方 async frame;边界同时必须随父 continuation 取消子任务并保持 durable action、batch、run/session 身份及恢复防重语义当前使用 boxed future 与 `JoinSet` 承担该约束。CI 和生产均使用默认 worker 栈验证,不以提高 `RUST_MIN_STACK` 代替代码边界。
- 2026-07-18 起,同一 Runtime 文档的“V1.34 动态隔离子 Agent writeScopes 命令绕过封堵”作为 isolated child 的现行能力事实源。在 scope-aware OS sandbox 完成前,动态 child 无条件禁用 `project.verify / project.git_commit / command.exec / command.start / command.stdin / preview.start / agent.delegate / agent.spawn_isolated / project.restore / agent.schedule_ready / canvas.asset_generate / task.create / task.update / blackboard.write` 和全部 MCP;原生工具策略统一显示 `denied`,模板、项目 policy 与用户确认均不能放宽。保留固定只读 `command.run_limited`、同身份 `command.output_read / command.poll / command.terminate`、既有预览的 `preview.validate`,以及严格位于 `writeScopes` 内的 `file.write / file.patch / file.delete / project.patchset`
- V1.34 的新单动作在 confirmation 和 OS launcher 前拒绝;新多 action 原生 batch 只要含一个 denied member 就在独立 pending-action sidecar、confirmation、OS spawn、revision 和任何成员项目副作用前整批 abort,只保留 `aborted / nextActionIndex=0` batch 事实。旧 pending / approval / batch 真正进入执行器时仍重新应用当前 child 边界,旧 executing 未知结果继续进入既有 reconciliation。该安全收紧由恶意 sibling 写入、策略快照、batch、旧 pending 执行器重验和 isolated/mixed/collaboration/provider-batch 回归证明;不因本切片重跑已通过且 isolated mutation 为 0 的 V1.31/V1.32 外部 Provider suite。通用命令只有在后续 scope-aware OS sandbox 对所有后代强制同一 `writeScopes` 并通过独立决策与测试后才可重新评估开放。
- 2026-07-18 起,同一 Runtime 文档的“V1.35 多 ready isolated all-join 原子认领与恢复”作为 `agent.run_status` 同父 run 多 group 认领的现行事实源。Runtime 按 `delegationGroupId` 排序并一次性预取全部 join 锁;任一后续锁忙时保持零 delivery mutation、零 claim sidecar。全锁就绪后,同一 action 的 durable claim journal 按 `prepared -> committed -> observed` 推进;部分 commit 或 Runner 恢复只能复用该 journal 幂等补齐。只认领可完整放入优先 `readyIsolatedJoins` 观察预算的有序前缀,未观察旧 claim 可由后续 action 完整重放,但不创建第二份 isolated claim。每个 claimed delivery 必须由匹配原 action/group 的 journal 覆盖;无 journal 的旧 delivery 每轮只迁移一个原 action,已有 journal 不得扩写或状态倒退,跨 action group 归属冲突失败关闭。成功 observation 写入 pending sidecar 后只能把本轮完整输出的 claim 标记 `observed`,任一未观察或无 journal claim 继续阻断 finalization;每个 group 审计按 `actionId + delegationGroupId` 唯一,并在 Agent DB 锁内修复 torn tail、全量核对后幂等追加。
@@ -60,7 +60,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 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 为空。