Skip to content

flaky(e2e): unfenced Enter races a 30s send admission against a 10s expect (streaming-remount:161, transcript-scroll:316) #4573

Description

@Astro-Han

Two Desktop e2e tests each failed once on main today, and once each across the first two attempts of the same PR run. Both are element(s) not found after a send, both are green on rerun, and both have the same cause.

Shared root cause

Every send in the app goes through an unbounded asynchronous admission chain before anything renders:

app-shell-chat-actions.ts:424   if (!initialSessionId && !initialNewTaskTarget) return false;   // silent false
app-shell-chat-actions.ts:425   if (!(await checkTaskSubmissionReadiness())) return false;      // async IPC probe, silent false
app-shell-chat-actions.ts:499   await activateSessionForFirstSend(session.id);                  // first-send observation barrier

activateSessionForFirstSend (app-shell.tsx:1745-1766) waits for completeObservationSeed, bounded by FIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000 (app-shell.tsx:244). Upstream of all of it, sendCurrent (packages/ui/src/composer.tsx:1185-1196) returns early on props.disabled || props.sendBlocked || sendPendingRef.current — silently, with no queue, no retry and no toast, so an Enter that lands in a blocked window is simply lost.

Meanwhile e2e/playwright.config.ts:52-54 sets expect: { timeout: 10_000 } with retries: 0.

A 10s assertion is watching a production path the app itself allows 30s for, and neither test fences the gap: both wait on an intermediate signal (a cleared draft, an updated label) that does not mean admission is ready.

The margin was there until concurrency changed. #4523 (merge commit 9859c68e, 2026-09-02 07:08 UTC) moved Desktop e2e from 1 worker to 4 workers plus four Xvfb displays on one runner, cutting each worker's CPU share to a quarter. It touched neither spec.

Over the last 150 main CI runs (2026-08-30 → 09-02), counting only runs that actually executed the Desktop e2e step:

Window Runs with e2e e2e failures
before #4523 merged 48 0
after 10 2

Small sample — #4523 had been on main seven hours — so the direction is credible and the 20% is not.


1. streaming-remount.spec.ts:161

Symptom

apps/desktop/e2e/streaming-remount.spec.ts:133keeps a completed reply after an interrupted turn and conversation remount — fails at line 161:

Error: expect(locator).toContainText(expected) failed
Locator:  locator('.maka-bubble-streaming')
Expected substring: "Fake backend waiting"
Timeout: 10000ms
Error: element(s) not found

100 passed / 1 failed / 4 skipped.

Occurrences

Run Commit / attempt Date (UTC)
33615108103 1581dc1c41 (main) 2026-09-02 10:00
33640506920 PR #4455, attempt 1 2026-09-02

Attempt 2 of that run failed on transcript-scroll.spec.ts:316 instead; attempt 3 was green. Same code, three different outcomes.

Root cause

Not a product race — a missing fence.

155:  await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
156:  await expect(composer).toHaveText('');
159:  await composer.fill(FAKE_HOLD_OPEN_PROMPT);
160:  await composer.press('Enter');
161:  await expect(page.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting');

toHaveText('') proves the draft key swapped. It does not prove the shell will accept a submission. Clicking 新任务 clears activeId, which re-projects useShellConnections({ kind: 'new-task' }) (app-shell.tsx:575-607) and restarts the useTaskSubmissionReadiness probe (app-shell.tsx:1513-1519). An Enter landing in that window is either dropped by sendCurrent or blocks on the 30s first-send barrier.

The same test already knows this. Fifteen lines later, at :177, it fences the identical fill+Enter with

await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });

and every other live-Turn assertion in the test carries an explicit timeout: 20_000. Line 161 is the only bare one.

This is why #4018 (e0be705c0, which closed #3177) did not settle it: that PR replaced the old race with the bounded activateSessionForFirstSend barrier and relaxed the expected substring, but never widened the window observing it. The barrier's 30s timeout and its recovery path — discard the unsent Session, keep the draft — can never run before a 10s assertion fires.

Fix

  1. Fence line 160 the way line 177 already is: await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });. sendDisabled (composer.tsx:1379-1385) folds in both sendBlocked and noModelConnection, so this single wait proves the readiness probe and the connections projection have settled.
  2. Give line 161 an explicit { timeout: 20_000 }, matching the rest of the test and leaving an order of magnitude under the product's 30s barrier.
  3. Apply the same to line 60 in the first test of the file, which has the identical shape.

Reproduces only under load: 20/20 green locally on macOS at 1 worker (--repeat-each 5), this test taking 3.4–3.8s end to end. CI needed more than 10s for a single assertion inside it.


2. transcript-scroll.spec.ts:316

Symptom

