fix(terminal): 缩放/高频输出冻结修复(reflow 节流 + 写入批量化),新增诊断日志(7 天滚动 + 开关),v1.0.26
CI / typecheck + test + build (windows) (push) Canceled after 0s

This commit is contained in:
Bill committed 2026-10-11 09:52:39 +08:00
1 parent fd4f1a3956
commit bca7033689
29 files changed
+728 -12

No files matched your search

+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')