Ver código fonte

fix(terminal-bash): bound retained storage and status reads

Turtle 1 mês atrás
pai
commit
68f9708e02

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md
-2026-09-11-incremental-terminal-retention.md: 4db0e8219b7a24808c4f53538b443d871407fd96
-2026-09-11-incremental-terminal-retention.zh.md: ae70eb33f923e8b65b8fb22789d11f62a5eb8209
+2026-09-11-incremental-terminal-retention.md: bc2a92f9f5c5e199d9abdb7ad068471162e1e0ef
+2026-09-11-incremental-terminal-retention.zh.md: 7b72a3afdabfbe7fe3a39f417b9020096b5c3ab5

+ 15 - 11
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md

@@ -14,7 +14,9 @@ The private buffer in [terminal-bash](../../../../packages/terminal/terminal-bas
 
 The retained suffix matches line trimming followed by UTF-8 trimming. A trailing newline contributes an empty logical line. Truncation stays sticky until consumption clears the buffer. Adjacent surrogate halves across chunks count as one four-byte code point and are evicted together; unpaired halves retain JavaScript string identity and count as three UTF-8 bytes. The read-time `utf8Tail()` remains independent and unchanged.
 
-After at least half of the leading string is discarded, its suffix is copied through UTF-16 to release the original backing storage without replacing unpaired surrogates. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor seals that tail into a flat string. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text.
+Every nonempty input is copied through UTF-16 before retention, preserving unpaired surrogates while detaching slices returned by the sanitizer from discarded control text. This applies to small pending fragments as well as large chunks. Private `truncated` and `isEmpty` getters let send settlement and startup polling inspect status without assembling scrollback.
+
+After at least half of the leading string is discarded, its suffix is copied to release the original backing storage. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor copies those fragments into one owned string. Large tails already own their storage and are not copied again at this point. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text.
 
 The [persistent PTY decision](../feature/2026-07-16-persistent-pty-sessions.md) continues to own session lifecycle, model-visible output, and retention semantics. This decision specializes storage and performance; it supersedes no active decision record.
 
@@ -30,20 +32,22 @@ On Apple M5 Pro, macOS arm64, Node v26.5.0, five fresh workers per case compare
 
 | Case / metric | Eager retention samples | Incremental retention samples |
 |---|---|---|
-| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 9.094, 8.756, 8.949, 8.867, 8.974 |
-| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 18.800, 18.329, 19.348, 18.439, 18.414 |
-| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 4.572, 4.526, 4.697, 5.630, 4.780 |
-| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 30.130, 30.521, 32.170, 37.161, 27.441 |
-| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 20.994, 19.533, 21.156, 24.193, 20.366 |
-| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 76.656, 78.543, 80.052, 82.274, 76.274 |
+| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 |
+| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 |
+| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 |
+| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 |
+| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 |
+| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 |
+
+The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.84 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 89.339 ms (58.3×). Maximum retained heap for the large steady case rises from 4,512,656 to 6,219,296 bytes; this measures live session and result allocations together, not just buffer strings.
 
-The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.52 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 78.543 ms (66.4×). Maximum retained heap for the large steady case rises from 4,512,656 to 5,661,016 bytes; this measures live session and result allocations together, not just buffer strings.
+A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,802,840 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict.
 
-A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,793,048 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict.
+The filtered-output memory case emits 513 callbacks of 64 KiB each, containing a complete 56 KiB OSC sequence followed by 8 KiB of visible text. This passes through the production sanitizer before filling a 4 MiB visible window and taking a bounded read. With incoming slices retained directly (`0b81fd50e4`), the assertion fails at 36,706,592 bytes. Copying inputs into independent storage reduces retained heap to 7,635,440 bytes, below the unchanged 16 MiB limit. These measurements use Node v26.5.0 and fresh workers.
 
-A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 222.546 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip.
+A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 249.007 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip.
 
-The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all three cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build.
+The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all four cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build.
 
 ## Alternatives considered
 

+ 15 - 11
.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md

@@ -14,7 +14,9 @@ Status: implemented
 
 保留后缀与先按行数裁剪、再按 UTF-8 字节数裁剪的结果一致。末尾换行符贡献一个空逻辑行。截断标志保持为真,直到消费操作清空缓冲区。跨分片相邻的代理项两半按一个四字节码点计数,并共同淘汰;未配对代理项保留 JavaScript 字符串原值,按三个 UTF-8 字节计数。读取时的 `utf8Tail()` 保持独立且不变。
 
-头部字符串至少一半被丢弃后,其后缀通过 UTF-16 复制,以释放原始底层存储,同时保留未配对代理项。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将该尾节点固化为平坦字符串。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。
+每个非空输入都在保留前通过 UTF-16 复制,在保留未配对代理项的同时,使清理器返回的切片脱离已丢弃的控制文本。待处理的小片段与大分片都遵循这一规则。私有 `truncated` 和 `isEmpty` getter 让发送结算和启动轮询无需拼接 scrollback 即可检查状态。
+
+头部字符串至少一半被丢弃后,其后缀会被复制,以释放原始底层存储。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将这些片段复制为一个独立字符串。大尾块已经拥有独立存储,此处不会再次复制。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。
 
 [持久 PTY 决策](../feature/2026-07-16-persistent-pty-sessions.zh.md)继续负责会话生命周期、模型可见输出与保留语义。本决策细化存储与性能,不取代任何活跃决策记录。
 
@@ -30,20 +32,22 @@ Status: implemented
 
 | 场景 / 指标 | 全量保留计算样本 | 增量保留计算样本 |
 |---|---|---|
-| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 9.094, 8.756, 8.949, 8.867, 8.974 |
-| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 18.800, 18.329, 19.348, 18.439, 18.414 |
-| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 4.572, 4.526, 4.697, 5.630, 4.780 |
-| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 30.130, 30.521, 32.170, 37.161, 27.441 |
-| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 20.994, 19.533, 21.156, 24.193, 20.366 |
-| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 76.656, 78.543, 80.052, 82.274, 76.274 |
+| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 |
+| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 |
+| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 |
+| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 |
+| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 |
+| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 |
+
+大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.84。5 MiB 完成时间的中位数从 5211.802 ms 降至 89.339 ms(58.3×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 6,219,296 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。
 
-大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.52。5 MiB 完成时间的中位数从 5211.802 ms 降至 78.543 ms(66.4×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 5,661,016 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。
+独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,802,840 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。
 
-独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,793,048 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。
+过滤输出的内存场景发送 513 个 64 KiB 回调,每个含完整的 56 KiB OSC 序列及其后的 8 KiB 可见文本。数据经过生产清理器,填满 4 MiB 可见窗口后进行有界读取。直接保留输入切片时(`0b81fd50e4`),断言以 36,706,592 字节失败。将输入复制到独立存储后,保留堆降至 7,635,440 字节,低于不变的 16 MiB 上限。这些测量使用 Node v26.5.0 和全新 worker。
 
-真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 222.546 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。
+真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 249.007 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。
 
-[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的三个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。
+[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的四个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。
 
 ## 考虑过的替代方案
 

+ 7 - 1
benchmarks/terminal-io/terminal-io.bench.ts

@@ -17,7 +17,7 @@ function median(values: readonly number[]): number {
   return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] as number
 }
 
-async function sample(capacity: number, mode: 'steady' | 'full' | 'tiny'): Promise<TerminalIoReport> {
+async function sample(capacity: number, mode: 'steady' | 'full' | 'tiny' | 'filtered'): Promise<TerminalIoReport> {
   const outcome = await runBuiltBenchmarkWorker<TerminalIoReport>({
     worker: WORKER, args: [String(capacity), mode], timeoutMs: 120_000, exposeGc: true,
   })
@@ -52,6 +52,12 @@ it('bounds retained memory when five MiB arrives in sixteen-byte chunks', async
   expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
 })
 
+it('releases filtered OSC storage behind retained visible string slices', async () => {
+  const report = await sample(4 * MIB, 'filtered')
+  console.log(JSON.stringify({ scenario: 'terminal-filtered-chunks', report, heapBudgetBytes: MAX_RETAINED_HEAP_BYTES }))
+  expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
+})
+
 it('completes a five MiB terminal send with bounded retained output', async () => {
   const samples: TerminalIoReport[] = []
   for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) samples.push(await sample(4 * MIB, 'full'))

+ 9 - 4
benchmarks/terminal-io/terminal-io.worker.ts

@@ -25,10 +25,15 @@ export interface TerminalIoReport {
 }
 
 async function measure(capacityBytes: number, mode: string): Promise<TerminalIoReport> {
-  const chunkBytes = mode === 'tiny' ? 16 : CHUNK_BYTES
+  const chunkBytes = mode === 'filtered' ? 64 * 1024 : mode === 'tiny' ? 16 : CHUNK_BYTES
   const chunk = Buffer.alloc(chunkBytes, 'x')
+  if (mode === 'filtered') {
+    // Each decoded callback has 56 KiB of discarded OSC followed by an 8 KiB string slice.
+    chunk.write('\x1b]0;', 0)
+    chunk[56 * 1024 - 1] = 7
+  }
   const prefillBytes = mode === 'steady' ? capacityBytes : 0
-  const timedBytes = mode === 'steady' ? MIB : 5 * MIB
+  const timedBytes = mode === 'filtered' ? 513 * chunkBytes : mode === 'steady' ? MIB : 5 * MIB
   const output = new Readable({ read() {} })
   const ended = Promise.withResolvers<SubprocessOutcome>()
   const writeReady = Promise.withResolvers<void>()
@@ -94,7 +99,7 @@ assertBuiltBenchmarkRuntime(import.meta.url, {
 })
 const capacityBytes = Number(process.argv[2])
 const mode = process.argv[3]
-if (![128 * 1024, 4 * MIB].includes(capacityBytes) || (mode !== 'steady' && mode !== 'full' && mode !== 'tiny')) {
-  throw new Error('usage: terminal-io.worker.js <131072|4194304> <steady|full|tiny>')
+if (![128 * 1024, 4 * MIB].includes(capacityBytes) || (mode !== 'steady' && mode !== 'full' && mode !== 'tiny' && mode !== 'filtered')) {
+  throw new Error('usage: terminal-io.worker.js <131072|4194304> <steady|full|tiny|filtered>')
 }
 console.log(JSON.stringify(await measure(capacityBytes, mode)))

+ 2 - 2
packages/terminal/terminal-bash/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md
-README.md: d0defd5f0395fc3bceca357f908d5fa185dc7fed
-README.zh.md: 0b51e83bafb73205296509f8b592b590d0ff21a6
+README.md: eda8ba212cf182c62a1eebf176b2dc9a4795afc7
+README.zh.md: cc6141a2e4abf72ab796cb969845e249eca68a21

+ 1 - 1
packages/terminal/terminal-bash/README.md

@@ -85,7 +85,7 @@ This section explains the design behind the backend and points at the code that
 
 One backend serves both dialects: bash and pwsh share the same session machinery — sanitizer, bounded buffers, readiness polling, cancellation, and teardown — and differ only in argv, environment, and prompt installation. Bash receives a private marker through `PS1` plus `PROMPT_COMMAND`. Pwsh writes a prompt function, pins UTF-8 console encoding, and publishes startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and returns terminal-protocol replies through the same handle, while the line sanitizer remains the only output projection.
 
-Scrollback and unread send output retain chunks with incremental byte and newline counts. Appending and evicting text takes amortized time proportional to incoming text; reads assemble the retained chunks. Retention preserves code-point boundaries and counts the empty line after a trailing newline. The [retention decision](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md) owns the complexity and measurement rationale.
+Scrollback and unread send output retain independently owned strings with incremental byte and newline counts, so sanitized slices cannot retain discarded control sequences. Appending and evicting text takes amortized time proportional to incoming text; reads assemble the retained chunks. Retention preserves code-point boundaries and counts the empty line after a trailing newline. The [retention decision](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md) owns the complexity and measurement rationale.
 
 ### Source map
 

+ 1 - 1
packages/terminal/terminal-bash/README.zh.md

@@ -85,7 +85,7 @@ shell 在整个生命周期内运行在有效的沙箱边界之下。当所有
 
 一个后端服务两种方言:bash 与 pwsh 共享同一套会话机制——清理器、有界缓冲区、就绪轮询、取消与关闭——只在 argv、环境与提示符安装方式上不同。bash 通过 `PS1` 加 `PROMPT_COMMAND` 接收私有标记。pwsh 会写入提示符函数、固定 UTF-8 控制台编码,并只在后端报告 `stdin_read` 后发布启动;回显的设置文本不能发布 shell。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过同一句柄返回终端协议响应;逐行 sanitizer 仍是唯一输出投影。
 
-Scrollback 和尚未读取的发送输出保留分片,并增量维护字节数与换行符数。追加与淘汰文本的摊还耗时与输入文本量成正比;读取时才拼接保留的分片。保留策略维持码点边界,并将末尾换行符之后的空行计入行数。[保留策略决策](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md)记录复杂度与测量依据。
+Scrollback 和尚未读取的发送输出保留独立拥有的字符串,并增量维护字节数与换行符数,因此清理后的切片不会保留已丢弃的控制序列。追加与淘汰文本的摊还耗时与输入文本量成正比;读取时才拼接保留的分片。保留策略维持码点边界,并将末尾换行符之后的空行计入行数。[保留策略决策](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md)记录复杂度与测量依据。
 
 ### 源码地图
 

+ 13 - 3
packages/terminal/terminal-bash/src/session.ts

@@ -65,8 +65,18 @@ class BoundedTextBuffer {
     private readonly maxLines?: number,
   ) {}
 
+  get truncated(): boolean {
+    return this.dropped
+  }
+
+  get isEmpty(): boolean {
+    return this.head === undefined
+  }
+
   append(text: string): void {
     if (text.length === 0) return
+    // Sanitized text can be a slice retaining discarded controls; copy UTF-16 without replacing lone surrogates.
+    text = Buffer.from(text, 'utf16le').toString('utf16le')
     this.bytes += Buffer.byteLength(text)
     const tail = this.tail
     if (tail !== undefined) {
@@ -84,7 +94,7 @@ class BoundedTextBuffer {
       tail.text += text
     } else {
       if (tail !== undefined && tail.text.length <= COALESCED_CHUNK_UNITS) {
-        // Seal small fragments into owned storage before retaining another chunk.
+        // Copy coalesced fragments into one string; large tails already own their storage.
         tail.text = Buffer.from(tail.text, 'utf16le').toString('utf16le')
       }
       const chunk: TextChunk = { text, start: 0, next: undefined }
@@ -562,7 +572,7 @@ export class LocalPtySession implements TerminalBackendSession {
         return
       }
       const elapsed = Date.now() - operation.startedAt
-      const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
+      const startupHasOutput = !this.initializing || !this.scrollback.isEmpty
       const acceptsStdinWait = startupHasOutput && foreground !== undefined
         && operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
       if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
@@ -687,7 +697,7 @@ export class LocalPtySession implements TerminalBackendSession {
   private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void {
     const operation = this.active
     if (operation === undefined) return
-    const scrollbackTruncated = this.scrollback.snapshot().truncated
+    const scrollbackTruncated = this.scrollback.truncated
     if (retainOwnership) {
       this.stopPolling()
       this.activeAbort?.()

+ 1 - 1
packages/terminal/terminal-bash/tests/session-buffer.spec.ts

@@ -250,7 +250,7 @@ describe('LocalPtySession incremental output compatibility', () => {
     expect(await operation.done).toMatchObject({ viewport: 'fresh', truncated: true, waitReason: 'session_exit' })
   })
 
-  it('preserves split surrogate pairs when sealed coalesced text reaches the eviction head', () => {
+  it('preserves split surrogate pairs when copied coalesced text reaches the eviction head', () => {
     const limit = 4100
     const { session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
     // Lone UTF-16 halves cannot pass through the session's TextDecoder unchanged.

+ 21 - 0
packages/terminal/terminal-bash/tests/session.spec.ts

@@ -153,6 +153,27 @@ async function initialize(session: LocalPtySession, terminal: FakeTerminal): Pro
 }
 
 describe('LocalPtySession readiness and output', () => {
+  it('polls startup and settles sends without assembling scrollback for status checks', async () => {
+    vi.useFakeTimers()
+    const terminal = new FakeTerminal()
+    const session = new LocalPtySession(terminal, config())
+    const snapshot = vi.spyOn(session['scrollback'], 'snapshot')
+    try {
+      const pending = session.initialize()
+      await vi.advanceTimersByTimeAsync(20)
+      expect(snapshot).not.toHaveBeenCalled()
+      terminal.emitData('x'.repeat(200) + '\x1b]133;D;0\x07dsh> ')
+      await vi.advanceTimersByTimeAsync(20)
+      await pending
+      expect(snapshot).not.toHaveBeenCalled()
+      expect(session.read({})).toMatchObject({ text: 'x'.repeat(59) + 'dsh> ', truncated: true })
+      expect(snapshot).toHaveBeenCalledTimes(1)
+    } finally {
+      snapshot.mockRestore()
+      await session.close('status check cleanup')
+    }
+  })
+
   it('answers split cursor-position queries before publishing prompt readiness', async () => {
     vi.useFakeTimers()
     const terminal = new FakeTerminal()