Skip to content

Commit 6f6a87c

Browse files
fix(image): runId-scoped cancellation (cross-run safe) — review #460
The shared cancelRequested boolean was cross-run unsafe: cancelling run #1 (while it still awaited _enhancePrompt/native) then starting run #2 — which reset the flag at its top — cleared run #1's cancel, so run #1 could proceed into generation and clobber state. Replace the boolean with a request-scoped run id: generateImage claims ; cancelGeneration (and any newer run) advances activeRunId, so every checkpoint captured under the old id (_enhancePrompt/_ensureImageModelLoaded checkpoints, the progress/preview callbacks, and the post-native result) bails via _isStale(runId). A stale run also no longer calls resetState (a newer run may own the state). Test: added a cross-run case — cancel run #1 mid-enhance, run run #2 to completion, then release run #1's stalled enhancement and assert it adds no generator call. Plus the existing enhancing-cancel case still passes. 9 batch4 + full image-gen flow green, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cee7d6b commit 6f6a87c

2 files changed

Lines changed: 67 additions & 18 deletions

File tree

__tests__/hardening/batch4-phase-state-machine.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,41 @@ describe('cancel mid-flight resets the machine to idle (case 20)', () => {
223223
expect(mockDream.generateImage).not.toHaveBeenCalled();
224224
expect(imageGenerationService.getState().phase).toBe('idle');
225225
});
226+
227+
// Review #460: cross-run safety. Cancel run #1 while it's still awaiting enhancement,
228+
// start run #2, then let run #1's enhancement resolve — run #1 must NOT resurrect and
229+
// call the generator. With a shared cancel boolean, run #2 clearing it would revive #1.
230+
it('a cancelled run does not proceed even after a new run starts (runId-scoped)', async () => {
231+
setupModel();
232+
useAppStore.setState({
233+
activeModelId: 'text-1',
234+
settings: { ...useAppStore.getState().settings, enhanceImagePrompts: true } as any,
235+
});
236+
mockLlm.isModelLoaded.mockReturnValue(true);
237+
// Run #1 enhancement hangs until we release it.
238+
let releaseEnhance1!: () => void;
239+
mockLlm.generateResponse.mockImplementationOnce(
240+
() => new Promise<string>((r) => { releaseEnhance1 = () => r('enhanced-1'); }),
241+
);
242+
243+
imageGenerationService.generateImage({ prompt: 'run 1' });
244+
await flushPromises();
245+
expect(imageGenerationService.getState().phase).toBe('enhancing');
246+
247+
await imageGenerationService.cancelGeneration(); // cancel run #1 (state → idle)
248+
249+
// Run #2 starts and completes (enhancement resolves immediately now).
250+
mockLlm.generateResponse.mockResolvedValue('enhanced-2');
251+
await imageGenerationService.generateImage({ prompt: 'run 2' });
252+
const dreamCallsAfterRun2 = mockDream.generateImage.mock.calls.length;
253+
254+
// Now release run #1's stalled enhancement — it must be inert (its runId is stale).
255+
releaseEnhance1();
256+
await flushPromises();
257+
258+
// Run #1 added NO extra generator call after run #2 finished.
259+
expect(mockDream.generateImage.mock.calls.length).toBe(dreamCallsAfterRun2);
260+
});
226261
});
227262

228263
describe('no-model / load-failure surface an error phase, never a silent hang (cases 30, 38)', () => {

src/services/imageGenerationService.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ interface ActiveImageModel {
6868
}
6969

7070
interface 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

Comments
 (0)