8000 fix(web): abort stalled initial ledger read and retry on a fresh socket by InfinityPacer · Pull Request #80 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.16.7",
"version": "0.16.8",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
18 changes: 16 additions & 2 deletions src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,14 @@ import { ChartStyleControl } from "./components/ChartStyleControl";
import { useMobileLayout } from "./lib/use-mobile-layout";
import { useLiveUpdates } from "./lib/use-live-updates";
import {
createLedgerReadSignal,
INITIAL_LEDGER_READ_TIMEOUT_MS,
isLedgerNetworkError,
isReportBuilding,
ReportBuildingError,
reportRefetchInterval,
reportRetry,
reportRetryDelay,
} from "./lib/report-building";
import type { ChartStyle } from "./components/UsageChart";
import type { ReportDimension } from "./lib/report";
Expand Down Expand Up @@ -203,6 +206,7 @@ async function readLedger(
usdBasis?: UsdBasis,
signal?: AbortSignal,
refresh = true,
timeoutMs?: number,
): Promise<LedgerView> {
const { filter } = viewQuery;
if (import.meta.env.VITE_METERLEAF_DEMO === "true") {
Expand Down Expand Up @@ -236,9 +240,10 @@ async function readLedger(
"desc",
] as const)
params.set(key, String(viewQuery[key]));
const readSignal = createLedgerReadSignal(signal, timeoutMs);
try {
const response = await fetch(`/api/view?${params.toString()}`, {
signal,
signal: readSignal.signal,
redirect: "manual",
});
if (isLoginRedirect(response)) throw new SessionExpiredError();
Expand All @@ -261,11 +266,18 @@ async function readLedger(
throw error;
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
if (
!readSignal.timedOut() &&
error instanceof Error &&
error.name === "AbortError"
)
throw error;
if (isReportBuilding(error) || isSessionExpired(error)) throw error;
if (error instanceof Error && error.message.startsWith("报表读取失败:"))
throw error;
throw new Error("报表读取失败:网络连接异常", { cause: error });
} finally {
readSignal.cleanup();
}
}

Expand Down Expand Up @@ -821,11 +833,13 @@ export function App() {
undefined,
signal,
failed || !cached?.reportStatus?.refreshing,
cached ? undefined : INITIAL_LEDGER_READ_TIMEOUT_MS,
);
},
// 仅在报表后台计算期间读取结果;同步完成由公共同步控件使所有报表缓存失效。
refetchInterval: (query) => reportRefetchInterval(query, liveUpdatesPaused),
retry: reportRetry,
retryDelay: reportRetryDelay,
refetchOnWindowFocus: false,
});
const disconnectedOnLoad = useRef(false);
Expand Down
44 changes: 44 additions & 0 deletions src/web/lib/report-building.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
import type { LedgerView, ReportBuilding } from "../../shared/ledger-view";
import { isSessionExpired } from "./session";

/**
* 尚无本地账本结果时的首屏读取超时上限。移动蜂窝网切换出口地址后,浏览器若复用旧空闲连接
* 会长时间既不返回也不报错,超时主动掐断后立即换新连接重试一次。
*/
export const INITIAL_LEDGER_READ_TIMEOUT_MS = 6000;

export function createLedgerReadSignal(
callerSignal?: AbortSignal,
timeoutMs?: number,
): { signal?: AbortSignal; timedOut: () => boolean; cleanup: () => void } {
if (!timeoutMs) {
return { signal: callerSignal, timedOut: () => false, cleanup: () => {} };
}
const controller = new AbortController();
let didTimeout = false;
const timer = setTimeout(() => {
didTimeout = true;
controller.abort();
}, timeoutMs);
const => {
clearTimeout(timer);
controller.abort();
};
if (callerSignal?.aborted) {
clearTimeout(timer);
controller.abort();
} else {
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
}
return {
signal: controller.signal,
timedOut: () => didTimeout,
cleanup: () => {
clearTimeout(timer);
callerSignal?.removeEventListener("abort", onCallerAbort);
},
};
}
/** 报表正在后台首次计算或重建,暂时没有结果;不是读取失败,稍后会自动可用。 */
export class ReportBuildingError extends Error {
readonly since: string;
Expand Down Expand Up @@ -29,6 +67,12 @@ export function reportRetry(failureCount: number, error: unknown) {
);
}

export function reportRetryDelay(attempt: number, error: unknown) {
return isLedgerNetworkError(error)
? 100
: Math.min(1000 * 2 ** attempt, 30_000);
}

/**
* 报表查询的轮询节奏:计算中每 2 秒读一次,旧结果刷新中每秒读一次,其余不轮询。
* 普通读取失败不自动轮询,避免失败请求持续占用报表线程。
Expand Down
27 changes: 27 additions & 0 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { expect, test } from "bun:test";
import {
createLedgerReadSignal,
INITIAL_LEDGER_READ_TIMEOUT_MS,
isLedgerNetworkError,
reportRefetchInterval,
reportRetry,
reportRetryDelay,
} from "../src/web/lib/report-building";
import {
SessionExpiredError,
Expand Down Expand Up @@ -106,3 +109,27 @@ test("reloadForSessionLogin clears cached app shells and prepares an 8000ms auth
});
}
});

test("createLedgerReadSignal distinguishes socket timeout from caller cancellation", async () => {
expect(INITIAL_LEDGER_READ_TIMEOUT_MS).toBe(6000);
const caller = new AbortController();
const timed = createLedgerReadSignal(caller.signal, 25);
await new Promise((resolve) => setTimeout(resolve, 40));
expect(timed.signal?.aborted).toBe(true);
expect(timed.timedOut()).toBe(true);
timed.cleanup();

const cancelledCaller = new AbortController();
const cancelled = createLedgerReadSignal(cancelledCaller.signal, 5000);
cancelledCaller.abort();
expect(cancelled.signal?.aborted).toBe(true);
expect(cancelled.timedOut()).toBe(false);
cancelled.cleanup();

expect(reportRetryDelay(0, new Error("报表读取失败:网络连接异常"))).toBe(
100,
);
expect(
reportRetryDelay(0, new Error("报表读取失败:网关不可用(HTTP 503)")),
).toBe(1000);
});
Loading
0