Skip to content

fix: poll the rootCID that was actually deployed after a catalyst retry - #1496

Merged
cyaiox merged 1 commit into
mainfrom
fix/poll-deployed-rootcid-after-catalyst-retry
Aug 17, 2026
Merged

fix: poll the rootCID that was actually deployed after a catalyst retry#1496
cyaiox merged 1 commit into
mainfrom
fix/poll-deployed-rootcid-after-catalyst-retry

Conversation

@RocioCM

@RocioCM RocioCM commented Aug 14, 2026

Copy link
Copy Markdown
Member

Problem

Publishing a scene to LAND could sit in "Publishing..." indefinitely — the upload step never ticked over to success, and no error was ever shown. The scene was actually live in-world the whole time.

Repro: publish to LAND when the first catalyst rejects the upload. In the observed case the initial /deploy returned 400 { message: "Could not upload content: <html>...404 Not Found..." } from peer.decentral.io, then again from peer-ap1.decentraland.org, and finally succeeded on peer.uadevops.com.

Cause

When a /deploy attempt fails, the deploy thunk selects another catalyst and dispatches updateDeploymentTarget, which respawns the CLI deploy server and rebuilds the scene. That rebuild is not byte-identical (autogenerated thumbnail, build outputs), so each retry yields a different rootCID:

attempt target rootCID
1 (initial) bafkreib3uzdc…vfjqzy
2 peer-ap1 bafkreidveasa…ov7ju
3 ✅ peer.uadevops.com bafkreigie7tx…kliry

The thunk tracked the new info internally via currentInfo, but returned deployFn's result — which is void — so it never reported which entity actually landed.

Meanwhile executeDeployment destructured info from the deployment before dispatching the deploy, and its fetchStatus closure captured that stale value. After the successful third attempt it polled the asset-bundle-registry for the attempt-1 rootCID — an entity never deployed anywhere — and got a 404 forever.

Why it hung rather than failing: checkDeploymentStatus treats a throwing fetchStatus as a soft retry, so componentsStatus stayed at all-idle. deriveOverallStatus of all-idle is 'idle', never 'failed', so isCancelled never flipped and the abort path never fired. It burned all 360 retries × 10s — a full hour — before surfacing MAX_RETRIES.

Fix

  • deploy returns { info: currentInfo } on success, so callers know which entity actually deployed.
  • executeDeployment consumes that returned info for fetchStatus and the fulfilled payload. info is dropped from the pre-deploy destructure so the stale value is unreachable rather than merely unused.
  • Closes a fall-through at the end of the retry loop: exiting the while without deploying returned undefined and fulfilled the thunk, sending the caller off to poll a deployment that never happened. It now rejects with CATALYST_SERVERS_EXHAUSTED.

The deploy thunk is the right seam — it is the only place that knows which catalyst attempt succeeded, and it already tracked the correct info; it just wasn't handing it back.

Tests

Added a when a retry rebuilds the scene under a new rootCID block with two cases: one asserting the poller queries the deployed rootCID, one asserting the returned info is the rebuilt one.

The existing retry tests could not catch this — fetchInfo was mocked to return the same info on every call, so the CID never changed and the stale capture was indistinguishable from the correct one.

Verified these are genuine regression tests by reintroducing the bug: both fail with expected 'QmTest123' to be 'QmRebuilt456', then pass once reverted.

Full creator-hub unit suite passes (185/185: main 28, preload 17, renderer 127, shared 13). Typecheck and ESLint clean.

Not addressed here: the upstream reason catalysts return 404 Not Found on upload is server-side and outside this repo. This fix means a successful retry now completes correctly instead of hanging. Separately worth considering — a prolonged registry 404 still consumes the full 60-minute retry window before surfacing; escalating earlier would be a reasonable follow-up.

🤖 Generated with Claude Code

Publishing to LAND could hang in "Publishing..." for an hour without ever
showing success or an error, even though the scene was live in-world.

When a /deploy attempt fails, the deploy thunk picks another catalyst and
respawns the CLI deploy server, which rebuilds the scene. The rebuild is not
byte-identical (autogenerated thumbnail, build outputs), so it produces a new
rootCID. The thunk tracked that new info internally but returned deployFn's
result, which is void, so it never reported which entity actually landed.

executeDeployment destructured `info` from the deployment *before* dispatching
the deploy, and its fetchStatus closure captured that stale value. After a
successful retry it therefore polled the asset-bundle-registry for the
first attempt's rootCID -- an entity that was never deployed anywhere -- and
got a 404 forever.

checkDeploymentStatus treats a throwing fetchStatus as a soft retry, leaving
componentsStatus at all-idle. deriveOverallStatus of all-idle is 'idle', never
'failed', so the cancel path never triggered and it burned all 360 retries
(60 minutes) before surfacing MAX_RETRIES.

