fix(bootstrap): derive observed stages from logs, and reset them on retry

Two review findings on #1896, both real:

1. Polling misses completed stages. `bootstrap_status` is sampled ~1/s, so a
   stage that starts and finishes between samples was never recorded — on a
   fast disk `creating_venv` routinely does — and would render pending
   forever even though it ran. The stage-tagged `bootstrap-log` stream is
   emitted as the work happens, so a line tagged with a stage is independent
   proof that stage ran. The observed set is now the union of the two; the
   comment claiming the poll "guarantees" a stage lands at least once was
   wrong and is gone.

2. Retry kept stale stages. The observed set was add-only and the splash
   stays mounted across a Retry, so a stage the failed attempt reached would
   still render done in the new attempt even when that attempt skipped it —
   the exact fabrication this change exists to remove. Arriving back at a
   restart stage from anywhere else now starts a fresh attempt. Keyed off the
   stage transition rather than our own Retry buttons, so a Rust-side restart
   resets it too. Log evidence is filtered to the current attempt; the
   visible log is deliberately left alone (clearing it would destroy the
   user's context, cf. #1847).

Regression tests added for both; each fails against 1c344bf9 and passes here.
Full BootstrapSplash suite green (6 files, 23 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
psiberfunk
2026-09-07 13:57:02 -04:00
co-authored by Claude Opus 5
parent 1c344bf9ec
commit ece08bd790
2 changed files with 107 additions and 14 deletions
+52 -10
View File
@@ -127,6 +127,10 @@ const STEPS = [
// journey chrome should already be armed by the time the step list appears.
const INSTALL_STAGES = ['downloading_uv', 'creating_venv', 'installing_deps', 'awaiting_setup'];
// Stages the bootstrap restarts *from*. Arriving at one of these from
// anywhere else means a new attempt began (Retry, or a Rust-side restart).
const RESTART_STAGES = new Set(['checking', 'awaiting_setup']);
const MAX_LOG_LINES = 200;
/** Scan logs + error message for known failure patterns and return i18n keys
@@ -452,14 +456,37 @@ export function BootstrapSplash({ stage, message }) {
const [progress, setProgress] = useState(null);
const [region, setRegionState] = useState('auto');
const [retrying, setRetrying] = useState(false);
// Stages actually seen this session (sticky/monotonic — see the tracking
// effect below). Drives "done" ticks and journey visibility off observed
// reality instead of list position (#1894).
const [observedStages, setObservedStages] = useState(() => new Set([stage]));
// Stages actually seen during the CURRENT bootstrap attempt (see the
// tracking effect below). Drives "done" ticks and journey visibility off
// observed reality instead of list position (#1894).
const [polledStages, setPolledStages] = useState(() => new Set([stage]));
// Wall-clock start of the current attempt. A retry restarts the bootstrap;
// log lines from the previous attempt must not count toward this one.
const [attemptStart, setAttemptStart] = useState(0);
const logRef = useRef(null);
const prevStageRef = useRef(stage); // previous stage, for restart detection
const prevProgRef = useRef(null); // {bytes, t} — last progress event
const rateRef = useRef(0); // EMA bytes/sec across events
// The union of what we polled and what actually logged.
//
// Neither source alone is complete. `bootstrap_status` is sampled ~1/s, so
// a stage that starts and finishes between samples is never polled — on a
// fast disk `creating_venv` routinely does. Stage-tagged `bootstrap-log`
// lines close that gap: the Rust side emits them as the work happens, so a
// line tagged with a stage is proof that stage ran, whether or not the
// poll ever saw it. Lines are filtered to the current attempt so a retry
// cannot inherit the previous one's evidence (the visible log is left
// alone — clearing it on a Rust-side restart would destroy the user's
// context, cf. #1847).
const observedStages = useMemo(() => {
const seen = new Set(polledStages);
for (const entry of logs) {
if (entry?.stage && (entry.t ?? 0) >= attemptStart) seen.add(entry.stage);
}
return seen;
}, [polledStages, logs, attemptStart]);
const label = t(`bootstrap.${stage}`, STAGE_LABEL[stage]);
const stepIndex = Math.max(0, STEPS.indexOf(stage));
const isFailed = stage === 'failed';
@@ -501,13 +528,28 @@ export function BootstrapSplash({ stage, message }) {
}
};
// Record `stage` as observed the moment it's seen. Sticky/monotonic: the
// functional updater bails out (same Set reference) once a stage is
// already recorded, so this never un-observes anything and never loops.
// `bootstrap_status` is polled ~1/s by useBootstrapStage, so a stage that
// actually ran is guaranteed to land here at least once (#1894).
// Record `stage` as observed the moment it's seen, and reset when a new
// attempt begins.
//
// Sticky WITHIN an attempt: the functional updater bails out (same Set
// reference) once a stage is recorded, so it never un-observes and never
// loops. But NOT across attempts — a Retry restarts the bootstrap from
// `checking`, and what the previous attempt did says nothing about what
// this one will do. Without the reset, stages the new attempt skips would
// still render as completed, which is the very fabrication this change
// exists to remove. Keyed off the stage moving back to a restart stage
// rather than off our own Retry buttons, so a restart initiated on the
// Rust side resets it too.
useEffect(() => {
setObservedStages((prev) => (prev.has(stage) ? prev : new Set(prev).add(stage)));
const prev = prevStageRef.current;
prevStageRef.current = stage;
const restarted = RESTART_STAGES.has(stage) && !RESTART_STAGES.has(prev);
if (restarted) {
setAttemptStart(Date.now());
setPolledStages(new Set([stage]));
return;
}
setPolledStages((p) => (p.has(stage) ? p : new Set(p).add(stage)));
}, [stage]);
// Load persisted region on mount.
@@ -12,13 +12,19 @@
* but `downloading_uv`/`creating_venv` still rendered done), and `JourneyRail`
* hardcoded Setup=done/Installing=active regardless of `stage`.
*
* The fix tracks which stages were actually observed (sticky, via the
* `bootstrap_status` poll) and derives doneness + journey-chrome visibility
* from that instead of list position.
* The fix tracks which stages were actually observed and derives doneness +
* journey-chrome visibility from that instead of list position.
*
* Two follow-up findings from bot review on PR #1896 are covered at the end:
* - the ~1s `bootstrap_status` poll can miss a stage that starts and
* finishes between samples, so stage-tagged `bootstrap-log` lines are
* unioned in as independent proof a stage ran;
* - a Retry restarts the bootstrap, so stages observed during the previous
* attempt must not carry over and render as done in the new one.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { BootstrapSplash } from '../components/BootstrapSplash';
vi.mock('@tauri-apps/api/core', () => ({
@@ -110,4 +116,49 @@ describe('BootstrapSplash — observed-stage tracking (#1894)', () => {
}
}
});
it('a stage the 1s poll never sampled still counts as done when its logs prove it ran', async () => {
// Greptile finding on #1896: `bootstrap_status` is sampled ~1/s, so on a
// fast disk `creating_venv` can start and finish between two samples and
// never be polled. Stage-tagged bootstrap logs are emitted as the work
// happens, so they are independent proof the stage ran.
window.__TAURI_INTERNALS__ = {};
const { invoke } = await import('@tauri-apps/api/core');
invoke.mockImplementation(async (cmd) =>
cmd === 'get_bootstrap_logs'
? [{ stage: 'creating_venv', line: 'Creating virtualenv at .venv' }]
: null,
);
// Poll sequence skips creating_venv entirely.
const { rerender } = render(<BootstrapSplash stage="downloading_uv" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
await waitFor(() => {
const venvStep = screen.getByText('Creating Python virtual environment…');
expect(venvStep.className).toMatch(/text-fg-muted/);
});
});
it('a retry drops stages observed during the previous attempt', () => {
// Greptile finding on #1896: the observed set was add-only and the splash
// stays mounted across a Retry, so a stage the FAILED attempt reached
// would still render done in the new attempt even if that attempt skips
// it. Arriving back at `checking` from elsewhere means a new attempt.
const { rerender } = render(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="downloading_uv" message={null} />);
rerender(<BootstrapSplash stage="installing_deps" message={null} />);
rerender(<BootstrapSplash stage="failed" message="uv sync failed" />);
// Retry: Rust goes back to `checking`, then this attempt finds the venv
// healthy and jumps straight to starting_backend.
rerender(<BootstrapSplash stage="checking" message={null} />);
rerender(<BootstrapSplash stage="starting_backend" message={null} />);
// Nothing from the previous attempt may be presented as this attempt's
// completed work — so the install chrome is gone entirely again.
expect(screen.queryByText('Downloading uv (Python package manager)…')).toBeNull();
expect(screen.queryByText(/first run, 5.10 min/)).toBeNull();
expect(screen.getByText('Starting backend…')).toBeInTheDocument();
});
});