parse only large transcript chunks off-isolate; deflake stream tests
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 24s

The hang fix offloaded every transcript parse to Isolate.run, including
the small per-poll appends. Spawning a one-shot isolate each tick is pure
overhead and, under concurrent test load, the spawn+round-trip latency
blew the streaming tests' fixed-delay window — transcript_reader_test
flaked intermittently in the full suite.

Only chunks >= 64KB now go off-isolate (the initial-tail case that
actually janks a frame); small appends parse inline. The streaming tests
poll until the expected items arrive instead of waiting a fixed delay, so
they're robust regardless of parse latency or scheduler load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:47:25 +02:00
co-authored by Claude Opus 4.7
parent 8fd251ac48
commit 0abc14ed1a
2 changed files with 31 additions and 11 deletions
+11 -3
View File
@@ -152,6 +152,11 @@ String _shortId(String uuid) => uuid.length >= 8 ? uuid.substring(0, 8) : uuid;
/// stream incrementally.
const _defaultInitialTailBytes = 256 * 1024;
/// Chunks at least this large are parsed in a background isolate; smaller
/// ones parse inline. Streaming appends are small, so this keeps the
/// off-thread parse to the initial-tail case that actually janks a frame.
const _isolateParseThreshold = 64 * 1024;
/// Known major transcript versions.
const _knownMajorVersions = {1, 2};
@@ -321,9 +326,12 @@ class TranscriptReader {
}
if (controller.isClosed) return;
// Parse off the UI isolate — the initial chunk can be sizeable and
// JSON-decoding it on the main thread would jank the frame.
final parsed = await Isolate.run(() => parseTranscriptChunk(chunk));
// Parse off the UI isolate only when the chunk is big enough to jank a
// frame — the initial tail read (up to [_initialTailBytes]) is the case
// that froze the app. Streaming appends are small (a message at a time);
// parsing those inline avoids spawning a one-shot isolate every poll
// tick, which is pure overhead and adds latency under load.
final parsed = chunk.length >= _isolateParseThreshold ? await Isolate.run(() => parseTranscriptChunk(chunk)) : parseTranscriptChunk(chunk);
if (controller.isClosed) return;
for (final w in parsed.warnings) {
_onWarn(w);
@@ -30,6 +30,19 @@ void appendLines(File file, List<Map<String, dynamic>> lines) {
);
}
/// Poll [ready] until it returns true or [timeout] elapses. Streaming
/// assertions use this instead of a fixed delay so they don't flake under
/// load (the reader polls on a timer and may parse off-isolate).
Future<void> pumpUntil(
bool Function() ready, {
Duration timeout = const Duration(seconds: 5),
}) async {
final deadline = DateTime.now().add(timeout);
while (!ready() && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
/// JSONL envelope skeleton with default sentinel values.
Map<String, dynamic> envelope({
required String type,
@@ -559,8 +572,7 @@ void main() {
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
// Allow a few poll cycles.
await Future<void>.delayed(const Duration(milliseconds: 150));
await pumpUntil(() => collected.whereType<UserMessage>().isNotEmpty && collected.whereType<AssistantTextMessage>().isNotEmpty);
await sub.cancel();
await reader.dispose();
@@ -585,14 +597,14 @@ void main() {
final sub = reader.stream.listen(collected.add);
// Let the reader consume the initial lines.
await Future<void>.delayed(const Duration(milliseconds: 100));
await pumpUntil(() => collected.whereType<UserMessage>().isNotEmpty);
final countAfterInit = collected.length;
// Append new lines.
appendLines(sessionFile, [assistantText('a1', 'appended reply')]);
// Let the reader pick up the append.
await Future<void>.delayed(const Duration(milliseconds: 100));
await pumpUntil(() => collected.whereType<AssistantTextMessage>().isNotEmpty);
await sub.cancel();
await reader.dispose();
@@ -619,7 +631,7 @@ void main() {
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
await Future<void>.delayed(const Duration(milliseconds: 100));
await pumpUntil(() => collected.whereType<UserMessage>().any((m) => m.text == 'old session'));
// Create a newer session file (ensure mtime difference with touch-like approach).
final newerFile = File('${projectDir.path}/session-xyz.jsonl');
@@ -631,7 +643,7 @@ void main() {
final now = DateTime.now();
await newerFile.setLastModified(now);
await Future<void>.delayed(const Duration(milliseconds: 200));
await pumpUntil(() => collected.whereType<UserMessage>().any((m) => m.text == 'new session'));
await sub.cancel();
await reader.dispose();
@@ -661,7 +673,7 @@ void main() {
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
await Future<void>.delayed(const Duration(milliseconds: 150));
await pumpUntil(() => collected.isNotEmpty);
await sub.cancel();
await reader.dispose();
@@ -692,7 +704,7 @@ void main() {
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
await Future<void>.delayed(const Duration(milliseconds: 200));
await pumpUntil(() => collected.isNotEmpty);
await sub.cancel();
await reader.dispose();