Skip to content

Commit 616d859

Browse files
jorgemoyaclaude
andauthored
LTRAC-440: ref(cli) - Extract pumpUntilRotation from tailLogs (#3029)
Split the SSE reader's connection lifecycle from its read pump. The outer loop in `tailLogs` now owns opening streams, retrying transient errors, and emitting user-facing reconnect messages. The inner loop becomes a standalone `pumpUntilRotation` that reads bytes from a single connection and returns a `Rotation` value ('ttl' | 'idle-timeout' | 'stream-done') describing why it stopped, instead of using break + side-effecting warnings. No behavior change — every rotation reason and error path maps to the same user-facing output and retry semantics as before, and the existing test suite passes unmodified. Refs LTRAC-440 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ad1fc3a commit 616d859

1 file changed

Lines changed: 69 additions & 50 deletions

File tree

  • packages/catalyst/src/cli/commands

packages/catalyst/src/cli/commands/logs.ts

Lines changed: 69 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ const processLogEvent = (event: string, format: LogFormat) => {
130130
type TimeoutRaceResult<T> = { kind: 'value'; value: T } | { kind: 'timeout' };
131131

132132
// Races a promise against a timer so a hung `reader.read()` doesn't block the
133-
// reconnect loop. Without this, the connection TTL check below the read() call
134-
// never runs when the API proxy half-closes the socket — bytes stop arriving
135-
// but no FIN/error surfaces, so the read promise stays pending forever.
133+
// read pump. Without this, the connection TTL check never runs when the API
134+
// proxy half-closes the socket — bytes stop arriving but no FIN/error
135+
// surfaces, so the read promise stays pending forever.
136136
const raceWithTimeout = async <T>(
137137
promise: Promise<T>,
138138
timeoutMs: number,
@@ -187,6 +187,67 @@ const openLogStream = async (
187187
return reader;
188188
};
189189

190+
// Reasons the read pump stops on its own (vs. throwing). The outer reconnect
191+
// loop maps each to a user-facing message — or silence — and opens a fresh
192+
// stream.
193+
type Rotation = 'ttl' | 'idle-timeout' | 'stream-done';
194+
195+
const pumpUntilRotation = async (
196+
reader: ReadableStreamDefaultReader<Uint8Array>,
197+
format: LogFormat,
198+
connectionTtlMs: number,
199+
): Promise<Rotation> => {
200+
const decoder = new TextDecoder();
201+
const connectTime = Date.now();
202+
let buffer = '';
203+
let receivedData = false;
204+
205+
// eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
206+
while (true) {
207+
const remainingTtlMs = connectionTtlMs - (Date.now() - connectTime);
208+
209+
if (remainingTtlMs <= 0) {
210+
void reader.cancel();
211+
212+
return 'ttl';
213+
}
214+
215+
// eslint-disable-next-line no-await-in-loop
216+
const readResult = await raceWithTimeout(reader.read(), remainingTtlMs);
217+
218+
if (readResult.kind === 'timeout') {
219+
void reader.cancel();
220+
221+
// No data for the whole window: proxy likely half-closed the socket.
222+
// If data flowed earlier, treat it as a normal TTL boundary instead.
223+
return receivedData ? 'ttl' : 'idle-timeout';
224+
}
225+
226+
const { value, done: streamDone } = readResult.value;
227+
228+
if (value) {
229+
receivedData = true;
230+
buffer += decoder.decode(value, { stream: true });
231+
232+
const parts = buffer.split('\n\n');
233+
234+
// Last element is either empty (complete event) or a partial chunk to carry over
235+
buffer = parts.pop() ?? '';
236+
237+
parts
238+
.map((raw) => parseSSEEvent(raw))
239+
.filter((event): event is string => event !== null)
240+
.forEach((event) => processLogEvent(event, format));
241+
}
242+
243+
if (streamDone) {
244+
void reader.cancel();
245+
246+
return 'stream-done';
247+
}
248+
}
249+
};
250+
190251
export const tailLogs = async (
191252
projectUuid: string,
192253
storeHash: string,
@@ -204,58 +265,16 @@ export const tailLogs = async (
204265
try {
205266
// eslint-disable-next-line no-await-in-loop
206267
const reader = await openLogStream(projectUuid, storeHash, accessToken, apiHost);
207-
const decoder = new TextDecoder();
208-
const connectTime = Date.now();
209-
let buffer = '';
210-
let receivedData = false;
211268

212269
retries = 0;
213270

214-
// eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
215-
while (true) {
216-
const remainingTtlMs = connectionTtlMs - (Date.now() - connectTime);
217-
218-
if (remainingTtlMs <= 0) {
219-
void reader.cancel();
220-
break;
221-
}
222-
223-
// eslint-disable-next-line no-await-in-loop
224-
const readResult = await raceWithTimeout(reader.read(), remainingTtlMs);
225-
226-
if (readResult.kind === 'timeout') {
227-
// No bytes for the whole TTL window — proxy likely half-closed the
228-
// socket. Warn the user so they see the reconnect happen.
229-
if (!receivedData) {
230-
consola.warn('Log stream idle, reconnecting...');
231-
}
232-
233-
void reader.cancel();
234-
break;
235-
}
236-
237-
const { value, done: streamDone } = readResult.value;
238-
239-
if (value) {
240-
receivedData = true;
241-
buffer += decoder.decode(value, { stream: true });
242-
243-
const parts = buffer.split('\n\n');
244-
245-
// Last element is either empty (complete event) or a partial chunk to carry over
246-
buffer = parts.pop() ?? '';
247-
248-
parts
249-
.map((raw) => parseSSEEvent(raw))
250-
.filter((event): event is string => event !== null)
251-
.forEach((event) => processLogEvent(event, format));
252-
}
271+
// eslint-disable-next-line no-await-in-loop
272+
const rotation = await pumpUntilRotation(reader, format, connectionTtlMs);
253273

254-
if (streamDone) {
255-
void reader.cancel();
256-
break;
257-
}
274+
if (rotation === 'idle-timeout') {
275+
consola.warn('Log stream idle, reconnecting...');
258276
}
277+
// 'ttl' and 'stream-done' are healthy rotations — reconnect silently.
259278
} catch (error) {
260279
if (error instanceof StreamError && error.fatal) {
261280
throw error;

0 commit comments

Comments
 (0)