session-buffer.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import { Buffer } from 'node:buffer'
  2. import { PassThrough } from 'node:stream'
  3. import { afterEach, describe, expect, it } from 'vitest'
  4. import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
  5. import type { TerminalReadRequest } from '@deepseek-ai/dsh-terminal'
  6. import type { ResolvedConfig } from '../src/config.ts'
  7. import { LocalPtySession } from '../src/session.ts'
  8. class OutputProducer implements SubprocessTerminalHandle {
  9. readonly pid = 123
  10. readonly output = new PassThrough()
  11. private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
  12. readonly done = this.outcome.promise
  13. emit(text: string | Uint8Array): void {
  14. this.output.write(typeof text === 'string' ? Buffer.from(text) : text)
  15. }
  16. exit(): void {
  17. this.output.end()
  18. this.outcome.resolve({ exitCode: 0, signal: null })
  19. }
  20. async write(): Promise<void> {}
  21. async resize(): Promise<void> {}
  22. async inspectActivity() { return { state: 'unknown' as const, revision: 0 } }
  23. async inspectForeground() { return undefined }
  24. async signalForeground(): Promise<number> { return this.pid }
  25. async terminate(): Promise<void> {
  26. this.exit()
  27. await this.done
  28. }
  29. }
  30. const sessions: LocalPtySession[] = []
  31. afterEach(async () => {
  32. await Promise.all(sessions.splice(0).map(session => session.close('buffer test cleanup')))
  33. })
  34. function fixture(overrides: Partial<ResolvedConfig> = {}) {
  35. const config: ResolvedConfig = {
  36. backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
  37. scrollbackLines: 10_000, scrollbackMaxBytes: 4 * 1024 * 1024, maxReadBytes: 256 * 1024,
  38. pollIntervalMs: 60_000, exactProbeAfterMs: 60_000, idleSilenceMs: 60_000,
  39. handoffGraceMs: 60_000, timeoutMs: 60_000, disposeGraceMs: 60_000,
  40. ...overrides,
  41. }
  42. const producer = new OutputProducer()
  43. const session = new LocalPtySession(producer, config)
  44. sessions.push(session)
  45. return { producer, session }
  46. }
  47. // The reference deliberately retains the eager line-first, then UTF-8-tail algorithm.
  48. function referenceTail(text: string, maxBytes: number) {
  49. if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
  50. const chars = Array.from(text)
  51. let bytes = 0
  52. let start = chars.length
  53. while (start > 0) {
  54. const size = Buffer.byteLength(chars[start - 1] as string)
  55. if (bytes + size > maxBytes) break
  56. bytes += size
  57. start -= 1
  58. }
  59. return { text: chars.slice(start).join(''), truncated: true }
  60. }
  61. class ReferenceBuffer {
  62. private text = ''
  63. private truncated = false
  64. constructor(private readonly maxBytes: number, private readonly maxLines?: number) {}
  65. append(chunk: string): void {
  66. if (chunk.length === 0) return
  67. this.text += chunk
  68. if (this.maxLines !== undefined) {
  69. const lines = this.text.split('\n')
  70. if (lines.length > this.maxLines) {
  71. this.text = lines.slice(-this.maxLines).join('\n')
  72. this.truncated = true
  73. }
  74. }
  75. const bounded = referenceTail(this.text, this.maxBytes)
  76. this.text = bounded.text
  77. this.truncated ||= bounded.truncated
  78. }
  79. snapshot() { return { text: this.text, truncated: this.truncated } }
  80. consume() {
  81. const result = { delta: this.text, truncated: this.truncated }
  82. this.text = ''
  83. this.truncated = false
  84. return result
  85. }
  86. }
  87. function referenceRead(buffer: ReferenceBuffer, maxBytes: number, request: TerminalReadRequest = {}) {
  88. const snapshot = buffer.snapshot()
  89. const lines = snapshot.text.split('\n')
  90. const totalLines = snapshot.text.length === 0 ? 0 : lines.length
  91. const offset = request.offset ?? 0
  92. if (offset >= totalLines) {
  93. return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
  94. }
  95. const end = totalLines - offset
  96. const bounded = referenceTail(lines.slice(Math.max(0, end - (request.count ?? 500)), end).join('\n'), maxBytes)
  97. const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
  98. return {
  99. text: bounded.text, totalLines, lineBegin: offset, lineEnd: offset + returnedLines,
  100. truncated: snapshot.truncated || bounded.truncated,
  101. }
  102. }
  103. function deterministicChunks(alphabet: readonly string[], count: number): string[] {
  104. let state = 0x12345678
  105. return Array.from({ length: count }, () => {
  106. state = (Math.imul(state, 1664525) + 1013904223) >>> 0
  107. return alphabet[state % alphabet.length] as string
  108. })
  109. }
  110. describe('LocalPtySession incremental output compatibility', () => {
  111. it('retains the exact tail across the default 4 MiB scrollback limit while consuming active output', async () => {
  112. const limit = 4 * 1024 * 1024
  113. const { producer, session } = fixture({ maxReadBytes: limit })
  114. const operation = session.startSend({ text: '', submit: false })
  115. const chunk = 'a'.repeat(4096)
  116. for (let index = 0; index < limit / chunk.length; index += 1) producer.emit(chunk)
  117. expect(session.read({})).toEqual({
  118. text: 'a'.repeat(limit), totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false,
  119. })
  120. producer.emit('界😀TAIL')
  121. const retained = `${'a'.repeat(limit - 11)}界😀TAIL`
  122. expect(session.read({})).toEqual({
  123. text: retained, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: true,
  124. })
  125. expect(operation.readOutput()).toEqual({ delta: retained, truncated: true })
  126. expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
  127. producer.emit('é')
  128. expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false })
  129. producer.emit('done')
  130. producer.exit()
  131. await expect(operation.done).resolves.toEqual({
  132. viewport: 'done', waitReason: 'session_exit',
  133. sessionStatus: { kind: 'exited', exitCode: 0, signal: null }, truncated: true,
  134. })
  135. expect(operation.readOutput()).toEqual({ delta: 'done', truncated: false })
  136. expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
  137. })
  138. it('counts trailing empty lines and keeps scrollback truncation sticky after empty reads', () => {
  139. const { producer, session } = fixture({ scrollbackLines: 3, scrollbackMaxBytes: 64, maxReadBytes: 64 })
  140. producer.emit('a\nb\n')
  141. expect(session.read({})).toEqual({ text: 'a\nb\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: false })
  142. producer.emit('\n')
  143. expect(session.read({})).toEqual({ text: 'b\n\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: true })
  144. expect(session.read({ count: 1 })).toEqual({ text: '', totalLines: 3, lineBegin: 0, lineEnd: 0, truncated: true })
  145. expect(session.read({ offset: 2, count: 1 }).text).toBe('b')
  146. expect(session.read({ offset: 3 }).truncated).toBe(true)
  147. producer.emit('')
  148. producer.emit('c')
  149. expect(session.read({}).text).toBe('b\n\nc')
  150. expect(session.read({}).truncated).toBe(true)
  151. })
  152. it('decodes split UTF-8 before byte eviction and bounds an oversized multibyte chunk', async () => {
  153. const { producer, session } = fixture({ scrollbackMaxBytes: 11, maxReadBytes: 11 })
  154. const operation = session.startSend({ text: '', submit: false })
  155. producer.emit('abc')
  156. const encoded = Buffer.from('界😀éz')
  157. producer.emit(encoded.subarray(0, 5))
  158. producer.emit(encoded.subarray(5, 7))
  159. producer.emit(encoded.subarray(7))
  160. expect(session.read({}).text).toBe('c界😀éz')
  161. expect(operation.readOutput()).toEqual({ delta: 'c界😀éz', truncated: true })
  162. producer.emit('界😀'.repeat(1000) + 'éEND')
  163. expect(session.read({}).text).toBe('😀éEND')
  164. expect(operation.readOutput()).toEqual({ delta: '😀éEND', truncated: true })
  165. expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
  166. producer.emit('ok')
  167. producer.exit()
  168. expect((await operation.done).viewport).toBe('ok')
  169. })
  170. it('resets operation truncation independently of retained scrollback', async () => {
  171. const { producer, session } = fixture({ scrollbackMaxBytes: 128, maxReadBytes: 5 })
  172. const operation = session.startSend({ text: '', submit: false })
  173. for (let index = 0; index < 4; index += 1) {
  174. producer.emit('123456')
  175. expect(operation.readOutput()).toEqual({ delta: '23456', truncated: true })
  176. expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
  177. producer.emit('é')
  178. expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false })
  179. }
  180. producer.exit()
  181. expect(await operation.done).toMatchObject({ viewport: '', truncated: false, waitReason: 'session_exit' })
  182. expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
  183. })
  184. it.each([[1, 1, 1], [17, 7, 3], [64, 13, 5]])(
  185. 'matches eager reads and active output with byte caps %i/%i and %i lines',
  186. async (scrollbackMaxBytes, maxReadBytes, scrollbackLines) => {
  187. const { producer, session } = fixture({ scrollbackMaxBytes, maxReadBytes, scrollbackLines })
  188. const scrollback = new ReferenceBuffer(scrollbackMaxBytes, scrollbackLines)
  189. const output = new ReferenceBuffer(maxReadBytes)
  190. const operation = session.startSend({ text: '', submit: false })
  191. const chunks = deterministicChunks(['a', 'bc', '\n', '\n\n', '界', '😀', 'éz', '', 'long line\nend\n'], 120)
  192. for (const [index, chunk] of chunks.entries()) {
  193. producer.emit(chunk)
  194. scrollback.append(chunk)
  195. output.append(chunk)
  196. for (const request of [{}, { offset: 1, count: 2 }, { offset: 9, count: 1 }]) {
  197. expect(session.read(request)).toEqual(referenceRead(scrollback, maxReadBytes, request))
  198. }
  199. if (index % 7 === 0) expect(operation.readOutput()).toEqual(output.consume())
  200. }
  201. producer.exit()
  202. const expected = output.snapshot()
  203. expect(await operation.done).toMatchObject({
  204. viewport: expected.text, truncated: expected.truncated || scrollback.snapshot().truncated,
  205. waitReason: 'session_exit',
  206. })
  207. expect(operation.readOutput()).toEqual(output.consume())
  208. expect(operation.readOutput()).toEqual(output.consume())
  209. },
  210. )
  211. it('retains tiny producer chunks through multiple coalesced-node rollovers', async () => {
  212. const limit = 5000
  213. const { producer, session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
  214. const operation = session.startSend({ text: '', submit: false })
  215. const text = '0123456789'.repeat(1000)
  216. const checkpoints = new Set([1, 4096, 4097, 5000, 5001, 8193, text.length])
  217. for (let index = 0; index < text.length; index += 1) {
  218. producer.emit(text[index] as string)
  219. if (checkpoints.has(index + 1)) {
  220. expect(session.read({})).toEqual({
  221. text: text.slice(Math.max(0, index + 1 - limit), index + 1),
  222. totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: index + 1 > limit,
  223. })
  224. }
  225. }
  226. expect(operation.readOutput()).toEqual({ delta: text.slice(-limit), truncated: true })
  227. expect(operation.readOutput()).toEqual({ delta: '', truncated: false })
  228. producer.emit('fresh')
  229. producer.exit()
  230. expect(await operation.done).toMatchObject({ viewport: 'fresh', truncated: true, waitReason: 'session_exit' })
  231. })
  232. it('preserves split surrogate pairs when copied coalesced text reaches the eviction head', () => {
  233. const limit = 4100
  234. const { session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit })
  235. // Lone UTF-16 halves cannot pass through the session's TextDecoder unchanged.
  236. const buffer = session['scrollback']
  237. const reference = new ReferenceBuffer(limit, 10_000)
  238. const chunks = ['H', 'a'.repeat(4095), '\ud83d', '\ude00', 'xy', 'b'.repeat(4094), 'z', '\ud800', '\udfff']
  239. for (const chunk of chunks) {
  240. buffer.append(chunk)
  241. reference.append(chunk)
  242. expect(buffer.snapshot()).toEqual(reference.snapshot())
  243. }
  244. expect(buffer.snapshot()).toEqual({ text: `y${'b'.repeat(4094)}z\ud800\udfff`, truncated: true })
  245. expect(buffer.consume()).toEqual(reference.consume())
  246. expect(buffer.consume()).toEqual(reference.consume())
  247. })
  248. it.each([1, 2, 3, 4, 5, 8, 17])('preserves lone and split surrogates with a %i-byte limit', (maxBytes) => {
  249. const { session } = fixture({ scrollbackMaxBytes: maxBytes, maxReadBytes: maxBytes, scrollbackLines: 3 })
  250. // TextDecoder replaces lone surrogates; only these UTF-16 cases use the private buffer.
  251. const buffer = session['scrollback']
  252. const reference = new ReferenceBuffer(maxBytes, 3)
  253. const chunks = [
  254. '\ud83d', '\ude00', 'x', '\ud83d', '', '\ude00', '\n', '\ud800', 'abc', '\udfff',
  255. 'prefix'.repeat(20) + '\ud800', '\udfff', '\n\n\n',
  256. ...deterministicChunks(['a', '\ud800', '\udfff', '\ud83d\ude00', '\n', 'é', '界', '', '\n\n'], 150),
  257. ]
  258. for (const [index, chunk] of chunks.entries()) {
  259. buffer.append(chunk)
  260. reference.append(chunk)
  261. expect(buffer.snapshot()).toEqual(reference.snapshot())
  262. if (index % 19 === 18) {
  263. expect(buffer.consume()).toEqual(reference.consume())
  264. expect(buffer.consume()).toEqual(reference.consume())
  265. }
  266. }
  267. expect(buffer.consume()).toEqual(reference.consume())
  268. buffer.append('x')
  269. reference.append('x')
  270. expect(buffer.snapshot()).toEqual(reference.snapshot())
  271. })
  272. })