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
66 changes: 62 additions & 4 deletions dashboard/src/workspaces/explorer/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ interface ExplorerFacet {
readonly value: string;
}

/** The request owns its admission scope, regardless of later UI selections. */
interface PlannerRequest {
readonly scope: DashboardScope;
readonly query: string;
readonly generation: number;
}

interface AcceptedPlannerRun {
readonly scope: DashboardScope;
readonly id: string;
}

/** How far the coordinator has got, counted in sources rather than in a
* percentage: sources have incommensurable units, so "2 of 3 concluded" is the
* only progress figure with a real denominator. */
Expand Down Expand Up @@ -220,13 +232,40 @@ export function useExplorerController(): ExplorerController {
const [peeked, setPeeked] = useState<Hit | null>(null);
const searching = submitted !== '';
const terms = useMemo(() => queryTerms(submitted), [submitted]);
// A superseded POST can still be admitted after a newer search or after
// unmount. Its accepted run must be explicitly cancelled, not orphaned.
const requestGeneration = useRef(0);
const acceptedRun = useRef<AcceptedPlannerRun | null>(null);
const retireAcceptedRun = () => {
const previous = acceptedRun.current;
acceptedRun.current = null;
if (previous !== null) void cancelPlannerQuery(previous.scope, previous.id);
};
Comment on lines +239 to +243

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Failed cancellations leave orphaned runs

When a superseding search's DELETE fails, retireAcceptedRun discards the run without checking the typed result. The old run keeps consuming server work until its deadline, with no cancellation retry or visible failure.

Learn more

fetchEnvelope returns { outcome: 'transport' } on offline and HTTP errors. The new fire-and-forget cancellation discards that result and clears acceptedRun first; the same pattern occurs for stale POST responses and unmount cleanup. No subsequent action retains the run identity to complete the cancellation. The daemon keeps pending runs until cancelled or timed out, and its run registry has finite capacity.

Example: Submit alpha, then beta while the DELETE for alpha receives HTTP 503. The controller forgets alpha and shows beta; alpha continues doing work, and nothing reports the failed cancellation.

Recommended fix: Centralize automatic cancellation and handle its EnvelopeResult explicitly. Retain failed cancellation identities for an appropriate bounded retry or expose a typed failure when retries are impossible; handle both non-2xx transport outcomes and rejected requests.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

useEffect(() => {
return () => {
requestGeneration.current += 1;
const previous = acceptedRun.current;
acceptedRun.current = null;
if (previous !== null) void cancelPlannerQuery(previous.scope, previous.id);
};
Comment on lines +244 to +250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Deep-linked searches cancel during mount

With ?q=graph, requestGeneration advances during StrictMode's simulated cleanup, invalidating the restored search. useExplorerUrlState restores only once, so the admitted run gets cancelled and the search remains pending.

Learn more

The production root wraps the router in React.StrictMode. On initial mount, useExplorerUrlState submits a query from the URL in an effect guarded by restored. StrictMode runs effect setup, cleanup, and setup again without discarding refs. This cleanup invalidates that admission, but restored.current prevents the second setup from dispatching a replacement. The old admission is then cancelled and no status polling starts, leaving the restored query pending.

Example: Open /explorer?q=graph in the dashboard. The first effect dispatches graph; the simulated cleanup increments its generation. When its POST returns pending, the controller sends DELETE instead of installing the run; the page still shows graph as submitted.

Recommended fix: Distinguish an actual unmount from StrictMode's effect replay, or make URL restoration re-dispatch when the replay invalidates its admission. Add an ExplorerPage test under StrictMode that opens a ?q= URL, waits for admission, and checks that its run stays active.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}, []);

const planner = useMutation({
mutationFn: (nextQuery: string) => createPlannerQuery(scope, nextQuery),
onSuccess: (result) => {
mutationFn: async (request: PlannerRequest) => {
const result = await createPlannerQuery(request.scope, request.query);
if (result.outcome === 'envelope') {
setActiveRunId(result.envelope.payload.run_id);
const run = result.envelope.payload;
const live = !runIsTerminal(run.state);
// Record ownership as soon as the daemon admits the request. The
// observer can unmount before React Query runs its success callbacks.
if (request.generation === requestGeneration.current) {
acceptedRun.current = live ? { id: run.run_id, scope: request.scope } : null;
setActiveRunId(run.run_id);
} else if (live) {
void cancelPlannerQuery(request.scope, run.run_id);
}
}
return result;
},
});
const activeRunIdForQuery = activeRunId ?? '';
Expand Down Expand Up @@ -278,6 +317,19 @@ export function useExplorerController(): ExplorerController {
runResult?.outcome === 'envelope' && runResult.envelope.payload.request.query === submitted
? runResult.envelope.payload
: undefined;
const currentRunId = run?.run_id;
const currentRunState = run?.state;
useEffect(() => {
// A completed read no longer needs an explicit cancel on unmount/reset.
if (
currentRunId !== undefined &&
currentRunState !== undefined &&
runIsTerminal(currentRunState) &&
acceptedRun.current?.id === currentRunId
) {
acceptedRun.current = null;
}
}, [currentRunId, currentRunState]);

const graphBrowse = useEnvelope(
['explorer', 'graph-overview'],
Expand Down Expand Up @@ -378,14 +430,18 @@ export function useExplorerController(): ExplorerController {
}, [facet, laneFilter, lanes]);

const dispatch = (nextQuery: string) => {
requestGeneration.current += 1;
retireAcceptedRun();
setActiveRunId(null);
// A fresh run starts at the top of the backoff ladder: the previous
// run's failures are not this one's, and must not slow its first reads.
transportFailures.current = 0;
planner.reset();
// Nothing is dispatched under a scope that will refuse it; the lanes
// render the refusal instead.
if (writability.state === 'writable') planner.mutate(nextQuery);
if (writability.state === 'writable') {
planner.mutate({ query: nextQuery, scope, generation: requestGeneration.current });
}
};

// The rows on screen must belong to the scope in the register. When the
Expand All @@ -407,6 +463,8 @@ export function useExplorerController(): ExplorerController {
}, [requestScope]);

const reset = () => {
requestGeneration.current += 1;
retireAcceptedRun();
setQuery('');
setSubmitted('');
setActiveRunId(null);
Expand Down
127 changes: 122 additions & 5 deletions dashboard/src/workspaces/explorer/controllerPolling.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* either hammering or abandoning the run, and the poll ends with the surface ,
* by driving the real controller against a scripted daemon on a fake clock.
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -24,7 +24,7 @@ const STATUS_PATH = `/api/explorer/queries/${RUN_ID}`;

type RunState = 'pending' | 'completed';

function runEnvelope(state: RunState) {
function runEnvelope(state: RunState, runId = RUN_ID, query = QUERY) {
return {
schema_revision: 1,
scope: {
Expand Down Expand Up @@ -52,8 +52,8 @@ function runEnvelope(state: RunState) {
domain_state: state === 'completed' ? 'ready' : 'partial',
legal_actions: [],
payload: {
run_id: RUN_ID,
request: { query: QUERY, limit: 50, offset: 0 },
run_id: runId,
request: { query, limit: 50, offset: 0 },
request_revision: 'explorer-query-request-v1',
plan_revision: 'explorer-query-plan-v1',
merge_revision: 'source-local-no-merge-v1',
Expand Down Expand Up @@ -93,8 +93,11 @@ type StatusStep =
*/
function scriptedDaemon(script: readonly StatusStep[]) {
const statusReads: StatusStep[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = String(input);
if (url.includes(STATUS_PATH) && init?.method === 'DELETE') {
return jsonResponse(200, runEnvelope('completed'));
}
if (url.includes(STATUS_PATH)) {
const step = script[Math.min(statusReads.length, script.length - 1)];
if (step === undefined) throw new Error('scripted daemon has no status step');
Expand Down Expand Up @@ -263,6 +266,32 @@ describe('explorer run-status polling', () => {
expect(statusReads.length).toBe(afterUnmount);
});

it('cancels an accepted run when unmounted before mutation callbacks finish', async () => {
let releaseCallbacks!: () => void;
const pending = new Promise<void>((resolve) => { releaseCallbacks = resolve; });
const afterAdmission = vi.fn(() => pending);
const client = new QueryClient({ mutationCache: new MutationCache({ onSuccess: afterAdmission }) });
const { fetchMock } = scriptedDaemon([{ kind: 'run', state: 'pending' }]);
vi.stubGlobal('fetch', fetchMock);
const view = renderHook(() => useExplorerController(), {
wrapper: ({ children }) => createElement(QueryClientProvider, { client }, children),
});
try {
await act(async () => {
view.result.current.submit(QUERY);
await vi.advanceTimersByTimeAsync(0);
});
expect(afterAdmission).toHaveBeenCalledOnce();
view.unmount();
expect(fetchMock.mock.calls.filter(([url, init]) =>
String(url).endsWith(STATUS_PATH) && init?.method === 'DELETE',
)).toHaveLength(1);
} finally {
releaseCallbacks();
client.clear();
}
});

it('stops polling when the search is reset', async () => {
// Reset drops the active run, so the poll it was the only reader of ends
// with it rather than surviving into the next search.
Expand All @@ -284,3 +313,91 @@ describe('explorer run-status polling', () => {
expect(statusReads.length).toBe(afterReset);
});
});

/** A held admission POST can complete after a newer query or an unmount.
* The result must cancel its exact server run rather than reclaim the UI. */
describe('explorer query admission ownership', () => {
it.each(['pending', 'completed'] as const)('keeps the newer query after a late %s admission', async (state) => {
let finishOlder!: (response: Response) => void;
const heldOlder = new Promise<Response>((resolve) => {
finishOlder = resolve;
});
const cancellations: string[] = [];
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = String(input);
if (init?.method === 'POST' && url.endsWith('/api/explorer/queries')) {
const { query } = JSON.parse(String(init.body)) as { query: string };
if (query === 'older') return heldOlder;
return Promise.resolve(jsonResponse(202, runEnvelope('pending', 'run-newer', 'newer')));
}
if (init?.method === 'DELETE') {
cancellations.push(url);
return Promise.resolve(jsonResponse(200, runEnvelope('completed', 'run-newer', 'newer')));
}
if (url.endsWith('/api/explorer/queries/run-newer')) {
return Promise.resolve(jsonResponse(200, runEnvelope('pending', 'run-newer', 'newer')));
}
return Promise.resolve(jsonResponse(404, { error: 'unknown run' }));
});
vi.stubGlobal('fetch', fetchMock);
const view = renderHook(() => useExplorerController(), { wrapper });

await act(async () => {
view.result.current.submit('older');
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
view.result.current.submit('newer');
await vi.advanceTimersByTimeAsync(0);
});
expect(view.result.current.run?.run_id).toBe('run-newer');

await act(async () => {
finishOlder(jsonResponse(202, runEnvelope(state, 'run-older', 'older')));
await vi.advanceTimersByTimeAsync(0);
});
expect(cancellations).toEqual(state === 'pending' ? ['/api/explorer/queries/run-older'] : []);
expect(view.result.current.run?.run_id).toBe('run-newer');
view.unmount();
});

it('cancels the old run on supersession and the live run on unmount', async () => {
const cancellations: string[] = [];
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = String(input);
if (init?.method === 'POST' && url.endsWith('/api/explorer/queries')) {
const { query } = JSON.parse(String(init.body)) as { query: string };
return Promise.resolve(jsonResponse(202, runEnvelope('pending', `run-${query}`, query)));
}
if (init?.method === 'DELETE') {
cancellations.push(url);
return Promise.resolve(jsonResponse(200, runEnvelope('completed')));
}
if (url.includes('/api/explorer/queries/run-')) {
const query = url.substring(url.lastIndexOf('run-') + 4);
return Promise.resolve(jsonResponse(200, runEnvelope('pending', `run-${query}`, query)));
}
return Promise.resolve(jsonResponse(404, { error: 'unknown run' }));
});
vi.stubGlobal('fetch', fetchMock);
const view = renderHook(() => useExplorerController(), { wrapper });

await act(async () => {
view.result.current.submit('first');
await vi.advanceTimersByTimeAsync(0);
});
expect(view.result.current.run?.run_id).toBe('run-first');
await act(async () => {
view.result.current.submit('second');
await vi.advanceTimersByTimeAsync(0);
});
expect(cancellations).toEqual(['/api/explorer/queries/run-first']);
expect(view.result.current.run?.run_id).toBe('run-second');

view.unmount();
expect(cancellations).toEqual([
'/api/explorer/queries/run-first',
'/api/explorer/queries/run-second',
]);
});
});
Loading