Skip to content

fix(seeders): guard S3-mode R2 ops + runSeed fetch-phase deadline (graceful degradation, #4786)#4789

Merged
koala73 merged 1 commit into
mainfrom
fix/seeder-r2-timeout-graceful-degradation
Jul 4, 2026
Merged

fix(seeders): guard S3-mode R2 ops + runSeed fetch-phase deadline (graceful degradation, #4786)#4789
koala73 merged 1 commit into
mainfrom
fix/seeder-r2-timeout-graceful-degradation

Conversation

@koala73

@koala73 koala73 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Closes #4786. Fixes the observed seed-forecasts crash and the whole class of "non-settling await → exit 13" failures fleet-wide, both in shared modules so all ~70 seeders benefit.

Problem

S3-mode getR2JsonObject / putR2JsonObject (scripts/_r2-storage.mjs) had no request timeout, while their Cloudflare-R2-API siblings bound every request with AbortSignal.timeout(30s). A stalled client.send, or a Body.transformToString() whose socket is silently reaped by the keep-alive agent, leaves a promise that never settles — no rejection for a try/catch to catch, no open handle to keep the loop alive. A seeder awaiting R2 at the top level (seed-forecasts reading prior trace state) then drains the event loop and Node exits 13 (Detected unsettled top-level await) — a red Railway badge that is neither a graceful skip nor a catchable failure. Production runs S3 mode ([Trace] Storage mode=s3).

Fix — two layers

1. _r2-storage.mjs — close the specific holes (every R2-reading seeder):

  • Wrap both S3 client.send calls and Body.transformToString() in a withSettleTimeout (+ per-call abortSignal). A stall now rejects → withR2Retry → the caller's existing .catch(() => fallback), so seed-forecasts degrades to "no priors this run" and still publishes.
  • Give the cached S3Client a client-level requestTimeout: 30s / connectionTimeout: 10s (verified retained by @aws-sdk/[email protected]).

2. _seed-utils.mjs runSeed — fleet-wide graceful-degradation backstop:

  • Race the Phase-1 fetch against a wall-clock deadline (lockTtlMs + margin, per-seed — never a fixed value, since seeders run from ~1min to 40min). Any non-settling await now routes into the existing graceful exit-75 path (TTL extended, last-good served, no data lost) instead of hanging into exit 13. Catches R2 and any future unguarded await.

Tests (reproduce-first)

  • tests/r2-storage-s3-timeout.test.mjs — never-settling send/transformToString now reject; happy path + 404/empty-body unchanged; putR2JsonObject covered.
  • tests/seed-runseed-fetch-deadline.test.mjs — a hanging fetchFn exits 75 (not 13) in ~55ms; an ordinary rejection still takes the same graceful path.
  • All new + existing _seed-utils/_r2-storage/seed-contract tests pass (147 tests) under tsx --test.

Blast radius

Both edits are additive (new exports, one optional opt, transparent wrappers) — no existing signature changes. Happy paths and the pre-existing graceful/SIGTERM paths are unchanged and covered.

https://claude.ai/code/session_01HUHf9mxHGA2ujGvBAQ8m2v

…#4786)

S3-mode getR2JsonObject/putR2JsonObject had no request timeout while their
Cloudflare-R2-API siblings bound every request with AbortSignal.timeout(30s).
A stalled client.send, or a Body.transformToString() whose socket is silently
reaped by the keep-alive agent, leaves a promise that NEVER settles — no
rejection for a try/catch to catch, no open handle to keep the loop alive — so
a seeder awaiting R2 at the top level (seed-forecasts prior-trace reads) drains
the event loop and Node exits 13 ("Detected unsettled top-level await"): a red
Railway badge that is neither a graceful skip nor a catchable failure.

Two layers, both in shared modules so every seeder benefits:

- _r2-storage.mjs: wrap both S3 sends + transformToString in a settle-timeout
  (+ per-call abortSignal) and give the cached S3Client a client-level
  requestTimeout/connectionTimeout. A stall now rejects -> withR2Retry ->
  caller fallback, instead of hanging. Fixes every R2-reading seeder.
- _seed-utils.mjs runSeed: race the fetch phase against a wall-clock deadline
  (lockTtlMs + margin, per-seed -- never a fixed value, since seeders run from
  ~1min to 40min) so ANY non-settling await routes into the existing graceful
  exit-75 path (TTL extended, last-good served, no data lost). Fleet-wide
  backstop for the whole dangling-await class.

Tests reproduce each hang (never-settling send/transformToString, hanging
fetchFn) and prove graceful settle; the ordinary-rejection path still exits 75.

Claude-Session: https://claude.ai/code/session_01HUHf9mxHGA2ujGvBAQ8m2v
@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 4, 2026 5:54pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes a fleet-wide exit-13 crash in Railway-deployed seeders by adding two independent layers of timeout protection: S3-mode R2 operations in _r2-storage.mjs are now wrapped with withSettleTimeout (plus a client-level requestHandler timeout), and runSeed in _seed-utils.mjs races the entire fetch phase against a wall-clock deadline tied to lockTtlMs, routing any non-settling hang into the existing graceful exit-75 path.

  • _r2-storage.mjs: Adds withSettleTimeout around both client.send and Body.transformToString() in S3-mode GET/PUT; adds requestHandler: { requestTimeout, connectionTimeout } to the cached S3Client; exports two test-injection hooks backed by module-level globals.
  • _seed-utils.mjs: Adds raceFetchDeadline and a fetchPhaseTimeoutMs opt; wraps withRetry(fetchFn) in the deadline race so any unguarded hanging await surfaces as a retryable rejection rather than a process hang.
  • Both new test files reproduce the original hang scenarios with synthetic timeouts and verify the graceful exit-75 outcome.

Confidence Score: 4/5

Safe to merge — all changed paths are additive wrappers around existing logic, and happy paths are unchanged and covered by tests.

The fix is well-reasoned and the tests genuinely reproduce the bug. The main things worth a second look are the default 240 s fetch-phase deadline (which could silently keep serving stale data for any seeder that has a legitimately long fetch phase but uses the default lock TTL), the duplicated timeout-race implementation across two modules, and the test-injection hooks baked into the production export surface.

scripts/_seed-utils.mjs — the default fetchDeadlineMs calculation warrants a cross-fleet check that no seeder's legitimate fetch phase exceeds lockTtlMs + 120s with the default TTL.

Important Files Changed

Filename Overview
scripts/_r2-storage.mjs Adds withSettleTimeout wrapper + AbortSignal + client-level requestHandler timeouts to both S3-mode R2 ops; also exports test-injection hooks as module-level globals. Logic is sound; minor redundancy between the three timeout layers on client.send and a test-global anti-pattern are the only concerns.
scripts/_seed-utils.mjs Adds raceFetchDeadline fleet backstop that races withRetry(fetchFn) against lockTtlMs+120s; the timeout logic is correct and the graceful exit-75 path is well-covered. The default deadline could be tighter than expected for seeders that legitimately extend their locks during fetch, worth a cross-fleet audit.
tests/r2-storage-s3-timeout.test.mjs Thorough regression tests covering all four cases (hang on send, hang on body, happy path, NoSuchKey/404). Uses fast synthetic timeouts (10ms) so the suite runs quickly. Test hooks are cleaned up reliably in afterEach.
tests/seed-runseed-fetch-deadline.test.mjs Validates both the non-settling-hang → exit-75 path and the ordinary rejection → exit-75 path; mocks Redis and process.exit cleanly with proper before/after teardown.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant S as runSeed (Phase 1)
    participant RFD as raceFetchDeadline<br/>(lockTtlMs + 120s)
    participant WR as withRetry(fetchFn)
    participant R2 as getR2JsonObject S3
    participant WST1 as withSettleTimeout<br/>(client.send, 30s)
    participant WST2 as withSettleTimeout<br/>(transformToString, 30s)
    participant SDK as @aws-sdk/client-s3
    participant CF as Cloudflare R2

    S->>RFD: race fetch vs wall-clock deadline
    RFD->>WR: start retryable fetch
    WR->>R2: call fetchFn
    R2->>WST1: wrap client.send + AbortSignal.timeout(30s)
    WST1->>SDK: send(GetObjectCommand)
    SDK->>CF: HTTP GET

    alt Happy path
        CF-->>SDK: 200 OK + streaming Body
        SDK-->>WST1: response
        WST1-->>WST1: clearTimeout(guard)
        WST1->>WST2: wrap Body.transformToString()
        WST2-->>R2: string body
        R2-->>WR: parsed JSON
        WR-->>RFD: data
        RFD-->>RFD: clearTimeout(deadline guard)
        RFD-->>S: data → proceed to publish
    else "S3 socket silently reaped (the #4786 bug)"
        Note over SDK,CF: keep-alive socket reaped, no rejection
        WST1-->>WST1: guard timer fires at 30s
        WST1->>R2: Error("timed out after 30ms")
        Note over R2: isRetryableR2Error matches "timed out" → retry
        WR-->>WR: withR2Retry up to 3x then throw
        RFD-->>S: reject → catch → graceful exit 75
    else All layers hang (fleet backstop)
        Note over WR,CF: even retried calls never settle
        RFD-->>RFD: deadline guard fires at lockTtlMs+120s
        RFD-->>S: reject → catch → graceful exit 75
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant S as runSeed (Phase 1)
    participant RFD as raceFetchDeadline<br/>(lockTtlMs + 120s)
    participant WR as withRetry(fetchFn)
    participant R2 as getR2JsonObject S3
    participant WST1 as withSettleTimeout<br/>(client.send, 30s)
    participant WST2 as withSettleTimeout<br/>(transformToString, 30s)
    participant SDK as @aws-sdk/client-s3
    participant CF as Cloudflare R2

    S->>RFD: race fetch vs wall-clock deadline
    RFD->>WR: start retryable fetch
    WR->>R2: call fetchFn
    R2->>WST1: wrap client.send + AbortSignal.timeout(30s)
    WST1->>SDK: send(GetObjectCommand)
    SDK->>CF: HTTP GET

    alt Happy path
        CF-->>SDK: 200 OK + streaming Body
        SDK-->>WST1: response
        WST1-->>WST1: clearTimeout(guard)
        WST1->>WST2: wrap Body.transformToString()
        WST2-->>R2: string body
        R2-->>WR: parsed JSON
        WR-->>RFD: data
        RFD-->>RFD: clearTimeout(deadline guard)
        RFD-->>S: data → proceed to publish
    else "S3 socket silently reaped (the #4786 bug)"
        Note over SDK,CF: keep-alive socket reaped, no rejection
        WST1-->>WST1: guard timer fires at 30s
        WST1->>R2: Error("timed out after 30ms")
        Note over R2: isRetryableR2Error matches "timed out" → retry
        WR-->>WR: withR2Retry up to 3x then throw
        RFD-->>S: reject → catch → graceful exit 75
    else All layers hang (fleet backstop)
        Note over WR,CF: even retried calls never settle
        RFD-->>RFD: deadline guard fires at lockTtlMs+120s
        RFD-->>S: reject → catch → graceful exit 75
    end
Loading

Reviews (1): Last reviewed commit: "fix(seeders): guard S3-mode R2 ops + add..." | Re-trigger Greptile

Comment thread scripts/_seed-utils.mjs
Comment on lines +1251 to +1260
export function raceFetchDeadline(promise, ms, label) {
let timer;
const guard = new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} fetch phase exceeded ${ms}ms deadline (likely a non-settling await — see issue #4786)`)),
ms,
);
});
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 raceFetchDeadline duplicates withSettleTimeout

