@@ -82,51 +82,131 @@ export class IOSLiveSyncService
8282 deviceProjectRootPath,
8383 } ,
8484 ] ) ;
85- await transferSyncZip ( ) ;
8685
87- // Verify the zip actually landed in the app sandbox. The AFC
88- // transfer has been observed to fail silently on some runs (the
89- // runtime then boots the stale JavaScript baked into the installed
90- // .app payload, with no indication anywhere that the sync was
91- // lost). One retry covers transient AFC hiccups; if delivery still
92- // cannot be confirmed, fail the sync loudly instead of printing
93- // "Successfully synced" over stale code — the run-controller
94- // surfaces the error and the user can re-run (or use --clean).
95- const verifySyncZipDelivered = async ( ) : Promise < boolean > => {
96- if ( ! device . fileSystem . getDirectoryEntries ) {
97- return true ; // no verification support — assume delivered
98- }
99- // NOTE: deviceProjectRootPath is `.../LiveSync/app` (the extracted
100- // app folder); the zip is uploaded one level up, at the LiveSync
101- // root — list THAT directory.
102- const entries = await device . fileSystem . getDirectoryEntries (
86+ // ── Fail-closed delivery verification ──────────────────────────
87+ //
88+ // The AFC transfer has been observed to fail without surfacing an
89+ // error, leaving the app to boot the stale JavaScript baked into
90+ // the installed .app payload with no indication anywhere that the
91+ // sync was lost. After the transfer we therefore confirm the zip
92+ // is actually present in the app sandbox; one retry covers
93+ // transient AFC hiccups, and an unconfirmed delivery fails the
94+ // sync loudly instead of printing "Successfully synced" over
95+ // stale code (the run-controller surfaces the error; --clean
96+ // reinstalls the full package).
97+ //
98+ // Presence alone is NOT sufficient evidence: a previous run that
99+ // transferred the zip but aborted before the app restarted leaves
100+ // a LEFTOVER sync.zip behind (the runtime only consumes it at
101+ // boot), which would satisfy the check even when THIS upload
102+ // failed. So any pre-existing zip is deleted up front — after
103+ // that, post-transfer presence can only be produced by this run's
104+ // upload.
105+ //
106+ // NOTE: deviceProjectRootPath is `.../LiveSync/app` (the extracted
107+ // app folder); the zip is uploaded one level up, at the LiveSync
108+ // root — the listing targets THAT directory.
109+ //
110+ // Escape hatch: NS_SKIP_IOS_SYNC_VERIFICATION=1 disables the
111+ // whole verification for exotic setups where directory listing
112+ // misbehaves but uploads are known-good.
113+ const syncZipDevicePath = deviceAppData . deviceSyncZipPath ;
114+ const verificationSupported =
115+ ! ! device . fileSystem . getDirectoryEntries &&
116+ process . env . NS_SKIP_IOS_SYNC_VERIFICATION !== "1" ;
117+ const listLiveSyncRoot = ( ) : Promise < string [ ] | null > =>
118+ device . fileSystem . getDirectoryEntries (
103119 LiveSyncPaths . IOS_DEVICE_PROJECT_ROOT_PATH ,
104120 deviceAppData . appIdentifier ,
105121 ) ;
122+ const containsSyncZip = ( entries : string [ ] ) : boolean =>
123+ entries . some (
124+ ( entry ) => entry === syncZipDevicePath || entry . endsWith ( "/sync.zip" ) ,
125+ ) ;
126+ // "delivered" / "missing" are definitive listings; "unknown" means
127+ // the listing itself could not be read (after one retry).
128+ const checkDelivery = async ( ) : Promise <
129+ "delivered" | "missing" | "unknown"
130+ > => {
131+ let entries = await listLiveSyncRoot ( ) ;
106132 if ( entries === null ) {
107- // Could not read the directory at all — don't fail the run on
108- // the verification mechanism itself, but leave a trace.
109- this . $logger . trace (
110- "Unable to verify sync.zip delivery (directory listing unavailable); continuing." ,
111- ) ;
112- return true ;
133+ entries = await listLiveSyncRoot ( ) ;
134+ }
135+ if ( entries === null ) {
136+ return "unknown" ;
113137 }
114- return entries . some ( ( entry ) => entry . endsWith ( "sync.zip" ) ) ;
138+ return containsSyncZip ( entries ) ? "delivered" : "missing" ;
115139 } ;
116140
117- if ( ! ( await verifySyncZipDelivered ( ) ) ) {
118- this . $logger . warn (
119- "sync.zip was not found on the device after transfer — retrying once..." ,
141+ let preListingAvailable = false ;
142+ let leftoverZipPresent = false ;
143+ if ( verificationSupported ) {
144+ // Clear any leftover zip so the post-transfer check attributes
145+ // presence to this run. Best-effort: AFC "file not found" is
146+ // tolerated inside deleteFile.
147+ await device . fileSystem . deleteFile (
148+ syncZipDevicePath ,
149+ deviceAppData . appIdentifier ,
120150 ) ;
121- await transferSyncZip ( ) ;
122- if ( ! ( await verifySyncZipDelivered ( ) ) ) {
123- throw new Error (
124- `Unable to deliver the application payload (sync.zip) to device ${ device . deviceInfo . identifier } . ` +
125- `The app would run stale JavaScript without it. ` +
126- `Re-run the command, or use a clean rebuild (--clean) to reinstall the full application package.` ,
151+ const preEntries = await listLiveSyncRoot ( ) ;
152+ preListingAvailable = Array . isArray ( preEntries ) ;
153+ leftoverZipPresent = preListingAvailable && containsSyncZip ( preEntries ) ;
154+ }
155+
156+ await transferSyncZip ( ) ;
157+
158+ if ( verificationSupported ) {
159+ if ( leftoverZipPresent ) {
160+ // The pre-transfer delete did not take effect, so presence
161+ // can no longer be attributed to this run. Most likely the
162+ // upload succeeded too, but say so explicitly rather than
163+ // claim verification.
164+ this . $logger . warn (
165+ "A leftover sync.zip from a previous run could not be removed — delivery verification for this sync is inconclusive. " +
166+ "If the app runs stale code, re-run the command or use a clean rebuild (--clean)." ,
127167 ) ;
168+ } else {
169+ let state = await checkDelivery ( ) ;
170+ if ( state === "missing" ) {
171+ this . $logger . warn (
172+ "sync.zip was not found on the device after transfer — retrying once..." ,
173+ ) ;
174+ await transferSyncZip ( ) ;
175+ state = await checkDelivery ( ) ;
176+ if ( state === "delivered" ) {
177+ this . $logger . info ( "sync.zip delivered on retry." ) ;
178+ }
179+ }
180+ if ( state === "missing" ) {
181+ throw new Error (
182+ `Unable to deliver the application payload (sync.zip) to device ${ device . deviceInfo . identifier } . ` +
183+ `The app would run stale JavaScript without it. ` +
184+ `Re-run the command, or use a clean rebuild (--clean) to reinstall the full application package.` ,
185+ ) ;
186+ }
187+ if ( state === "unknown" ) {
188+ if ( preListingAvailable ) {
189+ // The listing worked moments before the upload and
190+ // broke right after it — the AFC session is
191+ // misbehaving at exactly the point where the upload
192+ // itself is suspect. Fail closed.
193+ throw new Error (
194+ `Unable to confirm delivery of the application payload (sync.zip) to device ${ device . deviceInfo . identifier } : ` +
195+ `the device directory listing failed right after the transfer. ` +
196+ `Re-run the command, or use a clean rebuild (--clean) to reinstall the full application package. ` +
197+ `(Set NS_SKIP_IOS_SYNC_VERIFICATION=1 to bypass delivery verification.)` ,
198+ ) ;
199+ }
200+ // Listing was unavailable both before and after the
201+ // transfer — verification is unsupported for this
202+ // device/session. This is the single fail-open path,
203+ // and it is loud rather than silent.
204+ this . $logger . warn (
205+ "Could not verify sync.zip delivery (device directory listing unavailable). " +
206+ "If the transfer failed, the app will run stale JavaScript — re-run the command or use a clean rebuild (--clean)." ,
207+ ) ;
208+ }
128209 }
129- this . $logger . info ( "sync.zip delivered on retry." ) ;
130210 }
131211
132212 await deviceAppData . device . applicationManager . setTransferredAppFiles (
0 commit comments