@floegence/floeterm-terminal-web 0.5.13 → 0.5.15

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.
@@ -1,12 +1,20 @@
1
- import { createTerminalOutputPipeline, } from './TerminalOutputPipeline';
2
1
  const DEFAULT_POLICY = {
3
2
  maxRetainedLiveChunks: 2048,
4
3
  maxRetainedLiveBytes: 8 * 1024 * 1024,
5
4
  retryDelaysMs: [250, 1000, 4000],
5
+ maxWriteBatchBytes: 256 * 1024,
6
6
  };
7
- const normalizePositiveInteger = (value, fallback) => (typeof value === 'number' && Number.isFinite(value) && value > 0
8
- ? Math.floor(value)
7
+ const normalizePositiveInteger = (value, fallback) => (typeof value === 'number' && Number.isSafeInteger(value) && value > 0
8
+ ? value
9
9
  : fallback);
10
+ const normalizeSequence = (value, field, optional = false) => {
11
+ if (value === undefined && optional)
12
+ return undefined;
13
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
14
+ throw new HistoryContractError('history_contract_invalid', `${field} must be a non-negative safe integer`);
15
+ }
16
+ return value;
17
+ };
10
18
  const concatData = (chunks) => {
11
19
  const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
12
20
  const result = new Uint8Array(total);
@@ -17,22 +25,39 @@ const concatData = (chunks) => {
17
25
  }
18
26
  return result;
19
27
  };
