@floegence/floeterm-terminal-web 0.5.14 → 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,99 +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))
190
255
  return;
191
256
  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');
257
+ this.prepareRetainedLiveRebase();
258
+ this.recoveryRunning = false;
198
259
  await this.runRecovery();
199
260
  return;
200
261
  }
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
- }
262
+ const replayChunks = this.mergeRecoveryChunks(historyChunks, coveredEnd);
263
+ await this.writeOrdered(replayChunks, generation);
264
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
265
+ return;
266
+ if (this.needsRebase) {
267
+ this.prepareRetainedLiveRebase();
268
+ this.recoveryRunning = false;
269
+ await this.runRecovery();
270
+ return;
210
271
  }
272
+ this.coveredThroughSequence = Math.max(this.coveredThroughSequence, coveredEnd);
273
+ this.scheduledThroughSequence = Math.max(this.scheduledThroughSequence, coveredEnd);
274
+ const firstRetainedSequence = this.firstRetainedLiveSequence();
211
275
  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');
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();
215
284
  }
216
- this.pipeline.reset();
217
285
  this.retryAttempt = 0;
218
286
  this.lastError = null;
287
+ this.failure = null;
219
288
  this.setState('live');
220
- this.drainRetainedLive(replayedSequences);
221
- this.pipeline.flushNow();
289
+ this.recoveryRunning = false;
290
+ this.drainRetainedLive();
222
291
  }
223
292
  catch (error) {
224
- if (controller.signal.aborted || this.disposed || generation !== this.generation)
293
+ if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
225
294
  return;
226
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;
227
308
  this.scheduleRetry();
228
309
  }
229
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
+ }
230
397
  scheduleRetry() {
398
+ if (this.failure && !this.failure.retryable) {
399
+ this.setState('failed');
400
+ if (!this.baselineReady)
401
+ this.resolveBaselineWaiters();
402
+ return;
403
+ }
231
404
  const delay = this.policy.retryDelaysMs[this.retryAttempt];
232
405
  if (delay === undefined) {
233
406
  this.setState('failed');
407
+ if (!this.baselineReady)
408
+ this.resolveBaselineWaiters();
234
409
  return;
235
410
  }
236
411
  this.retryAttempt += 1;
237
412
  this.setState('retry-wait');
413
+ const generation = this.generation;
238
414
  const setTimer = this.options.scheduler?.setTimer ?? setTimeout;
239
415
  this.retryTimer = setTimer(() => {
240
416
  this.retryTimer = null;
417
+ if (!this.isCurrent(generation))
418
+ return;
241
419
  void this.runRecovery();
242
420
  }, delay);
243
421
  this.emitState();
@@ -255,93 +433,114 @@ class PagedTerminalOutputCoordinator {
255
433
  }
256
434
  this.emitState();
257
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
+ }
258
445
  canRenderLive() {
259
446
  return this.active && (this.options.isInteractive?.() ?? true);
260
447
  }
261
448
  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) {
449
+ const sequence = this.chunkSequence(chunk);
450
+ if (sequence !== undefined && sequence <= this.scheduledThroughSequence)
266
451
  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);
452
+ if (sequence !== undefined
453
+ && this.scheduledThroughSequence > 0
454
+ && sequence > this.scheduledThroughSequence + 1) {
455
+ this.retainLive(chunk);
456
+ this.beginCatchUp(this.scheduledThroughSequence + 1);
272
457
  return;
273
458
  }
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);
459
+ if (sequence !== undefined && this.scheduledThroughSequence === 0) {
460
+ this.coveredThroughSequence = sequence - 1;
461
+ this.scheduledThroughSequence = sequence - 1;
286
462
  }
463
+ this.enqueueLiveWrite(chunk);
287
464
  }
288
- drainRetainedLive(replayedSequences) {
289
- 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)
290
472
  return;
291
- 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)));
292
505
  this.retainedLive = [];
293
506
  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
507
  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);
508
+ this.acceptLive(retained[index]);
307
509
  if (this.state !== 'live') {
308
- for (const pending of retained.slice(index + 1)) {
510
+ for (const pending of retained.slice(index + 1))
309
511
  this.retainLive(pending);
310
- }
311
512
  break;
312
513
  }
313
514
  }
314
515
  }
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
- }
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;
340
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;
341
538
  }
342
539
  cancelRecovery(incrementGeneration = true) {
343
540
  this.abortController?.abort();
344
541
  this.abortController = null;
542
+ this.recoveryRunning = false;
543
+ this.recoverySerial += 1;
345
544
  if (this.retryTimer !== null) {
346
545
  const clearTimer = this.options.scheduler?.clearTimer ?? clearTimeout;
347
546
  clearTimer(this.retryTimer);
@@ -350,6 +549,14 @@ class PagedTerminalOutputCoordinator {
350
549
  if (incrementGeneration)
351
550
  this.generation += 1;
352
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
+ }
353
560
  setState(state) {
354
561
  this.state = state;
355
562
  this.emitState();