#22903 lands a recovery flow that discovers and persists wpApiRestUrl for sites whose row landed in the DB with NULL. The persist works, but on Atomic / Jetpack-WPCom-REST sites the recovered value is clobbered back to NULL on every app foreground — the in-memory heal lives only for the current session.
Background
#22903 fixes the underlying gap (WP.com /me/sites omits the field; headless mint goes through the Jetpack tunnel and skips discovery). The recovery's SiteApiRestUrlRecoverer.persistApiRootUrl writes via SiteSqlUtils.updateWpApiRestUrl — a targeted single-column UPDATE. That part works correctly.
The remaining bug is that other FluxC writers do a full-row UPDATE via insertOrUpdateSite → WellSql.update().put(model, UpdateAllExceptId(...)), which writes every column including WP_API_REST_URL. If the in-memory SiteModel passed to that write has a stale (NULL) value for wpApiRestUrl, the full-row write clobbers whatever the recovery just persisted.
The race
Reported by @oguzkocer with a logcat reproduction on quick-six.jurassic.ninja (Atomic site, manually NULL'd the column, killed and relaunched):
T+0: SiteAction-FETCH_SITES dispatched
T+679: A_P: Hiding card for https://quick-six.jurassic.ninja - authenticated
T+1380: Persisted wpApiRestUrl=https://quick-six.jurassic.ninja/wp-json/ for localId=9
T+1418: Site found by (local) ID: 9
T+1420: Updating site: https://quick-six.jurassic.ninja
After this sequence the DB column is NULL again.
What we tried in #22903
1. Preserve wpApiRestUrl from DB in createOrUpdateSites (reverted)
Hypothesis: the clobber came from FETCH_SITES → createOrUpdateSites (SiteStore.kt:1773). The existing preservation block in that function was gated on encrypted creds being present; moving wpApiRestUrl preservation outside the gate seemed like it'd plug the race.
Why it doesn't work: createOrUpdateSites receives sites from siteResponseToSiteModel (SiteRestClient.kt:1067), which constructs SiteModel() with id=0. insertOrUpdateSite's "Site found by (local) ID" log only fires when the inbound site's local id matches an existing row — it can't be 0. The handler that produced Oguz's "(local) ID: 9" log is somewhere else entirely.
2. Skip the persist for WPCom-REST sites in the slice (reverted)
Hypothesis: if we don't persist, we can't be clobbered. The slice's healApiRestUrlIfMissing would do an in-memory mutation only, and re-discover on next launch.
Why it doesn't work: the DB-level user-visible state is identical (NULL either way). The commit is honest about what it's doing — avoiding a pointless write — but it doesn't change the user-facing outcome and doesn't address the actual clobber.
Likely actual culprit
FETCH_SITE → SiteStore.updateSite (SiteStore.kt:1650-1677) is the strongest candidate. Two dispatches fire on every app foreground:
AppInitializer.kt:919 — updateSelectedSite.runIfNotLimited() → getSiteByLocalId(...) + newFetchSiteAction(site). The rate-limit is non-persistent so it always fires on relaunch.
WPMainActivity.java:1067 onResume → setSelectedSite → newFetchSiteAction.
SiteRestClient.fetchSite constructs a fresh SiteModel from the /sites/<id> response (which doesn't include wpApiRestUrl), explicitly stamps newSite.id = site.id, then returns. updateSite has the same buggy preservation gate as createOrUpdateSites — preserves wpApiRestUrl only when encrypted creds are present, and even when it does fire, the preservation is subject to the same TOCTOU between getSiteByLocalId and insertOrUpdateSite.
Not confirmed without further on-device instrumentation, but the log signature matches.
Other clobber sources worth investigating
ReactNativeStore.persistSiteSafely — actively sets wpApiRestUrl = null on a 404 path from RN. High likelihood on Atomic sites since the block editor uses RN.
CookieNonceAuthenticator — same 404 clobber pattern.
SiteStore.designateMobileEditor, updateSiteEditors, updateApplicationPassword, designateMobileEditorForAllSites, onAllSitesMobileEditorChanged — each does a full-row insertOrUpdateSite write that can clobber if the in-memory site is stale.
Recommended fix direction
The structurally correct fix is to exclude WP_API_REST_URL from the UpdateAllExceptId mapper for SiteModel — either by extending UpdateAllExceptId (libs/fluxc/.../persistence/UpdateAllExceptId.java) to accept additional columns to skip, or by introducing a sibling mapper that excludes both _id and WP_API_REST_URL.
With this in place, SiteSqlUtils.updateWpApiRestUrl (the targeted single-column UPDATE introduced in #22903) becomes the sole writer for that column. Every other writer ignores it. No more clobbers, no per-handler preservation logic, no TOCTOU window.
The work:
- New / extended mapper (~25 lines).
- Route every
insertOrUpdateSite UPDATE through it (already centralised in SiteSqlUtils.insertOrUpdateSite).
- Tests for the mapper and a regression test mimicking Oguz's race (concurrent
updateWpApiRestUrl and a FETCH_SITE round-trip — verify the URL survives).
Scope: a few files, ~50 lines net. Deserves its own PR with focused review.
Related
#22903 lands a recovery flow that discovers and persists
wpApiRestUrlfor sites whose row landed in the DB with NULL. The persist works, but on Atomic / Jetpack-WPCom-REST sites the recovered value is clobbered back to NULL on every app foreground — the in-memory heal lives only for the current session.Background
#22903 fixes the underlying gap (WP.com
/me/sitesomits the field; headless mint goes through the Jetpack tunnel and skips discovery). The recovery'sSiteApiRestUrlRecoverer.persistApiRootUrlwrites viaSiteSqlUtils.updateWpApiRestUrl— a targeted single-column UPDATE. That part works correctly.The remaining bug is that other FluxC writers do a full-row UPDATE via
insertOrUpdateSite→WellSql.update().put(model, UpdateAllExceptId(...)), which writes every column includingWP_API_REST_URL. If the in-memorySiteModelpassed to that write has a stale (NULL) value forwpApiRestUrl, the full-row write clobbers whatever the recovery just persisted.The race
Reported by @oguzkocer with a logcat reproduction on
quick-six.jurassic.ninja(Atomic site, manually NULL'd the column, killed and relaunched):After this sequence the DB column is NULL again.
What we tried in #22903
1. Preserve
wpApiRestUrlfrom DB increateOrUpdateSites(reverted)Hypothesis: the clobber came from
FETCH_SITES→createOrUpdateSites(SiteStore.kt:1773). The existing preservation block in that function was gated on encrypted creds being present; movingwpApiRestUrlpreservation outside the gate seemed like it'd plug the race.Why it doesn't work:
createOrUpdateSitesreceives sites fromsiteResponseToSiteModel(SiteRestClient.kt:1067), which constructsSiteModel()withid=0.insertOrUpdateSite's "Site found by (local) ID" log only fires when the inbound site's local id matches an existing row — it can't be0. The handler that produced Oguz's "(local) ID: 9" log is somewhere else entirely.2. Skip the persist for WPCom-REST sites in the slice (reverted)
Hypothesis: if we don't persist, we can't be clobbered. The slice's
healApiRestUrlIfMissingwould do an in-memory mutation only, and re-discover on next launch.Why it doesn't work: the DB-level user-visible state is identical (NULL either way). The commit is honest about what it's doing — avoiding a pointless write — but it doesn't change the user-facing outcome and doesn't address the actual clobber.
Likely actual culprit
FETCH_SITE→SiteStore.updateSite(SiteStore.kt:1650-1677) is the strongest candidate. Two dispatches fire on every app foreground:AppInitializer.kt:919—updateSelectedSite.runIfNotLimited()→getSiteByLocalId(...)+newFetchSiteAction(site). The rate-limit is non-persistent so it always fires on relaunch.WPMainActivity.java:1067onResume→setSelectedSite→newFetchSiteAction.SiteRestClient.fetchSiteconstructs a freshSiteModelfrom the/sites/<id>response (which doesn't includewpApiRestUrl), explicitly stampsnewSite.id = site.id, then returns.updateSitehas the same buggy preservation gate ascreateOrUpdateSites— preserveswpApiRestUrlonly when encrypted creds are present, and even when it does fire, the preservation is subject to the same TOCTOU betweengetSiteByLocalIdandinsertOrUpdateSite.Not confirmed without further on-device instrumentation, but the log signature matches.
Other clobber sources worth investigating
ReactNativeStore.persistSiteSafely— actively setswpApiRestUrl = nullon a 404 path from RN. High likelihood on Atomic sites since the block editor uses RN.CookieNonceAuthenticator— same 404 clobber pattern.SiteStore.designateMobileEditor,updateSiteEditors,updateApplicationPassword,designateMobileEditorForAllSites,onAllSitesMobileEditorChanged— each does a full-rowinsertOrUpdateSitewrite that can clobber if the in-memory site is stale.Recommended fix direction
The structurally correct fix is to exclude
WP_API_REST_URLfrom theUpdateAllExceptIdmapper forSiteModel— either by extendingUpdateAllExceptId(libs/fluxc/.../persistence/UpdateAllExceptId.java) to accept additional columns to skip, or by introducing a sibling mapper that excludes both_idandWP_API_REST_URL.With this in place,
SiteSqlUtils.updateWpApiRestUrl(the targeted single-column UPDATE introduced in #22903) becomes the sole writer for that column. Every other writer ignores it. No more clobbers, no per-handler preservation logic, no TOCTOU window.The work:
insertOrUpdateSiteUPDATE through it (already centralised inSiteSqlUtils.insertOrUpdateSite).updateWpApiRestUrland aFETCH_SITEround-trip — verify the URL survives).Scope: a few files, ~50 lines net. Deserves its own PR with focused review.
Related
wpApiRestUrlduring editor preload #22903 — original recovery PR (in-memory heal ships; durable DB heal is this issue)wpApiRestUrlduring editor preload viaWpLoginClient.apiDiscovery#22899 — the original "missing wpApiRestUrl" bug