Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions apps/desktop/src/main/__tests__/relaunchBusyActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* 手动更新重启的阻断判定 —— 六个活动来源的聚合与 fail-closed。
*
* 这个判定服务的是不可撤销的破坏性动作(forceQuit → process.exit(0)),所以两条不变量:
* 1. **任一来源报忙就是忙**(六源等价,没有主次);
* 2. **任一来源读不出来也算忙**(「无法确认」不等于「确认没有」)。
* 每个来源各有一条独立用例 —— 少一条就意味着少覆盖一个真实的静默中断入口。
*
* 有两个来源特别容易被漏,各自都有独立证据:scheduler 的 script 模式 / pre-run hook 阶段不
* 创建 session;run_in_background 的 Bash 不调模型(点不亮 loopback 信号)也不折算 running。
* 两者都只能单独查。
*/

import { describe, expect, it } from 'vitest';

import { evaluateRelaunchBusyActivity } from '../relaunchBusyActivity.js';

const idle = {
anySessionInTurn: () => false,
listClaudeBackgroundSessions: () => [] as readonly string[],
anyGhostSessionBusy: () => false,
anyBackgroundBashRunning: () => false,
anyCindySlotJobRunning: () => false,
anySchedulerRunRunning: async () => false,
};

describe('evaluateRelaunchBusyActivity', () => {
it('全部空闲时不阻断', async () => {
await expect(evaluateRelaunchBusyActivity(idle)).resolves.toEqual({ busy: false, reasons: [] });
});

it('逻辑 turn 在跑时阻断', async () => {
const r = await evaluateRelaunchBusyActivity({ ...idle, anySessionInTurn: () => true });
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['session-in-turn']);
});

it('Claude 后台活动(turn 已结束但仍在调模型)时阻断', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
listClaudeBackgroundSessions: () => ['sess-a'],
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['claude-background-activity']);
});

it('Ghost card-action 后台活动时阻断(它完全不经 LLM turn)', async () => {
const r = await evaluateRelaunchBusyActivity({ ...idle, anyGhostSessionBusy: () => true });
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['ghost-background-activity']);
});

it('多个来源同时命中时全部记进 reasons(不短路,便于诊断)', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
anySessionInTurn: () => true,
listClaudeBackgroundSessions: () => ['sess-a'],
anyGhostSessionBusy: () => true,
anyBackgroundBashRunning: () => true,
anyCindySlotJobRunning: () => true,
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual([
'session-in-turn',
'claude-background-activity',
'ghost-background-activity',
'background-bash',
'cindy-slot-async-job',
]);
});

it.each([
['anySessionInTurn', 'session-in-turn'],
['listClaudeBackgroundSessions', 'claude-background-activity'],
['anyGhostSessionBusy', 'ghost-background-activity'],
['anyBackgroundBashRunning', 'background-bash'],
['anyCindySlotJobRunning', 'cindy-slot-async-job'],
] as const)('%s 抛错时 fail closed 并标记探针失败', async (key, label) => {
const r = await evaluateRelaunchBusyActivity({
...idle,
[key]: () => { throw new Error('probe exploded'); },
});
expect(r.busy).toBe(true);
// 标签区分「真的有活动」与「探针坏了」—— 两者都拦,但排查方向完全不同。
expect(r.reasons).toEqual([`${label}-probe-failed`]);
});

it('一个来源抛错不影响其它来源继续被读到', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
anySessionInTurn: () => { throw new Error('probe exploded'); },
anyGhostSessionBusy: () => true,
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['session-in-turn-probe-failed', 'ghost-background-activity']);
});

// 后台 Bash(run_in_background):不调模型 → 点不亮 Claude 后台活动信号;不折算 running →
// 逻辑 turn 也看不到。重启会直接杀掉 dev server / 长跑脚本这类子进程。
it('后台 Bash 任务在跑时阻断(其它内存源全空闲也要拦)', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
anyBackgroundBashRunning: () => true,
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['background-bash']);
});

// Cindy slot 异步代办(mode:'submit' 的图片 / 视频生成):void runExec() 脱链执行,只记在
// GhostCindySlot 私有 jobs Map,发起 turn 结束后其它来源全看不到。
it('Cindy slot 异步代办在途时阻断', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
anyCindySlotJobRunning: () => true,
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['cindy-slot-async-job']);
});

// scheduler:script 模式与 pre-run hook 阶段的 run 都不创建 session,内存探针看不到 ——
// 漏掉它意味着重启会让 run 来不及落终态、脚本子进程变成失联进程。
it('scheduler 有 run 在跑时阻断(内存源全空闲也要拦)', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
anySchedulerRunRunning: async () => true,
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['scheduler-run-running']);
});

