fix(site-analysis): per-call AbortController, не shared cancelledRef (#1242)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m5s
CI / frontend-tests (push) Successful in 1m6s

Конкурентные analyze делили один cancelledRef: новая мутация ставила
ref=false и воскрешала спящий poll-цикл предыдущей — лишние fetch-status/
POST analyze и setFetchingState(null) затирали баннер активного запроса.

Patch: shared boolean ref → per-call AbortController, signal проброшен
в apiFetch/apiFetchWithStatus, добавлен abortableSleep чтобы цикл
просыпался мгновенно на cancel. setFetchingState(null) guarded
signal.aborted — зомби-цикл не может перезаписать состояние актуальной
мутации. 82/82 frontend тестов зелёные.

Closes #1242
This commit is contained in:
Light1YT 2026-06-13 12:43:47 +05:00
parent 06c4bb9fd6
commit 9fbc28fa33

View file

@ -51,6 +51,31 @@ export interface AnalyzeOptions {
weights?: Record<string, number> | null; weights?: Record<string, number> | null;
} }
/**
* Sleep, but resolve early if the AbortSignal fires.
*
* Используем чтобы цикл polling'а пробуждался сразу при cancel(),
* а не ждал полные POLL_INTERVAL_MS до следующей проверки.
*/
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
resolve();
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
/** /**
* Custom hook для analyze flow с graceful on-demand fetch fallback. * Custom hook для analyze flow с graceful on-demand fetch fallback.
* *
@ -60,6 +85,12 @@ export interface AnalyzeOptions {
* - fetching: получили 202, идёт polling /fetch-status * - fetching: получили 202, идёт polling /fetch-status
* - data set: ParcelAnalysis отрисовываем * - data set: ParcelAnalysis отрисовываем
* - error: not_in_nspd / invalid_format / failed * - error: not_in_nspd / invalid_format / failed
*
* Отмена (issue #1242): per-call AbortController. cancel() абортит ТОЛЬКО
* текущий цикл новая мутация создаёт свой controller, поэтому
* "воскресший" poll-цикл предыдущего вызова не может затронуть состояние
* актуального. setFetchingState вызывается только если signal не aborted
* иначе баннер ETA/cancel активного запроса был бы перезаписан зомби-циклом.
*/ */
export function useSiteAnalysis() { export function useSiteAnalysis() {
const [fetchingState, setFetchingState] = useState<{ const [fetchingState, setFetchingState] = useState<{
@ -67,8 +98,9 @@ export function useSiteAnalysis() {
jobId: number; jobId: number;
etaSeconds: number; etaSeconds: number;
} | null>(null); } | null>(null);
// ref для cancel polling — invalidates in-flight iteration // Per-call AbortController. cancel() абортит только текущий цикл;
const cancelledRef = useRef(false); // конкурентные мутации не делят токен отмены (фикс #1242).
const abortRef = useRef<AbortController | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async ({ mutationFn: async ({
@ -78,7 +110,13 @@ export function useSiteAnalysis() {
cad: string; cad: string;
options?: AnalyzeOptions; options?: AnalyzeOptions;
}): Promise<ParcelAnalysis> => { }): Promise<ParcelAnalysis> => {
cancelledRef.current = false; // Абортим предыдущий in-flight цикл (если ещё спит / поллит)
// и создаём новый controller для текущего вызова.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
setFetchingState(null); setFetchingState(null);
// Build optional query string for weight profile params. // Build optional query string for weight profile params.
@ -106,6 +144,7 @@ export function useSiteAnalysis() {
ParcelAnalysis | AnalyzeAcceptedResponse ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), { >(analyzeUrl(cad), {
method: "POST", method: "POST",
signal,
...(bodyPayload ...(bodyPayload
? { ? {
body: bodyPayload, body: bodyPayload,
@ -121,6 +160,11 @@ export function useSiteAnalysis() {
// 202 Accepted — entering polling mode // 202 Accepted — entering polling mode
if (first.status === 202) { if (first.status === 202) {
const accepted = first.body as AnalyzeAcceptedResponse; const accepted = first.body as AnalyzeAcceptedResponse;
// Guard: не перезаписываем баннер если этот вызов уже отменили
// (succeeded race: cancel пришёл во время первого POST).
if (signal.aborted) {
throw new Error("Загрузка отменена пользователем");
}
setFetchingState({ setFetchingState({
cadNum: cad, cadNum: cad,
jobId: accepted.job_id, jobId: accepted.job_id,
@ -129,12 +173,13 @@ export function useSiteAnalysis() {
// Poll loop // Poll loop
for (let i = 0; i < POLL_MAX_ITERATIONS; i++) { for (let i = 0; i < POLL_MAX_ITERATIONS; i++) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); await abortableSleep(POLL_INTERVAL_MS, signal);
if (cancelledRef.current) { if (signal.aborted) {
throw new Error("Загрузка отменена пользователем"); throw new Error("Загрузка отменена пользователем");
} }
const status = await apiFetch<FetchStatusResponse>( const status = await apiFetch<FetchStatusResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`, `/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
{ signal },
); );
if (status.status === "ready") { if (status.status === "ready") {
// Re-trigger analyze — should be 200 now. // Re-trigger analyze — should be 200 now.
@ -144,6 +189,7 @@ export function useSiteAnalysis() {
// mutation сразу резолвится с data — render skipped. // mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), { const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
method: "POST", method: "POST",
signal,
...(bodyPayload ...(bodyPayload
? { ? {
body: bodyPayload, body: bodyPayload,
@ -151,11 +197,15 @@ export function useSiteAnalysis() {
} }
: {}), : {}),
}); });
setFetchingState(null); // Только если этот вызов всё ещё актуален — иначе зомби-цикл
// стирает баннер свежей мутации (#1242).
if (!signal.aborted) {
setFetchingState(null);
}
return second; return second;
} }
if (status.status === "not_in_nspd") { if (status.status === "not_in_nspd") {
setFetchingState(null); if (!signal.aborted) setFetchingState(null);
throw new HTTPError( throw new HTTPError(
404, 404,
status, status,
@ -163,7 +213,7 @@ export function useSiteAnalysis() {
); );
} }
if (status.status === "failed") { if (status.status === "failed") {
setFetchingState(null); if (!signal.aborted) setFetchingState(null);
throw new HTTPError( throw new HTTPError(
503, 503,
status, status,
@ -171,7 +221,7 @@ export function useSiteAnalysis() {
); );
} }
if (status.status === "invalid_format") { if (status.status === "invalid_format") {
setFetchingState(null); if (!signal.aborted) setFetchingState(null);
throw new HTTPError( throw new HTTPError(
400, 400,
status, status,
@ -181,7 +231,7 @@ export function useSiteAnalysis() {
// status === "fetching" → continue polling // status === "fetching" → continue polling
} }
// Polling exhausted (2 min) // Polling exhausted (2 min)
setFetchingState(null); if (!signal.aborted) setFetchingState(null);
throw new Error( throw new Error(
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.", "Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
); );
@ -192,7 +242,8 @@ export function useSiteAnalysis() {
}); });
const cancel = useCallback(() => { const cancel = useCallback(() => {
cancelledRef.current = true; abortRef.current?.abort();
abortRef.current = null;
setFetchingState(null); setFetchingState(null);
mutation.reset(); mutation.reset();
}, [mutation]); }, [mutation]);