Skip to content

Commit 0bcf6a3

Browse files
committed
Studio: record a freeze that spans the end of the stream
The stall in progress was measured only while text was still arriving. A freeze that spans the moment the stream ends blocks the frame loop across it, so the first frame afterwards already observes a non-null streamEndedAtMs and the whole frozen interval was skipped. With the lost tail able to hide inside the 90% workload floor, thirty quiet frames then settled the reply and the run reported a short longest stall, which is the one shape this number exists to catch. Cap the interval at the absolute stream-end timestamp instead. A freeze across that moment is recorded in full, and the stall stops growing once there is no more text to wait for, so the settle check's own quiet frames are still not counted as a freeze. The rule moves into smoke-stream-pacing-stall.ts so it can be tested without importing the harness entry, which mounts React on import. The new tests cover the spanning freeze, the settle-window bound, idempotence and late tail paint; restoring the previous rule fails two of the five. Clean runs unchanged (stall 967 to 983ms, long tasks 5,442 to 5,842ms) and both mutations still caught: #7892 reverted fails the stall at 5,017ms, #8750 reverted fails long tasks at 63,687ms.
1 parent e483a17 commit 0bcf6a3

3 files changed

Lines changed: 95 additions & 4 deletions

File tree

studio/frontend/smoke-stream-pacing-main.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from "@assistant-ui/react";
4040
import { type ReactElement, useEffect } from "react";
4141
import { createRoot } from "react-dom/client";
42+
import { stallInProgress } from "./smoke-stream-pacing-stall.ts";
4243
import "./src/index.css";
4344

4445
/**
@@ -195,12 +196,18 @@ function Harness(): ReactElement {
195196
}
196197
state.paintedChars = painted;
197198
lastGrowthAt = now;
198-
} else if (state.streamEndedAtMs === null) {
199+
} else {
199200
// Measure the stall in progress: a stall closed only by a LATER paint misses a
200201
// freeze that runs to the end of the stream, whose lost tail can hide inside the
201-
// 90% floor. Only while text is still arriving; after the stream ends the settle
202-
// check's quiet frames would read as a stall.
203-
const stall = now - lastGrowthAt;
202+
// 90% floor. stallInProgress caps it at stream end, so a freeze spanning that
203+
// moment is still recorded in full while the settle check's own quiet frames are
204+
// not counted as one.
205+
const stall = stallInProgress(
206+
lastGrowthAt,
207+
now,
208+
state.startedAt,
209+
state.streamEndedAtMs,
210+
);
204211
if (stall > state.longestStallMs) {
205212
state.longestStallMs = stall;
206213
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
3+
4+
// The stall rule for smoke-stream-pacing-main.tsx, kept here so it can be tested without
5+
// importing the harness entry, which mounts React into the DOM on import.
6+
7+
/**
8+
* How long the bubble has gone without growing, for a stall that no later paint has closed.
9+
*
10+
* The subtlety is what the interval is measured UP TO. While text is still arriving that is
11+
* simply `now`. Once the stream has ended it is the moment it ended, for two opposite reasons
12+
* that both point the same way:
13+
*
14+
* - a freeze that spans the end of the stream blocks the frame loop across it, so the first
15+
* frame afterwards already observes an ended stream. Refusing to measure once the stream has
16+
* ended would skip that whole interval and report no freeze at all, which is the one shape
17+
* this number exists to catch. The lost tail can hide inside the 90% workload floor.
18+
* - measuring up to `now` after the stream has ended would count the quiet frames the settle
19+
* check itself needs, so every healthy run would report a stall the length of the settle
20+
* window.
21+
*
22+
* Capping at stream end satisfies both: the frozen interval is recorded in full, and it stops
23+
* growing once there is no more text to be waiting for. Repeat calls return the same value, so
24+
* calling this on every frame after the stream ends is idempotent.
25+
*/
26+
export function stallInProgress(
27+
lastGrowthAt: number,
28+
now: number,
29+
startedAt: number,
30+
streamEndedAtMs: number | null,
31+
): number {
32+
const until = streamEndedAtMs === null ? now : startedAt + streamEndedAtMs;
33+
const stall = until - lastGrowthAt;
34+
return stall > 0 ? stall : 0;
35+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
3+
4+
// The stall rule behind the stream-pacing smoke's longestStallMs, which is the budget that
5+
// catches the freeze class #7892 and #8845 fixed. A stall the harness fails to record is a
6+
// harness that passes on a frozen renderer, so the interesting cases here are the ones where
7+
// no later paint ever arrives to close the stall.
8+
9+
import assert from "node:assert/strict";
10+
import test from "node:test";
11+
12+
import { stallInProgress } from "../smoke-stream-pacing-stall.ts";
13+
14+
const STARTED_AT = 1_000;
15+
16+
test("while text is still arriving, the stall runs to now", () => {
17+
assert.equal(stallInProgress(2_000, 3_500, STARTED_AT, null), 1_500);
18+
});
19+
20+
test("a freeze spanning the end of the stream is still recorded in full", () => {
21+
// The regression this exists for. The frame loop is blocked across the moment the stream
22+
// ends, so the first frame afterwards (now = 9_000) already sees a non-null streamEndedAtMs.
23+
// Measuring only while the stream is live would skip the whole interval and report nothing,
24+
// and the missing tail can hide inside the harness's 90% workload floor.
25+
const streamEndedAtMs = 4_000; // absolute 5_000
26+
assert.equal(stallInProgress(1_500, 9_000, STARTED_AT, streamEndedAtMs), 3_500);
27+
});
28+
29+
test("after the stream ends the stall stops growing with the settle window", () => {
30+
// The settle check needs 30 quiet frames by design. Measuring to now would count them, so
31+
// every healthy run would report a stall the length of its own settle window.
32+
const streamEndedAtMs = 4_000; // absolute 5_000
33+
const atEnd = stallInProgress(4_800, 5_000, STARTED_AT, streamEndedAtMs);
34+
const muchLater = stallInProgress(4_800, 60_000, STARTED_AT, streamEndedAtMs);
35+
assert.equal(atEnd, 200);
36+
assert.equal(muchLater, 200, "quiet frames after the stream ended are not a stall");
37+
});
38+
39+
test("it is idempotent once the stream has ended", () => {
40+
const first = stallInProgress(2_000, 6_000, STARTED_AT, 4_000);
41+
const second = stallInProgress(2_000, 30_000, STARTED_AT, 4_000);
42+
assert.equal(first, second);
43+
});
44+
45+
test("growth after the stream ended is not a negative stall", () => {
46+
// lastGrowthAt can sit past the stream end when the tail paints late, which would make the
47+
// capped interval negative. Report no stall rather than a negative one.
48+
assert.equal(stallInProgress(8_000, 9_000, STARTED_AT, 4_000), 0);
49+
});

0 commit comments

Comments
 (0)