The deploy thunk now returns { info: currentInfo } so callers can poll the
entity that actually deployed, and executeDeployment consumes it instead of
the pre-deploy capture. `info` is dropped from the earlier destructure so the
stale value is unreachable.

Also closes a fall-through at the end of the retry loop: exiting the while
without deploying returned undefined and *fulfilled* the thunk, sending the
caller off to poll a deployment that never happened. It now rejects with
CATALYST_SERVERS_EXHAUSTED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@decentraland-bot decentraland-bot 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.

Review Summary

Excellent bug fix — well-analyzed root cause, minimal and targeted changes, and genuine regression tests that verify the fix.

What this fixes

When a catalyst /deploy attempt fails and retries against a different catalyst, the CLI server rebuilds the scene, producing a new rootCID. The old code had two problems:

  1. Stale closure in executeDeployment: info was destructured from the deployment before dispatching deploy, so the fetchStatus closure captured the pre-retry rootCID. After a successful retry, the poller queried the asset-bundle-registry for an entity that was never deployed, getting 404s forever.
  2. Void return from deploy thunk: deployFn returns void, so the thunk couldn't communicate which entity actually landed.

Analysis of the fix

deploy thunk — now returns { info: currentInfo } on success instead of deployFn's void result. This is the right seam: the thunk is the only place that tracks which catalyst attempt succeeded via its local currentInfo variable, which is updated on each retry via updateDeploymentTarget.

executeDeployment thunk — drops info from the pre-deploy destructure (eliminating the stale binding entirely) and destructures it from the deploy result instead. The fetchStatus closure now captures the correct post-deploy info.

Defensive rejectWithValue after while-loop — unreachable in practice (the loop always exits via return or rejectWithValue inside the catch), but prevents the function from silently fulfilling with undefined if the control flow ever changes. Good defensive coding.

Correctness verification

  • currentInfo tracks the correct info throughout the retry loop — starts as deployment.info, updated to result.info after each updateDeploymentTarget
  • ✅ The returned { info } is consumed correctly by executeDeployment for both the fetchStatus closure and the fulfilled payload
  • ✅ The executeDeployment.fulfilled reducer uses action.payload.info to update deployment.info in state — now receives the correct post-deploy info
  • ✅ The deploy thunk is internal (not exported in actions), so no external callers are affected by the return type change
  • ✅ If deploy rejects, .unwrap() throws before the destructure, and the catch block handles it correctly
  • ✅ Redux state is also updated via updateDeploymentTarget.fulfilled reducer during retries — no conflict with the returned info

Test quality

  • ✅ Two new targeted tests that directly verify the fix:
    • Extracts the fetchStatus callback passed to checkDeploymentStatus and verifies it calls fetchDeploymentStatus with the rebuilt rootCID
    • Verifies the returned info has the rebuilt rootCID
  • ✅ Tests are genuine regression tests — the PR description confirms they fail when the bug is reintroduced
  • fetchDeploymentStatus mock correctly added to both the vi.mock block and the import
  • REBUILT_SCENE_INFO is clearly distinguished from TEST_SCENE_INFO via a different rootCID
  • ✅ Follows existing test patterns (fake timers, advanceRetryTimers, mockDeployWithRetryOnce)

Security

No security issues found. The changes are purely internal control-flow and data-flow fixes — no new inputs, no auth changes, no secrets, no dependency changes.

Git conventions

  • ✅ PR title follows semantic commit format: fix: poll the rootCID that was actually deployed after a catalyst retry

CI Status

CI checks (lint, typecheck, unit tests) are still running at time of review.

No P0, P1, or P2 issues found. Clean, well-reasoned fix.


Reviewed by Jarvis 🤖 · Requested by RocioCM via GitHub

@github-actions

Copy link
Copy Markdown
Contributor

Test this pull request on windows-latest

Download the correct version for your architecture:

win-x64

@github-actions

Copy link
Copy Markdown
Contributor

Test this pull request on macos-latest

Download the correct version for your architecture:

mac-x64
mac-arm64

Click here if you don't know which version to download

For running this unsigned version of the app, you will need to run the xattr command on it:

  1. Extract the app from the downloaded .dmg file (double-click it)
  2. Place the extracted app anywhere you like in your file system
  3. Open a terminal on the directory where the app is
  4. Run xattr -c app-name, replacing "app-name" for the actual name of the app
  5. Double-click the app ✅

@cyaiox
cyaiox merged commit debcfda into main Aug 17, 2026
19 checks passed
@cyaiox
cyaiox deleted the fix/poll-deployed-rootcid-after-catalyst-retry branch August 17, 2026 08:16
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.

3 participants