Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/loopover-miner-ui/src/ledgers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,50 @@ describe("LedgersPage (#4855)", () => {
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("connection refused"));
expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy();
});

it("does not let a stale in-flight poll response revert a just-applied pause action (#7791)", async () => {
// The exact race from the bug report: a poll GET is already in flight when the operator clicks Pause, the
// action POST resolves first, and only THEN does the stale pre-pause poll resolve. The first (mount) poll
// resolves "not paused" so the Pause button renders; the SECOND poll tick is a deferred left in flight
// while we click and let the action land, then resolved by hand with the stale pre-pause state.
let resolveStalePoll: (value: GovernorPauseStateResult) => void = () => undefined;
let pollCall = 0;
const loadGovernorPauseState = vi.fn((): Promise<GovernorPauseStateResult> => {
pollCall += 1;
if (pollCall === 1) return Promise.resolve({ ok: true, pauseState: defaultGovernorPauseState() });
return new Promise<GovernorPauseStateResult>((resolve) => {
resolveStalePoll = resolve;
});
});
const pauseGovernorAction = vi.fn(async (): Promise<GovernorPauseStateResult> => ({
ok: true,
pauseState: { paused: true, reason: null, pausedAt: "2026-07-13T12:30:00.000Z" },
}));
render(
<LedgersPage
loadLedgers={loadLedgersEmpty}
loadGovernorPauseState={loadGovernorPauseState}
pauseGovernorAction={pauseGovernorAction}
pollIntervalMs={20}
/>,
);

// First poll resolved "not paused" -> the Pause button is shown.
await waitFor(() => expect(screen.getByRole("button", { name: "Pause governor" })).toBeTruthy());
// Let the next poll tick fire and leave its GET in flight (deferred, unresolved).
await waitFor(() => expect(pollCall).toBeGreaterThanOrEqual(2));

// Operator clicks Pause; the action POST resolves first -> UI reflects "paused" (Resume button shown).
fireEvent.click(screen.getByRole("button", { name: "Pause governor" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy());

// Now the STALE second poll (started before the action) finally resolves with the pre-pause "not paused"
// state. Without the ordering guard this clobbers the action's result; with it, the UI stays paused.
resolveStalePoll({ ok: true, pauseState: defaultGovernorPauseState() });
await Promise.resolve();
await waitFor(() => expect(screen.getByRole("button", { name: "Resume governor" })).toBeTruthy());
expect(screen.queryByRole("button", { name: "Pause governor" })).toBeNull();
});
});

describe("live refresh (#7082)", () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/loopover-miner-ui/src/routes/ledgers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,14 @@ export function LedgersPage({
const [pauseState, setPauseState] = useState<GovernorPauseStateResult | null>(null);
const [lastPolledPauseState, setLastPolledPauseState] = useState<GovernorPauseStateResult | null>(null);
const [actionPending, setActionPending] = useState(false);
// A pause/resume POST is an independent request that doesn't share the poll GET's single-flight guard, so a
// poll that was already in flight when the operator acted can resolve AFTER the action and clobber its fresh
// result with a stale pre-action value (#7791). `skipNextPollSync` marks that the very next poll-sync after an
// action lands must be ignored: that poll may have started before the action, so its result is not newer.
// Later ticks (started after the action) sync normally, mirroring the generation/cancellation discipline
// usePolledFetch/useStreamingText use internally. It's state (not a ref) so the render-phase sync below can
// read it without touching a ref during render.
const [skipNextPollSync, setSkipNextPollSync] = useState(false);

// Join the app's shared live-refresh cadence so newly-recorded claims/events appear without a manual reload,
// matching the Overview page's claims card that reads the same data source (#7082).
Expand All @@ -440,12 +448,19 @@ export function LedgersPage({
const { result: polledPauseState } = usePolledFetch(loadGovernorPauseState, pollIntervalMs);
if (polledPauseState !== lastPolledPauseState) {
setLastPolledPauseState(polledPauseState);
setPauseState(polledPauseState);
// Consume this poll result (so lastPolledPauseState advances and we don't keep skipping), but don't let a
// stale in-flight poll overwrite an action's just-applied result (#7791).
if (skipNextPollSync) {
setSkipNextPollSync(false);
} else {
setPauseState(polledPauseState);
}
}

const runGovernorAction = (action: () => Promise<GovernorPauseStateResult>) => {
setActionPending(true);
void action().then((next) => {
setSkipNextPollSync(true);
setPauseState(next);
setActionPending(false);
});
Expand Down
Loading