Compare commits

..
2 Commits
Author SHA1 Message Date
Bill bca7033689 fix(terminal): 缩放/高频输出冻结修复(reflow 节流 + 写入批量化),新增诊断日志(7 天滚动 + 开关),v1.0.26
CI / typecheck + test + build (windows) (push) Waiting to run
2026-10-11 09:52:39 +08:00
Bill fd4f1a3956 docs(roadmap): 补录通道重传缺口(exe/blockmap 409 无删除重试路径)
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-10 17:02:35 +08:00
30 changed files with 729 additions and 12 deletions

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ tests/.ipc-channels.cjs
tests/.log-sanitizer.cjs
tests/.reserved-accelerators.cjs
tests/.terminal-title.cjs
tests/.app-log.cjs
release/
# stray local test artifacts
+11 -1
View File
@@ -44,6 +44,14 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过)
## 诊断日志
- 正式版落盘的诊断日志在 `<userData>/logs/app-YYYY-MM-DD.log`(**本地日期**命名,`src/main/appLog.ts`):**保留 7 天**(今天 + 前 7 天,更早即删),启动时清扫一次 + 每 24h unref 定时器再扫(长开机的进程跨午夜也会清);只动 `app-*.log` 命名匹配的文件,目录里的外来文件不碰。会话录制日志(LOG_START)是用户文件,永远不走这里
- **开关**:`settings.system.diagnosticLog`(默认开,设置页「系统」区)。闸门在 `appLog()` 里(`setAppLogEnabled` 由 `applySystemSettings` 每次设置变更时跟随,index.ts 启动时先按 settings 设好再写启动行);渲染层看门狗发送前也自查一遍省 IPC。关闭 = 什么都不记,但保留期清扫照常(它只删不写)
- 写入方:主进程启动行 / `unhandledRejection` / `uncaughtException` / 事件循环 stall 看门狗(`index.ts`);渲染层 stall 看门狗经 `APP_LOG` IPC 中继(`main.tsx` → preload → `ipc.ts`)。`appLog()` 走异步串行队列、失败静默——**日志永远不能搞挂应用本身**;单行上限 2000 字符、内嵌换行压平、source 列定宽对齐
- 排障口径:卡死报告先让用户发当天的 app log——`[main] event loop stalled` = 主进程阻塞,`[renderer] main thread stalled` = 渲染层卡死,两条都没有 = 怀疑 GPU/外部因素
- 测试:`node tests/app-log.mjs`(经 `tests/.app-log.cjs` bundle;命名/保留窗口表驱动 + 真实临时目录的清扫、追加与开关门控)
## 更新机制
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
@@ -68,7 +76,9 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 终端尺寸同步
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效
- **fit 本身有 150ms 节流**(`src/renderer/src/terminal/fitThrottle.ts` 的 `refitDelay`,纯函数,`tests/refit-throttle.mjs` 表驱动):一次 fit 会全量 reflow 整个 scrollback(默认 5000 行,CJK 密集时几十 ms),拖动窗口每帧一次会占满主线程、饿死 xterm 写入队列,UI 在拖动结束后冻结数秒排积压。拖动期间 fit 最多每 150ms 一次,trailing 定时器在事件流停止后补一次终态 fit;`lastFitAtRef = 0` 表示「还没 fit 过」,**首次 fit 绝不等待**(PTY 以占位尺寸启动、shell 可能已在画),重绑时会重置
- 拖动窗口期间 xterm 网格按 150ms 节流更新,PTY 尺寸在停止后 100ms 生效
- **PTY 输出经 `WriteBatcher` 批量进 xterm**(`src/renderer/src/terminal/writeBatcher.ts`,`tests/write-batcher.mjs`):高频 TUI 每个 node-pty 读块一条 IPC,每条都 `term.write` 会让 xterm 解析调度器每秒重启数百次、永远追不上生产者。批量化后每 ~16ms 窗口合并成一次 write(与 carry flush 同节奏),顺序不变。两个别忘了的接线点:① `markDead` 先 `batcher.flushNow()` 再置 dead 标志——PTY_EXIT 在数据之后、flush 定时器之前到达,dead 后写入会被丢弃;② effect cleanup 里 `batcher.dispose()`(重绑的 pane 靠 session replay 补回,不丢可见内容)
## 性能与安全边界(M11 第二轮)
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal Changelog
## v1.0.26 - 2026-10-11
This release is about performance and diagnosability: it fixes the multi-second UI freezes when dragging to resize the window or when a terminal floods output, and adds an on-disk diagnostic log (on by default, auto-cleaned after 7 days) so freeze reports finally come with evidence.
### Fixed: intermittent UI freezes
- While dragging to resize, the terminal grid reflow now runs at most once every 150ms instead of every frame, with a final reflow at the settled size — dragging no longer locks up the UI
- Terminal output is now written to xterm in 16ms batches: when several high-refresh TUIs (e.g. an AI coding assistant's concurrent subagents) flood the screen at once, output no longer backlogs into a multi-second freeze
- Together, these keep tab switching, typing and pane dragging responsive under heavy load
### New: diagnostic log
- New Settings → System toggle "Diagnostic log" (**on by default**)
- Key events — startup, UI freezes (with duration and which process stalled), crashes — are written to the log directory, one file per day, **auto-deleted after 7 days**
- While off, nothing is recorded
- Attach the day's log file when reporting a problem to pinpoint the freeze source
## v1.0.25 - 2026-10-10
## v1.0.23
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal 更新履歴
## v1.0.26 - 2026-10-11
今回の重点はパフォーマンスと診断性です:ウィンドウのドラッグリサイズや端末の高頻度出力で UI 全体が数秒間フリーズする問題を修正し、診断ログの保存(デフォルト有効・7 日で自動削除)を追加しました。フリーズ報告に証拠が添えられるようになります。
### 修正:断続的な UI フリーズ
- ドラッグリサイズ中の端末グリッド再配置は「毎フレーム全量」から「最大 150ms ごと」になり、ドラッグ終了時に最終サイズへ自動調整されます。ドラッグ中に UI が固まらなくなりました
- 端末出力は 16ms ごとのバッチ書き込みに:高頻度 TUI(AI コーディングアシスタントの並行サブタスクなど)が同時に出力を流しても、数秒のフリーズにつながる滞留がなくなりました
- この 2 つにより、高負荷時のタブ切り替え・入力・ペインのドラッグが応答性を保ちます
### 新規:診断ログ
- 設定 → システムに「診断ログを記録」スイッチを追加(**デフォルト有効**)
- 起動・UI フリーズ(時間と発生プロセス付き)・クラッシュなどの主要イベントをログディレクトリに記録。日ごとのファイルで、**7 日間保持後に自動削除**
- オフの間は一切記録しません
- 問題報告の際に当日のログファイルを添付すれば、フリーズ原因を正確に特定できます
## v1.0.25 - 2026-10-10
## v1.0.23
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal 更新日志
## v1.0.26 - 2026-10-11
本次重点是性能与可诊断性:修复了拖动缩放窗口或终端高频输出时整个界面冻结数秒的问题;新增落盘的诊断日志(默认开启,7 天自动清理),以后反馈卡顿可以附上日志,定位不再靠猜。
### 修复:界面间歇性冻结
- 拖动缩放窗口时,终端网格重排从「每帧一次全量重排」改为最多每 150ms 一次,松手后自动落到最终尺寸——拖动过程不再把界面卡死
- 终端输出改为每 16ms 批量写入:多个高刷新 TUI(如 AI 编程助手的并发子任务)同时刷屏时,输出不再积压成数秒的界面冻结
- 上述两个修复叠加后,重负载下切标签、打字、拖动分屏都应保持可交互
### 新增:诊断日志
- 设置 → 系统新增「记录诊断日志」开关(**默认开启**)
- 启动、界面卡死(含卡顿时长与所在进程)、崩溃等关键事件写入日志目录,按天一个文件,**保留 7 天自动清理**
- 关闭后不再记录任何内容
- 反馈问题时附上当天的日志文件即可精确定位卡顿来源
## v1.0.25 - 2026-10-10
新增后台更新能力:应用可以在后台定时检查新版本,并在发现更新时静默下载好,安装时机仍完全由你决定。
+15
View File
@@ -1,5 +1,20 @@
# OpenTerminal 更新日誌
## v1.0.26 - 2026-10-11
本次重點是效能與可診斷性:修復拖曳縮放視窗或終端機高頻輸出時整個介面凍結數秒的問題;新增落盤的診斷日誌(預設開啟,7 天自動清理),日後回報卡頓可附上月誌,定位不再靠猜。
### 修復:介面間歇性凍結
- 拖曳縮放視窗時,終端機網格重排從「每幀一次全量重排」改為最多每 150ms 一次,放開後自動落到最終尺寸——拖曳過程不再把介面卡死
- 終端機輸出改為每 16ms 批次寫入:多個高刷新 TUI(如 AI 程式助手的並發子任務)同時刷屏時,輸出不再積壓成數秒的介面凍結
- 上述兩個修復疊加後,重負載下切換分頁、打字、拖曳分割畫面都應保持可互動
### 新增:診斷日誌
- 設定 → 系統新增「記錄診斷日誌」開關(**預設開啟**)
- 啟動、介面凍結(含凍結時長與所在處理序)、當機等關鍵事件寫入日誌目錄,每日一個檔案,**保留 7 天自動清除**
- 關閉後不再記錄任何內容
- 回報問題時附上當天的日誌檔案即可精確定位卡頓來源
## v1.0.25 - 2026-10-10
## v1.0.23
+1
View File
@@ -168,6 +168,7 @@
- [ ] 「启动时自动检查」运行中途拨开会在 ~5s 后立即检查(startupArmed 语义应为下次启动生效)
- [ ] 下载无整体超时:检查有 withTimeout,下载没有——慢滴流可让 downloading 卡死且自动下载不再重试(需设计,不能简单套超时杀死慢速下载)
- [ ] release.cjs `--channel-only` 与 `--skip-gitea` 组合矛盾(channel-only 先 return,警告永不打印,Gitea 通道仍会上传)
- [ ] release.cjs 通道重传缺口:exe/blockmap 的 PUT 撞 409 直接 throw(只有 release-notes/latest.yml 有「409→删除→重传」路径),同版本替换资产时必须先手动 DELETE 通道文件
- [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制)
- [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令)
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
"version": "1.0.25",
"version": "1.0.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "1.0.25",
"version": "1.0.26",
"license": "MIT",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.25",
"version": "1.0.26",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
@@ -13,7 +13,7 @@
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"pretest": "npm run typecheck",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs",
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs",
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir"
+121
View File
@@ -0,0 +1,121 @@
/**
* Diagnostic app log (appLog.ts).
*
* The "app froze, then recovered" watchdogs (`[main]` / `[renderer] stalled`)
* used to reach only the console, which a packaged build never shows — field
* reports came back with no evidence attached. This module persists them:
* daily files `app-YYYY-MM-DD.log` under `<userData>/logs`, appended
* asynchronously off the event loop's critical path, one event per line.
*
* Retention: files older than APP_LOG_RETENTION_DAYS are deleted at startup
* and once a day afterwards (a long-running process crosses midnights). Only
* names matching the app log pattern are touched — foreign files in the
* directory are left alone. Session recordings (LOG_START) are user files
* and never come near this mechanism.
*
* The pure naming/retention helpers are table-tested by tests/app-log.mjs
* through the bundled build (tests/.app-log.cjs).
*/
import { app } from 'electron'
import { appendFile, mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
export const APP_LOG_RETENTION_DAYS = 7
const NAME_RE = /^app-(\d{4})-(\d{2})-(\d{2})\.log$/
const DAY_MS = 24 * 60 * 60 * 1000
/** One event is one line; runaway payloads are truncated, not trusted. */
const MAX_LINE = 2000
const pad = (n: number, w = 2): string => String(n).padStart(w, '0')
/** `app-YYYY-MM-DD.log`, on the LOCAL date — users read these by hand. */
export function appLogName(date: Date): string {
return `app-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.log`
}
/** Local midnight (ms) encoded in an app log file name, or null for foreign names. */
export function parseAppLogName(name: string): number | null {
const m = NAME_RE.exec(name)
if (!m) return null
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime()
}
/**
* A log file expires when its day STARTS more than `retentionDays` before
* today's start: today plus the previous N days are kept, anything older
* goes. Foreign names are never expired.
*/
export function isExpiredAppLog(name: string, now: Date, retentionDays = APP_LOG_RETENTION_DAYS): boolean {
const day = parseAppLogName(name)
if (day === null) return false
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
return day < today - retentionDays * DAY_MS
}
let logDir: string | null = null
let queue: Promise<unknown> = Promise.resolve()
// The settings gate (settings.system.diagnosticLog, default on). Off = appLog
// records nothing; the retention sweep still runs, it only deletes.
let enabled = true
/** Follow `settings.system.diagnosticLog`; called on every settings mutation. */
export function setAppLogEnabled(on: boolean): void {
enabled = on
}
function enqueue(task: () => Promise<unknown>): Promise<void> {
// Serialize appends, and never let a logging failure escape into the app.
const next = queue.then(task).then(
(): void => undefined,
(): void => undefined
)
queue = next
return next
}
/**
* Point the log at `<userData>/logs` (or `dir` in tests), sweep expired
* files, and re-sweep once a day so a process that never restarts still
* cleans up. The daily timer is unref'd like every other timer in main.
*/
export function initAppLog(dir?: string): void {
const target = dir ?? join(app.getPath('userData'), 'logs')
logDir = target
void enqueue(async () => {
await mkdir(target, { recursive: true })
await sweepAppLogs(target)
})
setInterval(() => {
void sweepAppLogs(target).catch(() => undefined)
}, DAY_MS).unref()
}
/** Remove expired log files under `dir`; returns the removed names. */
export async function sweepAppLogs(
dir: string,
now = new Date(),
retentionDays = APP_LOG_RETENTION_DAYS
): Promise<string[]> {
const entries = await readdir(dir)
const doomed = entries.filter((n) => isExpiredAppLog(n, now, retentionDays))
await Promise.all(doomed.map((n) => rm(join(dir, n), { force: true })))
return doomed
}
const stamp = (d: Date): string =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
/**
* Append one event line. Fire-and-forget for callers, but the returned
* promise settles once the line is on disk (tests drain on it). Before
* initAppLog — or with the setting gate off — the line is dropped, same as
* the console-only past. The source column is padded so stacked lines align.
*/
export function appLog(source: 'main' | 'renderer', line: string): Promise<void> {
if (!logDir || !enabled) return Promise.resolve()
const dir = logDir
const text = `[${stamp(new Date())}] [${source.padEnd(8)}] ${String(line).replace(/[\r\n]+/g, ' ').slice(0, MAX_LINE)}\n`
return enqueue(() => appendFile(join(dir, appLogName(new Date())), text, 'utf8'))
}
+16 -1
View File
@@ -11,6 +11,7 @@ import { applyMenuLockState } from './lockMenu'
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater'
import { appLog, initAppLog, setAppLogEnabled } from './appLog'
import { applyWindowChrome } from './windowChrome'
import { onLanguageChange, t } from '@shared/i18n'
import { getThemeById } from '@shared/theme'
@@ -72,6 +73,13 @@ if (!gotSingleInstanceLock) {
app
.whenReady()
.then(() => {
// The diagnostic log must exist before any IPC handler can reference it;
// startup also sweeps log files past the retention window. The gate is
// set from settings first, so even the startup line respects the switch.
setAppLogEnabled(loadSettings().system.diagnosticLog !== false)
initAppLog()
void appLog('main', `app started: v${app.getVersion()} (${process.platform}/${process.arch})`)
// Serve the configured background image (path lives in settings; anything
// else — including a path that is no longer configured — is refused, so the
// protocol cannot be used to read arbitrary files).
@@ -263,20 +271,27 @@ function createWindow(): void {
process.on('unhandledRejection', (reason) => {
console.error('[main] unhandledRejection:', reason)
void appLog('main', `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`)
})
process.on('uncaughtException', (err) => {
console.error('[main] uncaughtException:', err)
void appLog('main', `uncaughtException: ${err.stack ?? err.message}`)
})
// Diagnostics for "app froze then recovered" reports: WER logs an
// AppHangTransient but keeps no stack. A stall of THIS (UI) thread is what
// Windows flags as a hang, so measure event-loop lag and say so loudly when
// it happens — that distinguishes a main-process block from a renderer one.
// The same line goes to the on-disk diagnostic log (appLog.ts), which a
// packaged build's console cannot reach.
let lastTick = Date.now()
setInterval(() => {
const now = Date.now()
const lag = now - lastTick - 1000
if (lag > 2000) console.error(`[main] event loop stalled ~${lag}ms`)
if (lag > 2000) {
console.error(`[main] event loop stalled ~${lag}ms`)
void appLog('main', `event loop stalled ~${lag}ms`)
}
lastTick = now
}, 1000).unref()
+5
View File
@@ -36,6 +36,7 @@ import { CommandsStore, defaultCommandsPath } from './commands'
import type { CommandItem } from '../shared/commands'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty'
import { respondZmodem } from './zmodem'
import { appLog } from './appLog'
import type { ZmodemResponse } from '../shared/ipc'
/** The renderer document this app loads (dev builds load it from vite instead). */
@@ -149,6 +150,10 @@ export function registerIpc(): void {
Ipc.APP_INFO,
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
)
// Renderer watchdog reports land in the same diagnostic log as main's own.
ipcMain.on(Ipc.APP_LOG, (_event, line: string) => {
if (typeof line === 'string') void appLog('renderer', line)
})
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
+5
View File
@@ -22,6 +22,7 @@ import { broadcast } from './broadcast'
import { writeJson } from './store'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
import { setAppLogEnabled } from './appLog'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
@@ -372,6 +373,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
autoDownloadUpdate: candidate.autoDownloadUpdate === true,
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
restoreSession: candidate.restoreSession !== false,
diagnosticLog: candidate.diagnosticLog !== false,
shellIntegration: candidate.shellIntegration === true,
language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE
}
@@ -442,6 +444,9 @@ function applySystemSettings(system: SystemSettings): void {
}
// Re-register the global show/hide shortcut whenever system settings change.
applyGlobalShortcut(system.globalShowHide)
// The diagnostic log gate follows the setting live (appLog is imported here —
// it only touches electron/fs, so there is no cycle).
setAppLogEnabled(system.diagnosticLog !== false)
// Re-arm (or disarm) the periodic update check the same way. Routed through the
// injected applier instead of importing the updater, which would be a cycle.
updateScheduleApplier?.(system)
+1
View File
@@ -15,6 +15,7 @@ import type { TransferProgressEvent } from '../shared/sftp'
const api: AppApi = {
appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO),
appLog: (line: string) => ipcRenderer.send(Ipc.APP_LOG, line),
createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts),
openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts),
+11 -2
View File
@@ -14,7 +14,9 @@ import './global.css'
// Diagnostics: report renderer main-thread freezes after recovery. console.error
// is surfaced into the dev terminal by the main process's console-message hook,
// so a "卡死又恢复" report tells us which process stalled and for how long.
// and the same line goes through APP_LOG into the on-disk diagnostic log, so a
// packaged build's "卡死又恢复" report also tells us which process stalled and
// for how long.
{
let lastTick = performance.now()
setInterval(() => {
@@ -26,7 +28,13 @@ import './global.css'
// tray. A hidden window also has nothing user-visible to freeze, so the
// report is skipped until the page is visible again.
if (lag > 3000 && !document.hidden) {
console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
const line = `main thread stalled ~${Math.round(lag)}ms`
console.error(`[renderer] ${line}`)
// The settings gate is enforced again in main; checking here just saves
// the IPC round-trip for a stalled-process report nobody wants stored.
if (useSettingsStore.getState().settings.system.diagnosticLog !== false) {
window.api?.appLog?.(line)
}
}
lastTick = now
}, 2000)
@@ -49,6 +57,7 @@ if (typeof window !== 'undefined' && !window.api) {
})
const api: AppApi = {
appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }),
appLog: noop,
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
openSession: async () => ({ id: stubId() }),
getSessionReplay: async () => '',
@@ -416,6 +416,16 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label={t('settings.system.diagnosticLog')}
desc={t('settings.system.diagnosticLogDesc')}
control={
<Switch
checked={settings.system.diagnosticLog !== false}
onChange={(checked) => void updateSystem({ diagnosticLog: checked })}
/>
}
/>
<SettingRow
label={t('settings.system.updateCheckInterval')}
desc={t('settings.system.updateCheckIntervalDesc')}
+53 -2
View File
@@ -19,6 +19,8 @@ import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
import { publishHighlightStats } from './highlightStats'
import { applyThemeColors } from '@renderer/theme/highlightColors'
import { findUrls } from './urlLinks'
import { WriteBatcher } from './writeBatcher'
import { refitDelay } from './fitThrottle'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
@@ -348,6 +350,12 @@ export function TerminalView({
const searchRef = useRef<SearchAddon | null>(null)
const observerRef = useRef<ResizeObserver | null>(null)
const timerRef = useRef<number>(0)
// Reflow throttle: `lastFitAtRef` is the last fit attempt's timestamp (0 =
// none yet, so a rebound session's first fit runs immediately),
// `fitTrailingRef` is the trailing timer that guarantees a final fit at the
// settled size once a drag's resize-event stream stops.
const lastFitAtRef = useRef(0)
const fitTrailingRef = useRef<number>(0)
// PTY resize bookkeeping: `ptyTimerRef` debounces the resize while a window
// drag/maximize settles, `ptySizeRef` drops the no-op resizes in between.
// Poking the pty on every intermediate layout tick makes full-screen TUIs
@@ -518,6 +526,24 @@ export function TerminalView({
const scheduleFit = useCallback(() => {
window.cancelAnimationFrame(timerRef.current)
timerRef.current = window.requestAnimationFrame(() => {
// Throttle reflows while a window drag resizes the pane every frame: one
// fit rewraps the whole scrollback (tens of ms at the default 5000
// lines, more with CJK), and one per frame saturates the main thread and
// starves xterm's write queue — the UI then stays frozen for seconds
// after the drag while the backlog drains. During a drag fits run at
// most every FIT_REFLOW_THROTTLE_MS; the trailing timer lands a final
// fit at the settled size.
const wait = refitDelay(lastFitAtRef.current, performance.now())
if (wait > 0) {
if (!fitTrailingRef.current) {
fitTrailingRef.current = window.setTimeout(() => {
fitTrailingRef.current = 0
scheduleFit()
}, wait)
}
return
}
lastFitAtRef.current = performance.now()
const fit = fitAddonRef.current
if (!fit) return
try {
@@ -907,6 +933,10 @@ export function TerminalView({
// from a template apply or panel reuse; recording resumes idle.
lineBufRef.current = ''
ptySizeRef.current = null
// A rebound session's first fit must not inherit the previous session's
// throttle window — the pty spawns at a placeholder size and the shell
// may already be drawing.
lastFitAtRef.current = 0
setSuggestions([])
setSuggestionIndex(0)
if (recordingRef.current) {
@@ -928,18 +958,28 @@ export function TerminalView({
// A shell prompt is exactly such a run with no follow-up data, so flush the
// carry on a short timer — prompts render within a frame, anchors survive.
let carryTimer: number | undefined
// Batch xterm writes: a busy TUI delivers one IPC message per pty read,
// and every term.write re-arms xterm's parser scheduler — hundreds of
// tiny writes a second keep the parser permanently behind the producer,
// which is what froze the UI for seconds after an output burst. The
// batcher hands output over in one write per ~16ms window (the carry
// cadence), so a flood costs one parser run per frame instead of one per
// IPC message.
const batcher = new WriteBatcher((text) => {
if (!deadRef.current) term.write(text)
})
const scheduleCarryFlush = (): void => {
if (carryTimer !== undefined || !stream.hasPending()) return
carryTimer = window.setTimeout(() => {
carryTimer = undefined
if (deadRef.current) return
const rest = stream.flush()
if (rest) term.write(rest)
if (rest) batcher.push(rest)
}, 16)
}
const writeHighlighted = (data: string) => {
const out = stream.push(data)
if (out) term.write(out)
if (out) batcher.push(out)
scheduleCarryFlush()
}
// Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser;
@@ -1076,6 +1116,10 @@ export function TerminalView({
// Death state, shared by the PTY_EXIT subscription and the SESSION_STATE
// query below: the two paths must land identically.
const markDead = (code: number): void => {
// The batcher may still hold the session's final output (PTY_EXIT lands
// after the data but before the flush timer); release it before the
// dead flag starts dropping writes.
batcher.flushNow()
deadRef.current = true
setExitCode(code)
setDead(true)
@@ -1158,6 +1202,13 @@ export function TerminalView({
return () => {
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
if (fitTrailingRef.current) {
window.clearTimeout(fitTrailingRef.current)
fitTrailingRef.current = 0
}
// Teardown/rebind drops whatever the batcher still holds; a rebound
// pane re-fetches the session replay, so nothing visible is lost.
batcher.dispose()
host?.removeEventListener('keydown', onPasteKey, true)
for (const unsubscribe of unsubscribes) unsubscribe()
for (const disposable of disposables) disposable.dispose()
+28
View File
@@ -0,0 +1,28 @@
/**
* Refit throttle (fitThrottle.ts).
*
* `FitAddon.fit()` triggers an xterm resize, which reflows the WHOLE
* scrollback — tens of milliseconds at the default 5000 lines, more with
* CJK-dense buffers. A window drag fires the pane's ResizeObserver every
* animation frame, so an unthrottled fit-per-frame saturates the main
* thread and starves xterm's write queue; the UI then stays frozen for
* seconds after the drag while the backlog drains. The throttle caps
* reflows during a drag; a trailing timer in TerminalView lands the final
* fit at the settled size.
*
* Pure decision function (the rAF/trailing-timer wiring stays in
* TerminalView), table-tested by tests/refit-throttle.mjs.
*/
export const FIT_REFLOW_THROTTLE_MS = 150
/**
* Milliseconds to wait before the next fit may reflow; 0 = fit now.
* `lastFitAt <= 0` marks "no fit recorded yet" — a session's first fit must
* never wait, because the pty spawns at a placeholder size and the shell may
* already be drawing. `now` comes from a monotonic clock (performance.now).
*/
export function refitDelay(lastFitAt: number, now: number, throttleMs: number = FIT_REFLOW_THROTTLE_MS): number {
if (lastFitAt <= 0) return 0
const wait = throttleMs - (now - lastFitAt)
return wait > 0 ? wait : 0
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Output write batching (writeBatcher.ts).
*
* A busy TUI delivers one IPC message per pty read, and every `term.write`
* re-arms xterm's parser scheduler — hundreds of tiny writes a second keep
* the parser permanently behind the producer, which is what freezes the UI
* for seconds after an output burst while the backlog drains. This batcher
* accumulates flushed output and hands it to xterm in ONE write per ~16ms
* window, so a flood costs one parser run per frame instead of one per IPC
* message. Ordering is preserved: pushes concatenate and flush in arrival
* order.
*
* Pure + timer-only (no electron/xterm surface), so the self-test drives it
* with real timers at a small delay: tests/write-batcher.mjs.
*/
export class WriteBatcher {
private buf = ''
private timer: ReturnType<typeof setTimeout> | undefined
private readonly sink: (text: string) => void
private readonly delayMs: number
constructor(sink: (text: string) => void, delayMs = 16) {
this.sink = sink
this.delayMs = delayMs
}
/** True while buffered text is waiting for the flush timer. */
hasPending(): boolean {
return this.buf.length > 0
}
/** Append `text`; the first push in a window arms the single flush timer. */
push(text: string): void {
if (!text) return
this.buf += text
if (this.timer === undefined) {
this.timer = setTimeout(() => this.flushNow(), this.delayMs)
}
}
/**
* Emit everything buffered right now (no-op when empty). Used by the timer,
* and by the session-death path: PTY_EXIT lands after the final data but
* before the flush timer, and a dead session drops writes — so the exit
* handler flushes first.
*/
flushNow(): void {
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
if (!this.buf) return
const out = this.buf
this.buf = ''
this.sink(out)
}
/**
* Drop buffered text and cancel the timer (session teardown / rebind). A
* rebound pane re-fetches the session replay, so nothing visible is lost.
*/
dispose(): void {
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
this.buf = ''
}
}
+2
View File
@@ -30,6 +30,8 @@ import type { ZmodemDoneEvent, ZmodemOfferEvent, ZmodemResponse } from './ipc'
*/
export interface AppApi {
appInfo(): Promise<AppInfo>
/** append one line to the diagnostic app log (watchdog reports) */
appLog(line: string): void
// ---- sessions (pty data plane is shared by local and ssh sessions) ----
createPty(opts?: PtyCreateOptions): Promise<PtyCreateResult>
+3
View File
@@ -110,6 +110,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell integration',
'settings.system.shellIntegrationDesc':
'Let the shell report its working directory (OSC 7) for more precise path tracking, including cd inside scripts; it wraps your prompt, so turn it off if the prompt theme looks wrong',
'settings.system.diagnosticLog': 'Diagnostic log',
'settings.system.diagnosticLogDesc':
'Write key events (startup, UI freezes, crashes) to the log directory — one file per day, auto-deleted after 7 days; nothing is recorded while this is off',
'settings.system.closeAction': 'Close button behavior',
'settings.system.closeActionDesc':
'What happens when you click the window close button; after minimizing to the tray you can quit from the tray icon context menu',
+3
View File
@@ -107,6 +107,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell 統合',
'settings.system.shellIntegrationDesc':
'shell に作業ディレクトリを報告させ(OSC 7)、パスの記憶をより正確にします(スクリプト内の cd も含みます)。プロンプトをラップするため、プロンプトの表示が崩れる場合はオフにしてください',
'settings.system.diagnosticLog': '診断ログを記録',
'settings.system.diagnosticLogDesc':
'起動・UI フリーズ・クラッシュなどの主要イベントをログディレクトリに書き込みます(日ごとのファイル、7 日間保持で自動削除)。オフの間は記録しません',
'settings.system.closeAction': '閉じるボタンの動作',
'settings.system.closeActionDesc':
'ウィンドウの閉じるボタンを押したときの動作。タスクトレイに最小化した後は、トレイアイコンの右クリックメニューから終了できます',
+3
View File
@@ -105,6 +105,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell 集成',
'settings.system.shellIntegrationDesc':
'让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可',
'settings.system.diagnosticLog': '记录诊断日志',
'settings.system.diagnosticLogDesc':
'把启动、界面卡死、崩溃等关键事件写入日志目录(按天一个文件,保留 7 天自动清理),排查问题时用;关闭后不再记录',
'settings.system.closeAction': '关闭按钮行为',
'settings.system.closeActionDesc':
'点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出',
+3
View File
@@ -105,6 +105,9 @@ const settings: Record<string, string> = {
'settings.system.shellIntegration': 'Shell 整合',
'settings.system.shellIntegrationDesc':
'讓 shell 主動回報工作目錄(OSC 7),路徑記憶更精確(含指令碼內的 cd);會包裝你的命令提示字元,提示字元主題顯示異常時關閉即可',
'settings.system.diagnosticLog': '記錄診斷日誌',
'settings.system.diagnosticLogDesc':
'將啟動、介面凍結、當機等關鍵事件寫入日誌目錄(每日一個檔案,保留 7 天自動清除),排查問題時使用;關閉後不再記錄',
'settings.system.closeAction': '關閉按鈕行為',
'settings.system.closeActionDesc':
'點擊視窗關閉按鈕時的動作;最小化至系統匣後,可從系統匣圖示的右鍵選單結束',
+2
View File
@@ -98,6 +98,8 @@ export const Ipc = {
// ---- misc ----
APP_INFO: 'app:info',
/** renderer -> main: append one line to the diagnostic app log (fire-and-forget) */
APP_LOG: 'app:log',
// ---- updater (check/download/install + state broadcast) ----
UPDATE_CHECK: 'update:check',
+8 -1
View File
@@ -207,6 +207,12 @@ export interface SystemSettings {
shellIntegration?: boolean
/** interface language (see @shared/i18n); missing = 简体中文 */
language?: Language
/**
* Persist the diagnostic log (startup, freeze watchdogs, crashes) to
* <userData>/logs with a 7-day rolling cleanup. On by default — the log is
* what makes "the app froze" field reports diagnosable. Off = record nothing.
*/
diagnosticLog?: boolean
}
/**
@@ -501,7 +507,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
autoDownloadUpdate: false,
restoreSession: true,
shellIntegration: false,
language: 'zh-CN'
language: 'zh-CN',
diagnosticLog: true
},
// Off until the user sets a password and turns it on: an app that locks
// itself out of the box would be a support ticket, not a feature.
+152
View File
@@ -0,0 +1,152 @@
/**
* Diagnostic app log self-test (app-log.mjs).
*
* `src/main/appLog.ts` persists the freeze watchdogs (`[main]` / `[renderer]
* stalled`) that a packaged build's console can never show. What is pinned:
*
* - daily naming is local-date (`app-YYYY-MM-DD.log`) and round-trips
* through the parser; foreign names parse to null;
* - retention keeps today plus the previous N days: a file exactly N days
* old stays, N+1 days is removed, foreign files in the directory are
* never touched;
* - `sweepAppLogs` on a real temp dir removes exactly the expired set and
* reports it;
* - `appLog` appends one `[stamp] [source] line` per call, serializes
* concurrent appends (order preserved), flattens embedded newlines, and
* truncates runaway payloads;
* - before `initAppLog` a line is dropped, not written anywhere.
*
* Build: node tests/build-bundles.cjs
* Run: node tests/app-log.mjs (must exit 0)
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const { appLog, appLogName, initAppLog, isExpiredAppLog, parseAppLogName, setAppLogEnabled, sweepAppLogs, APP_LOG_RETENTION_DAYS } =
require('./.app-log.cjs')
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const DAY = 24 * 60 * 60 * 1000
// A fixed LOCAL timestamp: 2026-10-11 12:00 local time.
const now = new Date(2026, 9, 11, 12, 0, 0)
const daysAgo = (n) => appLogName(new Date(now.getTime() - n * DAY))
console.log('daily naming is local-date and round-trips')
ok(appLogName(now) === 'app-2026-10-11.log', `name is app-2026-10-11.log (got ${appLogName(now)})`)
ok(parseAppLogName('app-2026-10-11.log') === new Date(2026, 9, 11).getTime(), 'name parses to local midnight')
ok(parseAppLogName(appLogName(now)) === new Date(2026, 9, 11).getTime(), 'name round-trips')
ok(appLogName(new Date(2026, 0, 5, 23, 59)) === 'app-2026-01-05.log', 'month/day are zero-padded')
console.log('foreign names never parse and never expire')
for (const name of ['app.log', 'app-2026-1-1.log', 'notes.txt', 'app-2026-13-01.log.log', 'app-2026-10-11.log.bak']) {
ok(parseAppLogName(name) === null, `${name} parses to null`)
ok(!isExpiredAppLog(name, now), `${name} never expires`)
}
console.log('retention keeps today plus the previous N days')
ok(!isExpiredAppLog(daysAgo(0), now), 'today is kept')
ok(!isExpiredAppLog(daysAgo(APP_LOG_RETENTION_DAYS), now), `exactly ${APP_LOG_RETENTION_DAYS} days old is kept`)
ok(isExpiredAppLog(daysAgo(APP_LOG_RETENTION_DAYS + 1), now), `${APP_LOG_RETENTION_DAYS + 1} days old expires`)
ok(isExpiredAppLog(daysAgo(365), now), 'a year-old file expires')
console.log('sweep removes exactly the expired set on a real directory')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
for (const n of [daysAgo(0), daysAgo(3), daysAgo(APP_LOG_RETENTION_DAYS), daysAgo(APP_LOG_RETENTION_DAYS + 1), daysAgo(60)]) {
writeFileSync(join(dir, n), 'x')
}
writeFileSync(join(dir, 'keep-me.txt'), 'x')
const removed = await sweepAppLogs(dir, now)
const left = readdirSync(dir).sort()
ok(removed.length === 2, `two files removed (got ${removed.length})`)
ok(removed.includes(daysAgo(APP_LOG_RETENTION_DAYS + 1)) && removed.includes(daysAgo(60)), 'the expired set is exactly right')
ok(left.length === 4 && left.includes('keep-me.txt') && left.includes(daysAgo(APP_LOG_RETENTION_DAYS)), 'today, boundary and foreign files survive')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('appLog appends serialized, single-line entries')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
initAppLog(dir)
await Promise.all([appLog('main', 'first'), appLog('renderer', 'second\nwith newline'), appLog('main', 'third')])
const content = readFileSync(join(dir, appLogName(new Date())), 'utf8')
const lines = content.trimEnd().split('\n')
ok(lines.length === 3, `three appends -> three lines (got ${lines.length})`)
ok(/\[main\s*\] first$/.test(lines[0]), 'order is preserved (1)')
ok(/\[renderer\] second with newline$/.test(lines[1]), 'embedded newlines are flattened (2)')
ok(/\[main\s*\] third$/.test(lines[2]), 'order is preserved (3)')
ok(lines.every((l) => /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[(?:main|renderer)\s*\] /.test(l)), 'every line carries a local timestamp and source')
ok(lines[0].includes('[main ]') && lines[1].includes('[renderer]'), 'the source column is padded to a fixed width for readability')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('the settings gate drops lines while off and resumes when on')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
initAppLog(dir)
await appLog('main', 'before off')
setAppLogEnabled(false)
await appLog('main', 'while off')
setAppLogEnabled(true)
await appLog('main', 'back on')
const content = readFileSync(join(dir, appLogName(new Date())), 'utf8')
ok(content.includes('before off'), 'on: line recorded')
ok(!content.includes('while off'), 'off: line dropped, nothing recorded')
ok(content.includes('back on'), 're-enabled: recording resumes')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('runaway payloads are truncated to one bounded line')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
initAppLog(dir)
await appLog('main', 'x'.repeat(10000))
const content = readFileSync(join(dir, appLogName(new Date())), 'utf8')
ok(content.length < 3000, `the line is bounded (got ${content.length} bytes)`)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
console.log('init sweeps expired files on the way up')
{
const dir = mkdtempSync(join(tmpdir(), 'ot-applog-'))
try {
// Relative to the REAL today: init sweeps with the wall clock.
writeFileSync(join(dir, 'app-2020-01-01.log'), 'x')
writeFileSync(join(dir, appLogName(new Date())), 'x')
initAppLog(dir)
await appLog('main', 'after init') // drains behind the init sweep in the same queue
const left = readdirSync(dir)
ok(!left.includes('app-2020-01-01.log'), 'expired file is gone after init')
ok(left.includes(appLogName(new Date())), "today's file survives init")
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
if (failed > 0) {
console.error(`\n[app-log] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[app-log] ALL CHECKS PASSED')
+3 -1
View File
@@ -71,7 +71,9 @@ const BUNDLES = [
// The smoke test imports the renderer engine (.ts), so it needs bundling too.
{ entry: 'tests/hl-split-smoke.mjs', out: 'tests/.hl-split-smoke.cjs' },
// Preset-rule assertions (word boundaries, case flag) over the same engine.
{ entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' }
{ entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' },
// Diagnostic app log: daily naming, retention sweep, async append queue.
{ entry: 'src/main/appLog.ts', out: 'tests/.app-log.cjs' }
]
for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) {
+49
View File
@@ -0,0 +1,49 @@
/**
* Refit-throttle self-test (refit-throttle.mjs).
*
* `src/renderer/src/terminal/fitThrottle.ts` decides whether a resize event
* may reflow now or must wait. The contract it pins:
*
* - `lastFitAt <= 0` (no fit recorded, e.g. a freshly bound session) always
* fits immediately — the pty spawns at a placeholder size and the shell
* may already be drawing, so the first fit must never wait;
* - inside the throttle window the answer is the REMAINING wait (feeds the
* trailing timer that lands the final fit at the settled size);
* - at and past the window boundary the fit runs now;
* - the window length is configurable so the test does not depend on the
* production constant's value.
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and
* no runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/refit-throttle.mjs (must exit 0)
*/
import { FIT_REFLOW_THROTTLE_MS, refitDelay } from '../src/renderer/src/terminal/fitThrottle.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
console.log('the first fit never waits')
ok(refitDelay(0, 1000) === 0, 'lastFitAt = 0 fits immediately')
ok(refitDelay(-1, 1000) === 0, 'a negative marker also fits immediately')
console.log('inside the window the answer is the remaining wait')
ok(refitDelay(1000, 1000) === FIT_REFLOW_THROTTLE_MS, 'a fit at the same tick waits the full window')
ok(refitDelay(1000, 1100) === FIT_REFLOW_THROTTLE_MS - 100, 'mid-window returns the remainder')
ok(refitDelay(1000, 1000 + FIT_REFLOW_THROTTLE_MS - 1) === 1, 'one ms before the boundary waits 1ms')
console.log('at and past the boundary the fit runs now')
ok(refitDelay(1000, 1000 + FIT_REFLOW_THROTTLE_MS) === 0, 'exactly at the boundary fits')
ok(refitDelay(1000, 100000) === 0, 'long after the last fit fits')
console.log('the window length is a parameter')
ok(refitDelay(1000, 1050, 150) === 100, 'explicit throttleMs is honored')
ok(refitDelay(1000, 1150, 150) === 0, 'explicit window boundary fits')
if (failed > 0) {
console.error(`\n[refit-throttle] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[refit-throttle] ALL CHECKS PASSED')
+105
View File
@@ -0,0 +1,105 @@
/**
* WriteBatcher self-test (write-batcher.mjs).
*
* `src/renderer/src/terminal/writeBatcher.ts` sits between the highlight
* stream and `term.write`: it exists because a busy TUI delivers one IPC
* message per pty read, and one `term.write` per message re-arms xterm's
* parser scheduler hundreds of times a second — the parser falls permanently
* behind and the UI freezes while the backlog drains. The contract the table
* below pins:
*
* - pushes inside one window land in a SINGLE sink call, concatenated in
* arrival order; the first push arms the only timer;
* - after a flush the batcher is reusable: the next push arms a new timer;
* - `flushNow` emits immediately and cancels the pending timer (the session
* -death path relies on this — PTY_EXIT lands before the timer would);
* - `dispose` drops buffered text and cancels the timer (teardown/rebind);
* - flushing an empty batcher never calls the sink.
*
* Direct .ts import (no esbuild bundle needed): the module has no imports and
* no runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/write-batcher.mjs (must exit 0)
*/
import { WriteBatcher } from '../src/renderer/src/terminal/writeBatcher.ts'
const DELAY = 10
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
const makeSink = () => {
const calls = []
return { calls, sink: (text) => calls.push(text) }
}
console.log('pushes inside one window land in a single ordered flush')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('a')
b.push('b')
b.push('c')
ok(calls.length === 0, 'nothing is written before the window elapses')
ok(b.hasPending(), 'hasPending reports buffered text')
await sleep(DELAY * 4)
ok(calls.length === 1, `one sink call for three pushes (got ${calls.length})`)
ok(calls[0] === 'abc', 'order is preserved across pushes')
ok(!b.hasPending(), 'buffer is empty after the flush')
b.dispose()
}
console.log('the batcher is reusable after a flush')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('first')
await sleep(DELAY * 4)
b.push('second')
await sleep(DELAY * 4)
ok(calls.length === 2 && calls[0] === 'first' && calls[1] === 'second', 'each window flushes once')
b.dispose()
}
console.log('flushNow emits immediately and cancels the pending timer')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('tail')
b.flushNow()
ok(calls.length === 1 && calls[0] === 'tail', 'flushNow writes synchronously')
await sleep(DELAY * 4)
ok(calls.length === 1, 'the cancelled timer never fires a second write')
b.dispose()
}
console.log('dispose drops buffered text and the timer')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.push('dropped')
b.dispose()
ok(!b.hasPending(), 'dispose clears the buffer')
await sleep(DELAY * 4)
ok(calls.length === 0, 'disposed batcher never writes')
}
console.log('an empty flush never reaches the sink')
{
const { calls, sink } = makeSink()
const b = new WriteBatcher(sink, DELAY)
b.flushNow()
b.push('')
await sleep(DELAY * 4)
ok(calls.length === 0, 'no sink call without content')
b.dispose()
}
if (failed > 0) {
console.error(`\n[write-batcher] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[write-batcher] ALL CHECKS PASSED')