From f27981667680d5f54cad92f226c1c882db85249f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 16 May 2026 22:53:48 -0300 Subject: [PATCH] fix: prevent redundant restarts on rapid changes and recover from app crashes in watch mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues in the livesync watch loop: 1. Multiple rapid file changes caused each webpack recompilation to trigger a separate sync+restart, even when earlier restarts were already stale. Added exhaustMapWithTrailing semantics: while a sync is in progress, incoming compilation events are coalesced per-platform into a single pending event that runs after the current sync completes. 2. If the app crashed at certain times (e.g. during JS startup), the error handler removed the device from the descriptor list and stopped watchers, permanently killing the watch loop. Fixed by no longer calling stop() on transient sync errors — the device stays in the list and retries on the next file change. Also added .catch() to the promise action chain so a rejected action no longer breaks all subsequent chained actions. --- lib/controllers/run-controller.ts | 109 +++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 23 deletions(-) diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index b1860c5ead..2bafa60cca 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -26,6 +26,15 @@ import { injector } from "../common/yok"; export class RunController extends EventEmitter implements IRunController { private prepareReadyEventHandler: any = null; + private _syncInProgress = false; + private _pendingSyncs: Map< + string, + { + data: IFilesChangeEventData; + projectData: IProjectData; + liveSyncInfo: ILiveSyncInfo; + } + > = new Map(); constructor( protected $analyticsService: IAnalyticsService, @@ -97,16 +106,11 @@ export class RunController extends EventEmitter implements IRunController { projectData, prepareData, ); - if (changesInfo.hasChanges) { - await this.syncChangedDataOnDevices( - data, - projectData, - liveSyncInfo, - ); + if (!changesInfo.hasChanges) { + return; } - } else { - await this.syncChangedDataOnDevices(data, projectData, liveSyncInfo); } + this.scheduleSyncOnDevices(data, projectData, liveSyncInfo); }; this.prepareReadyEventHandler = handler.bind(this); @@ -840,11 +844,11 @@ export class RunController extends EventEmitter implements IRunController { watchInfo.connectTimeout = null; await watchAction(); } - } catch (err) { + } catch (err: any) { this.$logger.warn( `Unable to apply changes for device: ${ device.deviceInfo.identifier - }. Error is: ${err && err.message}.`, + }. Error is: ${err && err.message}. Will retry on next change.`, ); this.emitCore(RunOnDeviceEvents.runOnDeviceError, { @@ -856,12 +860,6 @@ export class RunController extends EventEmitter implements IRunController { ], error: err, }); - - await this.stop({ - projectDir: projectData.projectDir, - deviceIdentifiers: [device.deviceInfo.identifier], - stopOptions: { shouldAwaitAllActions: false }, - }); } }; @@ -885,6 +883,67 @@ export class RunController extends EventEmitter implements IRunController { ); } + private scheduleSyncOnDevices( + data: IFilesChangeEventData, + projectData: IProjectData, + liveSyncInfo: ILiveSyncInfo, + ): void { + if (this._syncInProgress) { + const platform = data.platform; + const existing = this._pendingSyncs.get(platform); + if (existing) { + existing.data = this.mergeFilesChangeEvents(existing.data, data); + } else { + this._pendingSyncs.set(platform, { data, projectData, liveSyncInfo }); + } + return; + } + + this.executeSyncOnDevices(data, projectData, liveSyncInfo); + } + + private async executeSyncOnDevices( + data: IFilesChangeEventData, + projectData: IProjectData, + liveSyncInfo: ILiveSyncInfo, + ): Promise { + this._syncInProgress = true; + try { + await this.syncChangedDataOnDevices(data, projectData, liveSyncInfo); + } catch (err: any) { + this.$logger.trace(`Error during sync on devices: ${err.message || err}`); + } finally { + const nextEntry = this._pendingSyncs.entries().next(); + if (!nextEntry.done) { + const [platform, pending] = nextEntry.value; + this._pendingSyncs.delete(platform); + this.executeSyncOnDevices( + pending.data, + pending.projectData, + pending.liveSyncInfo, + ); + } else { + this._syncInProgress = false; + } + } + } + + private mergeFilesChangeEvents( + a: IFilesChangeEventData, + b: IFilesChangeEventData, + ): IFilesChangeEventData { + return { + files: [...new Set([...a.files, ...b.files])], + staleFiles: [ + ...new Set([...(a.staleFiles || []), ...(b.staleFiles || [])]), + ], + hasOnlyHotUpdateFiles: a.hasOnlyHotUpdateFiles && b.hasOnlyHotUpdateFiles, + hasNativeChanges: a.hasNativeChanges || b.hasNativeChanges, + hmrData: b.hmrData, + platform: b.platform, + }; + } + private async addActionToChain( projectDir: string, action: () => Promise, @@ -892,13 +951,17 @@ export class RunController extends EventEmitter implements IRunController { const liveSyncInfo = this.$liveSyncProcessDataService.getPersistedData(projectDir); if (liveSyncInfo) { - liveSyncInfo.actionsChain = liveSyncInfo.actionsChain.then(async () => { - if (!liveSyncInfo.isStopped) { - liveSyncInfo.currentSyncAction = action(); - const res = await liveSyncInfo.currentSyncAction; - return res; - } - }); + liveSyncInfo.actionsChain = liveSyncInfo.actionsChain + .then(async () => { + if (!liveSyncInfo.isStopped) { + liveSyncInfo.currentSyncAction = action(); + const res = await liveSyncInfo.currentSyncAction; + return res; + } + }) + .catch((err: any) => { + this.$logger.warn(`Error in action chain: ${err.message || err}`); + }); const result = await liveSyncInfo.actionsChain; return result;