@@ -68,6 +68,7 @@ interface ActiveImageModel {
6868}
6969
7070interface RunGenerationOptions {
71+ runId : number ;
7172 params : GenerateImageParams ;
7273 enhancedPrompt : string ;
7374 activeImageModel : ActiveImageModel ;
@@ -99,7 +100,10 @@ class ImageGenerationService {
99100 } ;
100101
101102 private readonly listeners : Set < ImageGenerationListener > = new Set ( ) ;
102- private cancelRequested : boolean = false ;
103+ // Request-scoped cancellation. generateImage() claims a fresh runId; a cancel or a new
104+ // run advances activeRunId, invalidating every in-flight checkpoint captured under the
105+ // old id. (A shared boolean was cross-run unsafe — a new run could clear a prior run's cancel.)
106+ private activeRunId = 0 ;
103107 /** Last generate request, so a failure card's Retry button can re-run it. */
104108 private _lastParams : GenerateImageParams | null = null ;
105109
@@ -341,7 +345,7 @@ class ImageGenerationService {
341345 }
342346
343347 private async _runGenerationAndSave ( opts : RunGenerationOptions ) : Promise < GeneratedImage | null > {
344- const { params, enhancedPrompt, activeImageModel, steps, guidanceScale, imageWidth, imageHeight, useOpenCL } = opts ;
348+ const { runId , params, enhancedPrompt, activeImageModel, steps, guidanceScale, imageWidth, imageHeight, useOpenCL } = opts ;
345349
346350 // The first generation for a model compiles/warms the backend and takes ~120s.
347351 // This is platform-agnostic: on iOS the CoreML model compiles on first use, on
@@ -371,7 +375,7 @@ class ImageGenerationService {
371375 const result = await onnxImageGeneratorService . generateImage (
372376 { prompt : enhancedPrompt , negativePrompt : params . negativePrompt || '' , steps, guidanceScale, seed : params . seed , width : imageWidth , height : imageHeight , previewInterval : params . previewInterval ?? 2 , useOpenCL } ,
373377 ( progress ) => {
374- if ( this . cancelRequested ) return ;
378+ if ( this . _isStale ( runId ) ) return ;
375379 const displayStep = Math . min ( progress . step , steps ) ;
376380 if ( isFirstRun ) {
377381 this . updateState ( {
@@ -385,12 +389,15 @@ class ImageGenerationService {
385389 }
386390 } ,
387391 ( preview ) => {
388- if ( this . cancelRequested ) return ;
392+ if ( this . _isStale ( runId ) ) return ;
389393 const displayStep = Math . min ( preview . step , steps ) ;
390394 this . updateState ( { previewPath : `file://${ preview . previewPath } ?t=${ Date . now ( ) } ` , status : `Refining image (${ displayStep } /${ steps } )...` } ) ;
391395 } ,
392396 ) ;
393- if ( this . cancelRequested || ! result ?. imagePath ) { this . resetState ( ) ; return null ; }
397+ // Cancelled mid-native (cancelGeneration already reset state) or no image → bail.
398+ // A stale run must not resetState (a newer run may own it) nor save its result.
399+ if ( this . _isStale ( runId ) ) return null ;
400+ if ( ! result ?. imagePath ) { this . resetState ( ) ; return null ; }
394401 return this . _saveResult ( result , { params, activeImageModel, meta : { steps, guidanceScale, useOpenCL, startTime } } ) ;
395402 } catch ( error : any ) {
396403 const errorMsg = error ?. message || 'Image generation failed' ;
@@ -424,10 +431,10 @@ class ImageGenerationService {
424431 logger . log ( '[ImageGenerationService] Already generating, ignoring request' ) ;
425432 return null ;
426433 }
427- // Fresh request: clear any stale cancel flag from a prior run BEFORE any await, so a
428- // leftover `true` can't abort this one — and so a cancel arriving during
429- // _enhancePrompt below is genuinely this request's, not stale.
430- this . cancelRequested = false ;
434+ // Claim a fresh run id BEFORE any await. Every checkpoint below compares against it,
435+ // so a later run (or a cancel) that advances activeRunId invalidates THIS run's
436+ // remaining work — and this run can't be aborted by a stale signal from a prior one .
437+ const runId = ++ this . activeRunId ;
431438 this . _lastParams = params ; // so a failure card's Retry can re-run this exact request
432439 const { settings, activeImageModelId, downloadedImageModels } = useAppStore . getState ( ) ;
433440 const activeImageModel = downloadedImageModels . find ( m => m . id === activeImageModelId ) ;
@@ -440,12 +447,11 @@ class ImageGenerationService {
440447
441448 const enhancedPrompt = await this . _enhancePrompt ( params , steps ) ;
442449 logger . log ( '[ImageGen] enhanceImagePrompts setting:' , settings . enhanceImagePrompts ) ;
443- // Honor a cancel that arrived WHILE enhancing (cancelGeneration already reset the
444- // state to idle). The old code unconditionally cleared cancelRequested here, so a
445- // cancel tapped during the 'enhancing' phase was silently discarded and generation
446- // proceeded anyway. _enhancePrompt itself can't observe the flag (it awaits the LLM),
447- // so this is the checkpoint.
448- if ( this . cancelRequested ) { this . resetState ( ) ; return null ; }
450+ // Honor a cancel that arrived WHILE enhancing (cancelGeneration advanced activeRunId
451+ // and reset state to idle). _enhancePrompt awaits the LLM and can't observe the run
452+ // id, so this is the checkpoint. If cancelled, THIS run stops here — don't resetState
453+ // (a newer run may already own it); just bail.
454+ if ( this . _isStale ( runId ) ) return null ;
449455
450456 // Establish the generating state unconditionally — not only when enhancement
451457 // is off. When enhancement is ON but _enhancePrompt bailed early (e.g. no text
@@ -461,14 +467,22 @@ class ImageGenerationService {
461467
462468 const loaded = await this . _ensureImageModelLoaded ( activeImageModelId , activeImageModel , settings . imageThreads ?? 4 ) ;
463469 if ( ! loaded ) return null ;
464- if ( this . cancelRequested ) { this . resetState ( ) ; return null ; }
470+ if ( this . _isStale ( runId ) ) return null ;
465471
466- return this . _runGenerationAndSave ( { params, enhancedPrompt, activeImageModel, steps, guidanceScale, imageWidth, imageHeight, useOpenCL : settings . imageUseOpenCL ?? true } ) ;
472+ return this . _runGenerationAndSave ( { runId, params, enhancedPrompt, activeImageModel, steps, guidanceScale, imageWidth, imageHeight, useOpenCL : settings . imageUseOpenCL ?? true } ) ;
473+ }
474+
475+ /** True when `runId` is no longer the active run (cancelled, or superseded by a newer
476+ * generateImage). Every in-flight checkpoint bails on this instead of a shared flag. */
477+ private _isStale ( runId : number ) : boolean {
478+ return runId !== this . activeRunId ;
467479 }
468480
469481 async cancelGeneration ( ) : Promise < void > {
470482 if ( ! isInFlight ( this . state . phase ) ) return ;
471- this . cancelRequested = true ;
483+ // Advance the run id: the in-flight run's captured id no longer matches, so all its
484+ // remaining checkpoints bail. (A shared boolean could be cleared by a subsequent run.)
485+ this . activeRunId ++ ;
472486 try { await onnxImageGeneratorService . cancelGeneration ( ) ; } catch { /* Ignore */ }
473487 this . resetState ( ) ;
474488 }
0 commit comments