@floegence/floeterm-terminal-web 0.5.14 → 0.5.16

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