diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index ebf12f836c..19f4f108f2 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -8,7 +8,8 @@ import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; import { setSentryWpcomUserIdRenderer } from 'src/lib/renderer-sentry-utils'; -import { useI18nLocale } from 'src/stores'; +import { useAppDispatch, useI18nLocale } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { setWpcomClient } from 'src/stores/wpcom-api'; import type { WPCOM } from 'wpcom/types'; @@ -46,21 +47,22 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { const { __ } = useI18n(); const isOffline = useOffline(); + const dispatch = useAppDispatch(); const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); const handleInvalidToken = useCallback( async () => { try { void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); + dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); - setWpcomClient( undefined ); setUser( undefined ); setSentryWpcomUserIdRenderer( undefined ); } catch ( err ) { console.error( 'Failed to handle invalid token:', err ); } - }, [] ); + }, [ dispatch ] ); useIpcListener( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { @@ -121,16 +123,16 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { } try { + dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); - setWpcomClient( undefined ); setUser( undefined ); setSentryWpcomUserIdRenderer( undefined ); } catch ( err ) { console.error( err ); } - }, [ client, isOffline ] ); + }, [ client, dispatch, isOffline ] ); useEffect( () => { async function run() { diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index d46bd912b0..8ed14fec6c 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -344,17 +344,19 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport localSiteId: selectedSite.id, userId: user?.id, } ); - const isPulling = useRootSelector( ( state ) => + const isPullingLocally = useRootSelector( ( state ) => connectedSites.some( ( site ) => - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) + syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite.id, site.id )( state ) ) ); - const isPushing = useRootSelector( ( state ) => + // Only block import/export while the local machine is actively involved in sync. + // After the backup upload completes, the push continues remotely and should not block import/export. + const isUploadingPushBackup = useRootSelector( ( state ) => connectedSites.some( ( site ) => - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state ) + syncOperationsSelectors.selectIsSiteIdPushingLocally( selectedSite.id, site.id )( state ) ) ); - const isThisSiteSyncing = isPulling || isPushing; + const isThisSiteSyncing = isPullingLocally || isUploadingPushBackup; useEffect( () => { getIpcApi() diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index 0f049f42df..cdb2f023dd 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -18,9 +18,9 @@ export const PublishSiteButton = () => { localSiteId: selectedSite?.id, userId: user?.id, } ); - const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); - const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + const isAnySiteDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsAnySiteDoingLocalSyncWork + ); const handlePublishClick = useCallback( () => { if ( ! selectedSite ) return; @@ -36,9 +36,9 @@ export const PublishSiteButton = () => { variant="primary" icon={ cloudUpload } connectSite={ handlePublishClick } - disabled={ isAnySiteSyncing } + disabled={ isAnySiteDoingLocalSyncWork } tooltipText={ - isAnySiteSyncing + isAnySiteDoingLocalSyncWork ? __( 'Another site is syncing. Please wait for the sync to finish before you publish your site.' ) diff --git a/apps/studio/src/components/settings-site-menu.tsx b/apps/studio/src/components/settings-site-menu.tsx index 99a95edde0..d47d703a5c 100644 --- a/apps/studio/src/components/settings-site-menu.tsx +++ b/apps/studio/src/components/settings-site-menu.tsx @@ -15,18 +15,14 @@ export const SettingsMenuItem = ( { isDestructive = false, }: SettingsMenuItemProps ) => { const { isDeleting, sites, selectedSite } = useSiteDetails(); - const isPulling = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' ) - ); - const isPushing = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite?.id ?? '' ) + const isThisSiteDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite?.id ) ); if ( ! selectedSite ) { return null; } - const isThisSiteSyncing = isPulling || isPushing; const isAddingSite = sites.some( ( site ) => site.isAddingSite ); - const isDisabled = isDeleting || isThisSiteSyncing || isAddingSite; + const isDisabled = isDeleting || isThisSiteDoingLocalSyncWork || isAddingSite; return ( { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); - const isPulling = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' ) + const isPullingLocally = useRootSelector( ( state ) => + syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite?.id )( state ) ); if ( ! selectedSite ) { @@ -31,10 +31,10 @@ export const SiteManagementActions = ( { } const isImporting = isSiteImporting( selectedSite.id ); - const disabled = isImporting || isPulling; + const disabled = isImporting || isPullingLocally; let buttonLabelOnDisabled: string = __( 'Importing…' ); - if ( isPulling ) { + if ( isPullingLocally ) { buttonLabelOnDisabled = __( 'Pulling…' ); } diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index c7f0c3dc45..cab0edfd89 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -6,9 +6,20 @@ import { ValidatorEvents, } from '@studio/common/lib/import-export-events'; import { __, sprintf } from '@wordpress/i18n'; -import { createContext, useMemo, useState, useCallback, useContext } from 'react'; +import { + createContext, + useEffect, + useMemo, + useRef, + useState, + useCallback, + useContext, +} from 'react'; +import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync/sync-operations-slice'; export type ImportProgressState = { [ siteId: string ]: { @@ -65,6 +76,27 @@ const WP_CONTENT_TYPE_LABELS: Record< string, string > = { export const ImportExportProvider = ( { children }: { children: React.ReactNode } ) => { const [ importState, setImportState ] = useState< ImportProgressState >( {} ); const [ exportState, setExportState ] = useState< ExportProgressState >( {} ); + const { isAuthenticated } = useAuth(); + const isAnyPullActive = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); + + // Snapshot the latest pre-logout pull-active value while still authenticated. + // pullStates is reset synchronously on userLoggedOut, so by the time the effect + // below sees `isAuthenticated === false`, isAnyPullActive is already false. + const hadActivePullRef = useRef( false ); + if ( isAuthenticated ) { + hadActivePullRef.current = isAnyPullActive; + } + + useEffect( () => { + // On logout, only clear import/export state if no pull was in flight. + // An active pull's import phase is driven by main-process events that + // would just repopulate this state, so leave it alone and let the + // import finish. + if ( ! isAuthenticated && ! hadActivePullRef.current ) { + setImportState( {} ); + setExportState( {} ); + } + }, [ isAuthenticated ] ); const importFile = useCallback( async ( diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index 8767034f80..4bd6d3789c 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -59,22 +59,20 @@ const SyncConnectedSiteControls = ( { const isOffline = useOffline(); const dispatch = useAppDispatch(); const [ syncDialogType, setSyncDialogType ] = useState< 'pull' | 'push' | null >( null ); - const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const getLastSyncTimeText = useLastSyncTimeText(); const { user, client } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, } ); - const isAnyConnectedSiteSyncing = useRootSelector( ( state ) => - connectedSites.some( - ( site ) => - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) || - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state ) + const isAnyConnectedSiteDoingLocalSyncWork = useRootSelector( ( state ) => + connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite.id, site.id )( state ) ) ); - const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + const isAnySiteDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsAnySiteDoingLocalSyncWork + ); return (
- { isAnySiteSyncing ? ( + { isAnySiteDoingLocalSyncWork ? ( setSyncDialogType( 'pull' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isAnySiteDoingLocalSyncWork || isOffline } data-testid="sync-list-pull-button" > @@ -125,10 +122,10 @@ const SyncConnectedSiteControls = ( { ) } - { isAnySiteSyncing ? ( + { isAnySiteDoingLocalSyncWork ? ( setSyncDialogType( 'push' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isAnySiteDoingLocalSyncWork || isOffline } data-testid="sync-list-push-button" > @@ -655,11 +651,8 @@ const SyncConnectedSiteSection = ( { connectedSitesSelectors.selectIsLoadingSiteId( connectedSite.id ) ); const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected'; - const isPulling = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, connectedSite.id ) - ); - const isPushing = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, connectedSite.id ) + const isDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite.id, connectedSite.id ) ); let logo = ; @@ -690,18 +683,16 @@ const SyncConnectedSiteSection = ( { text={ __( 'This site is syncing. Please wait for the sync to finish before you can disconnect it.' ) } - disabled={ ! ( isPulling || isPushing ) || isOffline } + disabled={ ! isDoingLocalSyncWork || isOffline } placement="top-start" > diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index cd51336d66..f33ff10efa 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -10,6 +10,7 @@ import { removeConnectedWpcomSite, updateConnectedWpcomSites as updateConnectedWpcomSitesShared, } from '@studio/common/lib/connected-sites'; +import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { getCurrentUserId } from '@studio/common/lib/shared-config'; import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; @@ -409,11 +410,13 @@ export async function downloadSyncBackup( await download( downloadUrl, filePath, false, '', abortController.signal ); return filePath; } catch ( error ) { - if ( error instanceof Error && error.name === 'AbortError' ) { - // Download was cancelled, throw the error - } else { - console.error( `[Download] Download failed for operation: ${ operationId }`, error ); + // A cancelled operation (user cancel or logout cleanup) aborts this signal. That's an + // intentional stop, not a failure — return without logging or throwing so it doesn't + // surface as an error, and let the caller treat the missing path as "stopped". + if ( abortController.signal.aborted ) { + return undefined; } + console.error( `[Download] Download failed for operation: ${ operationId }`, error ); throw error; } finally { SYNC_ABORT_CONTROLLERS.delete( operationId ); @@ -422,7 +425,16 @@ export async function downloadSyncBackup( export async function removeSyncBackup( event: IpcMainInvokeEvent, remoteSiteId: number ) { const filePath = getSyncBackupTempPath( remoteSiteId ); - await fsPromises.unlink( filePath ); + try { + await fsPromises.unlink( filePath ); + } catch ( error ) { + // The backup file may never have been created — e.g. cancelling a pull that was still + // initializing the remote backup, before anything was downloaded. A missing file is + // not an error here, so only rethrow unexpected failures. + if ( ! isErrnoException( error ) || error.code !== 'ENOENT' ) { + throw error; + } + } } type WpcomSitesToConnect = { sites: SyncSite[]; localSiteId: string }[]; diff --git a/apps/studio/src/stores/auth-actions.ts b/apps/studio/src/stores/auth-actions.ts new file mode 100644 index 0000000000..bb746957ae --- /dev/null +++ b/apps/studio/src/stores/auth-actions.ts @@ -0,0 +1,3 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const userLoggedOut = createAction( 'auth/userLoggedOut' ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index a703b28645..03626a09f6 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -9,6 +9,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { appVersionApi } from 'src/stores/app-version-api'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; import { reducer as chatReducer } from 'src/stores/chat-slice'; @@ -23,13 +24,15 @@ import { import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; import { + abortAllSyncOperations, + stopPullPoller, + stopPushPoller, syncOperationsReducer, - syncOperationsSelectors, syncOperationsThunks, } from 'src/stores/sync/sync-operations-slice'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; -import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import { setWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from '@studio/common/lib/locale'; @@ -183,141 +186,19 @@ startAppListening( { }, } ); -const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; -const SYNC_POLLING_INTERVAL = 3000; - -const PUSH_POLLERS = new Map< string, AbortController >(); -const PULL_POLLERS = new Map< string, AbortController >(); - -function isPushPollable( selectedSiteId: string, remoteSiteId: number ) { - const pushState = syncOperationsSelectors.selectPushState( - selectedSiteId, - remoteSiteId - )( store.getState() ); - return pushState && PUSH_POLLING_KEYS.includes( pushState.status.key ); -} - -function isPullPollable( selectedSiteId: string, remoteSiteId: number ) { - const pullState = syncOperationsSelectors.selectPullState( - selectedSiteId, - remoteSiteId - )( store.getState() ); - return pullState?.status.key === 'in-progress' && !! pullState.backupId; -} - -function stopPushPoller( stateId: string ) { - PUSH_POLLERS.get( stateId )?.abort(); - PUSH_POLLERS.delete( stateId ); -} - -function stopPullPoller( stateId: string ) { - PULL_POLLERS.get( stateId )?.abort(); - PULL_POLLERS.delete( stateId ); -} - -async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - if ( PUSH_POLLERS.has( stateId ) ) { - return; - } - - const controller = new AbortController(); - PUSH_POLLERS.set( stateId, controller ); - - try { - while ( ! controller.signal.aborted ) { - const client = getWpcomClient(); - if ( ! client ) { - break; - } - - await store.dispatch( - syncOperationsThunks.pollPushProgress( { - client, - signal: controller.signal, - selectedSiteId, - remoteSiteId, - } ) - ); - - if ( controller.signal.aborted || ! isPushPollable( selectedSiteId, remoteSiteId ) ) { - break; - } - - await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); - } - } finally { - if ( PUSH_POLLERS.get( stateId ) === controller ) { - PUSH_POLLERS.delete( stateId ); - } - } -} - -async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - if ( PULL_POLLERS.has( stateId ) ) { - return; - } - - const controller = new AbortController(); - PULL_POLLERS.set( stateId, controller ); - - try { - while ( ! controller.signal.aborted ) { - const client = getWpcomClient(); - if ( ! client ) { - break; - } - - await store.dispatch( - syncOperationsThunks.pollPullBackup( { - client, - signal: controller.signal, - selectedSiteId, - remoteSiteId, - } ) - ); - - if ( controller.signal.aborted || ! isPullPollable( selectedSiteId, remoteSiteId ) ) { - break; - } - - await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); - } - } finally { - if ( PULL_POLLERS.get( stateId ) === controller ) { - PULL_POLLERS.delete( stateId ); - } - } -} - -// Poll push progress when state enters a pollable status +// Clear all sync state when user logs out. Slice state is reset via +// addCase(userLoggedOut) in sync-operations-slice; here we only abort the +// in-flight side effects (pollers, upload aborts, main-process operations) so +// they don't surface error UI after logout. startAppListening( { - actionCreator: syncOperationsActions.updatePushState, - effect( action ) { - const { selectedSiteId, remoteSiteId } = action.payload; - const stateId = generateStateId( selectedSiteId, remoteSiteId ); + actionCreator: userLoggedOut, + effect( action, listenerApi ) { + setWpcomClient( undefined ); - if ( isPushPollable( selectedSiteId, remoteSiteId ) ) { - void startPushPoller( selectedSiteId, remoteSiteId ); - } else { - stopPushPoller( stateId ); - } - }, -} ); + abortAllSyncOperations(); -// Poll pull backup when state has a backupId and is in-progress -startAppListening( { - actionCreator: syncOperationsActions.updatePullState, - effect( action ) { - const { selectedSiteId, remoteSiteId } = action.payload; - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - - if ( isPullPollable( selectedSiteId, remoteSiteId ) ) { - void startPullPoller( selectedSiteId, remoteSiteId ); - } else { - stopPullPoller( stateId ); - } + listenerApi.dispatch( wpcomSitesApi.util.resetApiState() ); + listenerApi.dispatch( wpcomApi.util.resetApiState() ); }, } ); diff --git a/apps/studio/src/stores/sync/connected-sites.ts b/apps/studio/src/stores/sync/connected-sites.ts index 53786891d1..712a3d1b80 100644 --- a/apps/studio/src/stores/sync/connected-sites.ts +++ b/apps/studio/src/stores/sync/connected-sites.ts @@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { RootState } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import type { SyncSite } from '@studio/common/types/sync'; import type { SyncModalMode } from 'src/modules/sync/types'; @@ -61,6 +62,9 @@ const connectedSitesSlice = createSlice( { delete state.loadingSiteIds[ action.payload ]; }, }, + extraReducers: ( builder ) => { + builder.addCase( userLoggedOut, () => getInitialState() ); + }, } ); export const connectedSitesActions = connectedSitesSlice.actions; diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 8a3e679fd1..7ed1551b5e 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -14,7 +14,9 @@ import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; +import { getWpcomClient } from 'src/stores/wpcom-api'; import type { ImportResponse, SyncSite } from '@studio/common/types/sync'; import type { PullStateProgressInfo, @@ -189,6 +191,7 @@ const syncOperationsSlice = createSlice( { }, extraReducers: ( builder ) => { builder + .addCase( userLoggedOut, () => initialState ) .addCase( pushSiteThunk.rejected, ( state, action ) => { const { connectedSite, selectedSite } = action.meta.arg; const stateId = generateStateId( selectedSite.id, connectedSite.id ); @@ -290,6 +293,135 @@ window.ipcListener.subscribe( 'sync-upload-resumed', ( _event, payload ) => { ); } ); +// Poller infrastructure — manages AbortControllers for push/pull polling loops +const PUSH_POLLERS = new Map< string, AbortController >(); +const PULL_POLLERS = new Map< string, AbortController >(); + +export function stopPushPoller( stateId: string ) { + PUSH_POLLERS.get( stateId )?.abort(); + PUSH_POLLERS.delete( stateId ); +} + +export function stopPullPoller( stateId: string ) { + PULL_POLLERS.get( stateId )?.abort(); + PULL_POLLERS.delete( stateId ); +} + +// Aborts every in-flight push/pull (pollers, push upload abort callbacks, and +// the corresponding main-process operations) without dispatching any state +// updates. Use from the userLoggedOut listener; rely on the slice's +// addCase(userLoggedOut) for state cleanup. +export function abortAllSyncOperations() { + const operationIds = new Set< string >( [ + ...PUSH_POLLERS.keys(), + ...PULL_POLLERS.keys(), + ...PUSH_SITE_ABORT_CALLBACKS.keys(), + ] ); + + for ( const operationId of operationIds ) { + stopPushPoller( operationId ); + stopPullPoller( operationId ); + PUSH_SITE_ABORT_CALLBACKS.get( operationId )?.(); + getIpcApi().cancelSyncOperation( operationId ); + } + PUSH_SITE_ABORT_CALLBACKS.clear(); +} + +const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; +const SYNC_POLLING_INTERVAL = 3000; + +function isPushPollable( selectedSiteId: string, remoteSiteId: number ) { + const pushState = syncOperationsSelectors.selectPushState( + selectedSiteId, + remoteSiteId + )( store.getState() ); + return pushState && PUSH_POLLING_KEYS.includes( pushState.status.key ); +} + +function isPullPollable( selectedSiteId: string, remoteSiteId: number ) { + const pullState = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( store.getState() ); + return pullState?.status.key === 'in-progress' && !! pullState.backupId; +} + +async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( PUSH_POLLERS.has( stateId ) ) { + return; + } + + const controller = new AbortController(); + PUSH_POLLERS.set( stateId, controller ); + + try { + while ( ! controller.signal.aborted ) { + const client = getWpcomClient(); + if ( ! client ) { + break; + } + + await store.dispatch( + pollPushProgressThunk( { + client, + signal: controller.signal, + selectedSiteId, + remoteSiteId, + } ) + ); + + if ( controller.signal.aborted || ! isPushPollable( selectedSiteId, remoteSiteId ) ) { + break; + } + + await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); + } + } finally { + if ( PUSH_POLLERS.get( stateId ) === controller ) { + PUSH_POLLERS.delete( stateId ); + } + } +} + +async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( PULL_POLLERS.has( stateId ) ) { + return; + } + + const controller = new AbortController(); + PULL_POLLERS.set( stateId, controller ); + + try { + while ( ! controller.signal.aborted ) { + const client = getWpcomClient(); + if ( ! client ) { + break; + } + + await store.dispatch( + pollPullBackupThunk( { + client, + signal: controller.signal, + selectedSiteId, + remoteSiteId, + } ) + ); + + if ( controller.signal.aborted || ! isPullPollable( selectedSiteId, remoteSiteId ) ) { + break; + } + + await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); + } + } finally { + if ( PULL_POLLERS.get( stateId ) === controller ) { + PULL_POLLERS.delete( stateId ); + } + } +} + const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState; dispatch: AppDispatch; @@ -310,9 +442,9 @@ const cancelPushThunk = createTypedAsyncThunk( 'syncOperations/cancelPush', async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); - const abortCallback = PUSH_SITE_ABORT_CALLBACKS.get( operationId ); - abortCallback?.(); + stopPushPoller( operationId ); + PUSH_SITE_ABORT_CALLBACKS.get( operationId )?.(); getIpcApi().cancelSyncOperation( operationId ); dispatch( @@ -334,6 +466,7 @@ const cancelPullThunk = createTypedAsyncThunk( 'syncOperations/cancelPull', async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); + stopPullPoller( operationId ); getIpcApi().cancelSyncOperation( operationId ); dispatch( @@ -454,6 +587,7 @@ const pushSiteThunk = createTypedAsyncThunk< void, PushSitePayload >( }, } ) ); + void startPushPoller( selectedSite.id, remoteSiteId ); } else { return rejectWithValue( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), @@ -493,7 +627,10 @@ type PullSiteResult = { export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayload >( 'syncOperations/pullSite', - async ( { client, connectedSite, selectedSite, options }, { dispatch, rejectWithValue } ) => { + async ( + { client, connectedSite, selectedSite, options }, + { dispatch, getState, rejectWithValue } + ) => { const pullStatesProgressInfo = getPullStatesProgressInfo(); const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; @@ -530,15 +667,28 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl const response = pullSiteResponseSchema.parse( rawResponse ); if ( response.success ) { - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId: selectedSite.id, - remoteSiteId, - state: { - backupId: response.backup_id, - }, - } ) - ); + // Creating the remote backup can take a while. If the user logged out (slice + // reset) or cancelled the pull while this request was in flight, don't resurrect + // the pull state or start a poller — that would leave an orphaned pull stuck at + // "Initializing remote backup…" after logout. + const currentState = syncOperationsSelectors.selectPullState( + selectedSite.id, + remoteSiteId + )( getState() ); + const isStillActive = !! currentState && currentState.status.key !== 'cancelled'; + + if ( isStillActive ) { + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + backupId: response.backup_id, + }, + } ) + ); + void startPullPoller( selectedSite.id, remoteSiteId ); + } return { backupId: response.backup_id, @@ -797,6 +947,12 @@ const pollPullBackupThunk = createTypedAsyncThunk( operationId ); + // downloadSyncBackup resolves without a path when it was cancelled (e.g. the user + // logged out mid-download). Stop silently instead of importing a missing backup. + if ( signal.aborted || ! filePath ) { + return; + } + dispatch( syncOperationsActions.updatePullState( { selectedSiteId, @@ -814,6 +970,13 @@ const pollPullBackupThunk = createTypedAsyncThunk( showNotification: false, } ); + // The import runs locally and is intentionally not aborted on logout, but if the + // user logged out / cancelled while it finished, don't surface post-logout + // "finished" UI (notification + re-populated sync state). + if ( signal.aborted ) { + return; + } + await updateSiteTimestamp( { siteId: remoteSiteId, localSiteId: selectedSiteId, @@ -865,12 +1028,11 @@ const pollPullBackupThunk = createTypedAsyncThunk( ); } } catch ( error ) { - const currentState = syncOperationsSelectors.selectPullState( - selectedSiteId, - remoteSiteId - )( getState() ); - - if ( signal.aborted && currentState?.status.key === 'cancelled' ) { + // The poller signal is aborted on user cancel and on logout cleanup — both are + // intentional stops, so don't capture the error or show the "Error pulling" modal. + // (On logout the slice is reset, so we can't rely on the state still being + // 'cancelled' here — the aborted signal is the reliable signal.) + if ( signal.aborted ) { return; } @@ -942,6 +1104,7 @@ export const initializeSyncStatesThunk = createTypedAsyncThunk( }, } ) ); + void startPushPoller( connectedSite.localSiteId, connectedSite.id ); } } catch ( error ) { // Continue checking other sites even if one fails @@ -967,22 +1130,38 @@ const isKeyPulling = ( key?: PullStateProgressInfo[ 'key' ] ): boolean => { if ( ! key ) { return false; } - const pullingStateKeys = [ 'in-progress', 'downloading', 'importing' ]; - return pullingStateKeys.includes( key ); + const keys = [ 'in-progress', 'downloading', 'importing' ]; + return keys.includes( key ); +}; + +const isKeyPullingLocally = ( key?: PullStateProgressInfo[ 'key' ] ): boolean => { + if ( ! key ) { + return false; + } + const keys = [ 'downloading', 'importing' ]; + return keys.includes( key ); +}; + +const isKeyUploading = ( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean => { + if ( ! key ) { + return false; + } + const keys = [ 'creatingBackup', 'uploading', 'uploadingManuallyPaused' ]; + return keys.includes( key ); }; const isKeyPushing = ( key?: PushStateProgressInfo[ 'key' ] ): boolean => { if ( ! key ) { return false; } - const pushingStateKeys = [ + const keys = [ 'creatingBackup', 'uploading', 'creatingRemoteBackup', 'applyingChanges', 'finishing', ]; - return pushingStateKeys.includes( key ); + return keys.includes( key ); }; export const syncOperationsSelectors = { @@ -1011,16 +1190,26 @@ export const syncOperationsSelectors = { ( selectedSiteId: string, remoteSiteId?: number ) => ( state: { syncOperations: SyncOperationsState } ): boolean => { return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => { - if ( ! pullState.selectedSite ) { + if ( pullState.selectedSite.id !== selectedSiteId ) { + return false; + } + if ( remoteSiteId !== undefined && pullState.remoteSiteId !== remoteSiteId ) { return false; } + return isKeyPulling( pullState.status.key ); + } ); + }, + selectIsSiteIdPullingLocally: + ( selectedSiteId?: string, remoteSiteId?: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => { if ( pullState.selectedSite.id !== selectedSiteId ) { return false; } - if ( remoteSiteId !== undefined ) { - return isKeyPulling( pullState.status.key ) && pullState.remoteSiteId === remoteSiteId; + if ( remoteSiteId !== undefined && pullState.remoteSiteId !== remoteSiteId ) { + return false; } - return pullState.status && isKeyPulling( pullState.status.key ); + return isKeyPullingLocally( pullState.status.key ); } ); }, selectIsAnySitePushing: ( state: { syncOperations: SyncOperationsState } ): boolean => { @@ -1029,19 +1218,57 @@ export const syncOperationsSelectors = { ); }, selectIsSiteIdPushing: - ( selectedSiteId: string, remoteSiteId?: number ) => + ( selectedSiteId?: string, remoteSiteId?: number ) => ( state: { syncOperations: SyncOperationsState } ): boolean => { return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => { - if ( ! pushState.selectedSite ) { + if ( pushState.selectedSite.id !== selectedSiteId ) { return false; } + if ( remoteSiteId !== undefined && pushState.remoteSiteId !== remoteSiteId ) { + return false; + } + return isKeyPushing( pushState.status.key ); + } ); + }, + selectIsSiteIdPushingLocally: + ( selectedSiteId?: string, remoteSiteId?: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => { if ( pushState.selectedSite.id !== selectedSiteId ) { return false; } - if ( remoteSiteId !== undefined ) { - return isKeyPushing( pushState.status.key ) && pushState.remoteSiteId === remoteSiteId; + if ( remoteSiteId !== undefined && pushState.remoteSiteId !== remoteSiteId ) { + return false; } - return isKeyPushing( pushState.status.key ); + return isKeyUploading( pushState.status.key ); } ); }, + // "Local sync work" = the phases the local machine is actively involved in (pull + // downloading/importing, push backup uploading). We can only block on these; the + // server-bound phases of a sync continue regardless. + selectIsSiteDoingLocalSyncWork: + ( selectedSiteId?: string, remoteSiteId?: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + return ( + syncOperationsSelectors.selectIsSiteIdPullingLocally( + selectedSiteId, + remoteSiteId + )( state ) || + syncOperationsSelectors.selectIsSiteIdPushingLocally( + selectedSiteId, + remoteSiteId + )( state ) + ); + }, + selectIsAnySiteDoingLocalSyncWork: ( state: { + syncOperations: SyncOperationsState; + } ): boolean => { + const anyPullLocal = Object.values( state.syncOperations.pullStates ).some( ( pullState ) => + isKeyPullingLocally( pullState.status.key ) + ); + const anyPushLocal = Object.values( state.syncOperations.pushStates ).some( ( pushState ) => + isKeyUploading( pushState.status.key ) + ); + return anyPullLocal || anyPushLocal; + }, }; diff --git a/apps/studio/src/stores/sync/sync-slice.ts b/apps/studio/src/stores/sync/sync-slice.ts index cc450453c8..7abe56e033 100644 --- a/apps/studio/src/stores/sync/sync-slice.ts +++ b/apps/studio/src/stores/sync/sync-slice.ts @@ -1,5 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; import { TreeNode } from 'src/components/tree-view'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { fetchRemoteFileTree } from './sync-api'; type RemoteFileTreeState = { @@ -33,6 +34,7 @@ const syncSlice = createSlice( { }, extraReducers: ( builder ) => { builder + .addCase( userLoggedOut, () => initialState ) .addCase( fetchRemoteFileTree.pending, ( state ) => { state.remoteFileTrees.loading = true; state.remoteFileTrees.error = null;