apps/desktop/e2e/transcript-scroll.spec.ts:251switching Sessions restores a Turn anchor while a tail Session follows background growth — fails at line 316:

Error: expect(locator).toBeVisible() failed
Locator: locator('.maka-user-message').filter({ hasText: '第 1 行' })
Timeout: 10000ms
Error: element(s) not found

Occurrences

Run Commit / attempt Date (UTC)
33619442681 ed4468e597 (main) 2026-09-02 10:42
33640506920 PR #4455, attempt 2 2026-09-02

This test itself only landed today, in #4414 (1581dc1c4), so the clean pre-#4523 window partly reflects its absence.

Root cause

A missing fence, plus a fixture readiness gap.

sendPrompt (transcript-scroll.spec.ts:196-200) is a bare fill + Enter with no gate:

async function sendPrompt(page: Page, text: string): Promise<void> {
  const composer = page.locator(COMPOSER_INPUT);
  await composer.fill(text);
  await composer.press('Enter');
}

Two things that restart asynchronous send admission happen immediately before the call at line 315:

294:  await rowButton(tailSessionId).click();                  // switches Session
300:  await modelSwitcher.click();
301:  await page.getByRole('menuitemradio', { name: 'glm-4.5' }).click();
302:  await expect(modelSwitcher).toContainText('glm-4.5');    // waits on the label only
315:  await sendPrompt(page, LONG_PROMPT);
316:  await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible();

Line 302 waits on the switcher's local label, not on the Session committing the model and connection identity. The test's own comment concedes the step has a side effect: "The fixture Session predates connection identities. Choosing any current model upgrades it onto the E2E Runtime Host before this test starts a Turn." That upgrade is asynchronous and nothing waits for it.

Compounding it, promptRailWindow is the only fixture in the suite that does not wait for the composer. fixtures.ts:676-682 uses seed: false and readinessSelector: '[data-turn-id]', while every other fixture uses readinessSelector: COMPOSER_INPUT — which fixtures.ts:468 documents as the cold-start convergence point (connection seed, onboarding cleared, renderer hydrated). #4523 then added warm worker-scoped reuse with a page.reload(), and the post-reload gate (fixtures.ts:522) is again only [data-turn-id]: the transcript paints well before the connections projection is ready.

So the Enter at line 315 can be dropped silently by sendCurrent (composer.tsx:1188), or stall in checkTaskSubmissionReadiness() (app-shell.tsx:1880-1885, an unbounded IPC probe). Either way nothing renders.

.maka-user-message is rendered optimistically (packages/ui/src/chat-turn.tsx:279, TransientUserMessage), so if the send had landed at all the element would appear almost immediately. Its total absence means the send never happened — not that rendering was slow. Which of the two mechanisms fired cannot be distinguished without a trace, but both take the same fence.

Fix

  1. Gate sendPrompt before the keypress: await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });. Give line 316 an explicit { timeout: 20_000 }.
  2. resetPromptRailWindow (fixtures.ts:508-524) should also wait for COMPOSER_INPUT after page.reload(). The fixture comment still says "This scenario is read-only at the Host boundary", but transcript-scroll.spec.ts:251 now changes the model and runs a full Turn through it.

Does not reproduce locally: 5/5 green on macOS at 1 worker, and 4/4 green in a 42-test combined run sharing the warm worker with prompt-rail.spec.ts and fixture-thread-search.spec.ts. The test takes 10.5–11.0s end to end on an idle machine — the whole test already sits at the single-assertion budget.


Also in this pass

  1. Fixture readiness gate. Both promptRailWindow (fixtures.ts:676-682, and the post-reload gate at fixtures.ts:522) and promptRailMotionWindow (fixtures.ts:717-725) gate on [data-turn-id] rather than COMPOSER_INPUT. This is reproducible locally on the motion fixture: prompt-rail.spec.ts:213 failed 1 of 2 local repeats with Expected: "smooth" / Received: undefined at line 228 — the transcript paints before data-maka-scroll-motion reaches documentElement. Same class of gap; worth fixing together.

  2. CI should upload apps/desktop/e2e/test-results/ on failure. e2e/playwright.config.ts sets trace: 'retain-on-failure', plus video and screenshot, but .github/workflows/ci.yml has no upload-artifact step, so the directory is discarded with the runner. gh api .../artifacts returns empty for all three runs above. This class of flake is currently undiagnosable after the fact — which is why the two mechanisms in section 2 cannot be separated.

  3. sendCurrent drops a submission silently (packages/ui/src/composer.tsx:1185-1196) — no queue, no retry, no feedback. User-visible in miniature: type and press Enter right after switching a conversation or a model, and the message can vanish with no indication. Pre-existing and independent of this flake; P3, worth its own issue.

Severity: P2 — does not block release, but each false red costs a 6-minute e2e rerun and masks real regressions.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions