diff --git a/.project-docs/30-worklog/tasks/20260819-design-freeze-live-6e2c.md b/.project-docs/30-worklog/tasks/20260819-design-freeze-live-6e2c.md new file mode 100644 index 0000000..6127a9f --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260819-design-freeze-live-6e2c.md @@ -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. diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 9f88ad6..f4ad5cf 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -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(); private readonly eventSubscriptionClosers = new Map void>>(); private readonly activeRunEventStreams = new Map(); @@ -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 { @@ -1505,59 +1522,89 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } private async requestJson(path: string, init: RequestInit = {}): Promise { - 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 { - 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 { - 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 { diff --git a/electron/services/works-square-session.ts b/electron/services/works-square-session.ts index 1a7f9af..cbfe6e5 100644 --- a/electron/services/works-square-session.ts +++ b/electron/services/works-square-session.ts @@ -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 { async function refreshWorksSquareSession( session: StoredWorksSquareSession, generation: number, - options: { fetchImpl?: typeof fetch; nowMs?: number } = {}, + options: { fetchImpl?: typeof fetch; nowMs?: number; requestTimeoutMs?: number } = {}, ): Promise { 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 { if (restoreStatus === 'unavailable') { diff --git a/electron/utils/proxy-fetch.ts b/electron/utils/proxy-fetch.ts index da1c23d..054721a 100644 --- a/electron/utils/proxy-fetch.ts +++ b/electron/utils/proxy-fetch.ts @@ -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( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, + sourceSignal?: AbortSignal | null, +): Promise { + 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((_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[0], + init: RequestInit | undefined, + timeoutMs: number, +): Promise { + 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. } } diff --git a/tests/unit/proxy-fetch.test.ts b/tests/unit/proxy-fetch.test.ts index bdbe003..20d8572 100644 --- a/tests/unit/proxy-fetch.test.ts +++ b/tests/unit/proxy-fetch.test.ts @@ -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'); diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index 93255af..906e70d 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -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((_input, init) => { + requestSignal = init?.signal ?? null; + return new Promise(() => 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((_input, init) => { + requestSignal = init?.signal ?? null; + return Promise.resolve(new Response(new ReadableStream({ + 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, diff --git a/tests/unit/works-square-session.test.ts b/tests/unit/works-square-session.test.ts index 0ea0b01..41e74c1 100644 --- a/tests/unit/works-square-session.test.ts +++ b/tests/unit/works-square-session.test.ts @@ -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((_input, init) => { + requestSignal = init?.signal ?? null; + return new Promise(() => 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;