fix(terminal): 缩放/高频输出冻结修复(reflow 节流 + 写入批量化),新增诊断日志(7 天滚动 + 开关),v1.0.26
CI / typecheck + test + build (windows) (push) Canceled after 0s
CI / typecheck + test + build (windows) (push) Canceled after 0s
This commit is contained in:
1 parent
fd4f1a3956
commit
bca7033689
29 files changed
+728
-12
No files matched your search
@@ -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')
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user