cc-viewer 1.6.310 → 1.6.312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,6 +81,12 @@ const DEFAULT_PT_COALESCE_MS = envIntAllowZero('CCV_FLOOD_PT_COALESCE_MS', 16);
81
81
  * @param {(buf: string, rawStart: number) => number} opts.findSafeSliceStart - ANSI 安全截断(pty-manager 导出)
82
82
  * @param {(buffered: number) => void} [opts.onFloodStart] - 进入限流态(observability 埋点)
83
83
  * @param {() => void} [opts.onFloodEnd] - 回落直通态
84
+ * @param {(droppedChars: number) => void} [opts.onTruncate] - 本轮洪泛**实际丢过字节**且已回落直通时触发
85
+ * (紧随 onFloodEnd 之后,每轮洪泛至多一次,携带累计丢弃量)。安全切片只保证残片不上屏,
86
+ * 被截掉的中段对增量 TUI 流(macOS/Linux forkpty)不会自愈——调用方应在此用权威快照
87
+ * (getOutputBuffer → data-resync)对齐客户端画面。洪泛进行中不触发:中途 resync 快照
88
+ * 立刻过期且加重负载,回落时一次终态对齐即可。reset()/dispose() 清零累计(resync 路径
89
+ * 自身就是快照对齐,无需重复触发)。
84
90
  * @param {number} [opts.flushMs]
85
91
  * @param {number} [opts.floodThresholdBytesPerWin]
86
92
  * @param {number} [opts.fallbackWins]
