fix(frontend): eliminate ref image doubling from race between Promise.all chains

IP adapters and model-free reference images were dispatched via two
independent Promise.all chains — one with replace:true, the other with
replace:false.  When a previous recall's promises were still in-flight
they could resolve after the clear and re-append stale entries, doubling
the list.  Combine both into a single Promise.all with one replace:true
dispatch so the race is impossible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lincoln Stein
2026-04-13 22:28:18 -04:00
parent 5b969af483
commit 3404cd7670

View File

@@ -737,163 +737,186 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
}
}
// Handle IP Adapters as Reference Images
if (data.parameters.ip_adapters !== undefined && Array.isArray(data.parameters.ip_adapters)) {
log.debug(`Processing ${data.parameters.ip_adapters.length} IP adapter(s)`);
// Handle IP Adapters and model-free reference images together.
//
// Both ip_adapters and reference_images feed into the same refImages
// Redux slice. Previously they were dispatched as two independent
// Promise.all chains — the first with replace:true, the second with
// replace:false — which created a race: if a previous recall's
// reference-image promises were still in-flight they could resolve
// after the clear and re-append stale entries, doubling the list.
//
// Fix: collect every promise into a single array and dispatch exactly
// once with replace:true after all of them settle.
{
/* eslint-disable @typescript-eslint/no-explicit-any */
const ipAdaptersArr: any[] = Array.isArray(data.parameters.ip_adapters)
? (data.parameters.ip_adapters as any[])
: [];
const refImagesArr: any[] = Array.isArray(data.parameters.reference_images)
? (data.parameters.reference_images as any[])
: [];
/* eslint-enable @typescript-eslint/no-explicit-any */
// If the list is explicitly empty, clear existing reference images
if (data.parameters.ip_adapters.length === 0) {
dispatch(refImagesRecalled({ entities: [], replace: true }));
log.info('Cleared all IP adapter reference images');
} else {
// Build promises for all IP adapters, then dispatch once with replace: true
const ipAdapterPromises = data.parameters.ip_adapters
.filter((cfg) => cfg.model_key && typeof cfg.model_key === 'string')
.map(async (adapterConfig) => {
try {
const modelConfig = await dispatch(
modelsApi.endpoints.getModelConfig.initiate(adapterConfig.model_key!)
).unwrap();
const hasIpAdapters = data.parameters.ip_adapters !== undefined;
const hasRefImages = data.parameters.reference_images !== undefined;
// Pre-fetch the image DTO if an image is provided, to avoid validation errors
if (adapterConfig.image?.image_name) {
try {
await dispatch(imagesApi.endpoints.getImageDTO.initiate(adapterConfig.image.image_name)).unwrap();
} catch (imageError) {
log.warn(
`Could not pre-fetch image ${adapterConfig.image.image_name}, continuing anyway: ${imageError}`
if (hasIpAdapters || hasRefImages) {
const allRefImagePromises: Promise<RefImageState | null>[] = [];
// --- IP Adapters ---
if (hasIpAdapters && ipAdaptersArr.length > 0) {
log.debug(`Processing ${ipAdaptersArr.length} IP adapter(s)`);
const ipAdapterPromises = ipAdaptersArr
.filter((cfg: any) => cfg.model_key && typeof cfg.model_key === 'string') // eslint-disable-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(async (adapterConfig: any): Promise<RefImageState | null> => {
try {
const modelConfig = await dispatch(
modelsApi.endpoints.getModelConfig.initiate(adapterConfig.model_key!)
).unwrap();
// Pre-fetch the image DTO if an image is provided, to avoid validation errors
if (adapterConfig.image?.image_name) {
try {
await dispatch(imagesApi.endpoints.getImageDTO.initiate(adapterConfig.image.image_name)).unwrap();
} catch (imageError) {
log.warn(
`Could not pre-fetch image ${adapterConfig.image.image_name}, continuing anyway: ${imageError}`
);
}
}
// Build RefImageState using helper function - supports both ip_adapter and flux_redux
const imageData = adapterConfig.image
? {
original: {
image: {
image_name: adapterConfig.image.image_name,
width: adapterConfig.image.width ?? 512,
height: adapterConfig.image.height ?? 512,
},
},
}
: null;
const isFluxRedux = modelConfig.type === 'flux_redux';
const refImageState = getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
isEnabled: true,
config: isFluxRedux
? {
type: 'flux_redux',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
imageInfluence: (adapterConfig.image_influence as FLUXReduxImageInfluence) || 'highest',
}
: {
type: 'ip_adapter',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
weight: typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0,
beginEndStepPct: [
typeof adapterConfig.begin_step_percent === 'number' ? adapterConfig.begin_step_percent : 0,
typeof adapterConfig.end_step_percent === 'number' ? adapterConfig.end_step_percent : 1,
] as [number, number],
method: (adapterConfig.method as IPMethodV2) || 'full',
clipVisionModel: 'ViT-H',
},
});
if (isFluxRedux) {
log.debug(`Built FLUX Redux ref image state: ${modelConfig.name}`);
} else {
log.debug(
`Built IP adapter ref image state: ${modelConfig.name} (weight: ${typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0})`
);
}
if (adapterConfig.image?.image_name) {
log.debug(
`IP adapter image: outputs/images/${adapterConfig.image.image_name} (${adapterConfig.image.width}x${adapterConfig.image.height})`
);
}
return refImageState;
} catch (error) {
log.error(`Failed to load IP adapter ${adapterConfig.model_key}: ${error}`);
return null;
}
});
allRefImagePromises.push(...ipAdapterPromises);
}
// --- Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit) ---
// These feed the reference image directly into the main model rather than going
// through an IP Adapter, so the backend sends them without a model_key and we
// pick the right config type via getDefaultRefImageConfig() based on the main
// model that is currently selected in the UI.
if (hasRefImages && refImagesArr.length > 0) {
log.debug(`Processing ${refImagesArr.length} reference image(s)`);
const referenceImagePromises = refImagesArr
.filter((cfg: any) => cfg.image?.image_name && typeof cfg.image.image_name === 'string') // eslint-disable-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(async (refConfig: any): Promise<RefImageState | null> => {
const imageName = refConfig.image.image_name as string;
try {
// Pre-fetch the image DTO so ref image validation succeeds.
await dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName)).unwrap();
} catch (imageError) {
log.warn(`Could not pre-fetch reference image ${imageName}, continuing anyway: ${imageError}`);
}
// Build RefImageState using helper function - supports both ip_adapter and flux_redux
const imageData = adapterConfig.image
? {
original: {
image: {
image_name: adapterConfig.image.image_name,
width: adapterConfig.image.width ?? 512,
height: adapterConfig.image.height ?? 512,
},
},
}
: null;
// Pick the config flavor (flux2 / flux_kontext / ip_adapter fallback) that
// matches the currently-selected main model.
const baseConfig = getDefaultRefImageConfig(getState);
const imageData = {
original: {
image: {
image_name: imageName,
width: typeof refConfig.image.width === 'number' ? refConfig.image.width : 512,
height: typeof refConfig.image.height === 'number' ? refConfig.image.height : 512,
},
},
};
const isFluxRedux = modelConfig.type === 'flux_redux';
const refImageState = getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
return getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
isEnabled: true,
config: isFluxRedux
? {
type: 'flux_redux',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
imageInfluence: (adapterConfig.image_influence as FLUXReduxImageInfluence) || 'highest',
}
: {
type: 'ip_adapter',
image: imageData,
model: {
key: modelConfig.key,
hash: modelConfig.hash,
name: modelConfig.name,
base: modelConfig.base,
type: modelConfig.type,
},
weight: typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0,
beginEndStepPct: [
typeof adapterConfig.begin_step_percent === 'number' ? adapterConfig.begin_step_percent : 0,
typeof adapterConfig.end_step_percent === 'number' ? adapterConfig.end_step_percent : 1,
] as [number, number],
method: (adapterConfig.method as IPMethodV2) || 'full',
clipVisionModel: 'ViT-H',
},
config: { ...baseConfig, image: imageData },
});
});
if (isFluxRedux) {
log.debug(`Built FLUX Redux ref image state: ${modelConfig.name}`);
} else {
log.debug(
`Built IP adapter ref image state: ${modelConfig.name} (weight: ${typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0})`
);
}
if (adapterConfig.image?.image_name) {
log.debug(
`IP adapter image: outputs/images/${adapterConfig.image.image_name} (${adapterConfig.image.width}x${adapterConfig.image.height})`
);
}
allRefImagePromises.push(...referenceImagePromises);
}
return refImageState;
} catch (error) {
log.error(`Failed to load IP adapter ${adapterConfig.model_key}: ${error}`);
return null;
}
});
// Wait for all IP adapters to load, then dispatch with replace: true
Promise.all(ipAdapterPromises).then((refImageStates) => {
const validStates = refImageStates.filter((state): state is RefImageState => state !== null);
// Single dispatch after all IP adapter + reference image promises settle.
// Always replace:true so stale entries from a previous recall are cleared.
Promise.all(allRefImagePromises).then((results) => {
const validStates = results.filter((state): state is RefImageState => state !== null);
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
if (validStates.length > 0) {
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
log.info(`Applied ${validStates.length} IP adapter(s), replacing existing list`);
log.info(
`Applied ${validStates.length} reference image(s) (IP adapters + model-free), replacing existing list`
);
} else {
log.info('Cleared all reference images');
}
});
}
}
// Handle model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit).
// These feed the reference image directly into the main model rather than going
// through an IP Adapter, so the backend sends them without a model_key and we
// pick the right config type via getDefaultRefImageConfig() based on the main
// model that is currently selected in the UI.
if (data.parameters.reference_images !== undefined && Array.isArray(data.parameters.reference_images)) {
log.debug(`Processing ${data.parameters.reference_images.length} reference image(s)`);
const referenceImagePromises = data.parameters.reference_images
.filter((cfg) => cfg.image?.image_name && typeof cfg.image.image_name === 'string')
.map(async (refConfig) => {
const imageName = refConfig.image.image_name as string;
try {
// Pre-fetch the image DTO so ref image validation succeeds.
await dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName)).unwrap();
} catch (imageError) {
log.warn(`Could not pre-fetch reference image ${imageName}, continuing anyway: ${imageError}`);
}
// Pick the config flavor (flux2 / flux_kontext / ip_adapter fallback) that
// matches the currently-selected main model.
const baseConfig = getDefaultRefImageConfig(getState);
const imageData = {
original: {
image: {
image_name: imageName,
width: typeof refConfig.image.width === 'number' ? refConfig.image.width : 512,
height: typeof refConfig.image.height === 'number' ? refConfig.image.height : 512,
},
},
};
return getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
isEnabled: true,
config: { ...baseConfig, image: imageData },
});
});
// Append reference images to whatever the ip_adapters branch already
// dispatched — replace:false so we don't clobber IP adapters.
Promise.all(referenceImagePromises).then((refImageStates) => {
const validStates = refImageStates.filter((state): state is RefImageState => state !== null);
if (validStates.length > 0) {
dispatch(refImagesRecalled({ entities: validStates, replace: false }));
log.info(`Applied ${validStates.length} model-free reference image(s)`);
}
});
}
}
});