stream-buffer.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. import type { DirectorState } from '@/lib/types/chat';
  2. /**
  3. * StreamBuffer — unified presentation pacing layer.
  4. *
  5. * Sits between data sources (SSE stream / PlaybackEngine) and React state.
  6. * Events are pushed into an ordered queue; a fixed-rate tick loop reveals
  7. * text character-by-character and fires typed callbacks so both the Chat
  8. * area and the Roundtable bubble consume identically-paced content.
  9. *
  10. * Key invariants:
  11. * - ONE source of pacing (this tick loop) — no double typewriter.
  12. * - pause() is O(1) instant — tick returns immediately.
  13. * - Actions fire only when the tick cursor reaches them (after preceding text).
  14. * - Roundtable sees only the current speech segment (resets on action / agent switch).
  15. */
  16. // ─── Buffer Item Types ───────────────────────────────────────────────
  17. export interface AgentStartItem {
  18. kind: 'agent_start';
  19. messageId: string;
  20. agentId: string;
  21. agentName: string;
  22. avatar?: string;
  23. color?: string;
  24. }
  25. export interface AgentEndItem {
  26. kind: 'agent_end';
  27. messageId: string;
  28. agentId: string;
  29. }
  30. export interface TextItem {
  31. kind: 'text';
  32. messageId: string;
  33. agentId: string;
  34. /** Unique ID for this text part — distinguishes multiple text items within one message (e.g. lecture). */
  35. partId: string;
  36. /** Growable — SSE deltas append here. */
  37. text: string;
  38. /** When true, no more text will be appended. Tick can advance past once fully revealed. */
  39. sealed: boolean;
  40. }
  41. export interface ActionItem {
  42. kind: 'action';
  43. messageId: string;
  44. actionId: string;
  45. actionName: string;
  46. params: Record<string, unknown>;
  47. agentId: string;
  48. }
  49. export interface ThinkingItem {
  50. kind: 'thinking';
  51. stage: string;
  52. agentId?: string;
  53. }
  54. export interface CueUserItem {
  55. kind: 'cue_user';
  56. fromAgentId?: string;
  57. prompt?: string;
  58. }
  59. export interface DoneItem {
  60. kind: 'done';
  61. totalActions: number;
  62. totalAgents: number;
  63. agentHadContent?: boolean;
  64. directorState?: DirectorState;
  65. }
  66. export interface ErrorItem {
  67. kind: 'error';
  68. message: string;
  69. }
  70. export type BufferItem =
  71. | AgentStartItem
  72. | AgentEndItem
  73. | TextItem
  74. | ActionItem
  75. | ThinkingItem
  76. | CueUserItem
  77. | DoneItem
  78. | ErrorItem;
  79. // ─── Callbacks ───────────────────────────────────────────────────────
  80. export interface StreamBufferCallbacks {
  81. onAgentStart(data: AgentStartItem): void;
  82. onAgentEnd(data: AgentEndItem): void;
  83. /**
  84. * Fired each tick while a text item is being revealed.
  85. * @param messageId — which message to update
  86. * @param partId — unique ID for this text part (stable across ticks)
  87. * @param revealedText — text visible so far (slice of full text)
  88. * @param isComplete — true when this text item is fully revealed AND sealed
  89. */
  90. onTextReveal(messageId: string, partId: string, revealedText: string, isComplete: boolean): void;
  91. /** Fired when tick reaches an action item. Callers should execute the effect + add badge. */
  92. onActionReady(messageId: string, data: ActionItem): void;
  93. /**
  94. * Unified speech feed for the Roundtable bubble.
  95. * Reports only the CURRENT segment text (resets on action / agent switch).
  96. * Called with (null, null) when buffer completes or is disposed.
  97. */
  98. onLiveSpeech(text: string | null, agentId: string | null): void;
  99. /**
  100. * Speech progress ratio for the Roundtable bubble auto-scroll.
  101. * Fired each tick during text reveal: ratio = charCursor / totalTextLength.
  102. * Called with null when buffer completes or is disposed.
  103. */
  104. onSpeechProgress(ratio: number | null): void;
  105. onThinking(data: { stage: string; agentId?: string } | null): void;
  106. onCueUser(fromAgentId?: string, prompt?: string): void;
  107. onDone(data: {
  108. totalActions: number;
  109. totalAgents: number;
  110. agentHadContent?: boolean;
  111. directorState?: DirectorState;
  112. }): void;
  113. onError(message: string): void;
  114. onSegmentSealed?: (
  115. messageId: string,
  116. partId: string,
  117. fullText: string,
  118. agentId: string | null,
  119. ) => void;
  120. /**
  121. * When provided, called after a text item is fully revealed and sealed.
  122. * If it returns true, the tick loop will NOT advance to the next item —
  123. * the bubble stays on the current text (e.g. waiting for TTS playback to finish).
  124. */
  125. shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean;
  126. }
  127. // ─── Options ─────────────────────────────────────────────────────────
  128. export interface StreamBufferOptions {
  129. /** Milliseconds between ticks. Default: 30 */
  130. tickMs?: number;
  131. /** Characters revealed per tick. Default: 1 (≈33 chars/s) */
  132. charsPerTick?: number;
  133. /**
  134. * Fixed delay (ms) after a text segment is fully revealed before advancing
  135. * to the next item. Gives the reader a breathing pause after each speech
  136. * block. Default: 0 (no delay).
  137. */
  138. postTextDelayMs?: number;
  139. /**
  140. * Delay (ms) after firing an action callback before advancing to the next
  141. * item. Gives action animations time to play out. Default: 0.
  142. */
  143. actionDelayMs?: number;
  144. }
  145. // ─── StreamBuffer Class ──────────────────────────────────────────────
  146. export class StreamBuffer {
  147. // Queue
  148. private items: BufferItem[] = [];
  149. private readIndex = 0;
  150. private charCursor = 0;
  151. // Roundtable segment tracking
  152. private currentSegmentText = '';
  153. private currentAgentId: string | null = null;
  154. // Control
  155. private _paused = false;
  156. private _disposed = false;
  157. private timer: ReturnType<typeof setInterval> | null = null;
  158. // Dwell / delay counters (in ticks)
  159. private _dwellTicksRemaining = 0;
  160. /** True when a text item's post-delay has elapsed and we're waiting for TTS to finish. */
  161. private _holdingForTTS = false;
  162. private _holdSegmentSnapshot = -1;
  163. // Config
  164. private readonly tickMs: number;
  165. private readonly charsPerTick: number;
  166. private readonly postTextDelayTicks: number;
  167. private readonly actionDelayTicks: number;
  168. private readonly cb: StreamBufferCallbacks;
  169. private partCounter = 0;
  170. private _drainResolve: (() => void) | null = null;
  171. private _drainReject: ((err: Error) => void) | null = null;
  172. constructor(callbacks: StreamBufferCallbacks, options?: StreamBufferOptions) {
  173. this.cb = callbacks;
  174. this.tickMs = options?.tickMs ?? 30;
  175. this.charsPerTick = options?.charsPerTick ?? 1;
  176. this.postTextDelayTicks = Math.ceil((options?.postTextDelayMs ?? 0) / this.tickMs);
  177. this.actionDelayTicks = Math.ceil((options?.actionDelayMs ?? 0) / this.tickMs);
  178. }
  179. // ─── Push Methods ────────────────────────────────────────────────
  180. pushAgentStart(data: Omit<AgentStartItem, 'kind'>): void {
  181. if (this._disposed) return;
  182. this.sealLastText();
  183. this.items.push({ kind: 'agent_start', ...data });
  184. }
  185. pushAgentEnd(data: Omit<AgentEndItem, 'kind'>): void {
  186. if (this._disposed) return;
  187. this.sealLastText();
  188. this.items.push({ kind: 'agent_end', ...data });
  189. }
  190. /**
  191. * Append text for a message.
  192. * If the last queue item is an unsealed text item for the same messageId,
  193. * the delta is appended in-place. Otherwise a new text item is created.
  194. */
  195. pushText(messageId: string, delta: string, agentId?: string): void {
  196. if (this._disposed) return;
  197. const last = this.items[this.items.length - 1];
  198. if (last && last.kind === 'text' && last.messageId === messageId && !last.sealed) {
  199. last.text += delta;
  200. } else {
  201. this.items.push({
  202. kind: 'text',
  203. messageId,
  204. agentId: agentId ?? this.currentAgentId ?? '',
  205. partId: `p${this.partCounter++}`,
  206. text: delta,
  207. sealed: false,
  208. });
  209. }
  210. }
  211. /** Mark the current (last) text item as complete — no more appends expected. */
  212. sealText(messageId: string): void {
  213. if (this._disposed) return;
  214. for (let i = this.items.length - 1; i >= 0; i--) {
  215. const item = this.items[i];
  216. if (item.kind === 'text' && item.messageId === messageId && !item.sealed) {
  217. item.sealed = true;
  218. break;
  219. }
  220. }
  221. }
  222. pushAction(data: Omit<ActionItem, 'kind'>): void {
  223. if (this._disposed) return;
  224. this.sealLastText();
  225. this.items.push({ kind: 'action', ...data });
  226. }
  227. pushThinking(data: { stage: string; agentId?: string }): void {
  228. if (this._disposed) return;
  229. this.items.push({ kind: 'thinking', ...data });
  230. }
  231. pushCueUser(data: { fromAgentId?: string; prompt?: string }): void {
  232. if (this._disposed) return;
  233. this.items.push({ kind: 'cue_user', ...data });
  234. }
  235. pushDone(data: {
  236. totalActions: number;
  237. totalAgents: number;
  238. agentHadContent?: boolean;
  239. directorState?: DirectorState;
  240. }): void {
  241. if (this._disposed) return;
  242. this.sealLastText();
  243. this.items.push({ kind: 'done', ...data });
  244. }
  245. pushError(message: string): void {
  246. if (this._disposed) return;
  247. this.items.push({ kind: 'error', message });
  248. }
  249. // ─── Control ─────────────────────────────────────────────────────
  250. /** Start the tick loop. Idempotent — calling twice is safe. */
  251. start(): void {
  252. if (this._disposed || this.timer) return;
  253. this.timer = setInterval(() => this.tick(), this.tickMs);
  254. }
  255. /** Instantly pause — tick becomes a no-op. */
  256. pause(): void {
  257. this._paused = true;
  258. }
  259. /** Resume from exactly where we left off. */
  260. resume(): void {
  261. this._paused = false;
  262. }
  263. /**
  264. * Returns a Promise that resolves when the buffer has processed all items
  265. * including the final `done` item. Rejects if the buffer is disposed/shutdown
  266. * before draining completes.
  267. *
  268. * NOTE: This will block indefinitely while the buffer is paused, by design.
  269. * Buffer-level pause (see `livePausedRef` in use-chat-sessions) freezes ALL
  270. * forward progress — the tick loop is a no-op while `_paused` is true, so
  271. * no items are processed and drain never fires until resumed.
  272. */
  273. waitUntilDrained(): Promise<void> {
  274. if (this._disposed) {
  275. return Promise.reject(new Error('Buffer already disposed'));
  276. }
  277. return new Promise<void>((resolve, reject) => {
  278. this._drainResolve = resolve;
  279. this._drainReject = reject;
  280. });
  281. }
  282. get paused(): boolean {
  283. return this._paused;
  284. }
  285. get disposed(): boolean {
  286. return this._disposed;
  287. }
  288. /**
  289. * Flush: instantly reveal everything remaining.
  290. * Used when restoring persisted sessions or force-completing.
  291. */
  292. flush(): void {
  293. if (this._disposed) return;
  294. while (this.readIndex < this.items.length) {
  295. const item = this.items[this.readIndex];
  296. switch (item.kind) {
  297. case 'text':
  298. this.cb.onTextReveal(item.messageId, item.partId, item.text, true);
  299. this.currentSegmentText = item.text;
  300. this.cb.onLiveSpeech(this.currentSegmentText, this.currentAgentId);
  301. this.cb.onSpeechProgress(1);
  302. break;
  303. case 'action':
  304. this.currentSegmentText = '';
  305. this.cb.onActionReady(item.messageId, item);
  306. this.cb.onLiveSpeech(null, this.currentAgentId);
  307. break;
  308. case 'agent_start':
  309. this.currentAgentId = item.agentId;
  310. this.currentSegmentText = '';
  311. this.cb.onThinking(null); // Agent selected — clear thinking indicator
  312. this.cb.onAgentStart(item);
  313. this.cb.onLiveSpeech(null, item.agentId);
  314. break;
  315. case 'agent_end':
  316. this.cb.onAgentEnd(item);
  317. break;
  318. case 'thinking':
  319. this.cb.onThinking(item);
  320. break;
  321. case 'cue_user':
  322. this.cb.onCueUser(item.fromAgentId, item.prompt);
  323. break;
  324. case 'done':
  325. this.cb.onLiveSpeech(null, null);
  326. this.cb.onSpeechProgress(null);
  327. this.cb.onThinking(null);
  328. this.cb.onDone(item);
  329. // Resolve drain promise
  330. this._drainResolve?.();
  331. this._drainResolve = null;
  332. this._drainReject = null;
  333. break;
  334. case 'error':
  335. this.cb.onError(item.message);
  336. break;
  337. }
  338. this.readIndex++;
  339. this.charCursor = 0;
  340. }
  341. }
  342. /** Stop tick loop, release resources. No more callbacks after this. */
  343. dispose(): void {
  344. if (this._disposed) return;
  345. this._disposed = true;
  346. if (this.timer) {
  347. clearInterval(this.timer);
  348. this.timer = null;
  349. }
  350. // Reject waiting drain promise
  351. this._drainReject?.(new Error('Buffer disposed'));
  352. this._drainResolve = null;
  353. this._drainReject = null;
  354. // Final cleanup signal
  355. this.cb.onLiveSpeech(null, null);
  356. this.cb.onSpeechProgress(null);
  357. }
  358. /**
  359. * Stop the tick timer and mark disposed WITHOUT firing final onLiveSpeech.
  360. * Used when replacing a buffer (e.g. resume after soft-pause) to avoid
  361. * the dispose callback clearing roundtable state via a stale microtask.
  362. */
  363. shutdown(): void {
  364. if (this._disposed) return;
  365. this._disposed = true;
  366. if (this.timer) {
  367. clearInterval(this.timer);
  368. this.timer = null;
  369. }
  370. // Reject waiting drain promise
  371. this._drainReject?.(new Error('Buffer shutdown'));
  372. this._drainResolve = null;
  373. this._drainReject = null;
  374. }
  375. // ─── Internals ───────────────────────────────────────────────────
  376. /** Seal the last text item in the queue (if any). */
  377. private sealLastText(): void {
  378. for (let i = this.items.length - 1; i >= 0; i--) {
  379. const item = this.items[i];
  380. if (item.kind === 'text' && !item.sealed) {
  381. item.sealed = true;
  382. // Ordering invariant: sealLastText() is called BEFORE pushAgentEnd/pushAgentStart,
  383. // so this.currentAgentId still refers to the agent whose text is being sealed.
  384. this.cb.onSegmentSealed?.(item.messageId, item.partId, item.text, this.currentAgentId);
  385. break;
  386. }
  387. // Stop searching once we hit a non-text item
  388. if (item.kind !== 'text') break;
  389. }
  390. }
  391. private tick(): void {
  392. if (this._paused || this._disposed) return;
  393. // Honour dwell / action-delay countdown before advancing
  394. if (this._dwellTicksRemaining > 0) {
  395. this._dwellTicksRemaining--;
  396. if (this._dwellTicksRemaining === 0 && this._holdingForTTS) {
  397. // Post-text delay just finished — fall through to the TTS hold check below
  398. } else {
  399. return;
  400. }
  401. }
  402. // TTS hold: after post-text delay, keep the bubble on screen while audio plays
  403. if (this._holdingForTTS) {
  404. const result = this.cb.shouldHoldAfterReveal?.();
  405. if (result) {
  406. if (typeof result === 'object') {
  407. if (!result.holding) {
  408. // TTS queue empty — release
  409. this._holdingForTTS = false;
  410. this._holdSegmentSnapshot = -1;
  411. this.advanceNonText();
  412. return;
  413. }
  414. if (result.segmentDone !== this._holdSegmentSnapshot) {
  415. // A segment just finished — release even if next segment is starting
  416. this._holdingForTTS = false;
  417. this._holdSegmentSnapshot = -1;
  418. this.advanceNonText();
  419. return;
  420. }
  421. return; // Same segment still playing — stay on current item
  422. }
  423. // Boolean form (legacy): hold as long as true
  424. return;
  425. }
  426. this._holdingForTTS = false;
  427. this._holdSegmentSnapshot = -1;
  428. // TTS done — continue to process next item
  429. this.advanceNonText();
  430. return;
  431. }
  432. const item = this.items[this.readIndex];
  433. if (!item) return; // Queue empty or caught up — wait
  434. switch (item.kind) {
  435. case 'text': {
  436. // Advance character cursor
  437. this.charCursor = Math.min(this.charCursor + this.charsPerTick, item.text.length);
  438. const revealed = item.text.slice(0, this.charCursor);
  439. const fullyRevealed = this.charCursor >= item.text.length;
  440. const isComplete = fullyRevealed && item.sealed;
  441. // Update chat area
  442. this.cb.onTextReveal(item.messageId, item.partId, revealed, isComplete);
  443. // Update roundtable (current segment only).
  444. // Use this.currentAgentId (set when tick processes agent_start) rather than
  445. // item.agentId — push-time race means item.agentId can carry a stale value
  446. // from the previous agent when SSE pushes outpace the tick loop.
  447. this.currentSegmentText = revealed;
  448. this.cb.onLiveSpeech(this.currentSegmentText, this.currentAgentId);
  449. this.cb.onSpeechProgress(item.text.length > 0 ? this.charCursor / item.text.length : 1);
  450. // Advance to next item if fully revealed and sealed
  451. if (isComplete) {
  452. this.readIndex++;
  453. this.charCursor = 0;
  454. // Fixed pause after text finishes — gives the reader a breathing gap
  455. // before the next action or agent turn fires.
  456. if (this.postTextDelayTicks > 0) {
  457. this._dwellTicksRemaining = this.postTextDelayTicks;
  458. // If TTS hold callback exists, mark that we need to check it after delay
  459. if (this.cb.shouldHoldAfterReveal) {
  460. this._holdingForTTS = true;
  461. const snap = this.cb.shouldHoldAfterReveal();
  462. this._holdSegmentSnapshot = typeof snap === 'object' ? snap.segmentDone : -1;
  463. }
  464. return; // next tick will count down, then advanceNonText
  465. }
  466. // No post-text delay — check TTS hold immediately
  467. {
  468. const result = this.cb.shouldHoldAfterReveal?.();
  469. if (result) {
  470. this._holdingForTTS = true;
  471. this._holdSegmentSnapshot = typeof result === 'object' ? result.segmentDone : -1;
  472. return; // TTS still playing — hold here
  473. }
  474. }
  475. // Process any immediately-advanceable items in the same tick
  476. // (e.g. action badges right after text)
  477. this.advanceNonText();
  478. }
  479. // If fullyRevealed but !sealed: wait for more SSE deltas
  480. break;
  481. }
  482. // Non-text items are processed immediately
  483. case 'agent_start':
  484. this.currentAgentId = item.agentId;
  485. this.currentSegmentText = '';
  486. this.cb.onThinking(null); // Agent selected — clear thinking indicator
  487. this.cb.onAgentStart(item);
  488. this.cb.onLiveSpeech(null, item.agentId);
  489. this.readIndex++;
  490. this.charCursor = 0;
  491. this.advanceNonText();
  492. break;
  493. case 'agent_end':
  494. this.cb.onAgentEnd(item);
  495. this.readIndex++;
  496. this.charCursor = 0;
  497. this.advanceNonText();
  498. break;
  499. case 'action':
  500. this.currentSegmentText = '';
  501. this.cb.onActionReady(item.messageId, item);
  502. this.cb.onLiveSpeech(null, this.currentAgentId);
  503. this.readIndex++;
  504. this.charCursor = 0;
  505. // Delay after action so animations have time to play out
  506. if (this.actionDelayTicks > 0) {
  507. this._dwellTicksRemaining = this.actionDelayTicks;
  508. return;
  509. }
  510. this.advanceNonText();
  511. break;
  512. case 'thinking':
  513. this.cb.onThinking(item);
  514. this.readIndex++;
  515. this.charCursor = 0;
  516. this.advanceNonText();
  517. break;
  518. case 'cue_user':
  519. this.cb.onCueUser(item.fromAgentId, item.prompt);
  520. this.readIndex++;
  521. this.charCursor = 0;
  522. this.advanceNonText();
  523. break;
  524. case 'done':
  525. this.cb.onLiveSpeech(null, null);
  526. this.cb.onSpeechProgress(null);
  527. this.cb.onThinking(null);
  528. this.cb.onDone(item);
  529. this.readIndex++;
  530. this.charCursor = 0;
  531. // Stop the timer — nothing more to process
  532. if (this.timer) {
  533. clearInterval(this.timer);
  534. this.timer = null;
  535. }
  536. // Resolve drain promise
  537. this._drainResolve?.();
  538. this._drainResolve = null;
  539. this._drainReject = null;
  540. break;
  541. case 'error':
  542. this.cb.onError(item.message);
  543. this.readIndex++;
  544. this.charCursor = 0;
  545. this.advanceNonText();
  546. break;
  547. }
  548. }
  549. /**
  550. * After processing a non-text item, keep advancing through consecutive
  551. * non-text items in the same tick. Stop when we hit a text item or
  552. * the end of the queue — the next tick will handle the text item
  553. * (so we don't skip the character-by-character reveal).
  554. *
  555. * Also stops when an action triggers a delay so its animation can play.
  556. */
  557. private advanceNonText(): void {
  558. while (this.readIndex < this.items.length) {
  559. const next = this.items[this.readIndex];
  560. if (next.kind === 'text') break; // Let the next tick handle text
  561. switch (next.kind) {
  562. case 'agent_start':
  563. this.currentAgentId = next.agentId;
  564. this.currentSegmentText = '';
  565. this.cb.onThinking(null); // Agent selected — clear thinking indicator
  566. this.cb.onAgentStart(next);
  567. this.cb.onLiveSpeech(null, next.agentId);
  568. break;
  569. case 'agent_end':
  570. this.cb.onAgentEnd(next);
  571. break;
  572. case 'action':
  573. this.currentSegmentText = '';
  574. this.cb.onActionReady(next.messageId, next);
  575. this.cb.onLiveSpeech(null, this.currentAgentId);
  576. this.readIndex++;
  577. this.charCursor = 0;
  578. // Pause after action to let animation play
  579. if (this.actionDelayTicks > 0) {
  580. this._dwellTicksRemaining = this.actionDelayTicks;
  581. return; // resume on next tick after countdown
  582. }
  583. continue; // no delay — keep advancing
  584. case 'thinking':
  585. this.cb.onThinking(next);
  586. break;
  587. case 'cue_user':
  588. this.cb.onCueUser(next.fromAgentId, next.prompt);
  589. break;
  590. case 'done':
  591. this.cb.onLiveSpeech(null, null);
  592. this.cb.onSpeechProgress(null);
  593. this.cb.onThinking(null);
  594. this.cb.onDone(next);
  595. this.readIndex++;
  596. this.charCursor = 0;
  597. if (this.timer) {
  598. clearInterval(this.timer);
  599. this.timer = null;
  600. }
  601. // Resolve drain promise
  602. this._drainResolve?.();
  603. this._drainResolve = null;
  604. this._drainReject = null;
  605. return; // done — stop advancing
  606. case 'error':
  607. this.cb.onError(next.message);
  608. break;
  609. }
  610. this.readIndex++;
  611. this.charCursor = 0;
  612. }
  613. }
  614. }