@@ -97,6 +103,7 @@ export function createFloodCoalescer({
97
103
  findSafeSliceStart,
98
104
  onFloodStart,
99
105
  onFloodEnd,
106
+ onTruncate,
100
107
  flushMs = DEFAULT_FLUSH_MS,
101
108
  floodThresholdBytesPerWin = DEFAULT_FLOOD_THRESHOLD,
102
109
  fallbackWins = DEFAULT_FALLBACK_WINS,
@@ -111,6 +118,7 @@ export function createFloodCoalescer({
111
118
  let pending = '';
112
119
  let winBytes = 0; // 当前桶累计字节(直通态由 offer 累计,限流态由 flush 结算)
113
120
  let calmWins = 0; // 连续低于阈值的桶数
121
+ let droppedChars = 0; // 本轮洪泛累计实际丢弃的字符数(>0 时回落触发 onTruncate)
114
122
  let flushTimer = null; // 限流态周期 flush;直通态下亦作为桶边界 timer(见 offer)
115
123
  let ptBuffer = ''; // 直通态微合并缓冲(窗口开启期间到达的后续 chunk)
116
124
  let ptTimer = null; // 直通态微合并窗口 timer(16ms),与 flushTimer(33ms 字节桶)并存、职责正交
@@ -157,7 +165,9 @@ export function createFloodCoalescer({
157
165
  // 保证洪泛期送达客户端的字节率 ≤ flushBudgetBytes/flushMs,与前端消化速率同量级。
158
166
  if (pending.length > flushBudgetBytes) {
159
167
  const rawStart = pending.length - flushBudgetBytes;
160
- pending = pending.slice(findSafeSliceStart(pending, rawStart));
168
+ const safeStart = findSafeSliceStart(pending, rawStart);
169
+ droppedChars += safeStart;
170
+ pending = pending.slice(safeStart);
161
171
  }
162
172
  const merged = SYNC_BEGIN + pending + SYNC_END;
163
173
  pending = '';
@@ -179,6 +189,13 @@ export function createFloodCoalescer({
179
189
  flooding = false;
180
190
  calmWins = 0;
181
191
  try { onFloodEnd?.(); } catch { }
192
+ // 本轮洪泛丢过字节 → 回落后通知一次终态对齐(见 opts.onTruncate doc)。
193
+ // 置零在调用**前**:回调内若同步 reset()/re-offer 不会重复触发。
194
+ if (droppedChars > 0) {
195
+ const dropped = droppedChars;
196
+ droppedChars = 0;
197
+ try { onTruncate?.(dropped); } catch { }
198
+ }
182
199
  return; // 不再续约 timer,回直通
183
200
  }
184
201
  flushTimer = setTimer(onFloodTick, flushMs);
@@ -227,6 +244,7 @@ export function createFloodCoalescer({
227
244
  if (pending.length > pendingCap) {
228
245
  const rawStart = pending.length - trimTo;
229
246
  const safeStart = findSafeSliceStart(pending, rawStart);
247
+ droppedChars += safeStart;
230
248
  pending = pending.slice(safeStart);
231
249
  }
232
250
  },
@@ -239,6 +257,7 @@ export function createFloodCoalescer({
239
257
  winBytes = 0;
240
258
  calmWins = 0;
241
259
  flooding = false;
260
+ droppedChars = 0; // resync 路径调用本方法,快照即对齐,不再补发 onTruncate
242
261
  },
243
262
  isFlooding() {
244
263
  return flooding;
@@ -250,6 +269,7 @@ export function createFloodCoalescer({
250
269
  stopPtTimer();
251
270
  pending = '';
252
271
  ptBuffer = '';
272
+ droppedChars = 0;
253
273
  },
254
274
  };
255
275
  }
@@ -6,6 +6,7 @@ import { platform, arch, homedir } from 'node:os';
6
6
  import { createRequire } from 'node:module';
7
7
  import { prepareEmbeddedShellSpawn, stripClaudeNoFlickerUnlessOptedIn } from './lib/terminal-env.js';
8
8
  import { killPtyTree } from './lib/term-signals.js';
9
+ import { findSafeSliceStart } from './lib/ansi-safe-slice.js';
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = dirname(__filename);
@@ -35,6 +36,9 @@ let _spawnInflight = null;
35
36
  const PTY_COLS_MIN = 2, PTY_COLS_MAX = 1000;
36
37
  const PTY_ROWS_MIN = 1, PTY_ROWS_MAX = 1000;
37
38
  const MAX_BUFFER = 200000;
39
+ // 裁剪滞回:超 MAX_BUFFER 触发后一次裁到 TRIM_TO,而非每个 chunk 都裁到 MAX_BUFFER——
40
+ // 把 ~200KB slice 重分配的频率从每 chunk 一次降到每 ~20KB 新输出一次。
41
+ const BUFFER_TRIM_TO = 180000;
38
42
  let batchBuffer = '';
39
43
  let batchScheduled = false;
40
44
  let _ptyImportForTests = null;
@@ -51,47 +55,9 @@ async function getPty() {
51
55
  return ptyMod.default || ptyMod;
52
56
  }
53
57
 
54
- /**
55
- * outputBuffer 截断时,找到安全的截断位置,
56
- * 避免从 ANSI 转义序列中间开始导致终端状态紊乱。
57
- * 策略:从截断点向后扫描,跳过可能被截断的不完整转义序列。
58
- * 注意:只保 ANSI 序列边界,不保 DEC 2026 同步标记的配对——
59
- * 跨 2026 块截断的配平由调用方负责(见 lib/pty-flood-coalescer.js)。
60
- * export 供洪泛限流器复用同一截断语义。
61
- */
62
- export function findSafeSliceStart(buf, rawStart) {
63
- // 从 rawStart 开始,向后最多扫描 64 字节寻找安全起点
64
- const scanLimit = Math.min(rawStart + 64, buf.length);
65
- let i = rawStart;
66
- while (i < scanLimit) {
67
- const ch = buf.charCodeAt(i);
68
- // 如果当前字符是 ESC (0x1b),可能是新转义序列的开头,
69
- // 但也可能是被截断的序列的中间部分,跳过整个序列
70
- if (ch === 0x1b) {
71
- // 找到 ESC,向后寻找序列结束符(字母字符)
72
- let j = i + 1;
73
- while (j < scanLimit && !((buf.charCodeAt(j) >= 0x40 && buf.charCodeAt(j) <= 0x7e) && j > i + 1)) {
74
- j++;
75
- }
76
- if (j < scanLimit) {
77
- // 找到完整序列末尾,从下一个字符开始是安全的
78
- return j + 1;
79
- }
80
- // 序列不完整,继续扫描
81
- i = j;
82
- continue;
83
- }
84
- // 如果字符是 CSI 参数字符 (0x30-0x3f) 或中间字符 (0x20-0x2f),
85
- // 说明我们在转义序列中间,继续向后
86
- if ((ch >= 0x20 && ch <= 0x3f)) {
87
- i++;
88
- continue;
89
- }
90
- // 普通可见字符或控制字符(非转义相关),这是安全位置
91
- break;
92
- }
93
- return i < buf.length ? i : rawStart;
94
- }
58
+ // ANSI 安全截断起点:实现迁至 lib/ansi-safe-slice.js(锚点扫描算法,见该文件 doc)。
59
+ // 保持从本模块 export——server.js 解构注入洪泛限流器、单测均从这里导入。
60
+ export { findSafeSliceStart };
95
61
 
96
62
  // DEC Private Mode 2026 (Synchronized Output) markers.
97
63
  // xterm.js 6.0+ 原生支持:收到 BEGIN 后缓存所有写入,收到 END 后一次性渲染,
@@ -297,7 +263,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
297
263
  ptyProcess.onData((data) => {
298
264
  outputBuffer += data;
299
265
  if (outputBuffer.length > MAX_BUFFER) {
300
- const rawStart = outputBuffer.length - MAX_BUFFER;
266
+ const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
301
267
  const safeStart = findSafeSliceStart(outputBuffer, rawStart);
302
268
  outputBuffer = outputBuffer.slice(safeStart);
303
269
  }
@@ -477,7 +443,7 @@ async function _spawnShellImpl() {
477
443
  ptyProcess.onData((data) => {
478
444
  outputBuffer += data;
479
445
  if (outputBuffer.length > MAX_BUFFER) {
480
- const rawStart = outputBuffer.length - MAX_BUFFER;
446
+ const rawStart = outputBuffer.length - BUFFER_TRIM_TO;
481
447
  const safeStart = findSafeSliceStart(outputBuffer, rawStart);
482
448
  outputBuffer = outputBuffer.slice(safeStart);
483
449
  }
@@ -5,6 +5,7 @@ import { platform, arch, homedir } from 'node:os';
5
5
  import { createRequire } from 'node:module';
6
6
  import { prepareEmbeddedShellSpawn } from './lib/terminal-env.js';
7
7
  import { killPtyTree } from './lib/term-signals.js';
8
+ import { findSafeSliceStart } from './lib/ansi-safe-slice.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
@@ -21,6 +22,8 @@ const STARTUP_CWD = (() => {
21
22
  })();
22
23
 
23
24
  const MAX_BUFFER = 50000;
25
+ // 裁剪滞回:超 MAX_BUFFER 后一次裁到 TRIM_TO,降低每 chunk slice 重分配频率(同 pty-manager.js)
26
+ const BUFFER_TRIM_TO = 45000;
24
27
 
25
28
  // id -> { ptyProcess, dataListeners, exitListeners, lastExitCode, outputBuffer, lastCols, lastRows, batchBuffer, batchScheduled }
26
29
  const ptys = new Map();
@@ -71,26 +74,6 @@ function maybeReap(id, s) {
71
74
  }
72
75
  }
73
76
 
74
- function findSafeSliceStart(buf, rawStart) {
75
- const scanLimit = Math.min(rawStart + 64, buf.length);
76
- let i = rawStart;
77
- while (i < scanLimit) {
78
- const ch = buf.charCodeAt(i);
79
- if (ch === 0x1b) {
80
- let j = i + 1;
81
- while (j < scanLimit && !((buf.charCodeAt(j) >= 0x40 && buf.charCodeAt(j) <= 0x7e) && j > i + 1)) {
82
- j++;
83
- }
84
- if (j < scanLimit) return j + 1;
85
- i = j;
86
- continue;
87
- }
88
- if (ch >= 0x20 && ch <= 0x3f) { i++; continue; }
89
- break;
90
- }
91
- return i < buf.length ? i : rawStart;
92
- }
93
-
94
77
  function flushBatch(s) {
95
78
  s.batchScheduled = false;
96
79
  if (!s.batchBuffer) return;
@@ -164,7 +147,7 @@ export async function spawnScratch(id) {
164
147
  s.ptyProcess.onData((data) => {
165
148
  s.outputBuffer += data;
166
149
  if (s.outputBuffer.length > MAX_BUFFER) {
167
- const rawStart = s.outputBuffer.length - MAX_BUFFER;
150
+ const rawStart = s.outputBuffer.length - BUFFER_TRIM_TO;
168
151
  const safeStart = findSafeSliceStart(s.outputBuffer, rawStart);
169
152
  s.outputBuffer = s.outputBuffer.slice(safeStart);
170
153
  }
package/server/server.js CHANGED
@@ -1166,6 +1166,9 @@ async function setupTerminalWebSocket(httpServer) {
1166
1166
 
1167
1167
  // resync nudge 冷却(CCV_RESYNC_NUDGE_COOLDOWN_MS,0 = 不冷却;详见 lib/resync-nudge-gate.js)
1168
1168
  const RESYNC_NUDGE_COOLDOWN_MS = envIntAllowZero('CCV_RESYNC_NUDGE_COOLDOWN_MS', 3000);
1169
+ // 客户端 resync-request 的服务端冷却兜底(客户端 write-queue trim 后自带节流,这里防风暴;
1170
+ // 复用 nudge 门实现,0 = 不冷却)
1171
+ const RESYNC_REQ_COOLDOWN_MS = envIntAllowZero('CCV_RESYNC_REQ_COOLDOWN_MS', 1000);
1169
1172
 
1170
1173
  // 洪泛限流器状态日志(与 makeBpLogger 同款 5s 节流,独立实例不共享计数)。
1171
1174
  // Windows 实机排"切主题/大流量卡死"时据此确认 ConPTY 洪泛是否触发、几次、量级。
@@ -1228,6 +1231,13 @@ async function setupTerminalWebSocket(httpServer) {
1228
1231
  // floodGate 在 bpGate 之后构造(send 闭包依赖 bpGate),onBehind/onResume 经 let 前向引用 reset:
1229
1232
  // resync 快照是唯一真相源,coalescer 残留 pending 不清会把早于快照的旧字节回灌导致画面回退。
1230
1233
  let floodGate = null;
1234
+ // 权威快照对齐:bpGate.onResume / floodGate.onTruncate / 客户端 resync-request 三路共用。
1235
+ // 调用前须 floodGate.reset()(快照已含 pending 字节,残留会在快照后回灌重复)。
1236
+ const sendScratchResync = () => {
1237
+ if (ws.readyState !== 1) return;
1238
+ try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id) })); } catch {}
1239
+ };
1240
+ const resyncReqGate = createResyncNudgeGate({ cooldownMs: RESYNC_REQ_COOLDOWN_MS });
1231
1241
  const bpGate = createBackpressureGate({
1232
1242
  getBufferedAmount: () => ws.bufferedAmount,
1233
1243
  onBehind: (buffered) => {
@@ -1237,8 +1247,7 @@ async function setupTerminalWebSocket(httpServer) {
1237
1247
  onResume: (buffered) => {
1238
1248
  _bpLog('resume', buffered);
1239
1249
  floodGate?.reset();
1240
- if (ws.readyState !== 1) return;
1241
- try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id) })); } catch {}
1250
+ sendScratchResync();
1242
1251
  },
1243
1252
  onTimeout: (buffered) => {
1244
1253
  _bpLog('timeout', buffered);
@@ -1260,6 +1269,12 @@ async function setupTerminalWebSocket(httpServer) {
1260
1269
  findSafeSliceStart,
1261
1270
  onFloodStart: (bytes) => _floodLog('start', bytes),
1262
1271
  onFloodEnd: () => _floodLog('end', 0),
1272
+ // 洪泛期丢过中段:增量输出流不会自愈,回落直通后用权威快照对齐一次
1273
+ onTruncate: (dropped) => {
1274
+ _floodLog('truncate', dropped);
1275
+ floodGate.reset();
1276
+ sendScratchResync();
1277
+ },
1263
1278
  });
1264
1279
 
1265
1280
  const removeDataListener = onScratchData(id, (data) => {
@@ -1283,6 +1298,12 @@ async function setupTerminalWebSocket(httpServer) {
1283
1298
  writeScratch(id, msg.data);
1284
1299
  } else if (msg.type === 'resize') {
1285
1300
  resizeScratch(id, msg.cols, msg.rows);
1301
+ } else if (msg.type === 'resync-request') {
1302
+ // 客户端 write-queue 积压丢弃后请求快照对齐(utils/terminalWriteQueue.js onTrim)
1303
+ if (resyncReqGate.shouldNudge()) {
1304
+ floodGate.reset();
1305
+ sendScratchResync();
1306
+ }
1286
1307
  } else if (msg.type === 'kill') {
1287
1308
  // 用户主动关闭 tab:杀 pty(killScratch 内部 ptys.delete 后配额自动释放);前端会随后 close ws
1288
1309
  killScratch(id);
@@ -1352,6 +1373,34 @@ async function setupTerminalWebSocket(httpServer) {
1352
1373
  // 走冷却——nudge 让 ConPTY 再吐全屏重绘 = 新洪泛燃料,紧 behind→resume 循环里反复
1353
1374
  // nudge 会自我维持(客户端每轮 reset+重放快照 = 永久冻结表象)。详见 lib/resync-nudge-gate.js。
1354
1375
  const nudgeGate = createResyncNudgeGate({ cooldownMs: RESYNC_NUDGE_COOLDOWN_MS });
1376
+ const resyncReqGate = createResyncNudgeGate({ cooldownMs: RESYNC_REQ_COOLDOWN_MS });
1377
+ // 权威快照对齐:data-resync 快照无条件发,全屏重绘 nudge 走 nudgeGate 冷却。
1378
+ // bpGate.onResume / floodGate.onTruncate / 客户端 resync-request 三路共用;
1379
+ // 调用前须 floodGate.reset()(快照已含 pending/微合并缓冲字节,残留会在快照后回灌重复)。
1380
+ const sendResync = (buffered = 0) => {
1381
+ if (ws.readyState !== 1) return;
1382
+ try { ws.send(JSON.stringify({ type: 'data-resync', data: getOutputBuffer() })); } catch {}
1383
+ if (!nudgeGate.shouldNudge()) { _bpLog('nudge-skip', buffered); return; }
1384
+ try {
1385
+ if (process.platform !== 'win32') {
1386
+ // POSIX:与下方 _needRedrawBootstrap 同款 SIGWINCH 兜底
1387
+ const pid = getClaudePid();
1388
+ if (pid && pid !== process.pid) process.kill(pid, 'SIGWINCH');
1389
+ } else {
1390
+ // Windows 无 SIGWINCH:resize 抖动经 ConPTY 通知重绘(恢复路径偶发,闪烁可接受)。
1391
+ // 尺寸仲裁与 resize 消息处理一致:移动端优先,否则活跃客户端(activeWs 为 null 时
1392
+ // 本 ws 视为所有者)——恢复的 ws 可能是非权威的慢后台 tab,用它自己的尺寸抖动会把
1393
+ // 共享 PTY 永久改成它的尺寸、挤掉活跃/移动端画面;无权威尺寸则跳过抖动。
1394
+ const mSize = getMobileSize();
1395
+ const size = mSize
1396
+ || ((activeWs === ws || activeWs === null) ? clientSizes.get(ws) : clientSizes.get(activeWs));
1397
+ if (size) {
1398
+ resizePty(size.cols, size.rows + 1);
1399
+ resizePty(size.cols, size.rows);
1400
+ }
1401
+ }
1402
+ } catch {}
1403
+ };
1355
1404
  const bpGate = createBackpressureGate({
1356
1405
  getBufferedAmount: () => ws.bufferedAmount,
1357
1406
  onBehind: (buffered) => {
@@ -1361,28 +1410,7 @@ async function setupTerminalWebSocket(httpServer) {
1361
1410
  onResume: (buffered) => {
1362
1411
  _bpLog('resume', buffered);
1363
1412
  floodGate?.reset();
1364
- if (ws.readyState !== 1) return;
1365
- try { ws.send(JSON.stringify({ type: 'data-resync', data: getOutputBuffer() })); } catch {}
1366
- if (!nudgeGate.shouldNudge()) { _bpLog('nudge-skip', buffered); return; }
1367
- try {
1368
- if (process.platform !== 'win32') {
1369
- // POSIX:与下方 _needRedrawBootstrap 同款 SIGWINCH 兜底
1370
- const pid = getClaudePid();
1371
- if (pid && pid !== process.pid) process.kill(pid, 'SIGWINCH');
1372
- } else {
1373
- // Windows 无 SIGWINCH:resize 抖动经 ConPTY 通知重绘(恢复路径偶发,闪烁可接受)。
1374
- // 尺寸仲裁与 resize 消息处理一致:移动端优先,否则活跃客户端(activeWs 为 null 时
1375
- // 本 ws 视为所有者)——恢复的 ws 可能是非权威的慢后台 tab,用它自己的尺寸抖动会把
1376
- // 共享 PTY 永久改成它的尺寸、挤掉活跃/移动端画面;无权威尺寸则跳过抖动。
1377
- const mSize = getMobileSize();
1378
- const size = mSize
1379
- || ((activeWs === ws || activeWs === null) ? clientSizes.get(ws) : clientSizes.get(activeWs));
1380
- if (size) {
1381
- resizePty(size.cols, size.rows + 1);
1382
- resizePty(size.cols, size.rows);
1383
- }
1384
- }
1385
- } catch {}
1413
+ sendResync(buffered);
1386
1414
  },
1387
1415
  onTimeout: (buffered) => {
1388
1416
  _bpLog('timeout', buffered);
@@ -1404,6 +1432,13 @@ async function setupTerminalWebSocket(httpServer) {
1404
1432
  findSafeSliceStart,
1405
1433
  onFloodStart: (bytes) => _floodLog('start', bytes),
1406
1434
  onFloodEnd: () => _floodLog('end', 0),
1435
+ // 洪泛期丢过中段:安全切片只保证残片不上屏,被截掉的内容对增量 TUI 流
1436
+ // (macOS/Linux forkpty)不会自愈——回落直通后用权威快照对齐一次
1437
+ onTruncate: (dropped) => {
1438
+ _floodLog('truncate', dropped);
1439
+ floodGate.reset();
1440
+ sendResync();
1441
+ },
1407
1442
  });
1408
1443
 
1409
1444
  // PTY 输出 → WebSocket(合并 ws 后客户端自行按 msg.type 分发,server 端不再 role 过滤)
@@ -1488,6 +1523,12 @@ async function setupTerminalWebSocket(httpServer) {
1488
1523
  } else {
1489
1524
  replyDone(false);
1490
1525
  }
1526
+ } else if (msg.type === 'resync-request') {
1527
+ // 客户端 write-queue 积压丢弃后请求快照对齐(utils/terminalWriteQueue.js onTrim)
1528
+ if (resyncReqGate.shouldNudge()) {
1529
+ floodGate.reset();
1530
+ sendResync();
1531
+ }
1491
1532
  } else if (msg.type === 'ask-hook-answer') {
1492
1533
  // Client answered AskUserQuestion via hook bridge.
1493
1534
  // New protocol: msg.id required to address one of multiple pending asks.