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:
co-authored by
Claude Opus 5
parent
1c344bf9ec
commit
ece08bd790
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user