@@ -30,6 +30,12 @@ export type AssetBundles = {
3030 invalidate ( ) : void
3131}
3232
33+ /** Past this, a lock's owner is presumed dead and its staging is litter. */
34+ const STALE_LOCK_MS = 10 * 60_000
35+ /** Past this, converting twice beats waiting any longer on another preview. */
36+ const WAIT_FOR_LOCK_MS = 2 * 60_000
37+ const POLL_LOCK_MS = 250
38+
3339/** "windows" | "mac" | "linux" — the platforms abgen's export lane accepts. */
3440export function hostPlatform ( ) : string {
3541 return process . platform === 'win32' ? 'windows' : process . platform === 'darwin' ? 'mac' : 'linux'
@@ -63,6 +69,8 @@ export async function setupAssetBundles(
6369 const converted = new Map < string , ConvertedScene > ( )
6470 const platform = hostPlatform ( )
6571
72+ await sweepStaging ( components , cacheRoot )
73+
6674 // One conversion per distinct content, and only the newest publishes.
6775 //
6876 // Two separate faults lived here. A second invalidate() while a conversion
@@ -197,12 +205,98 @@ async function convertScene(
197205 input : { files : SceneFile [ ] ; contentKey : string }
198206) : Promise < ConvertedScene | undefined > {
199207 const { files, contentKey } = input
200- const cached = await readCache ( components , { ...job , contentKey } )
208+ const full = { ...job , contentKey }
209+ const cached = await readCache ( components , full )
201210 if ( cached ) {
202211 components . logger . log ( `asset-bundles: scene already converted (${ cached . bundles . size } bundles, cached)` )
203212 return cached
204213 }
205214
215+ const lock = await acquireLock ( components , full )
216+ try {
217+ // The lock may have been held by another preview that has since finished,
218+ // so the miss above can be out of date by the time we get here.
219+ const justFinished = await readCache ( components , full )
220+ if ( justFinished ) {
221+ components . logger . log (
222+ `asset-bundles: scene converted by another preview (${ justFinished . bundles . size } bundles, cached)`
223+ )
224+ return justFinished
225+ }
226+ return await runConversion ( components , abgen , full , files )
227+ } finally {
228+ await lock . release ( )
229+ }
230+ }
231+
232+ /**
233+ * Keeps a second preview of the same project off work already in progress.
234+ *
235+ * Advisory only, and deliberately so: every failure mode here — a stale lock, a
236+ * timeout, an unwritable directory — degrades to converting twice, which
237+ * writeCache's atomic publish already makes safe. Correctness never rests on
238+ * the lock, so it can be as approximate as it likes.
239+ */
240+ async function acquireLock (
241+ components : Pick < CliComponents , 'fs' | 'logger' > ,
242+ job : { cacheRoot : string ; platform : string ; contentKey : string }
243+ ) : Promise < { release ( ) : Promise < void > } > {
244+ const lockPath = `${ cacheDir ( job ) } .lock`
245+ const noop = { release : async ( ) => undefined }
246+ const drop = {
247+ release : async ( ) => {
248+ try {
249+ await components . fs . unlink ( lockPath )
250+ } catch {
251+ // Someone judged it stale and took it. Theirs to remove now.
252+ }
253+ }
254+ }
255+
256+ const deadline = Date . now ( ) + WAIT_FOR_LOCK_MS
257+ for ( ; ; ) {
258+ try {
259+ await components . fs . mkdir ( job . cacheRoot , { recursive : true } )
260+ await components . fs . writeFile ( lockPath , `${ process . pid } \n` , { flag : 'wx' } )
261+ return drop
262+ } catch ( error : any ) {
263+ if ( error ?. code !== 'EEXIST' ) return noop
264+ }
265+
266+ const age = await lockAge ( components , lockPath )
267+ if ( age === undefined ) continue // released while we looked; try to take it
268+ if ( age > STALE_LOCK_MS ) {
269+ // Its owner died mid-conversion. Nothing here is transactional enough to
270+ // be worth a handshake — the cost of being wrong is a second conversion.
271+ try {
272+ await components . fs . unlink ( lockPath )
273+ } catch {
274+ // Lost the race to another waiter; it holds the lock now.
275+ }
276+ continue
277+ }
278+ if ( Date . now ( ) > deadline ) {
279+ components . logger . warn ( 'asset-bundles: another preview is still converting this scene; converting anyway' )
280+ return noop
281+ }
282+ await new Promise ( ( resolve ) => setTimeout ( resolve , POLL_LOCK_MS ) )
283+ }
284+ }
285+
286+ async function lockAge ( components : Pick < CliComponents , 'fs' > , lockPath : string ) : Promise < number | undefined > {
287+ try {
288+ return Date . now ( ) - ( await components . fs . stat ( lockPath ) ) . mtimeMs
289+ } catch {
290+ return undefined
291+ }
292+ }
293+
294+ async function runConversion (
295+ components : Pick < CliComponents , 'fs' | 'logger' > ,
296+ abgen : AbgenModule ,
297+ job : { projectRoot : string ; entityId : string ; platform : string ; cacheRoot : string ; contentKey : string } ,
298+ files : SceneFile [ ]
299+ ) : Promise < ConvertedScene | undefined > {
206300 const started = Date . now ( )
207301 printProgressInfo ( components . logger , 'asset-bundles: converting the scene (cached after the first run)...' )
208302
@@ -233,7 +327,7 @@ async function convertScene(
233327 manifest,
234328 bundles : new Map ( result . bundles . map ( ( b ) => [ b . name , b . data ] ) )
235329 }
236- await writeCache ( components , { ... job , contentKey } , scene )
330+ await writeCache ( components , job , scene )
237331 components . logger . log ( `asset-bundles: scene converted (${ scene . bundles . size } bundles, ${ elapsed } s)` )
238332 return scene
239333}
@@ -291,18 +385,59 @@ async function readCache(
291385 }
292386}
293387
388+ /**
389+ * Publishes the conversion as one indivisible step.
390+ *
391+ * Written into a private staging directory and moved into place with a single
392+ * rename, which is atomic on every filesystem this runs on. A reader therefore
393+ * sees the entry complete or not at all, and two previews converting the same
394+ * content race harmlessly: the loser's bytes are identical to the winner's, so
395+ * it discards them rather than merging into a directory someone else owns.
396+ */
294397async function writeCache (
295398 components : Pick < CliComponents , 'fs' > ,
296399 job : { cacheRoot : string ; entityId : string ; platform : string ; contentKey : string } ,
297400 scene : ConvertedScene
298401) : Promise < void > {
299402 const dir = cacheDir ( job )
300- await components . fs . mkdir ( path . join ( dir , 'bundles' ) , { recursive : true } )
403+ const staging = `${ dir } .tmp-${ crypto . randomBytes ( 6 ) . toString ( 'hex' ) } `
404+ await components . fs . mkdir ( path . join ( staging , 'bundles' ) , { recursive : true } )
301405 for ( const [ name , data ] of scene . bundles ) {
302- await components . fs . writeFile ( path . join ( dir , 'bundles' , name ) , data )
406+ await components . fs . writeFile ( path . join ( staging , 'bundles' , name ) , data )
407+ }
408+ await components . fs . writeFile ( path . join ( staging , 'manifest.json' ) , scene . manifest )
409+
410+ try {
411+ await components . fs . rename ( staging , dir )
412+ } catch {
413+ await components . fs . rm ( staging , { recursive : true , force : true } )
414+ }
415+ }
416+
417+ /**
418+ * Clears staging directories a killed conversion left behind.
419+ *
420+ * Age-gated because a young one is not litter — it belongs to a preview that is
421+ * converting right now, and removing it would delete that conversion's output
422+ * from under it.
423+ */
424+ async function sweepStaging ( components : Pick < CliComponents , 'fs' > , cacheRoot : string ) : Promise < void > {
425+ let entries : string [ ]
426+ try {
427+ entries = await components . fs . readdir ( cacheRoot )
428+ } catch {
429+ return // no cache yet, nothing to sweep
430+ }
431+ for ( const entry of entries ) {
432+ if ( ! / \. t m p - [ 0 - 9 a - f ] + $ / . test ( entry ) ) continue
433+ const full = path . join ( cacheRoot , entry )
434+ try {
435+ if ( Date . now ( ) - ( await components . fs . stat ( full ) ) . mtimeMs < STALE_LOCK_MS ) continue
436+ await components . fs . rm ( full , { recursive : true , force : true } )
437+ } catch {
438+ // Being removed by whoever owns it, or not ours to remove.
439+ }
303440 }
304- // Written last: its presence is what marks the cache complete.
305- await components . fs . writeFile ( path . join ( dir , 'manifest.json' ) , scene . manifest )
306441}
307442
308443/** The slice of @dcl/abgen-node this command uses. */
0 commit comments