fix(design): bound requests and prevent mutation replay
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# Task: Diagnose live AI design freeze after HTTP1 package
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260819-design-freeze-live-6e2c
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260819-design-freeze-live-6e2c-design-freeze-live
|
||||
- Worktree: D:\mk-design-freeze-live-6e2c
|
||||
- Base commit: 1ba68a9e41ea5eb4cf03bd11ef18ad61023cd069
|
||||
- Owner: codex-root
|
||||
- Status: Completed
|
||||
|
||||
## Scope
|
||||
|
||||
- Inspect the live installed Makelore process after the combined native-login and HTTP/2-disabled diagnostic package froze in AI Design.
|
||||
- Verify package provenance, Chromium transport flags, process health, TCP state, local Host API responsiveness, session-refresh eligibility, Works service health, and the matching request implementation.
|
||||
- Implement the directly authorized fix in the same task: bound finite AI Design and Works session-refresh requests, prevent implicit replay of non-idempotent requests, and add focused regressions.
|
||||
- Build a Windows diagnostic package that retains native password/SMS login and `disable-http2` so this behavior change can be isolated in the user's next smoke test.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- The user explicitly requested direct execution without sub-agents.
|
||||
- Treat the observed 409, global Loading state, and previous `ERR_HTTP2_PROTOCOL_ERROR` as separate signals until live evidence connects them.
|
||||
- Read the encrypted session only to project expiration/activity metadata and to issue bounded read-only requests; never print or persist access/refresh tokens.
|
||||
- Do not replay the Quote PATCH because its idempotency and server-side commit state are not proven.
|
||||
- Remove every temporary harness and isolated Chromium profile before completion.
|
||||
|
||||
## Outcome
|
||||
|
||||
### Confirmed facts
|
||||
|
||||
- The installed `app.asar` SHA-256 exactly matched the combined diagnostic package: `80948DDBBD5F9BDE9B4C75CB40E515E63200AA79582BF16CF88347DECCD864C6`.
|
||||
- The live Network Service process had `--disable-http2`; HTTP/2 was genuinely disabled.
|
||||
- Main, Renderer, GPU, and Network Service remained Windows-responsive. A three-second sample showed zero Main/Renderer CPU growth and only `0.0156` seconds in Network Service, so the process was passively waiting rather than spinning or blocking the UI thread.
|
||||
- While the UI remained stuck, three rounds of 20 concurrent requests to the live Host API all completed with the expected unauthenticated `401` in `47/16/14 ms`. Main's HTTP event loop and listener were not globally blocked.
|
||||
- The stored access token was more than 157 minutes from expiry and outside the 30-second refresh skew. The shared `refreshFlight` was not active for this incident.
|
||||
- With the same authenticated session, read-only Node probes to `/api/auth/me`, Design capabilities, Workspace list, the affected Workspace, and its two Conversations returned `200` in `31-159 ms`. Works, the account, and that Workspace were not globally locked.
|
||||
- A fresh Electron `43.4.0` Network Service with `disable-http2` returned `200` for the same three read-only endpoints in `1764-1853 ms`. HTTP/1.1 itself remained functional.
|
||||
- Product code gives `net.fetch`, its global-fetch fallback, session refresh, design `authorizedFetch`, IPC Host API requests, Workspace/Conversation loads, and Quote repricing no common deadline or abort contract. Their UI cleanup runs only after the Promise settles.
|
||||
- `proxyAwareFetch` catches every Electron `net.fetch` exception and unconditionally replays the same request with global `fetch`. That is unsafe for mutation methods such as Quote `PATCH` when the first request may have committed before its response transport failed.
|
||||
|
||||
### Conclusion
|
||||
|
||||
- Disabling HTTP/2 did not fix the freeze and is not the root cause.
|
||||
- The live symptom is an unbounded in-process network/IPC Promise in the current Makelore network context. The process remains healthy but a request can wait forever, leaving Renderer loading/busy state waiting for `finally`.
|
||||
- The observed `409` is a completed conflict response and cannot itself block the JavaScript thread. It may be a secondary symptom of the unconditional transport fallback replay after an earlier protocol failure, but that specific mutation was not replayed during this diagnostic task.
|
||||
- The user's “one request hangs, then everything stays Loading” report is consistent with missing deadlines/cancellation plus page-level state ownership, not with a dead request thread.
|
||||
|
||||
### Implemented fix
|
||||
|
||||
- Added a reusable deadline primitive that aborts the active transport and still rejects deterministically when a transport ignores `AbortSignal`.
|
||||
- Bounded the complete AI Design JSON request lifecycle to 30 seconds, including token lookup/refresh, response headers, and response-body parsing. The adapter returns a stable `504 DESIGN_WORKSPACE_REQUEST_TIMEOUT` with `AI 设计服务响应超时,请重试`.
|
||||
- Bounded the shared Works access-token refresh to 30 seconds. All coalesced callers settle together, `refreshFlight` is released by its existing `finally`, and a later refresh can retry without clearing a still-valid refresh token.
|
||||
- Restricted Electron-to-Node fetch fallback to safe reads (`GET`, `HEAD`, `OPTIONS`). `POST`, `PATCH`, `PUT`, and `DELETE` transport failures now propagate without implicitly replaying the mutation.
|
||||
- Preserved native password/SMS login and the temporary global `disable-http2` switch in the diagnostic installer so the user can isolate this request-lifetime/replay fix before HTTP/2 policy is revisited.
|
||||
|
||||
## Verification
|
||||
|
||||
- Live installed-package hash comparison — PASS, exact match.
|
||||
- Live process command-line projection — PASS, Network Service had `disable-http2`.
|
||||
- Live process responsiveness/CPU sampling — PASS, responsive passive wait.
|
||||
- Host API concurrency probe — PASS, 60/60 requests completed within 47 ms per round maximum.
|
||||
- Encrypted-session metadata projection — PASS, no refresh window and no token output.
|
||||
- Authenticated read-only Works differential probes — PASS, account/Design/Workspace/Conversation endpoints responsive.
|
||||
- Fresh Electron HTTP/1.1 differential probe — PASS.
|
||||
- Static request-path inspection — confirmed missing deadlines/AbortSignals and unconditional fallback replay.
|
||||
- Temporary scripts, metadata output, and isolated Electron profile — removed.
|
||||
- Red-phase regression run — PASS as evidence: the new mutation-replay, stuck request, and stuck shared-refresh tests all failed against the previous implementation.
|
||||
- Focused unit tests — PASS, 59/59.
|
||||
- TypeScript check — PASS, `pnpm run typecheck`.
|
||||
- Lint check — PASS with zero errors and seven pre-existing unrelated warnings.
|
||||
- Full unit suite — PASS on clean rerun, 184 files and 2190 tests. The first run had one unrelated Windows port-release timing failure; its focused rerun passed before the clean full rerun.
|
||||
- Production compile — PASS, `pnpm run build:vite`.
|
||||
- Windows package build — PASS after the verified prior uv binary and verified Learning Player artifact were reused when GitHub download timed out.
|
||||
- Packaged Learning Player verification — PASS, SHA-256 `748d6d7c74d9d0ba444e0c051a50010f7070e7d6e46442b1e60f1e2be80fd020`.
|
||||
- Packaged `app.asar` marker inspection — PASS for `disable-http2`, `DESIGN_WORKSPACE_REQUEST_TIMEOUT`, and the request-deadline implementation.
|
||||
- Packaged Electron runtime verification — PASS, including Electron `43.4.0`, Node `24.18.1`, OpenCode, Python, uv, npm, and native addons.
|
||||
- Installer SHA-256 — `FB3BDA4F464770E58EA0AA6E989366685976D5FA7B76E6944B92879B423D83D4`.
|
||||
- Packaged `app.asar` SHA-256 — `A752255B43694814CC3D3FB86C75E024D39E67BBF87BE273960A1FA9FA0366A6`.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Smoke the original Quote edit/retry/confirm sequence in the diagnostic package. A stalled request should now leave Loading within 30 seconds and show the explicit timeout message; a transport exception must not produce a second Quote PATCH.
|
||||
- After the user confirms the bounded-request fix, restore HTTP/2 in a separate package and repeat the same regression smoke. `disable-http2` remains a diagnostic variable, not the root-cause fix.
|
||||
- Add privacy-safe request lifecycle instrumentation (`route kind`, transport, phase, elapsed time, deadline outcome, correlation id) so a future live freeze identifies the exact pending boundary without DevTools or credentials.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/20-architecture/data-flow.md` and the AI Design transport boundary.
|
||||
- Proposal: document the implemented bounded AbortSignal/deadline contract for Main-owned finite Works requests and the prohibition on implicit mutation replay without an explicit idempotency contract.
|
||||
- Evidence: this live differential diagnosis, the focused/full regression results, `electron/utils/proxy-fetch.ts`, `electron/services/works-square-session.ts`, `electron/image-workspace/works-square-workspace.ts`, `src/lib/host-api.ts`, and `src/stores/image-workspace.ts`.
|
||||
- Future impact: network failures become terminal and recoverable instead of leaving permanent Loading state or ambiguously duplicating Quote mutations.
|
||||
- Semantic conflicts: none with ADR-001; Workspace/Conversation ownership remains unchanged.
|
||||
- Human confirmation required: no for a bounded bug fix, but permanent HTTP protocol policy should be decided separately after the fixed package is validated.
|
||||
@@ -27,7 +27,12 @@ import type {
|
||||
import WebSocket from 'ws';
|
||||
import { designAssetContentPath } from '../../shared/image-workspace';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import {
|
||||
fetchWithDeadline,
|
||||
proxyAwareFetch,
|
||||
RequestDeadlineExceededError,
|
||||
runWithDeadline,
|
||||
} from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
|
||||
import {
|
||||
DesignWorkspaceModuleError,
|
||||
@@ -169,6 +174,7 @@ type WorksSquareDesignWorkspaceOptions = {
|
||||
apiBaseUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
webSocketFactory?: AgentWebSocketFactory;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type ServerAgentStreamTicket = {
|
||||
@@ -269,6 +275,7 @@ type AgentCommandChannel = {
|
||||
};
|
||||
|
||||
const AGENT_WEBSOCKET_OPEN = 1;
|
||||
const DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000;
|
||||
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
|
||||
const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000;
|
||||
@@ -850,10 +857,19 @@ function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModul
|
||||
|| error.code === 'agent_session_closed');
|
||||
}
|
||||
|
||||
function designRequestTimeoutError(): DesignWorkspaceModuleError {
|
||||
return new DesignWorkspaceModuleError(
|
||||
504,
|
||||
'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
|
||||
'AI 设计服务响应超时,请重试',
|
||||
);
|
||||
}
|
||||
|
||||
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly webSocketFactory: AgentWebSocketFactory;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly conversationSessionIds = new Map<string, string>();
|
||||
private readonly eventSubscriptionClosers = new Map<string, Set<() => void>>();
|
||||
private readonly activeRunEventStreams = new Map<string, number>();
|
||||
@@ -868,6 +884,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DESIGN_WORKSPACE_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
|
||||
@@ -1505,59 +1522,89 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await this.authorizedFetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body && !(init.body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const detail = asErrorDetail(payload);
|
||||
const code = typeof detail.code === 'string'
|
||||
? detail.code
|
||||
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
|
||||
throw new DesignWorkspaceModuleError(
|
||||
response.status,
|
||||
code,
|
||||
userFacingErrorMessage(code),
|
||||
);
|
||||
try {
|
||||
return await runWithDeadline(async (signal) => {
|
||||
const response = await this.authorizedFetch(path, {
|
||||
...init,
|
||||
signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body && !(init.body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const detail = asErrorDetail(payload);
|
||||
const code = typeof detail.code === 'string'
|
||||
? detail.code
|
||||
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
|
||||
throw new DesignWorkspaceModuleError(
|
||||
response.status,
|
||||
code,
|
||||
userFacingErrorMessage(code),
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
}, this.requestTimeoutMs, init.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestDeadlineExceededError) {
|
||||
throw designRequestTimeoutError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
|
||||
}
|
||||
try {
|
||||
let token = await getValidWorksSquareAccessToken({
|
||||
fetchImpl: this.fetchImpl,
|
||||
requestTimeoutMs: this.requestTimeoutMs,
|
||||
});
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
|
||||
}
|
||||
|
||||
let response = await this.fetchWithToken(path, token, init);
|
||||
if (response.status !== 401) return response;
|
||||
let response = await this.fetchWithToken(path, token, init);
|
||||
if (response.status !== 401) return response;
|
||||
|
||||
token = await getValidWorksSquareAccessToken({
|
||||
fetchImpl: this.fetchImpl,
|
||||
forceRefresh: true,
|
||||
});
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
|
||||
token = await getValidWorksSquareAccessToken({
|
||||
fetchImpl: this.fetchImpl,
|
||||
forceRefresh: true,
|
||||
requestTimeoutMs: this.requestTimeoutMs,
|
||||
});
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
|
||||
}
|
||||
response = await this.fetchWithToken(path, token, init);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof RequestDeadlineExceededError) {
|
||||
throw designRequestTimeoutError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
response = await this.fetchWithToken(path, token, init);
|
||||
return response;
|
||||
}
|
||||
|
||||
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
|
||||
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
|
||||
const requestInit: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (init.signal) {
|
||||
return this.fetchImpl(`${this.apiBaseUrl}${path}`, requestInit);
|
||||
}
|
||||
return fetchWithDeadline(
|
||||
this.fetchImpl,
|
||||
`${this.apiBaseUrl}${path}`,
|
||||
requestInit,
|
||||
this.requestTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
private conversationKey(workspaceId: string, conversationId: string): string {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { NIANCODE_AUTH_CONFIG } from '../api/auth-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
|
||||
import { logger } from '../utils/logger';
|
||||
import { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session';
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||
const WORKS_SQUARE_AUTH_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const SESSION_STORE_SCHEMA_VERSION = 1;
|
||||
export { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session';
|
||||
|
||||
@@ -716,7 +717,7 @@ async function waitForCredentialPersistence(): Promise<boolean> {
|
||||
async function refreshWorksSquareSession(
|
||||
session: StoredWorksSquareSession,
|
||||
generation: number,
|
||||
options: { fetchImpl?: typeof fetch; nowMs?: number } = {},
|
||||
options: { fetchImpl?: typeof fetch; nowMs?: number; requestTimeoutMs?: number } = {},
|
||||
): Promise<string | null> {
|
||||
if (!session.refreshToken) return null;
|
||||
|
||||
@@ -726,17 +727,25 @@ async function refreshWorksSquareSession(
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: session.refreshToken,
|
||||
});
|
||||
const response = await fetchImpl(`${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: createBasicAuthHeader(
|
||||
NIANCODE_AUTH_CONFIG.clientId,
|
||||
NIANCODE_AUTH_CONFIG.clientSecret,
|
||||
),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const { response, payload } = await runWithDeadline(async (signal) => {
|
||||
const response = await fetchImpl(
|
||||
`${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: createBasicAuthHeader(
|
||||
NIANCODE_AUTH_CONFIG.clientId,
|
||||
NIANCODE_AUTH_CONFIG.clientSecret,
|
||||
),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const payload = response.ok ? await readResponsePayload(response) : null;
|
||||
return { response, payload };
|
||||
}, options.requestTimeoutMs ?? WORKS_SQUARE_AUTH_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('[works-square-session] Refresh failed', { status: response.status });
|
||||
@@ -746,7 +755,6 @@ async function refreshWorksSquareSession(
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
logger.warn('[works-square-session] Refresh returned an invalid payload');
|
||||
return null;
|
||||
@@ -780,6 +788,7 @@ export async function getValidWorksSquareAccessToken(
|
||||
fetchImpl?: typeof fetch;
|
||||
nowMs?: number;
|
||||
forceRefresh?: boolean;
|
||||
requestTimeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
if (restoreStatus === 'unavailable') {
|
||||
|
||||
@@ -6,6 +6,77 @@
|
||||
|
||||
import { net } from 'electron';
|
||||
|
||||
const SAFE_FALLBACK_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export class RequestDeadlineExceededError extends Error {
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(timeoutMs: number) {
|
||||
super(`Request did not complete within ${timeoutMs}ms`);
|
||||
this.name = 'RequestDeadlineExceededError';
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
if (signal.reason !== undefined) return signal.reason;
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function runWithDeadline<T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
sourceSignal?: AbortSignal | null,
|
||||
): Promise<T> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError('Request timeout must be a positive finite number');
|
||||
}
|
||||
|
||||
if (sourceSignal?.aborted) throw abortReason(sourceSignal);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectInterruption!: (reason: unknown) => void;
|
||||
const interruption = new Promise<never>((_resolve, reject) => {
|
||||
rejectInterruption = reject;
|
||||
});
|
||||
const abortFromSource = () => {
|
||||
if (!sourceSignal) return;
|
||||
const reason = abortReason(sourceSignal);
|
||||
controller.abort(reason);
|
||||
rejectInterruption(reason);
|
||||
};
|
||||
sourceSignal?.addEventListener('abort', abortFromSource, { once: true });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const error = new RequestDeadlineExceededError(timeoutMs);
|
||||
controller.abort(error);
|
||||
rejectInterruption(error);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
try {
|
||||
return await Promise.race([operation(controller.signal), interruption]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
sourceSignal?.removeEventListener('abort', abortFromSource);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchWithDeadline(
|
||||
fetchImpl: typeof fetch,
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init: RequestInit | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
return runWithDeadline(
|
||||
(signal) => fetchImpl(input, { ...init, signal }),
|
||||
timeoutMs,
|
||||
init?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
export async function proxyAwareFetch(
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
@@ -13,8 +84,12 @@ export async function proxyAwareFetch(
|
||||
if (process.versions.electron) {
|
||||
try {
|
||||
return await net.fetch(input, init);
|
||||
} catch {
|
||||
// Fall through to the global fetch.
|
||||
} catch (error) {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (!SAFE_FALLBACK_METHODS.has(method) || init?.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
// Safe reads retain the Node fallback for proxy compatibility.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,36 @@ describe('proxyAwareFetch', () => {
|
||||
expect(net.fetch).toHaveBeenCalledWith('https://example.test', undefined);
|
||||
});
|
||||
|
||||
it('does not replay a mutation through global fetch after Electron net.fetch fails', async () => {
|
||||
setElectronVersion('43.4.0');
|
||||
const transportError = new Error('response transport failed');
|
||||
const fetchMock = vi.fn();
|
||||
vi.mocked(net.fetch).mockRejectedValue(transportError);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(proxyAwareFetch('https://example.test/quote', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ aspect_ratio: '16:9' }),
|
||||
})).rejects.toBe(transportError);
|
||||
|
||||
expect(net.fetch).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the global fallback for safe reads after Electron net.fetch fails', async () => {
|
||||
setElectronVersion('43.4.0');
|
||||
const response = new Response('node fallback');
|
||||
const fetchMock = vi.fn().mockResolvedValue(response);
|
||||
vi.mocked(net.fetch).mockRejectedValue(new Error('electron transport failed'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(proxyAwareFetch('https://example.test/read', {
|
||||
method: 'GET',
|
||||
})).resolves.toBe(response);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.test/read', { method: 'GET' });
|
||||
});
|
||||
|
||||
it('uses the global fetch outside Electron', async () => {
|
||||
setElectronVersion(undefined);
|
||||
const response = new Response('node');
|
||||
|
||||
@@ -627,6 +627,95 @@ describe('Works Square AI design adapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts a stuck Quote request at the design request deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const fetchMock = vi.fn<typeof fetch>((_input, init) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
return new Promise<Response>(() => undefined);
|
||||
});
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const outcome = adapter.updateGenerationQuote({
|
||||
workspaceId: 'workspace-one',
|
||||
quoteId: 'quote-one',
|
||||
finalPrompt: 'updated prompt',
|
||||
generationParameters: quoteConfirmationInput.generationParameters,
|
||||
}).then(
|
||||
() => ({ state: 'resolved' as const }),
|
||||
(error: unknown) => ({ state: 'rejected' as const, error }),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(Promise.race([
|
||||
outcome,
|
||||
Promise.resolve({ state: 'pending' as const }),
|
||||
])).resolves.toMatchObject({
|
||||
state: 'rejected',
|
||||
error: {
|
||||
status: 504,
|
||||
code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
|
||||
message: 'AI 设计服务响应超时,请重试',
|
||||
},
|
||||
});
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('also bounds a Quote response whose JSON body never finishes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const fetchMock = vi.fn<typeof fetch>((_input, init) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
|
||||
start: () => undefined,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
});
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
|
||||
const outcome = adapter.updateGenerationQuote({
|
||||
workspaceId: 'workspace-one',
|
||||
quoteId: 'quote-one',
|
||||
finalPrompt: 'updated prompt',
|
||||
generationParameters: quoteConfirmationInput.generationParameters,
|
||||
}).then(
|
||||
() => ({ state: 'resolved' as const }),
|
||||
(error: unknown) => ({ state: 'rejected' as const, error }),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(Promise.race([
|
||||
outcome,
|
||||
Promise.resolve({ state: 'pending' as const }),
|
||||
])).resolves.toMatchObject({
|
||||
state: 'rejected',
|
||||
error: {
|
||||
status: 504,
|
||||
code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT',
|
||||
},
|
||||
});
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes the server video option shape so the duration picker stays available', async () => {
|
||||
const videoConversation = {
|
||||
...serverConversation,
|
||||
@@ -1731,6 +1820,7 @@ describe('Works Square AI design adapter', () => {
|
||||
expect(getTokenMock).toHaveBeenNthCalledWith(2, {
|
||||
fetchImpl: fetchMock,
|
||||
forceRefresh: true,
|
||||
requestTimeoutMs: 30_000,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
|
||||
@@ -284,6 +284,53 @@ describe('works-square-session service', () => {
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('expires a stuck shared refresh and permits the next refresh attempt', async () => {
|
||||
let requestSignal: AbortSignal | null = null;
|
||||
const fetchImpl = vi.fn<typeof fetch>((_input, init) => {
|
||||
requestSignal = init?.signal ?? null;
|
||||
return new Promise<Response>(() => undefined);
|
||||
});
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'old-access-token',
|
||||
refreshToken: 'old-refresh-token',
|
||||
expiresAt: Date.now() + 10_000,
|
||||
});
|
||||
|
||||
const first = getValidWorksSquareAccessToken({ fetchImpl, requestTimeoutMs: 25 });
|
||||
const second = getValidWorksSquareAccessToken({ fetchImpl, requestTimeoutMs: 25 });
|
||||
const outcomes = Promise.allSettled([first, second]);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(Promise.race([
|
||||
outcomes,
|
||||
Promise.resolve('pending'),
|
||||
])).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
status: 'rejected',
|
||||
reason: expect.objectContaining({ name: 'RequestDeadlineExceededError' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
status: 'rejected',
|
||||
reason: expect.objectContaining({ name: 'RequestDeadlineExceededError' }),
|
||||
}),
|
||||
]);
|
||||
expect(requestSignal?.aborted).toBe(true);
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
|
||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}), { status: 200 }));
|
||||
|
||||
await expect(getValidWorksSquareAccessToken({
|
||||
fetchImpl,
|
||||
requestTimeoutMs: 25,
|
||||
})).resolves.toBe('new-access-token');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('still allows refresh one millisecond before the seven-day boundary', async () => {
|
||||
const lastActiveAt = Date.now();
|
||||
const nowMs = lastActiveAt + WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS - 1;
|
||||
|
||||
Reference in New Issue
Block a user