Skip to content

Commit 3a3ce4c

Browse files
author
JooHyung Park
committed
[ai-assisted] test: add recovery-path coverage and reduce manifest churn
1 parent ad5899c commit 3a3ce4c

5 files changed

Lines changed: 125 additions & 21 deletions

File tree

runtime/llama/runtime-manifest.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
"assetName": "llama-b8855-bin-macos-x64.tar.gz",
2525
"assetUrl": "https://github.com/ggml-org/llama.cpp/releases/download/b8855/llama-b8855-bin-macos-x64.tar.gz",
2626
"assetSha256": "5f229b3a815280737f2e8b93b49d8ca9c6d0d87bddb280c69266ea1a06daa3e2",
27-
"lastResolvedAt": "2026-04-20T15:20:36.252Z"
27+
"lastResolvedAt": "2026-04-20T15:20:36.252Z",
28+
"binaryRelativePath": "bin/darwin-x64/llama-server",
29+
"binarySha256": "9351344633ce46a57be2c5ff40d2e06e51a0576c3e5c3c1f5780a7d36360d2c4",
30+
"runtimeDependencyCount": 24,
31+
"preparedAt": "2026-04-20T16:17:58.902Z"
2832
},
2933
"windows-x64": {
3034
"assetName": "llama-b8855-bin-win-cpu-x64.zip",
@@ -42,5 +46,5 @@
4246
"lastResolvedAt": "2026-04-20T15:20:36.252Z"
4347
}
4448
},
45-
"updatedAt": "2026-04-20T15:36:51.802Z"
49+
"updatedAt": "2026-04-20T16:17:58.903Z"
4650
}

scripts/prepare-llama-runtime.mjs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ async function main() {
280280
await mkdir(CACHE_ROOT, { recursive: true });
281281

282282
const manifest = await loadManifest();
283+
let manifestDirty = false;
283284
const shouldRefresh = options.refresh || !manifest.release;
284285

285286
if (shouldRefresh) {
@@ -308,8 +309,7 @@ async function main() {
308309
lastResolvedAt: new Date().toISOString(),
309310
};
310311
}
311-
manifest.updatedAt = new Date().toISOString();
312-
await saveManifest(manifest);
312+
manifestDirty = true;
313313
}
314314

315315
for (const target of targets) {
@@ -353,22 +353,38 @@ async function main() {
353353
await chmod(binaryDestination, 0o755);
354354
}
355355
const dependencyCount = await copyRuntimeDependencies(binarySource, outputDir);
356-
357356
const binarySha = await computeSha256(binaryDestination);
358-
manifest.platforms[target] = {
359-
...manifest.platforms[target],
360-
binaryRelativePath: relative(RUNTIME_ROOT, binaryDestination).replace(/\\/g, '/'),
357+
358+
const binaryRelativePath = relative(RUNTIME_ROOT, binaryDestination).replace(/\\/g, '/');
359+
const previousEntry = manifest.platforms[target] || {};
360+
const preservePreparedAt =
361+
previousEntry.binarySha256 === binarySha
362+
&& previousEntry.binaryRelativePath === binaryRelativePath
363+
&& previousEntry.runtimeDependencyCount === dependencyCount;
364+
const preparedAt = preservePreparedAt
365+
? (previousEntry.preparedAt || new Date().toISOString())
366+
: new Date().toISOString();
367+
368+
const nextEntry = {
369+
...previousEntry,
370+
binaryRelativePath,
361371
binarySha256: binarySha,
362372
runtimeDependencyCount: dependencyCount,
363-
preparedAt: new Date().toISOString(),
373+
preparedAt,
364374
};
375+
if (JSON.stringify(previousEntry) !== JSON.stringify(nextEntry)) {
376+
manifest.platforms[target] = nextEntry;
377+
manifestDirty = true;
378+
}
365379

366380
const size = (await stat(binaryDestination)).size;
367381
console.log(`[prepare-llama-runtime] prepared ${target}: ${binaryDestination} (${size} bytes, deps=${dependencyCount})`);
368382
}
369383

370-
manifest.updatedAt = new Date().toISOString();
371-
await saveManifest(manifest);
384+
if (manifestDirty) {
385+
manifest.updatedAt = new Date().toISOString();
386+
await saveManifest(manifest);
387+
}
372388
}
373389