it('scheduler 查询 reject 时 fail closed', async () => {
const r = await evaluateRelaunchBusyActivity({
...idle,
anySchedulerRunRunning: async () => { throw new Error('sqlite is gone'); },
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['scheduler-run-probe-failed']);
});

it('内存源已命中时不再查 scheduler(省一次 SQLite 往返)', async () => {
let called = 0;
const r = await evaluateRelaunchBusyActivity({
...idle,
anySessionInTurn: () => true,
anySchedulerRunRunning: async () => { called += 1; return false; },
});
expect(r.busy).toBe(true);
expect(called).toBe(0);
});

it('查库期间新起的 turn 会被二次采样抓到', async () => {
let turnRunning = false;
const r = await evaluateRelaunchBusyActivity({
...idle,
// 第一次读为空闲;scheduler 查询期间 turn 起来,复采时才为 true。
anySessionInTurn: () => turnRunning,
anySchedulerRunRunning: async () => { turnRunning = true; return false; },
});
expect(r.busy).toBe(true);
expect(r.reasons).toEqual(['session-in-turn']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 手动重启阻断查询的授权边界。
*
* 这个 handler 读的是**全局**会话 / Claude / Ghost / scheduler 活动态。带 preload 的窗口被
* 导航到不可信内容、WebView、子 frame 都能发 Electron IPC,不校验 sender 就等于把「本机现在
* 在跑什么」暴露给它们。按 docs/dev-rules/electron-security-and-process-boundaries.md §5,
* 新增 handler 不得以「旧代码没校验」为由省略 sender 验证 —— 这里把它钉住。
*
* 另一条要钉的:**断言必须发生在读取任何跟踪器之前**。先读后拦仍然会碰全局状态(也可能被
* 时序侧信道观察到),所以拒绝路径下的来源读取次数必须是 0。
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';

const h = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
trusted: true,
reads: 0,
removed: [] as string[],
}));

// 仿 Electron 的真实行为:同一 channel 第二次 handle 直接抛。幂等注册要靠 removeHandler,
// 用一个只会 set 的假 Map 是测不出来的。
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
if (h.handlers.has(channel)) {
throw new Error(`Attempted to register a second handler for '${channel}'`);
}
h.handlers.set(channel, handler);
}),
removeHandler: vi.fn((channel: string) => {
h.removed.push(channel);
h.handlers.delete(channel);
}),
},
}));
vi.mock('../logger', () => ({
createLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }),
}));
vi.mock('../security/trustedAppRenderer', () => ({
assertTrustedAppRendererEvent: () => {
if (!h.trusted) throw new Error('[PERMISSION_DENIED] 此操作只能从 Cindy 主页面发起');
},
}));

import {
RELAUNCH_BLOCKING_ACTIVITY_CHANNEL,
registerRelaunchBusyActivityIpc,
} from '../relaunchBusyActivityIpc.js';

/** 每个来源都记一次读取,用来证明拒绝路径下一个都没被碰。 */
function countingSources(busy: boolean) {
return () => ({
anySessionInTurn: (): boolean => { h.reads += 1; return busy; },
listClaudeBackgroundSessions: (): readonly string[] => { h.reads += 1; return []; },
anyGhostSessionBusy: (): boolean => { h.reads += 1; return false; },
anyBackgroundBashRunning: (): boolean => { h.reads += 1; return false; },
anyCindySlotJobRunning: (): boolean => { h.reads += 1; return false; },
anySchedulerRunRunning: async (): Promise<boolean> => { h.reads += 1; return false; },
});
}

/** handler 只把 event 交给 sender 断言(已被 mock),不读它的字段。 */
const fakeEvent = {} as never;

beforeEach(() => {
h.handlers.clear();
h.trusted = true;
h.reads = 0;
h.removed = [];
});

