Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions __tests__/unit/services/modelManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2649,11 +2649,21 @@ describe('ModelManager', () => {
expect(result).toHaveLength(0);
});

it('re-unzips from valid zip when _zip_name present and zip valid', async () => {
// coreml-named dir → the mnn/qnn completeness gate (ensureImageExtractionComplete) is a no-op
// (coreml integrity is validated in coreMLModelUtils). Asserts the re-unzip→register RECOVERY
// mechanics; the mnn completeness gate on the re-unzip path is covered by
// reconcilePartialUnzipG7.test.ts (B15/G7).
it('re-unzips from valid zip when _zip_name present and zip valid (coreml — completeness-exempt)', async () => {
setupInitialized();
// resetAllMocks (beforeEach) clears the module mock impl — re-establish it, like the coreml
// resolve test below.
(mockedResolveCoreML as jest.Mock).mockImplementation((dir: string) =>
Promise.resolve(`${dir}/model.mlpackage`),
);
mockedAsyncStorage.getItem
.mockResolvedValueOnce('[]')
.mockResolvedValueOnce('[]');
.mockResolvedValueOnce('[]') // reconciler read
.mockResolvedValueOnce('[]') // addDownloadedImageModel read
.mockResolvedValueOnce(null); // addDownloadedImageModel save

mockedRNFS.exists
.mockResolvedValueOnce(true) // imageModelsDir scan
Expand All @@ -2663,13 +2673,13 @@ describe('ModelManager', () => {

mockedRNFS.readDir
.mockResolvedValueOnce([
{ name: 'sd_v15_mnn', path: `${IMAGE_MODELS_DIR}/sd_v15_mnn`, size: 0, isFile: () => false, isDirectory: () => true },
{ name: 'sd_v15_coreml', path: `${IMAGE_MODELS_DIR}/sd_v15_coreml`, size: 0, isFile: () => false, isDirectory: () => true },
] as any)
.mockResolvedValueOnce([
{ name: 'unet.mnn', path: `${IMAGE_MODELS_DIR}/sd_v15_mnn/unet.mnn`, size: 500000000, isFile: () => true, isDirectory: () => false },
{ name: 'model.mlpackage', path: `${IMAGE_MODELS_DIR}/sd_v15_coreml/model.mlpackage`, size: 500000000, isFile: () => true, isDirectory: () => false },
] as any);

mockedRNFS.readFile = jest.fn().mockResolvedValueOnce('sd_v15_mnn.zip');
mockedRNFS.readFile = jest.fn().mockResolvedValueOnce('sd_v15_coreml.zip');
mockedRNFS.stat = jest.fn().mockResolvedValue({ size: 400000000, isFile: () => true } as any);
mockedRNFS.read = jest.fn().mockResolvedValue('PK\x03\x04');
mockedRNFS.writeFile = jest.fn().mockResolvedValue(undefined as any);
Expand All @@ -2678,11 +2688,11 @@ describe('ModelManager', () => {
const result = await modelManager.reconcileFinishedImageDownloads(new Set());

expect(mockedUnzip).toHaveBeenCalledWith(
`${IMAGE_MODELS_DIR}/sd_v15_mnn.zip`,
`${IMAGE_MODELS_DIR}/sd_v15_mnn`,
`${IMAGE_MODELS_DIR}/sd_v15_coreml.zip`,
`${IMAGE_MODELS_DIR}/sd_v15_coreml`,
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('sd_v15_mnn');
expect(result[0].id).toBe('sd_v15_coreml');
});

it('deletes partial dir when _zip_name present but zip is missing', async () => {
Expand Down
133 changes: 133 additions & 0 deletions __tests__/unit/services/modelManager/reconcilePartialUnzipG7.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* B15 / G7 — reconcileFinishedImageDownloads must NOT register a partially-extracted image model.
*
* The recovery path re-unzips a `_zip_name` remnant (a mid-unzip kill) and used to register the
* model on isValidZip alone (size>0 + PK header) — skipping the completeness gate the live/resume
* paths run. A truncated-but-PK zip yields a PARTIAL mnn tree (missing base files / weight pairs),
* registered as usable → native crash at generation. The fix runs ensureImageExtractionComplete
* after the recovery unzip; a still-incomplete extraction is cleaned up and NEVER marked _ready.
*
* This drives the REAL reconcile + the REAL completeness gate (ensureImageExtractionComplete /
* validateImageModelDir are NOT mocked). Only the third-party boundary — react-native-fs and the
* native unzip — is faked, via a path-addressed in-memory FS (order-independent, not brittle
* sequential mocks). addImageModel is the injected sink, so we assert the real outcome: a partial
* model is neither registered nor left on disk; a complete one is.
*/
jest.mock('react-native-fs', () => {
const files = new Map<string, { isFile: boolean; size: number }>();
const norm = (p: string) => p.replace(/\/+$/, '');
return {
__files: files,
exists: jest.fn(async (p: string) => files.has(norm(p))),
stat: jest.fn(async (p: string) => {
const f = files.get(norm(p));
if (!f) throw new Error('ENOENT');
return { size: f.size, isFile: () => f.isFile, isDirectory: () => !f.isFile };
}),
read: jest.fn(async (p: string) => (norm(p).endsWith('.zip') ? 'PK' : '')),
readFile: jest.fn(async (p: string) => {
const f = files.get(norm(p));
return f && (f as any).content !== undefined ? (f as any).content : '';
}),
readDir: jest.fn(async (p: string) => {
const base = norm(p);
const seen = new Set<string>();
const out: any[] = [];
for (const path of files.keys()) {
if (path === base || !path.startsWith(`${base}/`)) continue;
const rest = path.slice(base.length + 1);
const first = rest.split('/')[0];
const childPath = `${base}/${first}`;
if (seen.has(childPath)) continue;
seen.add(childPath);
const childMeta = files.get(childPath);
const isFile = rest.includes('/') ? false : (childMeta?.isFile ?? true);
out.push({
name: first,
path: childPath,
size: childMeta?.size ?? 0,
isFile: () => isFile,
isDirectory: () => !isFile,
});
}
return out;
}),
writeFile: jest.fn(async (p: string, content = '') => { files.set(norm(p), { isFile: true, size: content.length || 1, ...(content ? { content } : {}) } as any); }),
unlink: jest.fn(async (p: string) => {
const base = norm(p);
for (const key of [...files.keys()]) if (key === base || key.startsWith(`${base}/`)) files.delete(key);
}),
};
});
jest.mock('react-native-zip-archive', () => ({ unzip: jest.fn(async () => '') }));
jest.mock('../../../../src/services/modelManager/storage', () => ({
buildDownloadedModel: jest.fn(), persistDownloadedModel: jest.fn(),
loadDownloadedModels: jest.fn(async () => []), saveModelsList: jest.fn(),
}));
jest.mock('../../../../src/utils/coreMLModelUtils', () => ({ resolveCoreMLModelDir: jest.fn(async (d: string) => d) }));

import RNFS from 'react-native-fs';
import { reconcileFinishedImageDownloads } from '../../../../src/services/modelManager/scan';

const files: Map<string, { isFile: boolean; size: number; content?: string }> = (RNFS as any).__files;
const IMG = '/img';
const ID = 'Sd_Test_Mnn'; // detectBackend → 'mnn'
const DIR = `${IMG}/${ID}`;
const ZIP = `${IMG}/archive.zip`;

const seed = (p: string, size = 1024, content?: string) =>
files.set(p, { isFile: true, size, ...(content !== undefined ? { content } : {}) });

const COMPLETE_MNN = [
'pos_emb.bin', 'token_emb.bin', 'tokenizer.json',
'unet.mnn', 'unet.mnn.weight',
'vae_decoder.mnn', 'vae_decoder.mnn.weight',
'clip_v2.mnn', 'clip_v2.mnn.weight',
];

function stageCommon() {
files.clear();
files.set(IMG, { isFile: false, size: 0 });
files.set(DIR, { isFile: false, size: 0 });
seed(`${DIR}/_zip_name`, 12, 'archive.zip'); // remnant of a mid-unzip kill (no _ready)
seed(ZIP, 24 * 1024 * 1024); // valid PK zip, non-zero
}

const opts = () => ({
imageModelsDir: IMG,
getImageModels: jest.fn(async (): Promise<any[]> => []),
addImageModel: jest.fn(async () => {}),
activeModelIds: new Set<string>(),
});

describe('reconcileFinishedImageDownloads — partial re-unzip completeness gate (B15/G7)', () => {
it('does NOT register a partially-extracted mnn model, and cleans the dir up', async () => {
stageCommon();
// Partial extraction: only the unet graph landed — base files, weight pairs, clip, vae missing.
seed(`${DIR}/unet.mnn`, 4096);
const o = opts();

const out = await reconcileFinishedImageDownloads(o);

expect(o.addImageModel).not.toHaveBeenCalled(); // never published
expect(out).toEqual([]);
expect(files.has(`${DIR}/_ready`)).toBe(false); // never marked ready
expect(files.has(DIR)).toBe(false); // unusable dir removed → re-downloadable
expect(files.has(ZIP)).toBe(false); // and the bad zip cleaned up
});

it('DOES register a fully-extracted mnn model (gate passes — no over-block)', async () => {
stageCommon();
COMPLETE_MNN.forEach(f => seed(`${DIR}/${f}`, 4096));
const o = opts();

const out = await reconcileFinishedImageDownloads(o);

expect(o.addImageModel).toHaveBeenCalledTimes(1);
expect(out).toHaveLength(1);
expect(out[0].id).toBe(ID);
expect(out[0].backend).toBe('mnn');
expect(files.has(`${DIR}/_ready`)).toBe(true); // marked ready
expect(files.has(DIR)).toBe(true); // kept
});
});
11 changes: 7 additions & 4 deletions __tests__/unit/services/modelManager/scan.branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,24 @@ describe('reconcileFinishedImageDownloads', () => {
expect(out).toEqual([]);
});

it('re-unzips when _zip_name present and zip valid', async () => {
// coreml-named dir → the mnn/qnn completeness gate is a no-op (coreml integrity is validated in
// coreMLModelUtils). The mnn completeness gate on the re-unzip path is covered by
// reconcilePartialUnzipG7.test.ts (B15). This test asserts the re-unzip→register recovery mechanics.
it('re-unzips when _zip_name present and zip valid (coreml — completeness-exempt)', async () => {
const opts = base();
mockedRNFS.exists
.mockResolvedValueOnce(true) // dir
.mockResolvedValueOnce(false) // _ready missing
.mockResolvedValueOnce(true) // _zip_name present
.mockResolvedValueOnce(true); // isValidZip: zip exists
mockedRNFS.readDir
.mockResolvedValueOnce([dir('Zipped', '/img/Zipped')])
.mockResolvedValueOnce([file('w', '/img/Zipped/w', 999)]); // getDirSize
.mockResolvedValueOnce([dir('coreml_Zipped', '/img/coreml_Zipped')])
.mockResolvedValueOnce([file('w', '/img/coreml_Zipped/w', 999)]); // getDirSize
(mockedRNFS.readFile as jest.Mock).mockResolvedValueOnce('archive.zip');
(mockedRNFS.stat as jest.Mock).mockResolvedValueOnce({ size: 1024 });
(mockedRNFS.read as jest.Mock).mockResolvedValueOnce('PK');
const out = await reconcileFinishedImageDownloads(opts);
expect(unzip).toHaveBeenCalledWith('/img/archive.zip', '/img/Zipped');
expect(unzip).toHaveBeenCalledWith('/img/archive.zip', '/img/coreml_Zipped');
expect(out).toHaveLength(1);
expect(out[0].size).toBe(999);
});
Expand Down
110 changes: 59 additions & 51 deletions src/services/modelManager/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DownloadedModel, LlamaDownloadedModel, LiteRTDownloadedModel, ModelFile
import { buildDownloadedModel, persistDownloadedModel, loadDownloadedModels, saveModelsList } from './storage';
import { copyFileWithProgress } from './copyFile';
import { resolveCoreMLModelDir } from '../../utils/coreMLModelUtils';
import { ensureImageExtractionComplete } from '../../utils/imageModelIntegrity';
// Single source of truth for projector detection + model↔projector matching (see src/services/mmproj.ts).
import { isMMProjFile, pickMmProjForModel } from '../mmproj';

Expand Down Expand Up @@ -133,6 +134,57 @@ async function isValidZip(zipPath: string): Promise<boolean> {
return true;
}

/** Build the ONNXImageModel record for a recovered on-disk dir (coreml resolves its inner model dir). */
async function buildRecoveredImageModel(
item: { name: string; path: string },
backend: 'mnn' | 'qnn' | 'coreml',
): Promise<ONNXImageModel> {
let modelPath = item.path;
if (backend === 'coreml') modelPath = await resolveCoreMLModelDir(item.path).catch(() => item.path);
const totalSize = await getDirSize(item.path);
return {
id: item.name,
name: item.name.replaceAll('_', ' '),
description: '',
modelPath,
size: totalSize,
downloadedAt: new Date().toISOString(),
backend,
};
}

/**
* Recover an image model from a `_zip_name` remnant (a mid-unzip kill): re-unzip and register it —
* but ONLY if the extraction is complete. isValidZip checks the PK header + size>0 only, so a
* truncated-but-PK zip yields a PARTIAL mnn/qnn tree that crashes natively at generation (G7). Run
* the same completeness gate the live/resume paths run; on a still-incomplete extraction, clean up
* and return null so the dir resurfaces as re-downloadable. Returns null (and cleans up) on any
* unrecoverable state; never marks `_ready` for a partial model.
*/
async function recoverImageModelFromZipRemnant(
item: { name: string; path: string },
imageModelsDir: string,
): Promise<ONNXImageModel | null> {
const zipFileName = (await RNFS.readFile(`${item.path}/_zip_name`, 'utf8')).trim();
const zipPath = `${imageModelsDir}/${zipFileName}`;
if (!(await isValidZip(zipPath))) {
await RNFS.unlink(item.path).catch(() => {}); // zip gone or corrupt — partial dir unrecoverable
return null;
}
await unzip(zipPath, item.path);
const backend = detectBackend(item.name);
try {
await ensureImageExtractionComplete({ backend, modelDir: item.path, zipPath, modelId: item.name });
} catch {
await RNFS.unlink(item.path).catch(() => {});
await RNFS.unlink(zipPath).catch(() => {});
return null;
}
await RNFS.unlink(zipPath).catch(() => {});
await RNFS.writeFile(`${item.path}/_ready`, '', 'utf8').catch(() => {});
return buildRecoveredImageModel(item, backend);
}
Comment on lines +168 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Two cleanup gaps in the recovery flow.

  1. Lines 170-173: when the zip is invalid/corrupt, only item.path is removed — the corrupt zipPath itself is left on disk forever (no future pass will revisit it, since the _zip_name remnant dir is gone). This is a permanent storage leak for potentially large files.
  2. Lines 183-185: zipPath is deleted before _ready is written. A crash (or a swallowed writeFile failure) between these two lines makes the dir look like an unrecoverable _zip_name remnant on the next pass (isValidZip now fails since the zip is gone), causing the just-recovered model directory to be deleted outright — turning a successful recovery into full data loss.
🛠️ Proposed fix
   if (!(await isValidZip(zipPath))) {
     await RNFS.unlink(item.path).catch(() => {}); // zip gone or corrupt — partial dir unrecoverable
+    await RNFS.unlink(zipPath).catch(() => {});
     return null;
   }
   await unzip(zipPath, item.path);
   const backend = detectBackend(item.name);
   try {
     await ensureImageExtractionComplete({ backend, modelDir: item.path, zipPath, modelId: item.name });
   } catch {
     await RNFS.unlink(item.path).catch(() => {});
     await RNFS.unlink(zipPath).catch(() => {});
     return null;
   }
-  await RNFS.unlink(zipPath).catch(() => {});
   await RNFS.writeFile(`${item.path}/_ready`, '', 'utf8').catch(() => {});
+  await RNFS.unlink(zipPath).catch(() => {});
   return buildRecoveredImageModel(item, backend);

Reordering means a crash between the two lines only leaves a harmless orphaned zip (recovered dir is safe via the hasReady branch), instead of risking deletion of a fully-recovered model.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const zipFileName = (await RNFS.readFile(`${item.path}/_zip_name`, 'utf8')).trim();
const zipPath = `${imageModelsDir}/${zipFileName}`;
if (!(await isValidZip(zipPath))) {
await RNFS.unlink(item.path).catch(() => {}); // zip gone or corrupt — partial dir unrecoverable
return null;
}
await unzip(zipPath, item.path);
const backend = detectBackend(item.name);
try {
await ensureImageExtractionComplete({ backend, modelDir: item.path, zipPath, modelId: item.name });
} catch {
await RNFS.unlink(item.path).catch(() => {});
await RNFS.unlink(zipPath).catch(() => {});
return null;
}
await RNFS.unlink(zipPath).catch(() => {});
await RNFS.writeFile(`${item.path}/_ready`, '', 'utf8').catch(() => {});
return buildRecoveredImageModel(item, backend);
}
const zipFileName = (await RNFS.readFile(`${item.path}/_zip_name`, 'utf8')).trim();
const zipPath = `${imageModelsDir}/${zipFileName}`;
if (!(await isValidZip(zipPath))) {
await RNFS.unlink(item.path).catch(() => {}); // zip gone or corrupt — partial dir unrecoverable
await RNFS.unlink(zipPath).catch(() => {});
return null;
}
await unzip(zipPath, item.path);
const backend = detectBackend(item.name);
try {
await ensureImageExtractionComplete({ backend, modelDir: item.path, zipPath, modelId: item.name });
} catch {
await RNFS.unlink(item.path).catch(() => {});
await RNFS.unlink(zipPath).catch(() => {});
return null;
}
await RNFS.writeFile(`${item.path}/_ready`, '', 'utf8').catch(() => {});
await RNFS.unlink(zipPath).catch(() => {});
return buildRecoveredImageModel(item, backend);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/modelManager/scan.ts` around lines 168 - 186, Update the
recovery flow around isValidZip, ensureImageExtractionComplete, and _ready
creation to delete zipPath alongside item.path when the zip is invalid, and
write _ready before removing zipPath. Preserve cleanup on extraction failure and
only unlink the zip after the recovered directory is marked ready.


export async function reconcileFinishedImageDownloads(opts: ReconcileImageModelsOpts): Promise<ONNXImageModel[]> {
const { imageModelsDir, getImageModels, addImageModel, activeModelIds } = opts;
const recovered: ONNXImageModel[] = [];
Expand Down Expand Up @@ -188,63 +240,19 @@ export async function reconcileFinishedImageDownloads(opts: ReconcileImageModels

if (hasReady) {
// Unzip completed but registerAndNotify was killed — register now.
const backend = detectBackend(item.name);
let modelPath = item.path;
if (backend === 'coreml') {
modelPath = await resolveCoreMLModelDir(item.path).catch(() => item.path);
}
const totalSize = await getDirSize(item.path);
const newModel: ONNXImageModel = {
id: item.name,
name: item.name.replaceAll('_', ' '),
description: '',
modelPath,
size: totalSize,
downloadedAt: new Date().toISOString(),
backend,
};
const newModel = await buildRecoveredImageModel(item, detectBackend(item.name));
await addImageModel(newModel);
recovered.push(newModel);
continue;
}

// No _ready — check if a zip exists to re-unzip (mid-unzip kill).
const zipNamePath = `${item.path}/_zip_name`;
const hasZipName = await RNFS.exists(zipNamePath);

if (hasZipName) {
try {
const zipFileName = (await RNFS.readFile(zipNamePath, 'utf8')).trim();
const zipPath = `${imageModelsDir}/${zipFileName}`;
const zipOk = await isValidZip(zipPath);

if (zipOk) {
await unzip(zipPath, item.path);
await RNFS.unlink(zipPath).catch(() => {});
await RNFS.writeFile(readyPath, '', 'utf8').catch(() => {});
const backend = detectBackend(item.name);
let modelPath = item.path;
if (backend === 'coreml') {
modelPath = await resolveCoreMLModelDir(item.path).catch(() => item.path);
}
const totalSize = await getDirSize(item.path);
const newModel: ONNXImageModel = {
id: item.name,
name: item.name.replaceAll('_', ' '),
description: '',
modelPath,
size: totalSize,
downloadedAt: new Date().toISOString(),
backend,
};
await addImageModel(newModel);
recovered.push(newModel);
} else {
// Zip is gone or corrupt — partial dir is unrecoverable, clean up.
await RNFS.unlink(item.path).catch(() => {});
}
} catch {
// Non-fatal: leave for the next startup attempt.
if (await RNFS.exists(`${item.path}/_zip_name`)) {
// Non-fatal on unexpected error: leave the dir for the next startup attempt.
const model = await recoverImageModelFromZipRemnant(item, imageModelsDir).catch(() => null);
if (model) {
await addImageModel(model);
recovered.push(model);
}
} else {
// No _ready and no _zip_name — stale artifact from a cancelled or
Expand Down
Loading