raceFetchDeadline (here) and withSettleTimeout (in _r2-storage.mjs) are structurally identical — both race a promise against a setTimeout guard and clear the timer in finally. If the timeout behaviour ever needs to change (e.g. unref the timer, adjust the error format) the fix has to be applied in two places. Extracting the shared pattern into a single helper in a shared utility module (e.g. _async-utils.mjs) would keep the two call sites as thin wrappers that only supply a label.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread scripts/_r2-storage.mjs
Comment on lines +28 to +29
function __setS3ClientForTests(client) { _s3ClientOverride = client; }
function __setR2S3TimeoutForTests(ms) { _s3TimeoutMs = ms == null ? R2_S3_TIMEOUT_MS : ms; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test-injection hooks exported from production module

__setS3ClientForTests and __setR2S3TimeoutForTests mutate module-level globals (_s3ClientOverride, _s3TimeoutMs) and are part of the module's public export surface. If any consuming code accidentally imports and calls these outside of a test context (or if a test suite fails before afterEach cleans up and the module is shared across test files in the same process), the global state silently persists for all subsequent calls. A comment or a runtime guard (e.g. checking process.env.NODE_ENV) would make the test-only intent explicit at the call site, or these could be scoped behind a secondary test-only entry point.

Comment thread scripts/_r2-storage.mjs
Comment on lines +216 to +227
await withSettleTimeout(
client.send(new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: body,
ContentType: 'application/json; charset=utf-8',
CacheControl: 'no-store',
Metadata: metadata,
}), { abortSignal: AbortSignal.timeout(_s3TimeoutMs) }),
_s3TimeoutMs,
`R2 s3 put ${key}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Three concurrent timeouts on client.send, all at the same deadline

client.send is guarded by (1) the client-level requestHandler.requestTimeout: 30s, (2) AbortSignal.timeout(_s3TimeoutMs) passed as the SDK options argument, and (3) the withSettleTimeout wrapper — all at the same _s3TimeoutMs value. Layers 1 and 2 both cause client.send to reject with an abort/timeout error when they fire, so the withSettleTimeout race guard is effectively redundant for the send call (though it is genuinely necessary for transformToString(), which has no abort-signal path). The only scenario where withSettleTimeout adds value on send is an SDK bug that ignores the abort signal, which is unlikely. Consider removing the AbortSignal.timeout argument from client.send so each layer guards a distinct failure mode, keeping the code easier to reason about.

Comment thread scripts/_seed-utils.mjs
Comment on lines +1373 to +1375
const fetchDeadlineMs = Number.isFinite(fetchPhaseTimeoutMs) && fetchPhaseTimeoutMs > 0
? fetchPhaseTimeoutMs
: lockTtlMs + FETCH_PHASE_DEADLINE_MARGIN_MS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Default fetchDeadlineMs assumes lockTtlMs upper-bounds the fetch phase

When no fetchPhaseTimeoutMs is given, the deadline is lockTtlMs + 120s. The default lockTtlMs is 120 s, so any seeder that omits both opts gets a 240 s fetch-phase budget. The PR's invariant ("a healthy seeder never outlives its lock") holds if every long-running seeder sets a lockTtlMs proportional to its expected fetch time — but this isn't enforced at config time and is easy to miss when adding new seeders. A seeder with a fetch phase that legitimately runs beyond 4 minutes using the default lockTtlMs would now exit 75 on every run, silently serving stale data indefinitely. It's worth scanning the ~70 seeder files to confirm that every seeder whose fetch phase can exceed lockTtlMs + 120s explicitly overrides one or both opts, or surface a console warning when the derived deadline looks implausibly short.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@koala73
koala73 merged commit 1c56713 into main Jul 4, 2026
26 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.

fix(seed-forecasts): S3-mode getR2JsonObject lacks a timeout → rare 'unsettled top-level await' (exit 13) crash

1 participant