Skip to content

fix(export): close leaked fds and time out hung API requests - #292

Merged
petertzy merged 3 commits into
petertzy:mainfrom
harsh-thakkar7:fix/request-timeout-review
Sep 29, 2026
Merged

petertzy merged 3 commits into
petertzy:mainfrom
harsh-thakkar7:fix/request-timeout-review

Conversation

@harsh-thakkar7

Copy link
Copy Markdown
Contributor

Description

Follow-up to the closed fd/temp-file + request-timeout PR, incorporating review feedback from karaaslanz on the request-timeout half. The fd/temp-file cleanup (backend/routers/export.py, tests/test_export_fd_leak.py) is unchanged — the reviewer confirmed that half is well-scoped.

Request timeouts, revised per review

The previous version applied one 30 s default to every apiFetch/apiFetchBlob call, which would have broken legitimately long-running requests:

  • AI.chat, AI.work, AI.translate, AI.translateSentences, AI.translateSentenceBatch and provider model listing can exceed 30 s (slow/local models, long documents).
  • Conversions such as PDF/HTML → Markdown and DOCX/PDF export can also exceed 30 s for large files.

Changes in frontend/src/lib/api.ts + new frontend/src/lib/http-timeout.mjs:

  • Endpoint-specific timeouts. Settings/metadata/file-listing calls keep the short default (DEFAULT_REQUEST_TIMEOUT_MS, 30 s). Long-running AI/conversion/export endpoints pass LONG_REQUEST_TIMEOUT_MS (5 minutes) explicitly.
  • Composed abort signals. fetchWithTimeout previously did init.signal ?? controller.signal, which silently dropped the timeout whenever a caller supplied a signal (e.g. translateSentenceBatch(..., signal)). It now composes caller + timeout signals via composeAbortSignals (uses AbortSignal.any, with a manual-wiring fallback for older webviews), so both caller cancellation and the timeout stay effective.
  • Explicit no-deadline opt-out. timeoutMs <= 0 disables the automatic abort.
  • The helper lives in a plain ESM module (http-timeout.mjs) so the Node-based regression suite can exercise the exact code the app runs.

Regression tests (frontend/tests/api-fetch-timeout.test.mjs, 8 tests)

  • A hung request aborts at the configured budget instead of waiting forever.
  • A request that outlives the short default succeeds with the long-running budget.
  • A caller-supplied AbortSignal aborts the request even when a generous timeout is set.
  • The timeout still fires when a caller supplies a signal (the specific regression the reviewer flagged).
  • timeoutMs = 0 disables the deadline.
  • composeAbortSignals unit cases (single/absent signals, any-input abort, already-aborted input).

Why this matters

Without the per-endpoint budgets, the 30 s default would turn previously-working slow AI/conversion requests into client-side aborts. Without composing the caller signal, cancel-and-timeout would not coexist.

How Has This Been Tested?

  • node --test tests/api-fetch-timeout.test.mjs → 8/8 passing.
  • frontend/src/lib/api.ts type-checks (transpile clean); the new http-timeout.mjs is dependency-free plain ESM.
  • Backend fd/temp-file suite unchanged and previously green (tests/test_export_fd_leak.py).

Related

Supersedes the earlier fd-leak + timeout PR after review. Standalone PR — no matching open issue.

backend/routers/export.py ignored the descriptor returned by tempfile.mkstemp
in _make_output_path (one leaked fd per export without an explicit output
path, plus a blocked re-open on Windows) and in download_html (never adopted
or closed it, and left the temp file on disk forever). Close the descriptor
in _make_output_path, write through os.fdopen in download_html, and remove
the temporary file once the response has been streamed via a BackgroundTask.

frontend/src/lib/api.ts used plain fetch for every API call, so a hung
backend left the UI awaiting indefinitely (AI.getSettings even carried a
bespoke Promise.race timeout). Route apiFetch/apiFetchBlob through the
existing fetchWithTimeout (30s default, configurable per call) and drop the
hand-rolled race.

Adds backend regression tests for the fd leak and temp-file cleanup.
Addresses review feedback on the request-timeout half of the fd-leak PR:

- Endpoint-specific timeouts: settings/metadata keep the short default
  (30s); long-running AI/conversion endpoints (chat, work, translate,
  translateSentences, translateSentenceBatch, model listing, PDF/HTML
  conversion, DOCX/PDF export) get a generous 5-minute budget.
- fetchWithTimeout now composes a caller-supplied AbortSignal with the
  timeout signal instead of replacing it (init.signal ?? controller.signal
  silently dropped the timeout whenever a caller supplied a signal).
- timeoutMs <= 0 disables the automatic abort (explicit no-deadline opt-out).
- New regression tests covering hung-abort, long-budget success, caller
  abort winning, timeout firing alongside a caller signal, and the compose
  helper.

Signed-off-by: Harsh Thakkar <harsh-thakkar7@users.noreply.github.com>
@petertzy

Copy link
Copy Markdown
Owner

Thanks for addressing the earlier timeout review and cleaning up the export file descriptors. I found one remaining timeout gap: the deadline was cleared as soon as fetch() received response headers, so a stalled JSON or Blob response body could still hang indefinitely. I fixed this in f76c86f; the deadline now stays active until the API response body has been read. I also made the timeout tests independent of a local HTTP port.

After the fix, all 16 frontend tests, ESLint, TypeScript, Ruff checks on the changed backend files, and the diff check pass.

@petertzy
petertzy merged commit 2838463 into petertzy:main Sep 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants