diff --git a/frontend/src/components/BootstrapSplash.jsx b/frontend/src/components/BootstrapSplash.jsx
index 6e67271d..74c41946 100644
--- a/frontend/src/components/BootstrapSplash.jsx
+++ b/frontend/src/components/BootstrapSplash.jsx
@@ -465,6 +465,9 @@ export function BootstrapSplash({ stage, message }) {
const [attemptStart, setAttemptStart] = useState(0);
const logRef = useRef(null);
const prevStageRef = useRef(stage); // previous stage, for restart detection
+ // True between a retry WE initiated and the poll catching up to it, so the
+ // fallback effect below doesn't re-stamp an already-exact attempt boundary.
+ const selfInitiatedRef = useRef(false);
const prevProgRef = useRef(null); // {bytes, t} — last progress event
const rateRef = useRef(0); // EMA bytes/sec across events
@@ -499,12 +502,25 @@ export function BootstrapSplash({ stage, message }) {
// instead of fabricating completed work (#1894).
const installWorkSeen = INSTALL_STAGES.some((s) => observedStages.has(s));
+ // Open a new bootstrap attempt. Called at the moment a retry is INITIATED,
+ // not when the ~1s status poll later reports `checking`: the Rust side
+ // starts emitting logs for the new attempt immediately, and a boundary
+ // stamped at detection time would sit *after* those lines and discard them
+ // as belonging to the old attempt — losing exactly the fast-stage evidence
+ // the log union exists to capture.
+ const beginAttempt = () => {
+ selfInitiatedRef.current = true;
+ setLogs([]);
+ setPolledStages(new Set());
+ setAttemptStart(Date.now());
+ };
+
const handleRetry = async () => {
if (retrying) return;
setRetrying(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
- setLogs([]);
+ beginAttempt();
await invoke('retry_bootstrap');
} catch (e) {
console.error('retry failed', e);
@@ -519,7 +535,7 @@ export function BootstrapSplash({ stage, message }) {
setRetrying(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
- setLogs([]);
+ beginAttempt();
await invoke('clean_and_retry_bootstrap');
} catch (e) {
console.error('clean retry failed', e);
@@ -537,15 +553,32 @@ export function BootstrapSplash({ stage, message }) {
// `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.
+ // exists to remove.
+ //
+ // Retries we initiate call beginAttempt() directly, so their boundary is
+ // exact. This effect is the FALLBACK for a restart begun on the Rust side,
+ // where the stage poll is the only signal we get. There the boundary can
+ // land up to one poll interval late, and log lines from the new attempt in
+ // that window are discarded rather than counted. That is the deliberate
+ // direction to fail in: a discarded line can leave a step showing pending
+ // (conservative, and honest), whereas counting a stale line would render a
+ // step DONE for work this attempt never did — the bug this change exists to
+ // fix. Closing the window entirely needs a Rust-provided attempt id, which
+ // would be a new IPC surface and is deliberately out of scope here.
useEffect(() => {
const prev = prevStageRef.current;
prevStageRef.current = stage;
const restarted = RESTART_STAGES.has(stage) && !RESTART_STAGES.has(prev);
if (restarted) {
- setAttemptStart(Date.now());
+ if (selfInitiatedRef.current) {
+ // beginAttempt() already opened this attempt with an exact boundary.
+ // Re-stamping it here — a poll interval later — would discard the
+ // new attempt's own early log lines, which is the bug this guard
+ // exists to prevent.
+ selfInitiatedRef.current = false;
+ } else {
+ setAttemptStart(Date.now());
+ }
setPolledStages(new Set([stage]));
return;
}
diff --git a/frontend/src/test/BootstrapSplashObservedStages.test.jsx b/frontend/src/test/BootstrapSplashObservedStages.test.jsx
index d8a82aef..6ec15e4e 100644
--- a/frontend/src/test/BootstrapSplashObservedStages.test.jsx
+++ b/frontend/src/test/BootstrapSplashObservedStages.test.jsx
@@ -24,7 +24,7 @@
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { BootstrapSplash } from '../components/BootstrapSplash';
vi.mock('@tauri-apps/api/core', () => ({
@@ -161,4 +161,48 @@ describe('BootstrapSplash — observed-stage tracking (#1894)', () => {
expect(screen.queryByText(/first run, 5.10 min/)).toBeNull();
expect(screen.getByText('Starting backend…')).toBeInTheDocument();
});
+
+ it('logs arriving after Retry but before the next poll are not discarded', async () => {
+ // Greptile finding on ece08bd7: the attempt boundary was stamped when the
+ // ~1s poll first reported `checking`, which lands AFTER the Rust side has
+ // already emitted the new attempt's first log lines — so that evidence was
+ // filtered out as "previous attempt" and a fast stage missed by polling
+ // stayed pending. The boundary must open when the retry is initiated.
+ window.__TAURI_INTERNALS__ = {};
+ const { invoke } = await import('@tauri-apps/api/core');
+ const { listen } = await import('@tauri-apps/api/event');
+ const handlers = {};
+ listen.mockImplementation(async (name, cb) => {
+ handlers[name] = cb;
+ return () => {};
+ });
+ invoke.mockImplementation(async () => null);
+
+ const { rerender } = render();
+ await waitFor(() => expect(handlers['bootstrap-log']).toBeTypeOf('function'));
+
+ // User clicks Retry — this opens the new attempt.
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /^Retry$/ }));
+ });
+
+ // Rust immediately emits the new attempt's logs, still before the poll
+ // has reported `checking`.
+ await act(async () => {
+ handlers['bootstrap-log']({
+ payload: { stage: 'creating_venv', line: 'Creating virtualenv at .venv' },
+ });
+ });
+
+ // Only now does the poll catch up, and it never samples creating_venv.
+ rerender();
+ rerender();
+
+ // The log line proved creating_venv ran in THIS attempt; it must not have
+ // been discarded by a boundary stamped after it arrived.
+ await waitFor(() => {
+ const venvStep = screen.getByText('Creating Python virtual environment…');
+ expect(venvStep.className).toMatch(/text-fg-muted/);
+ });
+ });
});