describe('registerRelaunchBusyActivityIpc', () => {
it('注册在约定的 channel 上', () => {
registerRelaunchBusyActivityIpc(countingSources(false));
expect(h.handlers.has(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)).toBe(true);
});

it('可信 sender:正常返回判定结果', async () => {
registerRelaunchBusyActivityIpc(countingSources(true));
const handler = h.handlers.get(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)!;
await expect(handler(fakeEvent)).resolves.toBe(true);
expect(h.reads).toBeGreaterThan(0);
});

// splash 首次失败会整段重试注册(bootstrap-electron 那个 catch 明写「下次 splash retry
// 再尝试」),此时 makerIpcsRegistered 仍是 false。若不幂等,第二次 handle 抛出的异常会
// 把排在后面的全部 maker IPC 注册一起掀掉,且每次重试都卡在同一行。
it('重复注册不抛错(splash 重试路径),且 handler 仍然可用', async () => {
registerRelaunchBusyActivityIpc(countingSources(true));
expect(() => registerRelaunchBusyActivityIpc(countingSources(true))).not.toThrow();
expect(h.removed).toContain(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL);

const handler = h.handlers.get(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)!;
await expect(handler(fakeEvent)).resolves.toBe(true);
});

it('不可信 sender(WebView / 子 frame / 未登记窗口):拒绝,且一个来源都不读', async () => {
h.trusted = false;
registerRelaunchBusyActivityIpc(countingSources(true));
const handler = h.handlers.get(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)!;
await expect(handler(fakeEvent)).rejects.toThrow('PERMISSION_DENIED');
// 断言在读取之前 —— 拒绝路径下不该碰到任何全局跟踪器。
expect(h.reads).toBe(0);
});
});
28 changes: 28 additions & 0 deletions apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,14 +610,18 @@ import {
import { installNewMakerWindowShortcut } from './app-shortcuts/new-maker-window-shortcut.js';
import { registerLayoutIpc } from './layout/index.js';
import {
getGhostCindySlot,
getGhostManager,
getGhostSessionActivityTracker,
isGhostAvailableForActiveSession,
refreshGhostLocalization,
registerGhostIpc,
setGhostsChangedObserver,
suspendAllGhosts,
waitForGhostMutations,
} from './cindy-brain/index.js';
import { listActiveClaudeBackgroundActivitySessions } from './maker-host/claude-session-background-activity.js';
import { registerRelaunchBusyActivityIpc } from './relaunchBusyActivityIpc.js';
import { getGhostSetupChangeBus } from './cindy-brain/ghostSetupChangeBus.js';
import { getGhostSetupInteractionBridge } from './cindy-brain/ghostSetupInteractionBridge.js';
import { registerPluginMarketIpc } from './plugin-market/registerIpc.js';
Expand Down Expand Up @@ -3811,6 +3815,30 @@ const registerIpcHandlers = () => {
readScheduleBusy: () => readUpdateRelaunchScheduleBusy(getScheduleStorageIfInitialized()),
});
});
// 手动更新重启(侧栏 UpdateBanner)的阻断判定。与上面那个**无人值守**探针刻意分开:
// 无人值守要连「有远程设备在看会话」都让路,手动重启是用户主动发起的,只该关心
// 「这一下会打断哪些正在跑的活」。四个活动来源的聚合与 fail-closed 口径见
// relaunchBusyActivity.ts,handler 与 sender 断言见 relaunchBusyActivityIpc.ts;
// 这里只提供来源 —— 本进程唯一能同时看到 maker、cindy-brain 与 scheduler 三侧的位置。
//
// 不进 device-link allowlist:updater 类 channel 按 allowlist 顶部注释属「永不放行」,
// 且远程控制端不会代替用户点被控端的更新重启。
registerRelaunchBusyActivityIpc(() => ({
Comment thread
dashhuang marked this conversation as resolved.
anySessionInTurn: () => anySessionInTurn(getMakerCore()),
listClaudeBackgroundSessions: () => listActiveClaudeBackgroundActivitySessions(),
anyGhostSessionBusy: () => getGhostSessionActivityTracker().anySessionBusy(),
Comment thread
dashhuang marked this conversation as resolved.
Comment thread
dashhuang marked this conversation as resolved.
// run_in_background 的 Bash 不调模型、也不折算 running,前两个来源都看不到它。
anyBackgroundBashRunning: () =>
getMakerCore()
.listActiveSessions()
.some((session) => session.listBackgroundTasks().length > 0),
// Cindy slot 的全部在途工作:异步(mode:'submit' 的图 / 视频)与同步代办各自独立记账,
// 都可能不伴随任何 turn 或 card-action,只查一半就漏一半。
anyCindySlotJobRunning: () => getGhostCindySlot().anyInflightWork(),
// script 模式 / pre-run hook 阶段的 run 不创建 session,内存来源看不到它们。
anySchedulerRunRunning: () =>
readUpdateRelaunchScheduleBusy(getScheduleStorageIfInitialized()),
}));
// getMakerCore() 首次调用触发 Maker 构造,同时发起自定义 MCP 初始加载。
// await 确保第一个会话的 mcpProviders 数组已填入已保存的自定义 MCP(P2 冷启动竞态修复)。
getMakerCore();
Expand Down
Loading
Loading