28
+ class HistoryContractError extends Error {
29
+ constructor(code, message, firstRetainedSequence) {
30
+ super(message);
31
+ this.name = 'HistoryContractError';
32
+ this.code = code;
33
+ this.firstRetainedSequence = firstRetainedSequence;
34
+ }
35
+ }
20
36
  class PagedTerminalOutputCoordinator {
21
37
  constructor(options) {
22
38
  this.active = true;
23
39
  this.state = 'idle';
40
+ this.baselineReady = false;
24
41
  this.coveredThroughSequence = 0;
42
+ this.scheduledThroughSequence = 0;
25
43
  this.retainedLive = [];
26
44
  this.retainedLiveBytes = 0;
27
45
  this.retryAttempt = 0;
28
46
  this.retryTimer = null;
29
47
  this.abortController = null;
30
48
  this.generation = 0;
49
+ this.recoverySerial = 0;
31
50
  this.disposed = false;
32
51
  this.lastError = null;
52
+ this.failure = null;
33
53
  this.recoveryStartSequence = 1;
34
54
  this.recoveryKind = 'initial';
55
+ this.recoveryRunning = false;
35
56
  this.needsRebase = false;
57
+ this.writeChain = Promise.resolve();
58
+ this.pendingLiveWrites = [];
59
+ this.liveWriteScheduled = false;
60
+ this.baselineWaiters = [];
36
61
  this.options = options;
37
62
  this.policy = {
38
63
  maxRetainedLiveChunks: normalizePositiveInteger(options.policy?.maxRetainedLiveChunks, DEFAULT_POLICY.maxRetainedLiveChunks),
@@ -40,39 +65,45 @@ class PagedTerminalOutputCoordinator {
40
65
  retryDelaysMs: options.policy?.retryDelaysMs !== undefined
41
66
  ? options.policy.retryDelaysMs.map(delay => Math.max(0, Math.floor(delay)))
42
67
  : DEFAULT_POLICY.retryDelaysMs,
68
+ maxWriteBatchBytes: normalizePositiveInteger(options.policy?.maxWriteBatchBytes, DEFAULT_POLICY.maxWriteBatchBytes),
43
69
  };
44
- const pipelinePolicy = {
45
- maxInactiveChunks: this.policy.maxRetainedLiveChunks,
46
- maxInactiveBytes: this.policy.maxRetainedLiveBytes,
47
- };
48
- this.pipeline = createTerminalOutputPipeline({
49
- write: (_data, chunks) => this.writeAcceptedChunks(chunks),
50
- isInteractive: () => true,
51
- policy: pipelinePolicy,
52
- scheduler: options.scheduler,
53
- });
54
70
  }
55
71
  async attach(startSequence = 1) {
56
72
  if (this.disposed)
57
73
  return;
58
74
  this.cancelRecovery();
75
+ this.resolveBaselineWaiters();
59
76
  this.retainedLive = [];
60
77
  this.retainedLiveBytes = 0;
78
+ this.pendingLiveWrites = [];
79
+ this.liveWriteScheduled = false;
80
+ this.writeChain = Promise.resolve();
81
+ this.baselineReady = false;
61
82
  this.coveredThroughSequence = Math.max(0, Math.floor(startSequence) - 1);
62
- this.pipeline.reset({ startSequence: this.coveredThroughSequence + 1 });
83
+ this.scheduledThroughSequence = this.coveredThroughSequence;
63
84
  this.recoveryKind = 'initial';
64
85
  this.recoveryStartSequence = Math.max(0, Math.floor(startSequence));
65
86
  this.retryAttempt = 0;
87
+ this.failure = null;
88
+ this.lastError = null;
66
89
  await this.runRecovery();
67
90
  }
91
+ waitForBaseline() {
92
+ const snapshot = this.getSnapshot();
93
+ if (snapshot.baselineReady || snapshot.state === 'failed' || snapshot.disposed) {
94
+ return Promise.resolve(snapshot);
95
+ }
96
+ return new Promise(resolve => this.baselineWaiters.push(resolve));
97
+ }
68
98
  pushLive(chunk) {
69
99
  if (this.disposed)
70
100
  return;
101
+ const tagged = { ...chunk, source: 'live' };
71
102
  if (this.state !== 'live' || !this.canRenderLive()) {
72
- this.retainLive(chunk);
103
+ this.retainLive(tagged);
73
104
  return;
74
105
  }
75
- this.acceptLive(chunk);
106
+ this.acceptLive(tagged);
76
107
  }
77
108
  setActive(active) {
78
109
  if (this.disposed)
@@ -80,16 +111,13 @@ class PagedTerminalOutputCoordinator {
80
111
  this.active = active;
81
112
  if (active) {
82
113
  if (this.needsRebase) {
83
- this.needsRebase = false;
84
- this.recoveryKind = 'catch-up';
85
- this.recoveryStartSequence = 0;
86
- this.options.clear?.();
87
- this.options.onHistoryTruncated?.('retained-live-overflow');
88
- void this.runRecovery();
114
+ if (!this.recoveryRunning) {
115
+ this.prepareRetainedLiveRebase();
116
+ void this.runRecovery();
117
+ }
89
118
  }
90
- else {
91
- this.drainRetainedLive(new Set());
92
- this.pipeline.flush();
119
+ else if (this.state === 'live') {
120
+ this.drainRetainedLive();
93
121
  }
94
122
  }
95
123
  this.emitState();
@@ -101,28 +129,38 @@ class PagedTerminalOutputCoordinator {
101
129
  this.options.clear?.();
102
130
  this.retainedLive = [];
103
131
  this.retainedLiveBytes = 0;
132
+ this.pendingLiveWrites = [];
133
+ this.liveWriteScheduled = false;
134
+ this.writeChain = Promise.resolve();
135
+ this.baselineReady = false;
104
136
  this.coveredThroughSequence = Math.max(0, Math.floor(startSequence) - 1);
105
- this.pipeline.reset({ startSequence: this.coveredThroughSequence + 1 });
137
+ this.scheduledThroughSequence = this.coveredThroughSequence;
106
138
  this.retryAttempt = 0;
139
+ this.failure = null;
107
140
  this.lastError = null;
108
141
  this.setState('idle');
142
+ this.resolveBaselineWaiters();
109
143
  }
110
144
  retry() {
111
145
  if (this.disposed || this.state !== 'failed')
112
146
  return;
113
147
  this.retryAttempt = 0;
148
+ this.failure = null;
114
149
  void this.runRecovery();
115
150
  }
116
151
  getSnapshot() {
117
152
  return {
118
153
  state: this.state,
119
154
  active: this.active,
155
+ baselineReady: this.baselineReady,
120
156
  coveredThroughSequence: this.coveredThroughSequence,
121
- retainedLiveChunks: this.retainedLive.length + this.pipeline.getStats().catchUpChunks,
122
- retainedLiveBytes: this.retainedLiveBytes + this.pipeline.getStats().catchUpBytes,
157
+ retainedLiveChunks: this.retainedLive.length,
158
+ retainedLiveBytes: this.retainedLiveBytes,
123
159
  retryAttempt: this.retryAttempt,
124
160
  retryScheduled: this.retryTimer !== null,
161
+ failure: this.failure,
125
162
  lastError: this.lastError,
163
+ attachGeneration: this.generation,
126
164
  disposed: this.disposed,
127
165
  };
128
166
  }
@@ -133,11 +171,13 @@ class PagedTerminalOutputCoordinator {
133
171
  this.cancelRecovery();
134
172
  this.retainedLive = [];
135
173
  this.retainedLiveBytes = 0;
136
- this.pipeline.dispose();
174
+ this.pendingLiveWrites = [];
175
+ this.liveWriteScheduled = false;
137
176
  this.setState('disposed');
177
+ this.resolveBaselineWaiters();
138
178
  }
139
179
  beginCatchUp(startSequence) {
140
- if (this.disposed || this.state === 'initial-replay')
180
+ if (this.disposed || this.recoveryRunning || this.state === 'initial-replay')
141
181
  return;
142
182
  this.recoveryKind = 'catch-up';
143
183
  this.recoveryStartSequence = Math.max(0, Math.floor(startSequence));
@@ -145,84 +185,237 @@ class PagedTerminalOutputCoordinator {
145
185
  void this.runRecovery();
146
186
  }
147
187
  async runRecovery() {
148
- if (this.disposed)
188
+ if (this.disposed || this.recoveryRunning)
149
189
  return;
150
190
  this.cancelRecovery(false);
151
- const generation = ++this.generation;
191
+ const generation = this.generation;
192
+ const recoverySerial = ++this.recoverySerial;
152
193
  const controller = new AbortController();
153
194
  this.abortController = controller;
154
195
  this.lastError = null;
196
+ this.failure = null;
197
+ this.recoveryRunning = true;
155
198
  this.setState(this.recoveryKind === 'initial' ? 'initial-replay' : 'catching-up');
156
199
  try {
157
- const replayedSequences = new Set();
200
+ await this.writeChain;
201
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
202
+ return;
203
+ const historyChunks = [];
158
204
  let cursor;
159
205
  let startSequence = this.recoveryStartSequence;
206
+ let snapshotEnd;
207
+ let historyGeneration;
208
+ let coveredEnd = this.coveredThroughSequence;
160
209
  let firstPage = true;
161
210
  do {
162
211
  const page = await this.options.fetchPage({
163
212
  startSequence,
213
+ endSequence: snapshotEnd,
214
+ historyGeneration,
164
215
  cursor,
165
216
  signal: controller.signal,
166
217
  });
167
- if (this.disposed || generation !== this.generation)
218
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
168
219
  return;
169
- const firstAvailable = page.firstAvailableSequence;
170
- if (firstPage && firstAvailable && startSequence > 0 && firstAvailable > startSequence) {
171
- this.options.clear?.();
172
- this.options.onHistoryTruncated?.('history-evicted');
173
- this.coveredThroughSequence = firstAvailable - 1;
174
- this.pipeline.reset({ startSequence: firstAvailable });
220
+ const coverage = this.validatePage(page, coveredEnd);
221
+ const pageSnapshotEnd = normalizeSequence(page.snapshotEndSequence, 'snapshotEndSequence', true);
222
+ const pageGeneration = normalizeSequence(page.historyGeneration, 'historyGeneration', true);
223
+ const firstRetained = normalizeSequence(page.firstRetainedSequence ?? page.firstAvailableSequence, 'firstRetainedSequence', true);
224
+ if (firstPage) {
225
+ snapshotEnd = pageSnapshotEnd;
226
+ historyGeneration = pageGeneration;
227
+ const effectiveStart = Math.max(1, startSequence);
228
+ if (page.historyReset || page.historyTruncated || (firstRetained !== undefined && firstRetained > effectiveStart)) {
229
+ this.options.clear?.();
230
+ this.options.onHistoryTruncated?.('history-evicted');
231
+ this.coveredThroughSequence = Math.max(0, (firstRetained ?? effectiveStart) - 1);
232
+ this.scheduledThroughSequence = this.coveredThroughSequence;
233
+ }
175
234
  }
176
- firstPage = false;
177
- const replayChunks = page.chunks.filter(chunk => (!chunk.sequence || chunk.sequence > this.coveredThroughSequence));
178
- for (const chunk of replayChunks) {
179
- if (chunk.sequence)
180
- replayedSequences.add(chunk.sequence);
235
+ else {
236
+ if (snapshotEnd !== undefined && pageSnapshotEnd !== undefined && pageSnapshotEnd !== snapshotEnd) {
237
+ throw new HistoryContractError('history_contract_invalid', 'snapshotEndSequence changed during pagination');
238
+ }
239
+ if (historyGeneration !== undefined && pageGeneration !== undefined && pageGeneration !== historyGeneration) {
240
+ throw new HistoryContractError('history_evicted', 'historyGeneration changed during pagination', firstRetained);
241
+ }
181
242
  }
182
- this.writeAcceptedChunks(replayChunks);
183
- this.coveredThroughSequence = Math.max(this.coveredThroughSequence, Math.floor(page.coveredThroughSequence || 0));
243
+ firstPage = false;
244
+ historyChunks.push(...page.chunks.map(item => ({ ...item, source: 'history' })));
245
+ coveredEnd = Math.max(coveredEnd, coverage);
184
246
  cursor = page.nextCursor;
185
- startSequence = this.coveredThroughSequence + 1;
186
247
  if (!page.hasMore)
187
248
  break;
249
+ if (cursor === undefined) {
250
+ throw new HistoryContractError('history_contract_invalid', 'nextCursor is required when hasMore is true');
251
+ }
252
+ startSequence = coverage + 1;
188
253
  } while (!controller.signal.aborted);
189
- if (this.disposed || generation !== this.generation)
254
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
255
+ return;
256
+ if (this.needsRebase) {
257
+ this.prepareRetainedLiveRebase();
258
+ this.recoveryRunning = false;
259
+ await this.runRecovery();
260
+ return;
261
+ }
262
+ const replayChunks = this.mergeRecoveryChunks(historyChunks, coveredEnd);
263
+ await this.writeOrdered(replayChunks, generation);
264
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
190
265
  return;
191
266
  if (this.needsRebase) {
192
- this.needsRebase = false;
193
- this.options.clear?.();
194
- this.pipeline.reset({ startSequence: 1 });
195
- this.coveredThroughSequence = 0;
196
- this.recoveryStartSequence = 0;
197
- this.options.onHistoryTruncated?.('retained-live-overflow');
267
+ this.prepareRetainedLiveRebase();
268
+ this.recoveryRunning = false;
198
269
  await this.runRecovery();
199
270
  return;
200
271
  }
201
- this.pipeline.reset();
272
+ this.coveredThroughSequence = Math.max(this.coveredThroughSequence, coveredEnd);
273
+ this.scheduledThroughSequence = Math.max(this.scheduledThroughSequence, coveredEnd);
274
+ const firstRetainedSequence = this.firstRetainedLiveSequence();
275
+ if (this.recoveryKind === 'catch-up'
276
+ && firstRetainedSequence !== undefined
277
+ && firstRetainedSequence > coveredEnd + 1) {
278
+ throw new HistoryContractError('history_coverage_incomplete', 'terminal history coverage has not reached retained live output', firstRetainedSequence);
279
+ }
280
+ if (this.recoveryKind === 'initial' && !this.baselineReady) {
281
+ this.baselineReady = true;
282
+ this.resolveBaselineWaiters();
283
+ this.emitState();
284
+ }
202
285
  this.retryAttempt = 0;
203
286
  this.lastError = null;
287
+ this.failure = null;
204
288
  this.setState('live');
205
- this.drainRetainedLive(replayedSequences);
206
- this.pipeline.flush();
289
+ this.recoveryRunning = false;
290
+ this.drainRetainedLive();
207
291
  }
208
292
  catch (error) {
209
- if (controller.signal.aborted || this.disposed || generation !== this.generation)
293
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
210
294
  return;
211
295
  this.lastError = error;
296
+ const contract = error instanceof HistoryContractError ? error : null;
297
+ this.failure = {
298
+ code: contract?.code ?? 'history_fetch_failed',
299
+ phase: this.recoveryKind === 'initial' ? 'initial' : 'catch_up',
300
+ retryable: contract?.code !== 'history_contract_missing' && contract?.code !== 'history_contract_invalid',
301
+ attempt: this.retryAttempt,
302
+ coveredSequence: this.coveredThroughSequence,
303
+ firstRetainedSequence: contract?.firstRetainedSequence,
304
+ attachGeneration: generation,
305
+ cause: error,
306
+ };
307
+ this.recoveryRunning = false;
212
308
  this.scheduleRetry();
213
309
  }
214
310
  }
311
+ validatePage(page, previousCoverage) {
312
+ if (!Object.prototype.hasOwnProperty.call(page, 'coveredThroughSequence')) {
313
+ throw new HistoryContractError('history_contract_missing', 'coveredThroughSequence is required');
314
+ }
315
+ const coverage = normalizeSequence(page.coveredThroughSequence, 'coveredThroughSequence');
316
+ if (coverage < previousCoverage) {
317
+ throw new HistoryContractError('history_contract_invalid', 'coveredThroughSequence regressed');
318
+ }
319
+ return coverage;
320
+ }
321
+ mergeRecoveryChunks(history, coveredEnd) {
322
+ const selected = new Map();
323
+ const unsequenced = [];
324
+ for (const item of history) {
325
+ const sequence = this.chunkSequence(item);
326
+ if (sequence === undefined)
327
+ unsequenced.push(item);
328
+ else if (sequence > this.coveredThroughSequence && sequence <= coveredEnd)
329
+ selected.set(sequence, item);
330
+ }
331
+ const remaining = [];
332
+ let remainingBytes = 0;
333
+ for (const item of this.retainedLive) {
334
+ const sequence = this.chunkSequence(item);
335
+ if (sequence !== undefined && sequence <= coveredEnd) {
336
+ if (!selected.has(sequence))
337
+ selected.set(sequence, item);
338
+ }
339
+ else {
340
+ remaining.push(item);
341
+ remainingBytes += item.data.byteLength;
342
+ }
343
+ }
344
+ this.retainedLive = remaining;
345
+ this.retainedLiveBytes = remainingBytes;
346
+ return [
347
+ ...[...selected.entries()].sort(([left], [right]) => left - right).map(([, item]) => item),
348
+ ...unsequenced,
349
+ ];
350
+ }
351
+ async writeOrdered(chunks, generation) {
352
+ let batch = [];
353
+ let batchBytes = 0;
354
+ let batchSource;
355
+ const flush = async () => {
356
+ if (batch.length === 0 || !this.isCurrent(generation))
357
+ return;
358
+ const current = batch;
359
+ const source = batchSource;
360
+ batch = [];
361
+ batchBytes = 0;
362
+ batchSource = undefined;
363
+ const accepted = [];
364
+ const data = [];
365
+ for (const item of current) {
366
+ const transformed = this.options.transformChunk ? this.options.transformChunk(item) : item.data;
367
+ if (transformed === null)
368
+ continue;
369
+ accepted.push({ ...item, data: transformed });
370
+ data.push(transformed);
371
+ }
372
+ if (accepted.length === 0 || !this.isCurrent(generation))
373
+ return;
374
+ const writer = source === 'history' ? (this.options.writeHistory ?? this.options.write) : this.options.write;
375
+ await writer(concatData(data), accepted);
376
+ if (!this.isCurrent(generation))
377
+ return;
378
+ for (const item of accepted) {
379
+ const sequence = this.chunkSequence(item);
380
+ if (sequence !== undefined) {
381
+ this.coveredThroughSequence = Math.max(this.coveredThroughSequence, sequence);
382
+ this.scheduledThroughSequence = Math.max(this.scheduledThroughSequence, sequence);
383
+ }
384
+ }
385
+ };
386
+ for (const item of chunks) {
387
+ const source = item.source ?? 'live';
388
+ if (batch.length > 0 && (source !== batchSource || batchBytes + item.data.byteLength > this.policy.maxWriteBatchBytes)) {
389
+ await flush();
390
+ }
391
+ batchSource = source;
392
+ batch.push(item);
393
+ batchBytes += item.data.byteLength;
394
+ }
395
+ await flush();
396
+ }
215
397
  scheduleRetry() {
398
+ if (this.failure && !this.failure.retryable) {
399
+ this.setState('failed');
400
+ if (!this.baselineReady)
401
+ this.resolveBaselineWaiters();
402
+ return;
403
+ }
216
404
  const delay = this.policy.retryDelaysMs[this.retryAttempt];
217
405
  if (delay === undefined) {
218
406
  this.setState('failed');
407
+ if (!this.baselineReady)
408
+ this.resolveBaselineWaiters();
219
409
  return;
220
410
  }
221
411
  this.retryAttempt += 1;
222
412
  this.setState('retry-wait');
413
+ const generation = this.generation;
223
414
  const setTimer = this.options.scheduler?.setTimer ?? setTimeout;
224
415
  this.retryTimer = setTimer(() => {
225
416
  this.retryTimer = null;
417
+ if (!this.isCurrent(generation))
418
+ return;
226
419
  void this.runRecovery();
227
420
  }, delay);
228
421
  this.emitState();
@@ -240,87 +433,114 @@ class PagedTerminalOutputCoordinator {
240
433
  }
241
434
  this.emitState();
242
435
  }
436
+ prepareRetainedLiveRebase() {
437
+ this.needsRebase = false;
438
+ this.recoveryKind = this.baselineReady ? 'catch-up' : 'initial';
439
+ this.recoveryStartSequence = 0;
440
+ this.coveredThroughSequence = 0;
441
+ this.scheduledThroughSequence = 0;
442
+ this.options.clear?.();
443
+ this.options.onHistoryTruncated?.('retained-live-overflow');
444
+ }
243
445
  canRenderLive() {
244
446
  return this.active && (this.options.isInteractive?.() ?? true);
245
447
  }
246
448
  acceptLive(chunk) {
247
- const sequence = typeof chunk.sequence === 'number' && Number.isFinite(chunk.sequence) && chunk.sequence > 0
248
- ? Math.floor(chunk.sequence)
249
- : undefined;
250
- if (sequence && sequence <= this.coveredThroughSequence) {
449
+ const sequence = this.chunkSequence(chunk);
450
+ if (sequence !== undefined && sequence <= this.scheduledThroughSequence)
251
451
  return;
252
- }
253
- if (sequence && this.coveredThroughSequence > 0 && sequence > this.coveredThroughSequence + 1) {
254
- this.retainLive({ ...chunk, sequence });
255
- this.beginCatchUp(this.coveredThroughSequence + 1);
452
+ if (sequence !== undefined
453
+ && this.scheduledThroughSequence > 0
454
+ && sequence > this.scheduledThroughSequence + 1) {
455
+ this.retainLive(chunk);
456
+ this.beginCatchUp(this.scheduledThroughSequence + 1);
256
457
  return;
257
458
  }
258
- this.enqueueForRender(sequence ? { ...chunk, sequence } : chunk);
259
- }
260
- enqueueForRender(chunk) {
261
- const sequence = chunk.sequence;
262
- const pipelineChunk = {
263
- ...chunk,
264
- sequence: undefined,
265
- coordinatorSequence: sequence,
266
- };
267
- this.pipeline.enqueue(pipelineChunk);
268
- if (sequence) {
269
- this.coveredThroughSequence = Math.max(this.coveredThroughSequence, sequence);
459
+ if (sequence !== undefined && this.scheduledThroughSequence === 0) {
460
+ this.coveredThroughSequence = sequence - 1;
461
+ this.scheduledThroughSequence = sequence - 1;
270
462
  }
463
+ this.enqueueLiveWrite(chunk);
271
464
  }
272
- drainRetainedLive(replayedSequences) {
273
- if (!this.canRenderLive() || this.retainedLive.length === 0)
465
+ enqueueLiveWrite(chunk) {
466
+ const generation = this.generation;
467
+ const sequence = this.chunkSequence(chunk);
468
+ if (sequence !== undefined)
469
+ this.scheduledThroughSequence = Math.max(this.scheduledThroughSequence, sequence);
470
+ this.pendingLiveWrites.push(chunk);
471
+ if (this.liveWriteScheduled)
274
472
  return;
275
- const retained = [...this.retainedLive].sort((left, right) => ((left.sequence ?? 0) - (right.sequence ?? 0)));
473
+ this.liveWriteScheduled = true;
474
+ this.writeChain = this.writeChain.then(async () => {
475
+ if (!this.isCurrent(generation))
476
+ return;
477
+ await Promise.resolve();
478
+ if (!this.isCurrent(generation))
479
+ return;
480
+ const pending = this.pendingLiveWrites.splice(0);
481
+ this.liveWriteScheduled = false;
482
+ await this.writeOrdered(pending, generation);
483
+ }).catch(error => {
484
+ this.liveWriteScheduled = false;
485
+ if (!this.isCurrent(generation))
486
+ return;
487
+ this.lastError = error;
488
+ this.failure = {
489
+ code: 'history_fetch_failed',
490
+ phase: 'catch_up',
491
+ retryable: true,
492
+ attempt: this.retryAttempt,
493
+ coveredSequence: this.coveredThroughSequence,
494
+ attachGeneration: generation,
495
+ cause: error,
496
+ };
497
+ this.setState('failed');
498
+ });
499
+ }
500
+ drainRetainedLive() {
501
+ if (!this.canRenderLive() || this.retainedLive.length === 0 || this.state !== 'live')
502
+ return;
503
+ const retained = [...this.retainedLive].sort((left, right) => ((this.chunkSequence(left) ?? Number.MAX_SAFE_INTEGER)
504
+ - (this.chunkSequence(right) ?? Number.MAX_SAFE_INTEGER)));
276
505
  this.retainedLive = [];
277
506
  this.retainedLiveBytes = 0;
278
- const firstSequence = retained.find(chunk => chunk.sequence)?.sequence;
279
- if (this.coveredThroughSequence === 0 && firstSequence && replayedSequences.size === 0) {
280
- this.coveredThroughSequence = firstSequence - 1;
281
- }
282
- for (const chunk of retained) {
283
- if (chunk.sequence && replayedSequences.has(chunk.sequence))
284
- continue;
285
- if (chunk.sequence && chunk.sequence <= this.coveredThroughSequence) {
286
- this.enqueueForRender(chunk);
287
- continue;
288
- }
289
- this.acceptLive(chunk);
290
- if (this.state !== 'live')
507
+ for (let index = 0; index < retained.length; index += 1) {
508
+ this.acceptLive(retained[index]);
509
+ if (this.state !== 'live') {
510
+ for (const pending of retained.slice(index + 1))
511
+ this.retainLive(pending);
291
512
  break;
513
+ }
292
514
  }
293
515
  }
294
- writeAcceptedChunks(chunks) {
295
- if (chunks.length === 0)
296
- return;
297
- const acceptedChunks = [];
298
- const data = [];
299
- for (const queuedChunk of chunks) {
300
- const coordinatorSequence = queuedChunk.coordinatorSequence;
301
- const chunk = coordinatorSequence
302
- ? { ...queuedChunk, sequence: coordinatorSequence }
303
- : queuedChunk;
304
- const transformed = this.options.transformChunk
305
- ? this.options.transformChunk(chunk)
306
- : chunk.data;
307
- if (transformed === null)
308
- continue;
309
- acceptedChunks.push({ ...chunk, data: transformed });
310
- data.push(transformed);
311
- }
312
- if (acceptedChunks.length > 0) {
313
- this.options.write(concatData(data), acceptedChunks);
314
- for (const chunk of acceptedChunks) {
315
- if (chunk.sequence) {
316
- this.coveredThroughSequence = Math.max(this.coveredThroughSequence, chunk.sequence);
317
- }
318
- }
516
+ chunkSequence(chunk) {
517
+ const sequence = chunk.sequence;
518
+ return typeof sequence === 'number' && Number.isSafeInteger(sequence) && sequence > 0
519
+ ? sequence
520
+ : undefined;
521
+ }
522
+ firstRetainedLiveSequence() {
523
+ let first;
524
+ for (const item of this.retainedLive) {
525
+ const sequence = this.chunkSequence(item);
526
+ if (sequence !== undefined && (first === undefined || sequence < first))
527
+ first = sequence;
319
528
  }
529
+ return first;
530
+ }
531
+ isCurrent(generation) {
532
+ return !this.disposed && generation === this.generation;
533
+ }
534
+ isRecoveryCurrent(generation, recoverySerial, controller) {
535
+ return this.isCurrent(generation)
536
+ && recoverySerial === this.recoverySerial
537
+ && !controller.signal.aborted;
320
538
  }
321
539
  cancelRecovery(incrementGeneration = true) {
322
540
  this.abortController?.abort();
323
541
  this.abortController = null;
542
+ this.recoveryRunning = false;
543
+ this.recoverySerial += 1;
324
544
  if (this.retryTimer !== null) {
325
545
  const clearTimer = this.options.scheduler?.clearTimer ?? clearTimeout;
326
546
  clearTimer(this.retryTimer);
@@ -329,6 +549,14 @@ class PagedTerminalOutputCoordinator {
329
549
  if (incrementGeneration)
330
550
  this.generation += 1;
331
551
  }
552
+ resolveBaselineWaiters() {
553
+ if (this.baselineWaiters.length === 0)
554
+ return;
555
+ const snapshot = this.getSnapshot();
556
+ const waiters = this.baselineWaiters.splice(0);
557
+ for (const resolve of waiters)
558
+ resolve(snapshot);
559
+ }
332
560
  setState(state) {
333
561
  this.state = state;
334
562
  this.emitState();