From 5ba6afdf105af4b76083e47eeaa661a763b1dcdb Mon Sep 17 00:00:00 2001 From: PuppetWen Date: Fri, 31 Jul 2026 06:49:47 -0400 Subject: [PATCH] fix: stabilize shutdown and idle resource usage --- CHANGELOG.md | 46 ++ README.md | 14 +- README.zh-CN.md | 109 ++++ electron/main.ts | 100 +++- electron/search.ts | 136 ++++- electron/system.ts | 113 ++-- native/indexer/Cargo.lock | 2 +- native/indexer/Cargo.toml | 3 +- native/indexer/src/main.rs | 725 +++++++++++++++++------- native/updater/Cargo.lock | 2 +- native/updater/Cargo.toml | 2 +- package-lock.json | 4 +- package.json | 5 +- scripts/smoke-app-performance.mjs | 262 +++++++++ scripts/smoke-auto-update.mjs | 20 +- scripts/smoke-clean-exit.mjs | 99 ++++ scripts/smoke-installed-update.mjs | 56 +- scripts/smoke-tray-idle-performance.mjs | 202 +++++++ src/App.tsx | 46 +- src/components/BackgroundFX.tsx | 45 +- src/lib/api.ts | 14 +- src/main.tsx | 19 +- src/styles.css | 36 +- src/views/SettingsView.tsx | 2 +- tests/update-download.test.ts | 8 +- 25 files changed, 1725 insertions(+), 345 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 README.zh-CN.md create mode 100644 scripts/smoke-app-performance.mjs create mode 100644 scripts/smoke-clean-exit.mjs create mode 100644 scripts/smoke-tray-idle-performance.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2cdeaa2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# 更新记录 + +本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在 +[GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 + +## 0.0.3 + +### 修复 + +- 修复从托盘菜单退出时,索引器管道恰好关闭导致 Electron 主进程出现 `write EPIPE` 异常的问题。 +- 退出时先停止新请求、取消重启计时器、关闭标准输入、等待索引器退出,超时后再定向结束子进程。 +- 修复索引器标准输入流缺少错误监听器导致管道错误升级为未捕获异常的问题。 + +### 性能 + +- 主窗口在 DOM 可显示后立即呈现,不再只等待较晚的 `ready-to-show`。 +- 全盘索引延迟到首帧之后启动,并在缓存解析与全量构建期间使用较低进程优先级。 +- 移除索引辅助结构构建中的数百万次临时字符串和字符数组分配,保持相同匹配算法。 +- 修复 Windows 模糊删除事件被误判为目录树删除、反复全表扫描约数百万条索引的问题。 +- 文件变更按路径合并、批量持久化;真实目录删除改用前缀墓碑,不再把整个缓存重新载入工作集。 +- 合并磁盘发现与文件系统查询,空间总览从多个 PowerShell 进程降低为一次短时缓存查询。 +- 搜索、归属地图、分析、迁移、历史和设置页面改为按需加载。 +- 三套动态背景只在交互期高帧率运行,闲置时低频刷新;总览装饰改为有限交互动画,最小化后完全暂停。 +- 关闭到通知区域后销毁并按需重建界面渲染窗口,后台仅保留索引、监听、快捷键和托盘能力。 +- 移除与现有主题材质重复的原生 Mica 合成层,避免静止页面持续占用 GPU 进程。 +- 自动更新检查延后到首屏稳定后执行。 + +### 文档与验证 + +- 增加独立的 `README.zh-CN.md` 中文使用说明。 +- 增加本更新记录文档。 +- 增加应用启动、前台/托盘空闲资源和干净退出回归测试。 +- 安装回归会检查 CDriveShiftAI 自身进程是否完整退出;项目不依赖也不会主动调用 .NET `InstallUtil.exe`。 + +## 0.0.2 + +- 启用安装版静默更新和便携版原路径自替换。 +- 增加断点续传、自动重试、SHA-512 校验、失败回滚和启动后清理。 +- 增加主题化更新进度界面。 +- 增加可配置鼠标按键长按唤起独立极速搜索。 +- 增强全局快捷键冲突检测。 + +## 0.0.1 + +- 首个公开测试版本。 +- 提供全盘名称搜索、指定目录内容索引、目录归属分析、跨盘安全迁移与迁移恢复。 diff --git a/README.md b/README.md index 05792b8..8954e6e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CDriveShiftAI +[简体中文使用说明](README.zh-CN.md) · [更新记录](CHANGELOG.md) + CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于: - 搜索本机所有固定磁盘中的文件名、目录名和文件夹名; @@ -10,7 +12,7 @@ CDriveShiftAI 是一个 Windows 桌面端磁盘整理工具,用于: 当前版本完全使用自研索引管线,不调用 Everything。 -当前正式版本:`0.0.2`。安装包与便携包见 +当前正式版本:`0.0.3`。安装包与便携包见 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 ## 已实现能力 @@ -122,6 +124,16 @@ AI 是可选的二次判断层,支持三类真实协议: - 鼠标快捷操作使用 Windows Raw Input 被动监听,只在达到长按阈值时触发,不拦截原程序的短按前进、后退或中键行为; - 所有快捷设置在控件失焦或选择完成后自动保存并立即生效,并提供实际唤起测试按钮。 +### 性能与进程生命周期 + +- 主窗口优先完成首帧显示,全盘索引在界面可交互后延迟启动,加载内容和搜索准确率不变; +- 磁盘盘符、文件系统和空间信息合并查询并短时缓存,避免空间总览为每个盘符重复拉起 PowerShell; +- 非首页功能按需加载,降低首次启动时 Chromium 解析的 JavaScript 数量和渲染进程常驻内存; +- 像素湖境、未来中枢和月白晶境在交互期间保持流畅,闲置或失焦时自动降低背景帧率,最小化后完全暂停; +- 关闭到通知区域后释放 Chromium 渲染进程和 GPU 表面;原生增量索引、文件变化监听、托盘菜单与全局快捷键继续运行; +- 原生索引在载入和全量构建期间使用较低 Windows 进程优先级,完成后恢复正常搜索优先级; +- 托盘退出会先关闭索引通信管道并等待原生进程退出,避免 `write EPIPE` 主进程异常。 + ## 安全边界 - 盘符根目录、Windows 目录、系统卷信息、回收站、默认/公共用户目录和关键 Microsoft 系统数据会被硬性拦截; diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..6f19214 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,109 @@ +# CDriveShiftAI 中文说明 + +CDriveShiftAI 是面向 Windows 10/11 x64 的全盘文件搜索、目录归属分析与跨盘安全迁移工具。名称索引、内容索引和迁移记录都由项目自身维护,不依赖 Everything。 + +当前版本:`0.0.3` + +## 下载与运行 + +- 安装版:`CDriveShiftAI-x64.exe`,可以选择安装目录;检测到旧版本后沿用原目录覆盖更新。 +- 便携版:`CDriveShiftAI-x64-portable.exe`,程序数据保存在便携程序所在目录或项目根目录的 `.cdriveshiftai-data`。 +- 正式发布:[GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases) + +安装包尚未使用商业 Authenticode 证书,Windows SmartScreen 可能显示“未知发布者”。请只从官方仓库 Release 下载,并对照 Release 中的 `update-manifest.json` 校验 SHA-512。 + +## 核心功能 + +### 极速名称搜索 + +- 搜索所有本地磁盘的文件名、目录名和完整路径。 +- NTFS 优先读取 MFT;不可用时自动使用多线程目录扫描。 +- 支持文件类型、盘符、目录范围、扩展名、大小、日期、大小写、完整单词和正则表达式组合筛选。 +- 支持搜索书签、书签文件夹、拖放整理、结果状态恢复和独立极速搜索窗口。 +- 结果支持双击打开、主题化右键菜单、属性、重命名、复制、删除、归属分析和安全迁移。 + +### 指定目录内容搜索 + +- 只索引用户明确选择的目录。 +- 使用本地 SQLite FTS5 trigram 索引,支持中文、代码、配置和日志正文。 +- 默认跳过依赖目录、构建产物、二进制文件、重解析点和超过限制的单文件。 + +### 应用归属分析 + +- 优先交叉匹配卸载注册表、AppX/MSIX、App Paths、安装目录和便携应用特征。 +- 再根据目录结构、文件类型、包元数据和已知应用规则判断用途与来源。 +- 本地证据不足时可以调用用户配置的 AI 服务;结果包含可信度、证据、迁移风险和风险说明。 +- 支持 OpenAI 兼容接口、Anthropic、Gemini、Ollama、LM Studio 及常见国内外模型平台。 + +### 跨盘安全迁移 + +1. 检查保护路径、重解析点、目标空间和关联应用。 +2. 复制到目标盘临时目录。 +3. 校验文件数、目录数和总字节数。 +4. 原子切换源目录。 +5. 创建 Windows 符号链接;权限不足时使用目录联接。 +6. 验证链接目标后才删除旧副本。 +7. 持久化迁移记录,可恢复后再次迁移并累计次数。 + +目标目录直接使用源目录名称。例如: + +```text +源目录:C:\Users\Example\AppData\Local\D3DSCache +目标基础目录:E:\MovedData +实际目标:E:\MovedData\D3DSCache +``` + +## 快捷键与托盘 + +- 主窗口和独立极速搜索均支持 Windows 全局快捷键。 +- 保存快捷键前会实际尝试注册,检查应用内重复以及系统或其他软件占用。 +- 独立极速搜索也可以配置鼠标后退键、前进键或中键及长按时间。 +- 鼠标监听为被动 Raw Input,不拦截原有短按动作。 +- 托盘菜单提供主界面、独立搜索、常用功能、AI 服务、主题、设置和退出入口。 + +## 自动更新 + +- 安装版静默更新到原安装路径,便携版在原文件位置自替换。 +- 支持断点续传、最多三次自动重试和 SHA-512 双重校验。 +- 校验失败拒绝安装;替换失败自动恢复旧版本。 +- 新版本确认启动成功后才删除安装包、备份和更新临时目录。 + +## 性能策略 + +- 主界面先显示,索引加载稍后低优先级执行。 +- 有效索引缓存不会在普通重启时全量重建。 +- 全量更新只发生在首次运行、开机后首次运行、缓存损坏、用户手动刷新或超过 24 小时。 +- 磁盘信息合并查询;非首页模块按需加载。 +- 窗口闲置时降低视觉背景帧率,最小化后暂停动画和重型索引工作。 +- 关闭到通知区域后释放 Chromium 渲染进程和 GPU 表面,只保留原生增量索引、文件变化监听、托盘菜单与全局快捷键。 +- 托盘退出会等待索引器关闭,不留下 `cshift-indexer.exe`。 + +## 数据位置 + +应用不会把持久化索引和迁移记录放入 `%APPDATA%\CDriveShiftAI`。 + +```text +<项目、安装或便携目录>\.cdriveshiftai-data +``` + +其中包含设置、窗口状态、搜索书签、迁移记录、归属分析、名称索引、内容索引、日志和更新临时文件。 + +## 开发 + +环境要求: + +- Node.js 20+ +- Rust 1.75+ +- Visual Studio 2022 C++ 工具 +- Windows SDK + +```powershell +npm install +npm run dev +npm run lint +npm test +npm run test:native +npm run dist:all +``` + +详细实现见 [架构说明](docs/ARCHITECTURE.md),版本变化见 [更新记录](CHANGELOG.md)。 diff --git a/electron/main.ts b/electron/main.ts index 66c10a1..6713839 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -70,6 +70,8 @@ let searchService: SearchService | undefined; let updateService: UpdateService | undefined; let tray: Tray | undefined; let isQuitting = false; +let shutdownComplete = false; +let shutdownPromise: Promise | undefined; const store = new AppStore(); let migrationService: MigrationService; const aiVerifications = new Map(); @@ -283,6 +285,24 @@ function trackWindowBounds( }); } +function showAsSoonAsRenderable(window: BrowserWindow, maximized = false): void { + let shown = false; + const show = () => { + if (shown || window.isDestroyed()) return; + shown = true; + if (maximized) window.maximize(); + window.show(); + }; + // ready-to-show may be delayed by GPU initialization or a busy first paint. + // dom-ready is sufficient because the document has an effect-matched opaque + // background, so showing here improves perceived startup without a white flash. + window.webContents.once("dom-ready", show); + window.once("ready-to-show", show); + const fallback = setTimeout(show, 1_200); + fallback.unref(); + window.once("closed", () => clearTimeout(fallback)); +} + function createWindow(): BrowserWindow { const effect = store.getSettings().effectMode; const colors = effectColors[effect]; @@ -307,7 +327,6 @@ function createWindow(): BrowserWindow { symbolColor: colors.symbols, height: 48 }, - ...(process.platform === "win32" ? { backgroundMaterial: "mica" as const } : {}), webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, @@ -326,12 +345,24 @@ function createWindow(): BrowserWindow { if (isQuitting || !store.getSettings().minimizeToTray) return; event.preventDefault(); window.hide(); + // Force a native background notification even if visibility events raced + // during startup. Besides pausing heavy work, this trims reclaimable pages + // across the resident process tree after the renderer is released. + searchService?.setBackgroundMode(true, true); createTray(); + // A hidden Chromium renderer and its GPU surfaces otherwise remain the + // largest part of the tray working set. Search, watchers, global + // shortcuts and the tray all live in the main/native processes, so the UI + // can be destroyed after its persisted layout/state has been saved and + // recreated on demand without reducing background accuracy. + setTimeout(() => { + if (!window.isDestroyed() && !window.isVisible()) window.destroy(); + }, 150); }); - window.once("ready-to-show", () => { - if (restored?.maximized) window.maximize(); - window.show(); + window.on("closed", () => { + if (mainWindow === window) mainWindow = undefined; }); + showAsSoonAsRenderable(window, Boolean(restored?.maximized)); window.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:\/\//i.test(url)) void shell.openExternal(url); return { action: "deny" }; @@ -653,10 +684,7 @@ function createTray(): void { { label: "退出", icon: createTrayMenuIcon("exit", "#bd5d5d"), - click: () => { - isQuitting = true; - app.quit(); - } + click: () => app.quit() } ]; tray.setContextMenu(Menu.buildFromTemplate(template)); @@ -1625,10 +1653,10 @@ if (!singleInstance) { }); } else { app.on("second-instance", () => { - if (!mainWindow) return; - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); + // The renderer is intentionally destroyed while resident in the tray. + // Launching the executable again must therefore recreate the main window, + // not silently return just because no BrowserWindow currently exists. + if (searchService) showMainView("overview"); }); app.whenReady().then(async () => { @@ -1679,7 +1707,30 @@ if (!singleInstance) { }); } await migrationService.recoverIncomplete(); - void searchService.start(); + // Let Chromium finish the first interactive frame before parsing a + // multi-million-entry persistent index. Search remains fully accurate once + // the same cache is loaded; this only removes startup contention. + setTimeout(() => { + void searchService?.start().catch(() => { + // SearchService publishes its own unavailable/restart status. + }); + }, 850); + const smokeQuitDelay = Number.parseInt( + process.env.CDRIVESHIFTAI_SMOKE_QUIT_AFTER_READY_MS ?? "", + 10 + ); + if (app.isPackaged && Number.isFinite(smokeQuitDelay) && smokeQuitDelay >= 1_000) { + const smokeQuitTimer = setTimeout(() => app.quit(), smokeQuitDelay); + smokeQuitTimer.unref(); + } + const smokeTrayDelay = Number.parseInt( + process.env.CDRIVESHIFTAI_SMOKE_CLOSE_TO_TRAY_AFTER_READY_MS ?? "", + 10 + ); + if (app.isPackaged && Number.isFinite(smokeTrayDelay) && smokeTrayDelay >= 1_000) { + const smokeTrayTimer = setTimeout(() => mainWindow?.close(), smokeTrayDelay); + smokeTrayTimer.unref(); + } app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) mainWindow = createWindow(); @@ -1688,12 +1739,29 @@ if (!singleInstance) { } app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); + const keepInTray = + !isQuitting && + store.getSettings().minimizeToTray && + tray != null && + !tray.isDestroyed(); + if (process.platform !== "darwin" && !keepInTray) app.quit(); }); -app.on("before-quit", () => { +app.on("before-quit", (event) => { isQuitting = true; + if (shutdownComplete) return; + event.preventDefault(); globalShortcut.unregisterAll(); tray?.destroy(); - void searchService?.stop(); + tray = undefined; + if (!shutdownPromise) { + shutdownPromise = (async () => { + try { + await searchService?.stop(); + } finally { + shutdownComplete = true; + app.quit(); + } + })(); + } }); diff --git a/electron/search.ts b/electron/search.ts index ded2cc3..1da468c 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -1,5 +1,6 @@ import { app } from "electron"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; import { createInterface } from "node:readline"; import { access, lstat, opendir, stat } from "node:fs/promises"; import { uptime } from "node:os"; @@ -53,6 +54,8 @@ export class SearchService { private executableCatalogCache?: { at: number; paths: string[] }; private cachePath?: string; private dailyRefreshTimer?: NodeJS.Timeout; + private restartTimer?: NodeJS.Timeout; + private stopPromise?: Promise; private lastFullRefreshRequestedAt = 0; private backgroundMode = false; private mouseShortcutStatus: MouseShortcutStatus; @@ -72,6 +75,7 @@ export class SearchService { } async start(): Promise { + if (this.stopping || this.child) return; const executable = app.isPackaged ? path.join(process.resourcesPath, "bin", "cshift-indexer.exe") : path.resolve(__dirname, "..", "native", "indexer", "target", "release", "cshift-indexer.exe"); @@ -87,27 +91,42 @@ export class SearchService { return; } - this.child = spawn(executable, ["--serve"], { + const child = spawn(executable, ["--serve"], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); - const lines = createInterface({ input: this.child.stdout, crlfDelay: Infinity }); + this.child = child; + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); lines.on("line", (line) => this.handleLine(line)); - this.child.stderr.on("data", (chunk) => { + child.stderr.on("data", (chunk) => { const message = chunk.toString("utf8").trim(); if (message) this.status.message = message.slice(-300); - }); - this.child.on("exit", (code) => { - this.child = undefined; - for (const pending of this.pending.values()) { - clearTimeout(pending.timer); - pending.reject(new Error(`索引进程已退出(${code ?? "unknown"})`)); + if (message && process.env.CDRIVESHIFTAI_TRIM_DIAGNOSTICS === "1") { + process.stderr.write(`${message}\n`); } - this.pending.clear(); + }); + // A pipe can close between the writable-state check and write(). Node emits + // an "error" event in addition to invoking the write callback; without a + // listener that EPIPE becomes an uncaught main-process exception. + child.stdin.on("error", (error) => { + if (this.child === child) this.rejectPending(error); + }); + child.on("error", (error) => { + if (this.child === child) this.rejectPending(error); + }); + child.on("exit", (code) => { + lines.close(); + if (this.child === child) this.child = undefined; + this.rejectPending(new Error(`索引进程已退出(${code ?? "unknown"})`)); if (!this.stopping && this.restartCount < 2) { this.restartCount += 1; this.updateStatus({ ...this.status, state: "error", message: "索引核心意外退出,正在重启" }); - setTimeout(() => void this.start(), 1_500 * this.restartCount); + this.restartTimer = setTimeout(() => { + this.restartTimer = undefined; + void this.start().catch(() => { + // The child lifecycle handlers already publish the degraded state. + }); + }, 1_500 * this.restartCount); } else if (!this.stopping) { this.updateStatus({ ...this.status, @@ -154,17 +173,42 @@ export class SearchService { } async stop(): Promise { + if (this.stopPromise) return this.stopPromise; this.stopping = true; - if (this.dailyRefreshTimer) { - clearTimeout(this.dailyRefreshTimer); - this.dailyRefreshTimer = undefined; - } - if (!this.child) return; - try { - await this.request({ op: "quit" }, 2_000); - } catch { - this.child.kill(); - } + this.stopPromise = (async () => { + if (this.dailyRefreshTimer) { + clearTimeout(this.dailyRefreshTimer); + this.dailyRefreshTimer = undefined; + } + if (this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = undefined; + } + const child = this.child; + if (!child || child.exitCode != null) return; + const exited = once(child, "exit").then(() => true).catch(() => true); + try { + await this.requestWithChild(child, { op: "quit" }, 1_500); + } catch { + // The process may already be closing. The exit wait and bounded kill + // below complete shutdown without surfacing a JavaScript error dialog. + } + if (!child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end(); + const graceful = await Promise.race([ + exited, + new Promise((resolve) => setTimeout(() => resolve(false), 1_500)) + ]); + if (!graceful && child.exitCode == null && !child.killed) { + child.kill(); + await Promise.race([ + exited, + new Promise((resolve) => setTimeout(resolve, 500)) + ]); + } + if (this.child === child) this.child = undefined; + this.rejectPending(new Error("索引服务已停止")); + })(); + return this.stopPromise; } getStatus(): IndexerStatus { @@ -206,11 +250,14 @@ export class SearchService { return this.getMouseShortcutStatus(); } - setBackgroundMode(background: boolean): void { - if (this.backgroundMode === background) return; + setBackgroundMode(background: boolean, force = false): void { + if (!force && this.backgroundMode === background) return; this.backgroundMode = background; if (!this.child) return; - void this.request({ op: "setBackground", background }, 3_000).catch(() => { + void this.request( + { op: "setBackground", background, processId: process.pid }, + 3_000 + ).catch(() => { // The process may be between a crash and its automatic restart. start() // includes the current mode in init, so no retry loop is needed here. }); @@ -553,7 +600,25 @@ export class SearchService { payload: Record, timeout = 10_000 ): Promise { - if (!this.child) return Promise.reject(new Error("索引核心未运行")); + const child = this.child; + if (!child) return Promise.reject(new Error("索引核心未运行")); + return this.requestWithChild(child, payload, timeout); + } + + private requestWithChild( + child: ChildProcessWithoutNullStreams, + payload: Record, + timeout: number + ): Promise { + if ( + child.exitCode != null || + child.killed || + child.stdin.destroyed || + child.stdin.writableEnded || + !child.stdin.writable + ) { + return Promise.reject(new Error("索引通信管道已关闭")); + } const id = ++this.requestId; return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -561,16 +626,29 @@ export class SearchService { reject(new Error("索引请求超时")); }, timeout); this.pending.set(id, { resolve, reject, timer }); - this.child!.stdin.write(`${JSON.stringify({ id, ...payload })}\n`, (error) => { - if (error) { + try { + child.stdin.write(`${JSON.stringify({ id, ...payload })}\n`, (error) => { + if (!error) return; clearTimeout(timer); this.pending.delete(id); reject(error); - } - }); + }); + } catch (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + } }); } + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + private handleLine(line: string): void { let response: NativeResponse; try { diff --git a/electron/system.ts b/electron/system.ts index cdef364..f2245d0 100644 --- a/electron/system.ts +++ b/electron/system.ts @@ -6,6 +6,17 @@ import { promisify } from "node:util"; import type { DriveInfo } from "./types"; const execFileAsync = promisify(execFile); +const DRIVE_DISCOVERY_TTL_MS = 30_000; + +interface LocalDriveMetadata { + root: string; + fileSystem: string; +} + +let driveDiscoveryCache: + | { expiresAt: number; promise: Promise } + | undefined; +let elevationCheck: Promise | undefined; const windowsRoot = process.env.SystemRoot || "C:\\Windows"; const systemDrive = path.parse(windowsRoot).root || "C:\\"; @@ -64,23 +75,57 @@ export function isHighRiskApplicationPath(candidate: string): boolean { return programFilesPaths.some((programPath) => isPathWithin(candidate, programPath)); } +async function discoverLocalDrives(): Promise { + const now = Date.now(); + if (driveDiscoveryCache && driveDiscoveryCache.expiresAt > now) { + return driveDiscoveryCache.promise; + } + const promise = (async () => { + try { + const script = + "(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3 OR DriveType=2' | " + + "Select-Object DeviceID,FileSystem) | ConvertTo-Json -Compress"; + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { windowsHide: true, timeout: 8_000, maxBuffer: 256 * 1024 } + ); + const parsed = JSON.parse(stdout.trim()) as + | { DeviceID?: unknown; FileSystem?: unknown } + | Array<{ DeviceID?: unknown; FileSystem?: unknown }>; + const values = Array.isArray(parsed) ? parsed : [parsed]; + const drives = values + .filter( + (item): item is { DeviceID: string; FileSystem?: unknown } => + typeof item?.DeviceID === "string" && /^[A-Z]:$/i.test(item.DeviceID) + ) + .map((item) => ({ + root: `${item.DeviceID.toUpperCase()}\\`, + fileSystem: + typeof item.FileSystem === "string" && item.FileSystem.trim() + ? item.FileSystem.trim() + : "Unknown" + })); + return drives.length ? drives : [{ root: "C:\\", fileSystem: "Unknown" }]; + } catch { + return [{ root: "C:\\", fileSystem: "Unknown" }]; + } + })(); + driveDiscoveryCache = { + expiresAt: now + DRIVE_DISCOVERY_TTL_MS, + promise + }; + return promise; +} + export async function getDriveInfo(root = "C:\\"): Promise { - const stats = await statfs(root); + const [stats, drives] = await Promise.all([statfs(root), discoverLocalDrives()]); const totalBytes = Number(stats.blocks) * Number(stats.bsize); const freeBytes = Number(stats.bavail) * Number(stats.bsize); - let fileSystem = "Unknown"; - try { - const driveLetter = root.slice(0, 1); - const script = `(Get-Volume -DriveLetter '${driveLetter}' -ErrorAction Stop).FileSystem`; - const { stdout } = await execFileAsync( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", script], - { windowsHide: true, timeout: 5_000 } - ); - fileSystem = stdout.trim() || fileSystem; - } catch { - // statfs is still enough for capacity information. - } + const normalizedRoot = `${root.slice(0, 1).toUpperCase()}:\\`; + const fileSystem = + drives.find((drive) => drive.root.toUpperCase() === normalizedRoot)?.fileSystem ?? + "Unknown"; return { name: root.toUpperCase().startsWith("C:") ? "System" : `Drive ${root.slice(0, 1).toUpperCase()}`, root, @@ -92,36 +137,22 @@ export async function getDriveInfo(root = "C:\\"): Promise { } export async function getLocalDriveRoots(): Promise { - try { - const script = - "(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3 OR DriveType=2' | " + - "Select-Object -ExpandProperty DeviceID) | ConvertTo-Json -Compress"; - const { stdout } = await execFileAsync( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", script], - { windowsHide: true, timeout: 8_000 } - ); - const parsed = JSON.parse(stdout.trim()) as string | string[]; - const values = Array.isArray(parsed) ? parsed : [parsed]; - const roots = values - .filter((value): value is string => typeof value === "string" && /^[A-Z]:$/i.test(value)) - .map((value) => `${value.toUpperCase()}\\`); - return roots.length ? roots : ["C:\\"]; - } catch { - return ["C:\\"]; - } + return (await discoverLocalDrives()).map((drive) => drive.root); } export async function isElevated(): Promise { - try { - await execFileAsync("net.exe", ["session"], { - windowsHide: true, - timeout: 3_000 - }); - return true; - } catch { - return false; - } + elevationCheck ??= (async () => { + try { + await execFileAsync("net.exe", ["session"], { + windowsHide: true, + timeout: 3_000 + }); + return true; + } catch { + return false; + } + })(); + return elevationCheck; } export function systemIdentity() { diff --git a/native/indexer/Cargo.lock b/native/indexer/Cargo.lock index ab5fad2..c8f05ab 100644 --- a/native/indexer/Cargo.lock +++ b/native/indexer/Cargo.lock @@ -68,7 +68,7 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "cshift-indexer" -version = "0.0.2" +version = "0.0.3" dependencies = [ "memmap2", "notify", diff --git a/native/indexer/Cargo.toml b/native/indexer/Cargo.toml index 5668a7d..7976bc2 100644 --- a/native/indexer/Cargo.toml +++ b/native/indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-indexer" -version = "0.0.2" +version = "0.0.3" edition = "2021" description = "First-party NTFS MFT indexer for CDriveShiftAI" @@ -18,6 +18,7 @@ windows-sys = { version = "0.52.0", features = [ "Win32_Graphics_Gdi", "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Threading", diff --git a/native/indexer/src/main.rs b/native/indexer/src/main.rs index b88a02e..d3e33e9 100644 --- a/native/indexer/src/main.rs +++ b/native/indexer/src/main.rs @@ -105,6 +105,11 @@ struct SearchIndex { // a hash table. Live watcher additions remain in a small hash map. path_positions: Vec<(u64, u32)>, delta_positions: HashMap, + // Directory removals are represented as compact prefix tombstones. The + // previous eager implementation scanned all entries for every ambiguous + // Windows remove event, which could peg one core and page the full cache + // back into memory. Search results still exclude the complete subtree. + removed_trees: Vec, live_count: usize, } @@ -136,6 +141,12 @@ struct SharedState { delta_lock: Mutex<()>, } +#[derive(Clone, Copy, Default)] +struct PendingWatchChange { + new_tree: bool, + folder_hint: bool, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Request { @@ -155,6 +166,7 @@ struct Request { match_path: Option, regex: Option, background: Option, + process_id: Option, force_rebuild: Option, rebuild_reason: Option, mouse_button: Option, @@ -494,9 +506,15 @@ fn normalized_path_hash(value: &str) -> u64 { // compact, and sufficient for local path identity. Exact paths are still // verified against Entry when an unlikely collision is encountered. let mut hash = 14_695_981_039_346_656_037u64; - for byte in normalized(value).bytes() { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(1_099_511_628_211); + for character in value.chars() { + let separator_normalized = if character == '/' { '\\' } else { character }; + for lowered in separator_normalized.to_lowercase() { + let mut encoded = [0u8; 4]; + for byte in lowered.encode_utf8(&mut encoded).bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(1_099_511_628_211); + } + } } hash } @@ -521,6 +539,111 @@ fn trim_process_working_set() { #[cfg(not(windows))] fn trim_process_working_set() {} +#[cfg(windows)] +fn trim_process_tree_working_sets(root_pid: u32) { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + use windows_sys::Win32::System::Threading::{ + OpenProcess, SetProcessWorkingSetSize, PROCESS_QUERY_INFORMATION, PROCESS_SET_QUOTA, + }; + + if root_pid == 0 { + return; + } + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return; + } + let mut processes = Vec::new(); + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = std::mem::size_of::() as u32; + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) } != 0; + while available { + let name_end = entry + .szExeFile + .iter() + .position(|character| *character == 0) + .unwrap_or(entry.szExeFile.len()); + let executable_name = String::from_utf16_lossy(&entry.szExeFile[..name_end]); + let owned_process = executable_name.eq_ignore_ascii_case("CDriveShiftAI.exe") + || executable_name.eq_ignore_ascii_case("cshift-indexer.exe"); + processes.push(( + entry.th32ProcessID, + entry.th32ParentProcessID, + owned_process, + )); + available = unsafe { Process32NextW(snapshot, &mut entry) } != 0; + } + unsafe { + CloseHandle(snapshot); + } + + let mut tree = HashSet::new(); + tree.insert(root_pid); + loop { + let before = tree.len(); + for (pid, parent_pid, owned_process) in &processes { + if *owned_process && tree.contains(parent_pid) { + tree.insert(*pid); + } + } + if tree.len() == before { + break; + } + } + + // EmptyWorkingSet only removes inactive resident pages. Virtual mappings, + // indexes and process state stay intact and are paged back by Windows on + // demand, so tray/global-shortcut accuracy is unaffected. + let process_count = tree.len(); + let mut opened = 0usize; + let mut trimmed = 0usize; + for pid in tree { + let handle = + unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_SET_QUOTA, 0, pid) }; + if handle == 0 { + continue; + } + opened += 1; + unsafe { + if SetProcessWorkingSetSize(handle, usize::MAX, usize::MAX) != 0 { + trimmed += 1; + } + CloseHandle(handle); + } + } + if env::var_os("CDRIVESHIFTAI_TRIM_DIAGNOSTICS").is_some() { + eprintln!( + "tray working-set trim: root={}, processes={}, opened={}, trimmed={}", + root_pid, process_count, opened, trimmed + ); + } +} + +#[cfg(not(windows))] +fn trim_process_tree_working_sets(_root_pid: u32) {} + +#[cfg(windows)] +fn set_indexing_priority(active: bool) { + use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, SetPriorityClass, BELOW_NORMAL_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, + }; + unsafe { + let priority = if active { + BELOW_NORMAL_PRIORITY_CLASS + } else { + NORMAL_PRIORITY_CLASS + }; + let _ = SetPriorityClass(GetCurrentProcess(), priority); + } +} + +#[cfg(not(windows))] +fn set_indexing_priority(_active: bool) {} + fn file_name(value: &str) -> &str { value .trim_end_matches(['\\', '/']) @@ -579,22 +702,31 @@ fn contains_whole_word(target: &str, needle: &str) -> bool { }) } -fn trigram_hash(chars: &[char]) -> u32 { +fn trigram_hash(chars: [char; 3]) -> u32 { let mut hash = 2_166_136_261u32; for character in chars { - hash ^= *character as u32; + hash ^= character as u32; hash = hash.wrapping_mul(16_777_619); } hash } -fn name_signature(value: &str) -> u64 { - let chars: Vec = value.chars().collect(); - if chars.len() < 3 { - return 0; - } +fn name_signature_from_chars(chars: impl Iterator) -> u64 { let mut signature = 0u64; - for window in chars.windows(3) { + let mut window = ['\0'; 3]; + let mut seen = 0usize; + for character in chars { + if seen < 3 { + window[seen] = character; + seen += 1; + if seen < 3 { + continue; + } + } else { + window[0] = window[1]; + window[1] = window[2]; + window[2] = character; + } let hash = trigram_hash(window); signature |= 1u64 << (hash & 63); signature |= 1u64 << ((hash >> 6) & 63); @@ -602,6 +734,14 @@ fn name_signature(value: &str) -> u64 { signature } +fn name_signature(value: &str) -> u64 { + name_signature_from_chars(value.chars()) +} + +fn lowercase_name_signature(value: &str) -> u64 { + name_signature_from_chars(value.chars().flat_map(char::to_lowercase)) +} + impl SearchIndex { fn path(&self, index: usize) -> Option<&str> { let entry = *self.entries.get(index)?; @@ -632,6 +772,7 @@ impl SearchIndex { live: vec![true; entries.len()], path_positions: Vec::with_capacity(entries.len()), delta_positions: HashMap::new(), + removed_trees: Vec::new(), live_count: entries.len(), entries, }; @@ -642,7 +783,7 @@ impl SearchIndex { let (signature, path_hash) = { let path_value = index.path(entry_index)?; ( - name_signature(&file_name(path_value).to_lowercase()), + lowercase_name_signature(file_name(path_value)), normalized_path_hash(path_value), ) }; @@ -718,6 +859,25 @@ impl SearchIndex { None } + fn path_is_directory(&self, path_value: &str) -> Option { + self.find_path(path_value) + .and_then(|index| self.entries.get(index)) + .map(|entry| entry.is_directory()) + } + + fn path_is_removed(&self, path_value: &str) -> bool { + if self.removed_trees.is_empty() { + return false; + } + let candidate = normalized(path_value); + self.removed_trees.iter().any(|tree| { + candidate == *tree + || candidate + .strip_prefix(tree) + .is_some_and(|suffix| suffix.starts_with('\\')) + }) + } + fn remove_path(&mut self, path_value: &str) { let hash = normalized_path_hash(path_value); if let Some(index) = self.find_path(path_value) { @@ -739,35 +899,19 @@ impl SearchIndex { fn remove_tree(&mut self, path_value: &str) { let key = normalized(path_value); - let prefix = format!("{}\\", key); - let removed: Vec = self - .entries - .iter() - .enumerate() - .filter(|(index, _entry)| { - self.live.get(*index).copied().unwrap_or(false) && { - let candidate = normalized(self.path(*index).unwrap_or_default()); - candidate == key || candidate.starts_with(&prefix) - } - }) - .map(|(index, _)| index) - .collect(); - for index in removed { - if self - .entries - .get(index) - .is_some_and(|entry| entry.is_delta()) - { - if let Some(path_value) = self.path(index) { - let hash = normalized_path_hash(path_value); - self.delta_positions.remove(&hash); - } - } - if let Some(live) = self.live.get_mut(index) { - *live = false; - } - self.live_count = self.live_count.saturating_sub(1); + let covered = self.removed_trees.iter().any(|tree| { + key == *tree + || key + .strip_prefix(tree) + .is_some_and(|suffix| suffix.starts_with('\\')) + }); + if !covered { + let prefix = format!("{key}\\"); + self.removed_trees + .retain(|tree| tree != &key && !tree.starts_with(&prefix)); + self.removed_trees.push(key); } + self.remove_path(path_value); } fn len(&self) -> usize { @@ -775,6 +919,10 @@ impl SearchIndex { } fn upsert(&mut self, entry: Entry) { + if entry.is_directory && !self.removed_trees.is_empty() { + let key = normalized(&entry.path); + self.removed_trees.retain(|tree| tree != &key); + } self.remove_path(&entry.path); let index = self.entries.len() as u32; let offset = self.delta_paths.len() as u64; @@ -787,7 +935,7 @@ impl SearchIndex { entry.size, )); self.name_signatures - .push(name_signature(&file_name(&entry.path).to_lowercase())); + .push(lowercase_name_signature(file_name(&entry.path))); self.live.push(true); self.delta_positions .insert(normalized_path_hash(&entry.path), index); @@ -872,6 +1020,9 @@ impl SearchIndex { Some(value) => value, None => continue, }; + if self.path_is_removed(path_value) { + continue; + } let is_directory = entry.is_directory(); if kind == "folder" && !is_directory { continue; @@ -977,6 +1128,7 @@ impl SearchIndex { if !self.live.get(index).copied().unwrap_or(false) || entry.is_directory() || extension_name(path_value) != "exe" + || self.path_is_removed(path_value) { continue; } @@ -2200,196 +2352,239 @@ fn collect_subtree(root: &Path, maximum: usize, state: &SharedState) -> Vec, output: Output) { + if env::var_os("CDRIVESHIFTAI_DISABLE_WATCHERS").is_some() { + return; + } if state.watching.swap(true, Ordering::SeqCst) { return; } - thread::spawn(move || { - let (sender, receiver) = mpsc::channel(); - let mut watchers = Vec::new(); - for root in &state.roots { - let event_sender = sender.clone(); - let watcher = RecommendedWatcher::new( - move |result| { - let _ = event_sender.send(result); - }, - NotifyConfig::default(), - ); - match watcher { - Ok(mut value) => { - if value - .watch(Path::new(root), RecursiveMode::Recursive) - .is_ok() - { - watchers.push(value); + let _ = thread::Builder::new() + .name("cshift change coalescer".to_string()) + .spawn(move || { + // ReadDirectoryChangesW can emit many repeated attribute/write + // notifications for one path. Keep only the final path state and + // wake the worker once until the batch is drained. + let pending = Arc::new(Mutex::new(HashMap::::new())); + let watcher_error = Arc::new(Mutex::new(None::)); + let overflowed = Arc::new(AtomicBool::new(false)); + let (wake_sender, wake_receiver) = mpsc::sync_channel::<()>(1); + let mut watchers = Vec::new(); + let application_data_path = state.cache_path.parent().map(Path::to_path_buf); + + for root in &state.roots { + let pending = Arc::clone(&pending); + let watcher_error = Arc::clone(&watcher_error); + let overflowed = Arc::clone(&overflowed); + let wake_sender = wake_sender.clone(); + let application_data_path = application_data_path.clone(); + let watcher = RecommendedWatcher::new( + move |result: notify::Result| { + match result { + Ok(event) => { + let new_tree = matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(_)) + ); + let folder_hint = + matches!(event.kind, EventKind::Remove(RemoveKind::Folder)); + let mut items = + pending.lock().unwrap_or_else(|error| error.into_inner()); + for changed_path in event.paths { + // The index, state and content databases + // live below this directory. Never feed + // our own writes back into the watcher. + if application_data_path + .as_ref() + .is_some_and(|root| changed_path.starts_with(root)) + { + continue; + } + if items.len() >= 250_000 && !items.contains_key(&changed_path) + { + overflowed.store(true, Ordering::Relaxed); + continue; + } + let item = items.entry(changed_path).or_default(); + item.new_tree |= new_tree; + item.folder_hint |= folder_hint; + } + } + Err(error) => { + *watcher_error + .lock() + .unwrap_or_else(|item| item.into_inner()) = + Some(error.to_string()); + } + } + let _ = wake_sender.try_send(()); + }, + NotifyConfig::default(), + ); + match watcher { + Ok(mut value) => { + if value + .watch(Path::new(root), RecursiveMode::Recursive) + .is_ok() + { + watchers.push(value); + } } + Err(_) => continue, } - Err(_) => continue, } - } - drop(sender); - if watchers.is_empty() { - output.status(&Status { - mode: state + drop(wake_sender); + + if watchers.is_empty() { + let mut status = state .status .lock() .unwrap_or_else(|error| error.into_inner()) - .mode - .clone(), - state: "ready".to_string(), - entries: state - .index - .read() - .unwrap_or_else(|error| error.into_inner()) - .entries - .len(), - progress: 1.0, - root: state.display_root.clone(), - updated_at: Some(now_iso_like()), - message: Some("实时变更监听不可用,可手动刷新索引".to_string()), - }); - state.watching.store(false, Ordering::SeqCst); - return; - } - - while !state.stopping.load(Ordering::Relaxed) { - if wait_while_backgrounded(&state.backgrounded, &state.stopping) { - break; + .clone(); + status.message = Some("实时变更监听不可用,可手动刷新索引。".to_string()); + update_status(&state, &output, status); + state.watching.store(false, Ordering::SeqCst); + return; } - let event = match receiver.recv_timeout(Duration::from_millis(500)) { - Ok(Ok(value)) => value, - Ok(Err(error)) => { - output.status(&Status { - mode: "hybrid".to_string(), - state: "ready".to_string(), - entries: state - .index - .read() - .unwrap_or_else(|item| item.into_inner()) - .entries - .len(), - progress: 1.0, - root: state.display_root.clone(), - updated_at: Some(now_iso_like()), - message: Some(format!("变更监听提示:{}", error)), - }); - continue; + + while !state.stopping.load(Ordering::Relaxed) { + let backgrounded = state.backgrounded.load(Ordering::Relaxed); + let timeout = if backgrounded { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + match wake_receiver.recv_timeout(timeout) { + Ok(()) | Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, } - Err(mpsc::RecvTimeoutError::Timeout) => continue, - Err(mpsc::RecvTimeoutError::Disconnected) => break, - }; - let event_kind = event.kind; - let is_new_tree = matches!( - &event_kind, - EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(_)) - ); - let remove_kind = match &event_kind { - EventKind::Remove(kind) => Some(kind.clone()), - _ => None, - }; - for changed_path in event.paths { - let changed_key = normalized(&changed_path.to_string_lossy()); - let cache_key = normalized(&state.cache_path.to_string_lossy()); - let delta_key = normalized(&delta_path(&state.cache_path).to_string_lossy()); - let content_cache_key = normalized(&state.content_cache_dir.to_string_lossy()); - if changed_key == cache_key - || changed_key == delta_key - || changed_key == content_cache_key - || changed_key.starts_with(&format!("{}\\", content_cache_key)) + if state.stopping.load(Ordering::Relaxed) { + break; + } + thread::sleep(if backgrounded { + Duration::from_secs(2) + } else { + Duration::from_millis(350) + }); + + if let Some(error) = watcher_error + .lock() + .unwrap_or_else(|item| item.into_inner()) + .take() { - continue; + let mut status = state + .status + .lock() + .unwrap_or_else(|item| item.into_inner()) + .clone(); + status.message = Some(format!("实时变更监听提示:{error}")); + update_status(&state, &output, status); } - if remove_kind.is_some() || !changed_path.exists() { - let path_text = changed_path.to_string_lossy().to_string(); - let remove_tree = !changed_path.exists() - && matches!( - remove_kind, - Some(RemoveKind::Folder | RemoveKind::Any | RemoveKind::Other) | None - ); - let delta_guard = state - .delta_lock + if overflowed.swap(false, Ordering::Relaxed) { + let mut status = state + .status .lock() - .unwrap_or_else(|error| error.into_inner()); - let delta_error = append_deltas( - &state.cache_path, - &[IndexDelta::Remove { - path: path_text.clone(), - tree: remove_tree, - }], - ) - .err(); - if remove_tree { - state - .index - .write() - .unwrap_or_else(|error| error.into_inner()) - .remove_tree(&path_text); - } else { - state + .unwrap_or_else(|item| item.into_inner()) + .clone(); + status.message = Some("短时间内的文件变更过多,请手动刷新索引。".to_string()); + update_status(&state, &output, status); + } + + let changes = { + let mut items = pending.lock().unwrap_or_else(|error| error.into_inner()); + if items.is_empty() { + continue; + } + std::mem::take(&mut *items) + }; + let mut deltas = Vec::with_capacity(changes.len()); + for (changed_path, change) in changes { + if !changed_path.exists() { + let path_text = changed_path.to_string_lossy().to_string(); + // RemoveKind::Any is also used for ordinary files. A + // blind remove_tree here scans every indexed entry and + // was the source of sustained single-core CPU and an + // 800+ MB working set. Resolve the existing indexed + // type in O(log n) and only scan descendants for a + // directory that really existed. + let tree = state .index - .write() + .read() .unwrap_or_else(|error| error.into_inner()) - .remove_path(&path_text); + .path_is_directory(&path_text) + .unwrap_or(change.folder_hint); + deltas.push(IndexDelta::Remove { + path: path_text, + tree, + }); + continue; } - drop(delta_guard); - if let Some(error) = delta_error { - let mut status = state - .status - .lock() - .unwrap_or_else(|item| item.into_inner()) - .clone(); - status.message = Some(format!("索引增量日志写入失败:{}", error)); - update_status(&state, &output, status); + if let Some(entry) = entry_from_path(&changed_path) { + let is_directory = entry.is_directory; + let mut additions = vec![entry]; + if is_directory && change.new_tree { + additions.extend(collect_subtree(&changed_path, 250_000, &state)); + } + deltas.extend(additions.into_iter().map(|item| IndexDelta::Upsert { + path: item.path, + is_directory: item.is_directory, + size: item.size, + })); } + } + if deltas.is_empty() { continue; } - if let Some(entry) = entry_from_path(&changed_path) { - let is_directory = entry.is_directory; - let mut additions = vec![entry]; - if is_directory && is_new_tree { - // Walking a newly created tree performs filesystem I/O. Do it without - // holding the index writer lock, then merge in responsive chunks. - additions.extend(collect_subtree(&changed_path, 250_000, &state)); - } - let deltas: Vec = additions - .iter() - .map(|item| IndexDelta::Upsert { - path: item.path.clone(), - is_directory: item.is_directory, - size: item.size, - }) - .collect(); - let delta_guard = state - .delta_lock - .lock() + + // One durable append and one writer-lock sequence per batch + // replaces thousands of tiny opens/locks under write-heavy + // workloads. + let delta_guard = state + .delta_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + let delta_error = append_deltas(&state.cache_path, &deltas).err(); + for mutations in deltas.chunks(2_048) { + let mut index = state + .index + .write() .unwrap_or_else(|error| error.into_inner()); - let delta_error = append_deltas(&state.cache_path, &deltas).err(); - for entries in additions.chunks(2_048) { - let mut index = state - .index - .write() - .unwrap_or_else(|error| error.into_inner()); - for entry in entries { - index.upsert(entry.clone()); + for mutation in mutations { + match mutation { + IndexDelta::Remove { path, tree } => { + if *tree { + index.remove_tree(path); + } else { + index.remove_path(path); + } + } + IndexDelta::Upsert { + path, + is_directory, + size, + } => index.upsert(Entry { + path: path.clone(), + is_directory: *is_directory, + size: *size, + }), } - drop(index); - thread::yield_now(); - } - drop(delta_guard); - if let Some(error) = delta_error { - let mut status = state - .status - .lock() - .unwrap_or_else(|item| item.into_inner()) - .clone(); - status.message = Some(format!("索引增量日志写入失败:{}", error)); - update_status(&state, &output, status); } + drop(index); + thread::yield_now(); + } + drop(delta_guard); + if let Some(error) = delta_error { + let mut status = state + .status + .lock() + .unwrap_or_else(|item| item.into_inner()) + .clone(); + status.message = Some(format!("索引增量日志写入失败:{error}")); + update_status(&state, &output, status); } } - } - drop(watchers); - state.watching.store(false, Ordering::SeqCst); - }); + drop(watchers); + state.watching.store(false, Ordering::SeqCst); + }); } fn start_initial_cache_load(state: Arc, output: Output) { @@ -2397,6 +2592,7 @@ fn start_initial_cache_load(state: Arc, output: Output) { return; } thread::spawn(move || { + set_indexing_priority(true); let loaded = (|| -> io::Result<(SearchIndex, usize, String)> { let (cached_root, mut index) = load_cache_index(&state.cache_path, &state.backgrounded, &state.stopping)?; @@ -2458,8 +2654,13 @@ fn start_initial_cache_load(state: Arc, output: Output) { Err(error) if state.stopping.load(Ordering::Relaxed) => { let _ = error; } - Err(_) => start_scan(Arc::clone(&state), output.clone()), + Err(_) => { + set_indexing_priority(false); + start_scan(Arc::clone(&state), output.clone()); + return; + } } + set_indexing_priority(false); }); } @@ -2468,8 +2669,10 @@ fn start_scan(state: Arc, output: Output) { return; } thread::spawn(move || { + set_indexing_priority(true); if wait_while_backgrounded(&state.backgrounded, &state.stopping) { state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); return; } let reset_result = { @@ -2498,6 +2701,7 @@ fn start_scan(state: Arc, output: Output) { }, ); state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); return; } update_status( @@ -2583,6 +2787,7 @@ fn start_scan(state: Arc, output: Output) { Ok(entries) => { if state.stopping.load(Ordering::Relaxed) { state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); return; } let scanned_count = entries.len(); @@ -2615,6 +2820,7 @@ fn start_scan(state: Arc, output: Output) { Ok((_cached_root, index)) => index, Err(_) => { state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); return; } } @@ -2625,6 +2831,7 @@ fn start_scan(state: Arc, output: Output) { &state.stopping, ) else { state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); return; }; index @@ -2685,6 +2892,7 @@ fn start_scan(state: Arc, output: Output) { } } state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); }); } @@ -2785,6 +2993,7 @@ fn run_server() -> io::Result<()> { "setBackground" => { if let Some(shared) = &state { let background = request.background.unwrap_or(false); + let process_id = request.process_id.unwrap_or(0); shared.backgrounded.store(background, Ordering::SeqCst); if background { trim_process_working_set(); @@ -2794,6 +3003,15 @@ fn run_server() -> io::Result<()> { "ok": true, "background": background })); + if background && process_id != 0 { + thread::spawn(move || { + // The renderer is destroyed shortly after the close + // event. Delay the trim so Chromium has first + // released its renderer/GPU resources naturally. + thread::sleep(Duration::from_millis(750)); + trim_process_tree_working_sets(process_id); + }); + } } else { output.send( &json!({ "id": request.id, "ok": false, "error": "not initialized" }), @@ -2970,3 +3188,96 @@ fn main() { std::process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::{ + lowercase_name_signature, name_signature, normalized_path_hash, Entry, SearchIndex, + }; + use std::sync::atomic::AtomicBool; + + #[test] + fn streaming_name_signature_preserves_case_folded_matches() { + assert_eq!( + lowercase_name_signature("RedScope-AI"), + name_signature("redscope-ai") + ); + assert_ne!(lowercase_name_signature("redscope"), 0); + assert_eq!(lowercase_name_signature("ab"), 0); + } + + #[test] + fn streaming_path_hash_normalizes_case_and_separators() { + assert_eq!( + normalized_path_hash("C:/Users/Puppet/AppData"), + normalized_path_hash("c:\\users\\puppet\\appdata") + ); + } + + #[test] + fn directory_tombstone_hides_descendants_without_eager_full_scan() { + let backgrounded = AtomicBool::new(false); + let stopping = AtomicBool::new(false); + let mut index = SearchIndex::from_entries_controlled( + vec![ + Entry { + path: r"C:\Apps\RedScope".to_string(), + is_directory: true, + size: 0, + }, + Entry { + path: r"C:\Apps\RedScope\redscope.exe".to_string(), + is_directory: false, + size: 42, + }, + Entry { + path: r"C:\Apps\Other\other.exe".to_string(), + is_directory: false, + size: 7, + }, + ], + &backgrounded, + &stopping, + ) + .expect("test index"); + + index.remove_tree(r"C:\Apps\RedScope"); + assert!(index.path_is_removed(r"C:\Apps\RedScope\redscope.exe")); + assert_eq!( + index + .query( + "redscope", + "all", + &[], + &[], + &[], + false, + false, + false, + false, + 20, + ) + .expect("query") + .len(), + 0 + ); + assert_eq!( + index + .query( + "other", + "all", + &[], + &[], + &[], + false, + false, + false, + false, + 20, + ) + .expect("query") + .len(), + 1 + ); + } +} diff --git a/native/updater/Cargo.lock b/native/updater/Cargo.lock index 77edcc4..6de2a13 100644 --- a/native/updater/Cargo.lock +++ b/native/updater/Cargo.lock @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "cshift-updater" -version = "0.0.2" +version = "0.0.3" dependencies = [ "serde", "serde_json", diff --git a/native/updater/Cargo.toml b/native/updater/Cargo.toml index 3c373b2..a1d063f 100644 --- a/native/updater/Cargo.toml +++ b/native/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-updater" -version = "0.0.2" +version = "0.0.3" edition = "2021" description = "Transactional Windows updater for CDriveShiftAI" diff --git a/package-lock.json b/package-lock.json index e73159d..3987007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cdriveshiftai", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cdriveshiftai", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "dependencies": { "lucide-react": "^0.536.0", diff --git a/package.json b/package.json index 34301a8..8cc381c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cdriveshiftai", - "version": "0.0.2", + "version": "0.0.3", "private": true, "description": "CDriveShiftAI - AI-assisted Windows disk organizer and safe cross-drive directory migration tool", "main": "dist-electron/main.js", @@ -23,6 +23,9 @@ "test:index-cache": "node scripts/smoke-index-cache-restart.mjs", "test:packaged-index-cache": "node scripts/smoke-packaged-index-cache.mjs", "test:background-cpu": "node scripts/smoke-background-cpu.mjs", + "test:app-performance": "node scripts/smoke-app-performance.mjs", + "test:tray-performance": "node scripts/smoke-tray-idle-performance.mjs", + "test:clean-exit": "node scripts/smoke-clean-exit.mjs", "test:auto-update": "node scripts/smoke-auto-update.mjs", "test:auto-update-rejection": "node scripts/smoke-auto-update.mjs --tamper", "test:auto-update-rollback": "node scripts/smoke-auto-update.mjs --invalid-start", diff --git a/scripts/smoke-app-performance.mjs b/scripts/smoke-app-performance.mjs new file mode 100644 index 0000000..d686765 --- /dev/null +++ b/scripts/smoke-app-performance.mjs @@ -0,0 +1,262 @@ +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; +import { + access, + link, + mkdir, + readFile, + rm, + stat, + utimes, + writeFile +} from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") { + throw new Error("The packaged performance smoke test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const executable = path.join( + workspace, + "release-ready", + "win-unpacked", + "CDriveShiftAI.exe" +); +const sourceData = path.join(workspace, ".cdriveshiftai-data"); +const sourceCache = path.join(sourceData, "search-index-v1.bin"); +const testData = path.join(sourceData, "test-temp", "app-performance"); +const testCache = path.join(testData, "search-index-v1.bin"); +const debugPort = 9357; +const wait = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +await access(executable); +const cacheStats = await stat(sourceCache); +if (cacheStats.size < 1024) { + throw new Error("A populated persisted index is required for the performance smoke test"); +} +await rm(testData, { recursive: true, force: true }); +await mkdir(testData, { recursive: true }); +await link(sourceCache, testCache); +await writeFile( + path.join(testData, "cdriveshiftai-state.json"), + JSON.stringify({ + settings: { + effectMode: "calm", + minimizeToTray: false + } + }), + "utf8" +); +await utimes(testCache, cacheStats.atime, new Date()); + +function processTreeSnapshot(rootPid) { + const output = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$rootPid=[int]$env:CSHIFT_ROOT_PID;", + "$all=@(Get-CimInstance Win32_Process);", + "$ids=[System.Collections.Generic.HashSet[int]]::new();", + "$null=$ids.Add($rootPid);", + "do{$before=$ids.Count;foreach($p in $all){", + "if($ids.Contains([int]$p.ParentProcessId)){$null=$ids.Add([int]$p.ProcessId)}}", + "}while($ids.Count -gt $before);", + "$rows=@();foreach($id in $ids){$p=Get-Process -Id $id -ErrorAction SilentlyContinue;", + "if($p){$meta=$all|Where-Object ProcessId -eq $id|Select-Object -First 1;", + "$rows+=[pscustomobject]@{name=$p.ProcessName;pid=$p.Id;commandLine=$meta.CommandLine;", + "cpu=$p.TotalProcessorTime.TotalSeconds;working=$p.WorkingSet64;", + "private=$p.PrivateMemorySize64}}};", + "[pscustomobject]@{cpu=(($rows|Measure-Object cpu -Sum).Sum);", + "working=(($rows|Measure-Object working -Sum).Sum);", + "private=(($rows|Measure-Object private -Sum).Sum);processes=$rows}", + "|ConvertTo-Json -Depth 4 -Compress" + ].join("") + ], + { + encoding: "utf8", + windowsHide: true, + env: { ...process.env, CSHIFT_ROOT_PID: String(rootPid) } + } + ); + return JSON.parse(output); +} + +async function fetchPage() { + let lastError; + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${debugPort}/json/list`); + if (response.ok) { + const pages = await response.json(); + const page = pages.find((candidate) => candidate.type === "page"); + if (page?.webSocketDebuggerUrl) return page; + } + } catch (error) { + lastError = error; + } + await wait(100); + } + throw lastError ?? new Error("Electron renderer target was unavailable"); +} + +const launchedAt = performance.now(); +const child = spawn( + executable, + [`--remote-debugging-port=${debugPort}`, "--no-first-run"], + { + windowsHide: true, + stdio: ["ignore", "ignore", "pipe"], + env: { + ...process.env, + CDRIVESHIFTAI_DATA_DIR: testData, + CDRIVESHIFTAI_SMOKE_QUIT_AFTER_READY_MS: "30000" + } + } +); +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); +}); + +let socket; +try { + const page = await fetchPage(); + socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + let nextId = 0; + const pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + }); + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async (expression) => { + const response = await send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (response.exceptionDetails) throw new Error(response.exceptionDetails.text); + return response.result?.value; + }; + await send("Runtime.enable"); + for (let attempt = 0; attempt < 80; attempt += 1) { + if (await evaluate("Boolean(document.querySelector('.app-shell'))")) break; + await wait(50); + } + const uiReadyMs = Math.round(performance.now() - launchedAt); + // Test runners do not always receive foreground activation. Exercise the + // same show path as the tray/global shortcut so the cooperative index loader + // is intentionally resumed for the foreground benchmark. + await evaluate( + "window.cDriveShiftAI?.navigateApp?.({view:'overview'}).catch(() => undefined)" + ); + if (process.env.CDRIVESHIFTAI_DISABLE_VISUAL_EFFECTS === "1") { + await evaluate( + "document.querySelectorAll('.background-fx,.fx-surface').forEach((node) => { node.style.display = 'none'; }); document.head.insertAdjacentHTML('beforeend', '')" + ); + } + + let indexer; + for (let attempt = 0; attempt < 160; attempt += 1) { + indexer = await evaluate( + "window.cDriveShiftAI?.indexerStatus?.().catch(() => undefined)" + ); + if (indexer?.state === "ready") break; + await wait(125); + } + const indexReadyMs = Math.round(performance.now() - launchedAt); + if (indexer?.state !== "ready" || indexer.entries <= 0) { + throw new Error(`Persisted index did not become ready: ${JSON.stringify(indexer)}`); + } + + await wait(6_500); + const before = processTreeSnapshot(child.pid); + const sampleStartedAt = performance.now(); + await wait(5_000); + const after = processTreeSnapshot(child.pid); + const elapsedSeconds = (performance.now() - sampleStartedAt) / 1_000; + const coreEquivalentPct = ((after.cpu - before.cpu) / elapsedSeconds) * 100; + const taskManagerPct = + coreEquivalentPct / (Number(process.env.NUMBER_OF_PROCESSORS) || 1); + const deltaStats = await stat(path.join(testData, "search-index-v1.delta")).catch( + () => ({ size: 0 }) + ); + const metrics = { + result: "ok", + uiReadyMs, + indexReadyMs, + indexMode: indexer.mode, + indexEntries: indexer.entries, + idleCoreEquivalentPct: Number(coreEquivalentPct.toFixed(2)), + idleTaskManagerPct: Number(taskManagerPct.toFixed(2)), + workingSetMB: Number((after.working / 1024 ** 2).toFixed(1)), + privateMemoryMB: Number((after.private / 1024 ** 2).toFixed(1)), + watcherDeltaBytes: deltaStats.size, + processes: after.processes.map((item) => { + const previous = before.processes.find((candidate) => candidate.pid === item.pid); + return { + name: item.name, + pid: item.pid, + commandLine: item.commandLine, + coreEquivalentPct: Number( + (((item.cpu - (previous?.cpu ?? item.cpu)) / elapsedSeconds) * 100).toFixed(2) + ), + workingSetMB: Number((item.working / 1024 ** 2).toFixed(1)) + }; + }) + }; + console.log(JSON.stringify(metrics, null, 2)); + if (uiReadyMs > 5_000) throw new Error(`First UI took ${uiReadyMs} ms`); + if (indexReadyMs > 20_000) throw new Error(`Persisted index took ${indexReadyMs} ms`); + if (coreEquivalentPct > 25) { + throw new Error(`Visible idle CPU is too high: ${coreEquivalentPct.toFixed(2)}% of one core`); + } + if (after.working > 1_600 * 1024 ** 2) { + throw new Error(`Process tree working set is too high: ${metrics.workingSetMB} MB`); + } + await evaluate("window.close()"); + await Promise.race([ + once(child, "exit"), + wait(5_000).then(() => { + throw new Error("Application did not exit after its main window closed"); + }) + ]); + if (/write EPIPE|uncaught exception|javascript error/i.test(stderr)) { + throw new Error(`Main process emitted a shutdown error: ${stderr}`); + } +} finally { + socket?.close(); + if (child.exitCode == null) { + execFileSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore" + }); + } + await utimes(sourceCache, cacheStats.atime, cacheStats.mtime); + for (let attempt = 0; attempt < 12; attempt += 1) { + try { + await rm(testData, { recursive: true, force: true }); + break; + } catch { + await wait(250); + } + } +} diff --git a/scripts/smoke-auto-update.mjs b/scripts/smoke-auto-update.mjs index b9a90ab..8f7ed72 100644 --- a/scripts/smoke-auto-update.mjs +++ b/scripts/smoke-auto-update.mjs @@ -18,6 +18,9 @@ if (process.platform !== "win32") { } const workspace = path.resolve(import.meta.dirname, ".."); +const currentVersion = JSON.parse( + await readFile(path.join(workspace, "package.json"), "utf8") +).version; const tamperPackage = process.argv.includes("--tamper"); const invalidStartupPackage = process.argv.includes("--invalid-start"); const suppliedOldPortable = process.argv @@ -27,14 +30,21 @@ const testRoot = path.join( workspace, ".cdriveshiftai-data", "test-temp", - "update-smoke-0.0.2" + `update-smoke-${currentVersion}` ); const oldPortable = suppliedOldPortable ?? - path.join(testRoot, "download", "CDriveShiftAI-x64-portable.exe"); + path.join( + workspace, + ".cdriveshiftai-data", + "test-temp", + "update-smoke-0.0.2", + "download", + "CDriveShiftAI-x64-portable.exe" + ); const distribution = path.join(testRoot, "distribution"); const target = path.join(distribution, "CDriveShiftAI-update-smoke.exe"); -const staging = path.join(distribution, ".cdriveshiftai-update", "0.0.2"); +const staging = path.join(distribution, ".cdriveshiftai-update", currentVersion); const packagePath = path.join(staging, "CDriveShiftAI-x64-portable.exe"); const helperPath = path.join(staging, "cshift-updater.exe"); const planPath = path.join(staging, "update-plan.json"); @@ -152,7 +162,7 @@ await writeFile( stagingDir: staging, backupPath, successMarker, - expectedVersion: "0.0.2", + expectedVersion: currentVersion, expectedSha512, logPath }, @@ -244,7 +254,7 @@ try { ); } else { const afterVersion = await fileVersion(target); - if (afterVersion !== "0.0.2") { + if (afterVersion !== currentVersion) { throw new Error(`Portable target was not replaced: ${afterVersion}`); } for (let attempt = 0; attempt < 40; attempt += 1) { diff --git a/scripts/smoke-clean-exit.mjs b/scripts/smoke-clean-exit.mjs new file mode 100644 index 0000000..83f27d5 --- /dev/null +++ b/scripts/smoke-clean-exit.mjs @@ -0,0 +1,99 @@ +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; +import { access } from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") { + throw new Error("The clean-exit smoke test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const executable = path.join( + workspace, + "release-ready", + "win-unpacked", + "CDriveShiftAI.exe" +); +const dataDirectory = path.join(workspace, ".cdriveshiftai-data"); +await access(executable); + +const startedAt = performance.now(); +const child = spawn(executable, [], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + CDRIVESHIFTAI_DATA_DIR: dataDirectory, + CDRIVESHIFTAI_SMOKE_QUIT_AFTER_READY_MS: "1800" + } +}); +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); +}); + +const exitResult = await Promise.race([ + once(child, "exit").then(([code]) => ({ code, timedOut: false })), + new Promise((resolve) => + setTimeout(() => resolve({ code: null, timedOut: true }), 12_000) + ) +]); +if (exitResult.timedOut) { + execFileSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore" + }); + throw new Error("CDriveShiftAI did not finish graceful shutdown in 12 seconds"); +} +await new Promise((resolve) => setTimeout(resolve, 500)); + +const processAudit = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$root=[System.IO.Path]::GetFullPath($env:CSHIFT_APP_ROOT);", + "$items=@(Get-CimInstance Win32_Process | Where-Object {", + "$_.ExecutablePath -and [System.IO.Path]::GetFullPath($_.ExecutablePath).StartsWith(", + "$root + [System.IO.Path]::DirectorySeparatorChar,", + "[System.StringComparison]::OrdinalIgnoreCase)} |", + "Select-Object Name,ProcessId,ParentProcessId,ExecutablePath);", + "$items | ConvertTo-Json -Compress" + ].join("") + ], + { + encoding: "utf8", + windowsHide: true, + env: { + ...process.env, + CSHIFT_APP_ROOT: path.dirname(executable) + } + } +).trim(); +const lingering = processAudit ? JSON.parse(processAudit) : []; +const lingeringList = Array.isArray(lingering) ? lingering : [lingering]; + +if (exitResult.code !== 0) { + throw new Error(`CDriveShiftAI exited with code ${exitResult.code}: ${stderr}`); +} +if (/write EPIPE|uncaught exception|javascript error/i.test(stderr)) { + throw new Error(`CDriveShiftAI emitted a main-process pipe error: ${stderr}`); +} +if (lingeringList.length > 0) { + throw new Error(`CDriveShiftAI left processes behind: ${JSON.stringify(lingeringList)}`); +} + +console.log( + JSON.stringify( + { + result: "ok", + gracefulExitMs: Math.round(performance.now() - startedAt), + epipeObserved: false, + lingeringProcesses: 0 + }, + null, + 2 + ) +); diff --git a/scripts/smoke-installed-update.mjs b/scripts/smoke-installed-update.mjs index 04c9059..1bed8d6 100644 --- a/scripts/smoke-installed-update.mjs +++ b/scripts/smoke-installed-update.mjs @@ -17,6 +17,9 @@ if (process.platform !== "win32") { } const workspace = path.resolve(import.meta.dirname, ".."); +const currentVersion = JSON.parse( + await readFile(path.join(workspace, "package.json"), "utf8") +).version; const testRoot = path.join( workspace, ".cdriveshiftai-data", @@ -32,7 +35,7 @@ const installDirectory = path.join(testRoot, "installation"); const installedExecutable = path.join(installDirectory, "CDriveShiftAI.exe"); const dataDirectory = path.join(installDirectory, ".cdriveshiftai-data"); const sentinel = path.join(dataDirectory, "update-sentinel.txt"); -const staging = path.join(testRoot, ".cdriveshiftai-update", "0.0.2"); +const staging = path.join(testRoot, ".cdriveshiftai-update", currentVersion); const packagePath = path.join(staging, "CDriveShiftAI-x64.exe"); const helperPath = path.join(staging, "cshift-updater.exe"); const planPath = path.join(staging, "update-plan.json"); @@ -120,6 +123,27 @@ async function fileVersion(candidate) { return output.trim(); } +async function listDotNetInstallUtilities() { + const result = await run( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$items=@(Get-CimInstance Win32_Process | Where-Object {", + "$_.Name -ieq 'InstallUtil.exe'} |", + "Select-Object Name,ProcessId,ParentProcessId,ExecutablePath,CommandLine);", + "$items | ConvertTo-Json -Compress" + ].join("") + ], + 10_000 + ); + if (result.code !== 0 || !result.stdout.trim()) return []; + const parsed = JSON.parse(result.stdout); + return Array.isArray(parsed) ? parsed : [parsed]; +} + async function stopInstalledProcesses() { const script = ` $root = [System.IO.Path]::GetFullPath($env:CSHIFT_SMOKE_INSTALL_PATH) @@ -174,6 +198,10 @@ async function cleanup() { await cleanup(); await mkdir(staging, { recursive: true }); +const dotNetUtilitiesBefore = await listDotNetInstallUtilities(); +const baselineUtilityIds = new Set( + dotNetUtilitiesBefore.map((item) => Number(item.ProcessId)) +); let completed = false; try { const install = await run(oldInstaller, [ @@ -185,6 +213,17 @@ try { `0.0.1 test installation failed (${install.code}): ${install.stderr}` ); } + await new Promise((resolve) => setTimeout(resolve, 750)); + const unexpectedUtilities = (await listDotNetInstallUtilities()).filter((item) => { + return !baselineUtilityIds.has(Number(item.ProcessId)); + }); + if (unexpectedUtilities.length > 0) { + throw new Error( + `CDriveShiftAI installer left a .NET InstallUtil child behind: ${JSON.stringify( + unexpectedUtilities + )}` + ); + } const beforeVersion = await fileVersion(installedExecutable); if (beforeVersion !== "0.0.1") { throw new Error(`Expected installed 0.0.1, received ${beforeVersion}`); @@ -208,7 +247,7 @@ try { stagingDir: staging, backupPath, successMarker, - expectedVersion: "0.0.2", + expectedVersion: currentVersion, expectedSha512, logPath }, @@ -227,7 +266,7 @@ try { const afterVersion = await fileVersion(installedExecutable); const sentinelValue = await readFile(sentinel, "utf8"); if ( - afterVersion !== "0.0.2" || + afterVersion !== currentVersion || sentinelValue !== "preserve-index-settings-migrations" ) { throw new Error( @@ -241,6 +280,16 @@ try { ) { throw new Error("Installed update left package, backup or preserved data behind"); } + const postUpdateUtilities = (await listDotNetInstallUtilities()).filter((item) => { + return !baselineUtilityIds.has(Number(item.ProcessId)); + }); + if (postUpdateUtilities.length > 0) { + throw new Error( + `Installed update left a .NET InstallUtil child behind: ${JSON.stringify( + postUpdateUtilities + )}` + ); + } const targetStats = await stat(installedExecutable); completed = true; console.log( @@ -253,6 +302,7 @@ try { silentInstallPathPreserved: true, applicationDataPreservedAcrossOldUninstaller: true, packageAndBackupDeletedAfterStart: true, + unexpectedDotNetInstallUtilityProcesses: 0, targetBytes: targetStats.size }, null, diff --git a/scripts/smoke-tray-idle-performance.mjs b/scripts/smoke-tray-idle-performance.mjs new file mode 100644 index 0000000..357d530 --- /dev/null +++ b/scripts/smoke-tray-idle-performance.mjs @@ -0,0 +1,202 @@ +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; +import { access, link, mkdir, rm, stat, utimes, writeFile } from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") { + throw new Error("The tray performance smoke test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const executable = path.join( + workspace, + "release-ready", + "win-unpacked", + "CDriveShiftAI.exe" +); +const sourceData = path.join(workspace, ".cdriveshiftai-data"); +const sourceCache = path.join(sourceData, "search-index-v1.bin"); +const testData = path.join(sourceData, "test-temp", "tray-idle-performance"); +const testCache = path.join(testData, "search-index-v1.bin"); +const wait = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +await access(executable); +const cacheStats = await stat(sourceCache); +if (cacheStats.size < 1024) { + throw new Error("A populated persisted index is required for the tray performance test"); +} +await rm(testData, { recursive: true, force: true }); +await mkdir(testData, { recursive: true }); +await link(sourceCache, testCache); +await writeFile( + path.join(testData, "cdriveshiftai-state.json"), + JSON.stringify({ + settings: { + effectMode: "calm", + minimizeToTray: true + } + }), + "utf8" +); +await utimes(testCache, cacheStats.atime, new Date()); + +function processTreeSnapshot(rootPid) { + const output = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "$rootPid=[int]$env:CSHIFT_ROOT_PID;", + "$all=@(Get-CimInstance Win32_Process);", + "$ids=[System.Collections.Generic.HashSet[int]]::new();", + "$null=$ids.Add($rootPid);", + "do{$before=$ids.Count;foreach($p in $all){", + "if($ids.Contains([int]$p.ParentProcessId)){$null=$ids.Add([int]$p.ProcessId)}}", + "}while($ids.Count -gt $before);", + "$rows=@();foreach($id in $ids){$p=Get-Process -Id $id -ErrorAction SilentlyContinue;", + "if($p){$meta=$all|Where-Object ProcessId -eq $id|Select-Object -First 1;", + "$rows+=[pscustomobject]@{name=$p.ProcessName;pid=$p.Id;commandLine=$meta.CommandLine;", + "cpu=$p.TotalProcessorTime.TotalSeconds;working=$p.WorkingSet64;", + "private=$p.PrivateMemorySize64}}};", + "$owned=@($rows|Where-Object {$_.name -in @('CDriveShiftAI','cshift-indexer','conhost')});", + "[pscustomobject]@{cpu=(($owned|Measure-Object cpu -Sum).Sum);", + "working=(($owned|Measure-Object working -Sum).Sum);", + "private=(($owned|Measure-Object private -Sum).Sum);processes=$owned}", + "|ConvertTo-Json -Depth 4 -Compress" + ].join("") + ], + { + encoding: "utf8", + windowsHide: true, + env: { ...process.env, CSHIFT_ROOT_PID: String(rootPid) } + } + ); + return JSON.parse(output); +} + +const child = spawn(executable, [], { + windowsHide: true, + stdio: ["ignore", "ignore", "pipe"], + env: { + ...process.env, + CDRIVESHIFTAI_DATA_DIR: testData, + CDRIVESHIFTAI_TRIM_DIAGNOSTICS: "1", + CDRIVESHIFTAI_SMOKE_CLOSE_TO_TRAY_AFTER_READY_MS: "8000", + CDRIVESHIFTAI_SMOKE_QUIT_AFTER_READY_MS: "20000" + } +}); +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); +}); + +try { + // The real cache currently contains millions of entries. Eight seconds lets + // the cooperative cache loader reach ready before the app follows its normal + // close-to-tray path; two more seconds lets Chromium release the renderer. + await wait(10_000); + const before = processTreeSnapshot(child.pid); + await wait(5_000); + const after = processTreeSnapshot(child.pid); + const elapsedSeconds = 5; + const coreEquivalentPct = ((after.cpu - before.cpu) / elapsedSeconds) * 100; + const taskManagerPct = + coreEquivalentPct / (Number(process.env.NUMBER_OF_PROCESSORS) || 1); + const renderers = after.processes.filter((item) => + /--type=renderer(?:\s|$)/i.test(item.commandLine ?? "") + ); + const indexer = after.processes.find((item) => item.name === "cshift-indexer"); + const metrics = { + result: "ok", + cacheBytes: cacheStats.size, + idleCoreEquivalentPct: Number(coreEquivalentPct.toFixed(2)), + idleTaskManagerPct: Number(taskManagerPct.toFixed(2)), + workingSetMB: Number((after.working / 1024 ** 2).toFixed(1)), + privateMemoryMB: Number((after.private / 1024 ** 2).toFixed(1)), + rendererProcesses: renderers.length, + indexerPresent: Boolean(indexer), + trimDiagnostics: stderr.trim(), + processes: after.processes.map((item) => { + const previous = before.processes.find((candidate) => candidate.pid === item.pid); + return { + name: item.name, + pid: item.pid, + commandLine: item.commandLine, + coreEquivalentPct: Number( + (((item.cpu - (previous?.cpu ?? item.cpu)) / elapsedSeconds) * 100).toFixed(2) + ), + workingSetMB: Number((item.working / 1024 ** 2).toFixed(1)) + }; + }) + }; + console.log(JSON.stringify(metrics, null, 2)); + if (!indexer) throw new Error("Tray mode stopped the native indexer"); + if (renderers.length > 0) { + throw new Error(`Tray mode retained ${renderers.length} renderer process(es)`); + } + if (coreEquivalentPct > 5) { + throw new Error(`Tray idle CPU is too high: ${coreEquivalentPct.toFixed(2)}% of one core`); + } + if (after.working > 450 * 1024 ** 2) { + throw new Error(`Tray process tree working set is too high: ${metrics.workingSetMB} MB`); + } + + const secondInstance = spawn(executable, [], { + windowsHide: true, + stdio: "ignore", + env: { ...process.env, CDRIVESHIFTAI_DATA_DIR: testData } + }); + await Promise.race([ + once(secondInstance, "exit"), + wait(3_000).then(() => { + throw new Error("Second application instance did not hand off to the tray process"); + }) + ]); + await wait(1_500); + const reopened = processTreeSnapshot(child.pid); + const reopenedRenderers = reopened.processes.filter((item) => + /--type=renderer(?:\s|$)/i.test(item.commandLine ?? "") + ); + if (reopenedRenderers.length === 0) { + throw new Error("Launching CDriveShiftAI again did not recreate the tray window"); + } + console.log( + JSON.stringify( + { + result: "reopen-ok", + reopenedRendererProcesses: reopenedRenderers.length + }, + null, + 2 + ) + ); + + await Promise.race([ + once(child, "exit"), + wait(12_000).then(() => { + throw new Error("Application did not complete its timed graceful shutdown"); + }) + ]); + if (/write EPIPE|uncaught exception|javascript error/i.test(stderr)) { + throw new Error(`Main process emitted a tray/shutdown error: ${stderr}`); + } +} finally { + if (child.exitCode == null) { + execFileSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore" + }); + } + await utimes(sourceCache, cacheStats.atime, cacheStats.mtime); + for (let attempt = 0; attempt < 12; attempt += 1) { + try { + await rm(testData, { recursive: true, force: true }); + break; + } catch { + await wait(250); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index d1b70f3..0bb511e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Bot, CloudOff, Database, Palette, ShieldCheck } from "lucide-react"; import { api } from "./lib/api"; import { @@ -19,12 +19,25 @@ import { BackgroundFX } from "./components/BackgroundFX"; import { Sidebar } from "./components/Sidebar"; import { Toasts, type ToastItem } from "./components/ui"; import { OverviewView } from "./views/OverviewView"; -import { SearchView } from "./views/SearchView"; -import { OwnershipMapView } from "./views/OwnershipMapView"; -import { AnalyzeView } from "./views/AnalyzeView"; -import { MigrateView } from "./views/MigrateView"; -import { HistoryView } from "./views/HistoryView"; -import { SettingsView } from "./views/SettingsView"; + +const SearchView = lazy(() => + import("./views/SearchView").then((module) => ({ default: module.SearchView })) +); +const OwnershipMapView = lazy(() => + import("./views/OwnershipMapView").then((module) => ({ default: module.OwnershipMapView })) +); +const AnalyzeView = lazy(() => + import("./views/AnalyzeView").then((module) => ({ default: module.AnalyzeView })) +); +const MigrateView = lazy(() => + import("./views/MigrateView").then((module) => ({ default: module.MigrateView })) +); +const HistoryView = lazy(() => + import("./views/HistoryView").then((module) => ({ default: module.HistoryView })) +); +const SettingsView = lazy(() => + import("./views/SettingsView").then((module) => ({ default: module.SettingsView })) +); const fallbackStatus: IndexerStatus = { mode: "loading", @@ -152,8 +165,11 @@ export default function App() { }); const offSettings = api.onSettingsChanged(setSettings); const offUpdate = api.onUpdateStatus(setUpdateInfo); - void api.checkForUpdates().then(setUpdateInfo).catch(() => undefined); + const updateTimer = window.setTimeout(() => { + void api.checkForUpdates().then(setUpdateInfo).catch(() => undefined); + }, 2_500); return () => { + window.clearTimeout(updateTimer); offIndexer(); offMigration(); offNavigation(); @@ -367,7 +383,19 @@ export default function App() { -
{viewContent}
+
+ + + 正在打开功能页面 + 界面模块按需载入,索引和数据不会重新构建。 + + } + > + {viewContent} + +
setToasts((items) => items.filter((x) => x.id !== id))} /> diff --git a/src/components/BackgroundFX.tsx b/src/components/BackgroundFX.tsx index eedbe93..b99b583 100644 --- a/src/components/BackgroundFX.tsx +++ b/src/components/BackgroundFX.tsx @@ -33,11 +33,22 @@ export function BackgroundFX({ mode }: { mode: EffectMode }) { if (!context) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - const particles = createParticles(56); + const particles = createParticles(48); let width = 0; let height = 0; let animation = 0; - let lastDraw = 0; + let timer = 0; + let activeUntil = performance.now() + 6_000; + + const markInteractive = () => { + const wasIdle = performance.now() >= activeUntil; + activeUntil = performance.now() + 6_000; + if (wasIdle && !document.hidden && !animation) { + if (timer) window.clearTimeout(timer); + timer = 0; + animation = requestAnimationFrame(draw); + } + }; const resize = () => { width = window.innerWidth; @@ -423,14 +434,16 @@ export function BackgroundFX({ mode }: { mode: EffectMode }) { const draw = (time: number) => { animation = 0; if (document.hidden) return; - if (time - lastDraw >= (reducedMotion ? 120 : 32)) { - const visualTime = reducedMotion ? 0 : time; - if (mode === "aurora") drawBlockWorld(visualTime); - else if (mode === "matrix") drawTechnology(visualTime); - else drawCrystal(visualTime); - lastDraw = time; - } - animation = requestAnimationFrame(draw); + const interactive = document.hasFocus() && time < activeUntil; + const frameInterval = reducedMotion ? 2_000 : interactive ? 40 : 2_000; + const visualTime = reducedMotion ? 0 : time; + if (mode === "aurora") drawBlockWorld(visualTime); + else if (mode === "matrix") drawTechnology(visualTime); + else drawCrystal(visualTime); + timer = window.setTimeout(() => { + timer = 0; + animation = requestAnimationFrame(draw); + }, frameInterval); }; const updateVisibility = () => { @@ -438,22 +451,32 @@ export function BackgroundFX({ mode }: { mode: EffectMode }) { document.documentElement.classList.toggle("background-suspended", hidden); if (hidden) { if (animation) cancelAnimationFrame(animation); + if (timer) window.clearTimeout(timer); animation = 0; + timer = 0; return; } - lastDraw = performance.now(); if (!animation) animation = requestAnimationFrame(draw); }; resize(); window.addEventListener("resize", resize); + window.addEventListener("focus", markInteractive); + window.addEventListener("pointermove", markInteractive, { passive: true }); + window.addEventListener("pointerdown", markInteractive, { passive: true }); + window.addEventListener("keydown", markInteractive); document.addEventListener("visibilitychange", updateVisibility); updateVisibility(); return () => { window.removeEventListener("resize", resize); + window.removeEventListener("focus", markInteractive); + window.removeEventListener("pointermove", markInteractive); + window.removeEventListener("pointerdown", markInteractive); + window.removeEventListener("keydown", markInteractive); document.removeEventListener("visibilitychange", updateVisibility); document.documentElement.classList.remove("background-suspended"); if (animation) cancelAnimationFrame(animation); + if (timer) window.clearTimeout(timer); }; }, [mode]); diff --git a/src/lib/api.ts b/src/lib/api.ts index 0eb4732..818f55d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -202,11 +202,11 @@ const browserFallback: CDriveShiftApi = { status: "available", phase: "downloading", distribution: "portable", - currentVersion: "0.0.1", - latestVersion: "0.0.2", + currentVersion: "0.0.2", + latestVersion: "0.0.3", updateAvailable: true, canAutoUpdate: true, - releaseName: "CDriveShiftAI 0.0.2", + releaseName: "CDriveShiftAI 0.0.3", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", publishedAt: new Date().toISOString(), assets: [], @@ -231,14 +231,14 @@ const browserFallback: CDriveShiftApi = { status: "current", phase: "current", distribution: "development", - currentVersion: "0.0.2", - latestVersion: "0.0.2", + currentVersion: "0.0.3", + latestVersion: "0.0.3", updateAvailable: false, canAutoUpdate: false, - releaseName: "CDriveShiftAI 0.0.2", + releaseName: "CDriveShiftAI 0.0.3", releaseUrl: "https://github.com/PuppetWen/CDriveShiftAI/releases/latest", assets: [], - message: "当前已是最新版本 0.0.2", + message: "当前已是最新版本 0.0.3", checkedAt: new Date().toISOString() } as const; }, diff --git a/src/main.tsx b/src/main.tsx index 9036837..4d76ef2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,19 @@ -import { StrictMode } from "react"; +import { lazy, StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; -import { QuickSearchWindow } from "./views/QuickSearchWindow"; -import { UninstallRestoreView } from "./views/UninstallRestoreView"; import "./styles.css"; +const QuickSearchWindow = lazy(() => + import("./views/QuickSearchWindow").then((module) => ({ + default: module.QuickSearchWindow + })) +); +const UninstallRestoreView = lazy(() => + import("./views/UninstallRestoreView").then((module) => ({ + default: module.UninstallRestoreView + })) +); + const mode = new URLSearchParams(window.location.search).get("mode"); const Root = mode === "quick-search" @@ -15,6 +24,8 @@ const Root = createRoot(document.getElementById("root")!).render( - + }> + + ); diff --git a/src/styles.css b/src/styles.css index 4b99585..86b58d7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -657,6 +657,32 @@ input:disabled { overflow: auto; } +.view-loading-shell { + display: grid; + min-height: calc(100vh - 48px); + place-content: center; + justify-items: center; + gap: 9px; + color: var(--muted); + text-align: center; +} + +.bootstrap-loading { + min-height: 100vh; + background: + radial-gradient(circle at 28% 24%, color-mix(in srgb, var(--mint) 8%, transparent), transparent 34%), + var(--page); +} + +.view-loading-shell strong { + color: var(--text); + font-size: 15px; +} + +.view-loading-shell small { + font-size: 11px; +} + .page { width: min(1250px, calc(100% - 56px)); min-height: 100%; @@ -1175,8 +1201,14 @@ input:disabled { .principle-card p { margin: 0; color: var(--muted); font-size: 9px; line-height: 1.7; } .principle-orbit { position: relative; display: grid; flex: 0 0 105px; height: 105px; place-items: center; border-radius: 50%; color: var(--mint); background: color-mix(in srgb, var(--mint) 7%, transparent); } .principle-orbit .orbit { position: absolute; border: 1px solid color-mix(in srgb, var(--mint) 25%, transparent); border-radius: 50%; } -.principle-orbit .one { width: 78px; height: 78px; animation: orbit-pulse 2.8s ease-in-out infinite; } -.principle-orbit .two { width: 116px; height: 116px; opacity: 0.45; animation: orbit-pulse 2.8s 0.8s ease-in-out infinite; } +.principle-orbit .one { width: 78px; height: 78px; } +.principle-orbit .two { width: 116px; height: 116px; opacity: 0.45; } +.principle-card:hover .principle-orbit .one { + animation: orbit-pulse 2.8s ease-in-out 2; +} +.principle-card:hover .principle-orbit .two { + animation: orbit-pulse 2.8s 0.8s ease-in-out 2; +} @keyframes orbit-pulse { 50% { transform: scale(1.08); opacity: 0.15; } } .principle-tags { display: flex; gap: 7px; margin-top: 14px; } .principle-tags span { display: inline-flex; align-items: center; gap: 4px; border-radius: 20px; color: var(--muted); background: var(--border); padding: 4px 7px; font-size: 8px; } diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 052e9d7..d71f3cb 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -740,7 +740,7 @@ export function SettingsView({
当前版本 - v{updateInfo?.currentVersion ?? "0.0.2"} + v{updateInfo?.currentVersion ?? "0.0.3"}
最新版本 diff --git a/tests/update-download.test.ts b/tests/update-download.test.ts index da0a4fd..201c60d 100644 --- a/tests/update-download.test.ts +++ b/tests/update-download.test.ts @@ -72,8 +72,12 @@ describe("auto-update downloader", () => { if (requests === 1) { response.writeHead(200, { "Content-Length": payload.length }); response.flushHeaders(); - response.write(payload.subarray(0, payload.length / 2)); - setTimeout(() => response.socket?.destroy(), 20); + // Wait until Node has handed the first half to the local socket before + // simulating a broken connection. Destroying on a fixed short timer can + // race before fetch receives the headers and creates the .part file. + response.write(payload.subarray(0, payload.length / 2), () => { + setTimeout(() => response.socket?.destroy(), 50); + }); return; } const match = /^bytes=(\d+)-$/.exec(String(request.headers.range ?? ""));