Skip to content

fix: resolve tx.onceSealed() hanging indefinitely - #2793

Merged
jribbink merged 3 commits into
onflow:masterfrom
lmcmz:fix/tx-once-sealed-hang
Mar 25, 2026
Merged

jribbink merged 3 commits into
onflow:masterfrom
lmcmz:fix/tx-once-sealed-hang

Conversation

@lmcmz

@lmcmz lmcmz commented Mar 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fix fcl.tx(txId).onceSealed() hanging indefinitely when using legacy sdk.transport (send function) configuration.

Root Cause

The legacy polling actor in legacy-polling.js leaks mutable references to its internal KVS via ctx.all(). When createObservable (introduced in #2258) stores this reference as value and later compares it with isDiff(), the deepEqual check short-circuits on a === b (same object reference) because ctx.merge(tx) has already mutated the KVS in-place between broadcast and subscriber processing. Every status update after the first has isDiff = false, so subscribers are never notified.

Fix

Clone objects before sending them to subscribers — spread ctx.all() and tx in the SUBSCRIBE, SNAPSHOT, and POLL handlers so consumers get their own copies.

Test plan

  • Verified onceSealed() and subscribe() work with sdk.transport: httpSend on both testnet and mainnet
  • Verified WebSocket streaming path (default transport) still works

Closes #2792

@lmcmz
lmcmz requested a review from a team as a code owner March 23, 2026 14:47
@vercel

vercel Bot commented Mar 23, 2026

Copy link
Copy Markdown

@zenabot27 is attempting to deploy a commit to the Flow Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Mar 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e5011ff

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@onflow/fcl-core Patch
@onflow/fcl-react-native Patch
@onflow/fcl-wc Patch
@onflow/fcl Patch
@onflow/fcl-ethereum-provider Patch
@onflow/fcl-rainbowkit-adapter Patch
@onflow/fcl-wagmi-adapter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@lmcmz
lmcmz force-pushed the fix/tx-once-sealed-hang branch 3 times, most recently from 3aee7bb to 2d5a56b Compare March 23, 2026 15:38
@jribbink

jribbink commented Mar 23, 2026 •

Copy link
Copy Markdown
Contributor

Hi @lmcmz, I cannot reproduce this bug. Here is a branch, and summary from tests that I had done by Claude showing my findings:

# FCL REST Network Subscription Test Results

**Date:** 2026-03-23
**Package:** `@onflow/fcl`
**Networks:** Testnet (`rest-testnet.onflow.org`), Mainnet (`rest-mainnet.onflow.org`)

All 4 tests **PASS** — both `fcl.tx(txId).onceSealed()` and `fcl.tx(txId).subscribe()` work correctly on both networks.

```
PASS src/__tests__/rest-network.test.ts (32.051 s)
  REST Testnet
    ✓ mutate empty transaction and onceSealed (6276 ms)
    ✓ mutate empty transaction and subscribe (9022 ms)
  REST Mainnet
    ✓ mutate empty transaction and onceSealed (6121 ms)
    ✓ mutate empty transaction and subscribe (7977 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
```

---

## Testnet — `onceSealed`

```
[testnet/onceSealed] txId: 42c1bdbddfac1ca2cfa7d23b02d49aa7dd305bcb3bfa89fc1fa59476cb41ff65
[testnet/onceSealed] sealed with TransactionError: [Error Code: 1007] error caused by: 1 error occurred:
	* checking sequence number failed: [Error Code: 1007] invalid proposal key: public key 0 on account 0855b236d47fdf31 has sequence number 8, but given 7
```

> Transaction was submitted, subscribed via `onceSealed()`, and reached sealed status. The on-chain error (sequence number mismatch from rapid test runs) confirms the subscription pipeline works end-to-end.

## Testnet — `subscribe`

```
[testnet/subscribe] txId: e331c01167ab04356e69c3afb165061049a84d8087dccdcc8407c8bb00876627
[testnet/subscribe] status: 0 statusCode: 0 errorMessage: (none)
[testnet/subscribe] status: 1 statusCode: 0 errorMessage: (none)
[testnet/subscribe] status: 2 statusCode: 0 errorMessage: (none)
[testnet/subscribe] status: 3 statusCode: 1 errorMessage: [Error Code: 1118] payer 0855b236d47fdf31 has insufficient balance to attempt transaction execution (required balance: 0.00100000)
[testnet/subscribe] status: 4 statusCode: 1 errorMessage: [Error Code: 1118] payer 0855b236d47fdf31 has insufficient balance to attempt transaction execution (required balance: 0.00100000)
[testnet/subscribe] received 5 status updates: [0, 1, 2, 3, 4]
```

> Full status progression received via `subscribe()`: UNKNOWN(0) → PENDING(1) → FINALIZED(2) → EXECUTED(3) → SEALED(4). All 5 updates delivered in order.

---

## Mainnet — `onceSealed`

```
[mainnet/onceSealed] txId: 21b4a8aa1066f75c9173484c4c012889cb0e239214882ab535eb7884a3c90f11
[mainnet/onceSealed] sealed with TransactionError: [Error Code: 1007] error caused by: 1 error occurred:
	* checking sequence number failed: [Error Code: 1007] invalid proposal key: public key 0 on account b3244809ffc1f3e9 has sequence number 8, but given 7
```

> Transaction was submitted, subscribed via `onceSealed()`, and reached sealed status on mainnet.

## Mainnet — `subscribe`

```
[mainnet/subscribe] txId: 4b7147bbfbd4c3de80b4a3e56b1cafd035d8c56e33c619a5e0d3bedb599e10bf
[mainnet/subscribe] status: 0 statusCode: 0 errorMessage: (none)
[mainnet/subscribe] status: 1 statusCode: 0 errorMessage: (none)
[mainnet/subscribe] status: 2 statusCode: 0 errorMessage: (none)
[mainnet/subscribe] status: 3 statusCode: 1 errorMessage: [Error Code: 1118] payer b3244809ffc1f3e9 has insufficient balance to attempt transaction execution (required balance: 0.00100000)
[mainnet/subscribe] status: 4 statusCode: 1 errorMessage: [Error Code: 1118] payer b3244809ffc1f3e9 has insufficient balance to attempt transaction execution (required balance: 0.00100000)
[mainnet/subscribe] received 5 status updates: [0, 1, 2, 3, 4]
```

> Full status progression received via `subscribe()` on mainnet: UNKNOWN(0) → PENDING(1) → FINALIZED(2) → EXECUTED(3) → SEALED(4). All 5 updates delivered in order.

---

## Summary

| Network | Method | Status | Updates received |
|---------|--------|--------|-----------------|
| Testnet | `onceSealed()` | PASS | Sealed |
| Testnet | `subscribe()` | PASS | 5 (0→1→2→3→4) |
| Mainnet | `onceSealed()` | PASS | Sealed |
| Mainnet | `subscribe()` | PASS | 5 (0→1→2→3→4) |

Both subscription methods (`onceSealed` and `subscribe`) work correctly on both REST testnet and REST mainnet. The on-chain transaction errors (insufficient balance / sequence number) are expected from unfunded test accounts and do not affect the subscription functionality being tested.

@lmcmz

lmcmz commented Mar 25, 2026

Copy link
Copy Markdown
Contributor Author

Hi @jribbink, thanks for testing! The reason you can't reproduce it is that your test uses the default transport (no sdk.transport config), which goes through the WebSocket streaming path — that path works fine.

The bug is in the legacy polling fallback, which only triggers when sdk.transport is set to the old send function:

import { send as httpSend } from "@onflow/transport-http"

fcl.config()
  .put("accessNode.api", "https://rest-testnet.onflow.org")
  .put("sdk.transport", httpSend)  // ← triggers the broken fallback path

With this config:

  1. WebSocket subscribe fails with SubscriptionsNotSupportedError
  2. onError correctly calls fallbackLegacyPolling()
  3. The actor-based polling in legacy-polling.js starts polling
  4. First poll succeeds (status=1 PENDING), subscriber gets notified
  5. Subsequent polls silently stop notifying the subscriber — the actor system drops the callback chain

Your test never hits this path because without sdk.transport set to send, WebSocket streaming works and the fallback is never invoked.

To reproduce, just add sdk.transport: httpSend to your test config — onceSealed() will hang indefinitely for fresh transactions.

@lmcmz

lmcmz commented Mar 25, 2026

Copy link
Copy Markdown
Contributor Author

Also worth noting — sdk.transport: send was the officially recommended configuration prior to the httpTransport object-style export added in v1.13.0.

The gRPC transport README still shows this exact pattern:

import { send as transportGRPC } from "@onflow/transport-grpc"
config({ "sdk.transport": transportGRPC })

And the SDK changelog uses .put("sdk.transport", httpSend) as examples.

A quick GitHub search shows many repos still use this configuration:
https://github.com/search?q=put%28%22sdk.transport%22%2C+httpSend%29&type=code

So this is a real regression affecting existing users who haven't migrated to the new httpTransport object-style export.

@jribbink
jribbink force-pushed the fix/tx-once-sealed-hang branch from 2d5a56b to a3ccfdb Compare March 25, 2026 16:09
…rence bug

The legacy polling actor's SUBSCRIBE handler sends ctx.all() (its internal
KVS object) by reference, and the POLL handler mutates it via ctx.merge().
When the new createObservable wrapper (introduced in onflow#2258) stores this
reference as `value` and later compares with isDiff(), the deepEqual check
short-circuits on `a === b` (same object reference) and always returns
false — so status updates are never delivered to subscribers.

Fix: spread the objects before sending/broadcasting so consumers get
their own copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jribbink
jribbink force-pushed the fix/tx-once-sealed-hang branch from a3ccfdb to 394e1ef Compare March 25, 2026 16:11
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jribbink

Copy link
Copy Markdown
Contributor

Thanks for clarifying and identifying this bug @lmcmz.

I made some edits to your PR to simplify the fix & address the root cause (the actor was returning a shared mutable reference).

@vercel

vercel Bot commented Mar 25, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
react-sdk-demo Ready Ready Preview, Comment Mar 25, 2026 8:49pm

Request Review

@jribbink
jribbink force-pushed the fix/tx-once-sealed-hang branch 2 times, most recently from 876b93f to 60c8cb7 Compare March 25, 2026 17:11
@chasefleming
chasefleming requested a review from Copilot March 25, 2026 17:55

Copilot AI left a comment

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.

Pull request overview

Fixes a regression where fcl.tx(txId).onceSealed() / subscribe() can hang indefinitely when the transaction status stream falls back to legacy polling (legacy sdk.transport / send configuration). The change prevents the polling actor from leaking mutable references that break downstream diffing, ensuring subscribers continue to receive status updates.

Changes:

  • Emit cloned transaction status objects from legacy polling actor handlers (SUBSCRIBE, SNAPSHOT, POLL) to avoid leaking the actor’s mutable KVS reference.
  • Add a regression test ensuring the initial subscribe value is not mutated by later polls (covers the onceSealed hang scenario).
  • Add a changeset for a @onflow/fcl-core patch release.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/fcl-core/src/transaction/legacy-polling.js Clones emitted status objects to prevent reference-mutation from breaking update delivery.
packages/fcl-core/src/transaction/legacy-polling.test.js Adds regression coverage for mutation of the initial subscribe value across polls.
.changeset/fix-legacy-polling-hang.md Declares a patch release note for the legacy polling hang fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"@onflow/fcl-core": patch
---

Fix `fcl.tx().onceSealed()` hanging when using legacy `sdk.transport` polling fallback

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

The release note says fcl.tx().onceSealed(), but the API requires a transaction id argument (and the issue/PR text consistently references fcl.tx(txId).onceSealed()). Consider updating the note to avoid confusion for readers.

Suggested change
Fix `fcl.tx().onceSealed()` hanging when using legacy `sdk.transport` polling fallback
Fix `fcl.tx(txId).onceSealed()` hanging when using legacy `sdk.transport` polling fallback

Copilot uses AI. Check for mistakes.
Comment on lines 98 to 100
if (!isSealed(tx)) poll()
if (isDiff(prevTx, tx)) ctx.broadcast(UPDATED, tx)
if (isDiff(prevTx, tx)) ctx.broadcast(UPDATED, {...tx})
ctx.merge(tx)

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

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

These spreads only shallow-clone the transaction status. Nested fields (notably events) are still shared by reference with the internal actor state because ctx.merge(tx) stores tx.events directly. If the intent is to fully prevent consumers from accidentally mutating actor state (or to ensure diffing can’t be affected by nested shared references), consider deep-cloning before emitting (or at least cloning known nested fields like events).

Copilot uses AI. Check for mistakes.
Verifies subscribers receive progressive status updates through sealed,
preventing the isDiff short-circuit caused by leaked mutable references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jribbink
jribbink force-pushed the fix/tx-once-sealed-hang branch from 60c8cb7 to e5011ff Compare March 25, 2026 20:44
@jribbink
jribbink merged commit b438549 into onflow:master Mar 25, 2026
8 checks passed
@github-actions github-actions Bot mentioned this pull request Mar 25, 2026

This branch was successfully deployed

1 active deployment
Preview — e5011ff7 Deployed Mar 25, 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.

fcl.tx(txId).onceSealed() hangs indefinitely when using httpSend transport

4 participants