374390
main().catch((error) => {

src/main/assistant/ModelInstallService.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import type {
2828
AssistantModelDownloadState,
2929
AssistantModelUploadRequest,
3030
} from '../../shared/types';
31+
import {
32+
isResumeNotSatisfiable,
33+
resolveCorruptedArtifactCleanupPaths,
34+
resolveResumePreparation,
35+
} from './modelDownloadRecovery';
3136

3237
interface DownloadSnapshot {
3338
state: AssistantModelDownloadState;
@@ -479,34 +484,34 @@ export class ModelInstallService {
479484
unlinkSync(finalPath);
480485
}
481486

482-
let existingPartBytes = existsSync(partPath) ? statSync(partPath).size : 0;
483-
if (existingPartBytes >= artifact.expectedSizeBytes) {
487+
const existingPartBytes = existsSync(partPath) ? statSync(partPath).size : 0;
488+
const resumePreparation = resolveResumePreparation(existingPartBytes, artifact.expectedSizeBytes);
489+
if (resumePreparation.resetPart) {
484490
this.safeDeleteFile(partPath);
485-
existingPartBytes = 0;
486491
}
487492

488493
const headers: Record<string, string> = {};
489-
if (existingPartBytes > 0) {
490-
headers.Range = `bytes=${existingPartBytes}-`;
494+
if (resumePreparation.rangeHeader) {
495+
headers.Range = resumePreparation.rangeHeader;
491496
}
492497
const response = await fetch(artifact.url, {
493498
method: 'GET',
494499
...(Object.keys(headers).length > 0 ? { headers } : {}),
495500
signal,
496501
});
497-
if (response.status === 416) {
502+
if (isResumeNotSatisfiable(response.status)) {
498503
this.safeDeleteFile(partPath);
499504
throw new Error(`Failed to resume download for ${artifact.fileName}: HTTP 416`);
500505
}
501506
if (!response.ok && response.status !== 206) {
502507
throw new Error(`Failed to download ${artifact.fileName}: HTTP ${response.status}`);
503508
}
504509

505-
const appendMode = response.status === 206 && existingPartBytes > 0;
510+
const appendMode = response.status === 206 && resumePreparation.nextPartBytes > 0;
506511
const fileStream = createWriteStream(partPath, { flags: appendMode ? 'a' : 'w' });
507512

508513
if (this.activeDownloadTask && appendMode) {
509-
this.activeDownloadTask.downloadedBytes += existingPartBytes;
514+
this.activeDownloadTask.downloadedBytes += resumePreparation.nextPartBytes;
510515
this.updateDownloadProgress({ currentFile: artifact.fileName });
511516
}
512517

@@ -556,8 +561,9 @@ export class ModelInstallService {
556561

557562
private cleanupCorruptedArtifact(artifact: DownloadArtifact): void {
558563
const partPath = this.getPartPath(artifact.fileName);
559-
this.safeDeleteFile(partPath);
560-
this.safeDeleteFile(artifact.destinationPath);
564+
for (const cleanupPath of resolveCorruptedArtifactCleanupPaths(partPath, artifact.destinationPath)) {
565+
this.safeDeleteFile(cleanupPath);
566+
}
561567
}
562568

563569
private safeDeleteFile(filePath: string): void {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2026 Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved.
3+
*
4+
* Use of this source code is governed by an MIT-style license that can be found in the LICENSE file
5+
*/
6+
7+
import assert from 'node:assert/strict';
8+
import test from 'node:test';
9+
import {
10+
isResumeNotSatisfiable,
11+
resolveCorruptedArtifactCleanupPaths,
12+
resolveResumePreparation,
13+
} from './modelDownloadRecovery';
14+
15+
test('resolveResumePreparation keeps resumable partial downloads', () => {
16+
const result = resolveResumePreparation(1024, 4096);
17+
assert.equal(result.resetPart, false);
18+
assert.equal(result.nextPartBytes, 1024);
19+
assert.equal(result.rangeHeader, 'bytes=1024-');
20+
});
21+
22+
test('resolveResumePreparation resets oversized or complete partial files', () => {
23+
const equalSize = resolveResumePreparation(4096, 4096);
24+
assert.equal(equalSize.resetPart, true);
25+
assert.equal(equalSize.nextPartBytes, 0);
26+
assert.equal(equalSize.rangeHeader, null);
27+
28+
const oversized = resolveResumePreparation(8192, 4096);
29+
assert.equal(oversized.resetPart, true);
30+
assert.equal(oversized.nextPartBytes, 0);
31+
assert.equal(oversized.rangeHeader, null);
32+
});
33+
34+
test('isResumeNotSatisfiable only matches HTTP 416', () => {
35+
assert.equal(isResumeNotSatisfiable(416), true);
36+
assert.equal(isResumeNotSatisfiable(206), false);
37+
assert.equal(isResumeNotSatisfiable(500), false);
38+
});
39+
40+
test('resolveCorruptedArtifactCleanupPaths deduplicates paths', () => {
41+
const paths = resolveCorruptedArtifactCleanupPaths('/tmp/a.part', '/tmp/a.part');
42+
assert.deepEqual(paths, ['/tmp/a.part']);
43+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2026 Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved.
3+
*
4+
* Use of this source code is governed by an MIT-style license that can be found in the LICENSE file
5+
*/
6+
7+
export interface ResumePreparation {
8+
resetPart: boolean;
9+
nextPartBytes: number;
10+
rangeHeader: string | null;
11+
}
12+
13+
export function resolveResumePreparation(existingPartBytes: number, expectedSizeBytes: number): ResumePreparation {
14+
if (existingPartBytes >= expectedSizeBytes) {
15+
return {
16+
resetPart: true,
17+
nextPartBytes: 0,
18+
rangeHeader: null,
19+
};
20+
}
21+
22+
return {
23+
resetPart: false,
24+
nextPartBytes: existingPartBytes,
25+
rangeHeader: existingPartBytes > 0 ? `bytes=${existingPartBytes}-` : null,
26+
};
27+
}
28+
29+
export function isResumeNotSatisfiable(status: number): boolean {
30+
return status === 416;
31+
}
32+
33+
export function resolveCorruptedArtifactCleanupPaths(partPath: string, destinationPath: string): string[] {
34+
return Array.from(new Set([partPath, destinationPath]));
35+
}

0 commit comments

Comments
 (0)