Skip to content

fix(storage): bound 0G upload with a wall-clock timeout - #8

Closed
alkautsarf wants to merge 1 commit into
mainfrom
fix/upload-waitforlogentry-timeout
Closed

alkautsarf wants to merge 1 commit into
mainfrom
fix/upload-waitforlogentry-timeout

Conversation

@alkautsarf

Copy link
Copy Markdown
Member

The bug

The 0G SDK's Uploader.waitForLogEntry (@0gfoundation/0g-ts-sdk@1.2.6) is an unbounded while (true) poll that only exits on success. A storage node "stuck at a block" (logSyncHeight stalled) is reachable and answers zgs_getFileInfo with a fast HTTP-200 {result:null} — so it never throws, the SDK's own axios timeout never arms, and the loop spins forever.

OGStorage.putBlob drove that loop (via both indexer.upload and the discovered-nodes splitableUpload) with no timeout. So a wedged mainnet node could pin:

  • anima init (keystore upload) — first-time setup hangs forever
  • local anima sync
  • the daemon POST /sync handler

The download path in the same file was already hardened against exactly this ("a hung indexer or wedged storage node"); the upload path never got the same guard. This was flagged by a friend who hit it building a sibling 0G app, then independently validated (4-investigator + 3-skeptic adversarial pass) against the SDK source.

The fix

Mirror the download-side guard onto the write path:

  • Wrap putBlob in Promise.race against a wall-clock deadline (SDK_UPLOAD_TIMEOUT_MS = 120s, configurable via OGStorageOpts.uploadTimeoutMs). A wedged node now throws instead of hanging.
  • Swallow the abandoned upload's late rejection (inner.catch) so it can't crash the long-lived daemon as an unhandledRejection. The race still observes a rejection that lands first, so real errors propagate normally (not masked as timeouts).
  • Add AbortSignal.timeout(NODE_LIST_TIMEOUT_MS) to the indexer_getShardedNodes fetch, with a clear error translation.

New og.test.ts (5 tests): both upload paths time out, success passes through unchanged, real errors propagate (not masked), and the late rejection is swallowed without an unhandledRejection.

Verification

  • typecheck ✓ · lint ✓ · unit 1371/0 · forge 124/0 (all on the main tree)
  • Live against 0G testnet: the real upload path ran end-to-end through the wrapper + the new node-list fetch + the gas-balance error translation, and failed fast (~15s, no false-timeout) with the real actionable error — confirming the guard doesn't mask real errors or false-trip a healthy path.

Known trade-offs (documented in code) + deferred follow-ups

The SDK loop is uncancellable (no upstream AbortSignal), so a timed-out upload is abandoned, not stopped. This is bounded, rare (true wedge only), and strictly better than an unbounded hang, but it has follow-ups worth tracking separately:

  1. sync-manager doFlush atomicity — it advances lastPlaintextHash per-slot before the batched chain anchor, so a mid-flush putBlob throw (now reachable via timeout) can leave earlier slots marked-uploaded-but-unanchored until daemon restart. Pre-existing for any putBlob throw (e.g. insufficient gas); fix = advance the hash only after the anchor tx.
  2. skipIfFinalized: false on the discovered-nodes path means a timeout-then-retry re-submits storage gas even if the first tx finalized. Flip to true to let the SDK dedupe.
  3. Uncancellable detached loop — a persistent wedge leaks ~1/s pollers and can double-spend submit gas across the keystore 3x retry. Real deep fix needs upstream SDK abort support.

These are intentionally out of scope for this minimal, well-tested guard.

https://claude.ai/code/session_01JciX2J7DXGGBTDKuvk7q1K

The 0G SDK's Uploader.waitForLogEntry is an unbounded while(true) poll
that hangs forever on a reachable-but-stuck storage node (it returns
null, never throws, so the SDK's own axios timeout never arms).
OGStorage.putBlob drove that loop with no timeout, so a wedged mainnet
node could pin `anima init` (keystore upload), local `anima sync`, and
the daemon POST /sync handler indefinitely. The download path already
had this guard; the upload path did not.

Wrap putBlob in a Promise.race against a wall-clock deadline
(SDK_UPLOAD_TIMEOUT_MS=120s, configurable via uploadTimeoutMs),
mirroring downloadBlobByRoot. Swallow the abandoned upload's late
rejection so it can't crash the daemon as an unhandledRejection, and
add AbortSignal.timeout to the indexer node-list fetch. The detached
SDK loop is uncancellable (no AbortSignal upstream); documented as an
accepted, bounded trade-off vs an unbounded hang.

Adds og.test.ts: both upload paths time out, success passes through,
real errors propagate (not masked as timeouts), late rejection is
swallowed. Verified live against 0G testnet (real error in ~15s, no
false timeout).

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

vercel Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
anima Ready Ready Preview, Comment Jun 20, 2026 12:06pm

@alkautsarf

Copy link
Copy Markdown
Member Author

Subsumed by the v0.26.0 release: this work was squash-merged into main as part of commit e27f773 (web-app gateway combined release). Closing as merged-into-main.

@alkautsarf alkautsarf closed this Jun 21, 2026

This branch was successfully deployed

1 active deployment
Preview — b93c7530 Deployed Jun 20, 2026 by vercel[bot]
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.

1 participant