mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Both PyPI onnxruntime flavors unpack into the same site-packages directory, so a bundle carrying the CPU build (transcription, via faster-whisper) overwrote the GPU build's native libraries during install while the stale onnxruntime_gpu dist-info kept claiming otherwise. Every ONNX-backed tool then silently ran on CPU. The installer now reconciles the flavor before the venv merge and the GPU build always wins, in both install orders; reinstalling any GPU bundle repairs a previously clobbered venv. gpu.py's warning now says exactly that. Build-side, build-bundle.sh gains the same reconcile and verify-bundle-compatibility.sh layers bundles through the real installer merge and asserts a single flavor. Verified live on an RTX 4070 against the published bundles: reproduced the clobber with the stock installer, then confirmed both the prevention and repair paths with the patched one. Fixes #490
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
const scriptPath = join(process.cwd(), "packages/ai/python/install_feature.py");
|
|
|
|
let tempDir: string;
|
|
let aiDir: string;
|
|
let modelsDir: string;
|
|
let venvDir: string;
|
|
let sitePackagesDir: string;
|
|
let manifestPath: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "snapotter-install-test-"));
|
|
aiDir = join(tempDir, "ai");
|
|
modelsDir = join(aiDir, "models");
|
|
venvDir = join(aiDir, "venv");
|
|
sitePackagesDir = join(venvDir, "lib", "python3.12", "site-packages");
|
|
manifestPath = join(tempDir, "feature-manifest.json");
|
|
|
|
mkdirSync(sitePackagesDir, { recursive: true });
|
|
mkdirSync(modelsDir, { recursive: true });
|
|
mkdirSync(join(aiDir, "staging"), { recursive: true });
|
|
writeFileSync(join(aiDir, "installed.json"), JSON.stringify({ bundles: {} }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function createTestTar(bundleId: string): { tarPath: string; sha256: string } {
|
|
const buildDir = join(tempDir, "build");
|
|
mkdirSync(join(buildDir, "models", "testmodel"), { recursive: true });
|
|
mkdirSync(join(buildDir, "site-packages", "testpkg"), { recursive: true });
|
|
writeFileSync(join(buildDir, "models", "testmodel", "weights.bin"), "model-weights");
|
|
writeFileSync(join(buildDir, "site-packages", "testpkg", "__init__.py"), "# test");
|
|
writeFileSync(
|
|
join(buildDir, "bundle.json"),
|
|
JSON.stringify({
|
|
bundleId,
|
|
version: "1.0.0-test",
|
|
arch: "amd64-gpu",
|
|
imageVersion: "2.0.0",
|
|
pythonVersion: "3.12",
|
|
models: ["testmodel"],
|
|
}),
|
|
);
|
|
|
|
const tarPath = join(tempDir, `${bundleId}-test.tar.gz`);
|
|
execFileSync("tar", ["czf", tarPath, "-C", buildDir, "."]);
|
|
rmSync(buildDir, { recursive: true });
|
|
|
|
const hash = createHash("sha256").update(readFileSync(tarPath)).digest("hex");
|
|
return { tarPath, sha256: hash };
|
|
}
|
|
|
|
function writeManifest(
|
|
bundleId: string,
|
|
tarPath: string,
|
|
sha256: string,
|
|
extra: Record<string, unknown> = {},
|
|
) {
|
|
const size = readFileSync(tarPath).length;
|
|
const manifest = {
|
|
manifestVersion: 2,
|
|
imageVersion: "2.0.0",
|
|
pythonVersion: "3.12",
|
|
basePackages: [],
|
|
bundleRepo: "snapotter/feature-bundles",
|
|
bundles: {
|
|
[bundleId]: {
|
|
name: "Test Bundle",
|
|
archives: {
|
|
"amd64-gpu": { file: tarPath, sha256, compressedSize: size, extractedSize: size * 2 },
|
|
"arm64-cpu": { file: tarPath, sha256, compressedSize: size, extractedSize: size * 2 },
|
|
},
|
|
models: [{ id: "testmodel", path: "testmodel/weights.bin", minSize: 0 }],
|
|
enablesTools: [],
|
|
...extra,
|
|
},
|
|
},
|
|
};
|
|
writeFileSync(manifestPath, JSON.stringify(manifest));
|
|
}
|
|
|
|
/** Put a real python3 at venv/bin/python3 so the post-install smoke check runs. */
|
|
function linkVenvPython() {
|
|
const py = execFileSync("python3", ["-c", "import sys; print(sys.executable)"]).toString().trim();
|
|
mkdirSync(join(venvDir, "bin"), { recursive: true });
|
|
symlinkSync(py, join(venvDir, "bin", "python3"));
|
|
}
|
|
|
|
describe("install_feature.py prebuilt mode", () => {
|
|
it("extracts models and site-packages from a local tar", () => {
|
|
const { tarPath, sha256 } = createTestTar("face-detection");
|
|
writeManifest("face-detection", tarPath, sha256);
|
|
|
|
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
expect(existsSync(join(modelsDir, "testmodel", "weights.bin"))).toBe(true);
|
|
expect(existsSync(join(sitePackagesDir, "testpkg", "__init__.py"))).toBe(true);
|
|
|
|
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
|
expect(installed.bundles["face-detection"]).toBeDefined();
|
|
expect(installed.bundles["face-detection"].version).toBe("1.0.0-test");
|
|
});
|
|
|
|
it("exits non-zero when checksum mismatches", () => {
|
|
const { tarPath } = createTestTar("face-detection");
|
|
writeManifest("face-detection", tarPath, "badhash".padEnd(64, "0"));
|
|
|
|
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
});
|
|
|
|
it("writes progress JSON to stderr", () => {
|
|
const { tarPath, sha256 } = createTestTar("face-detection");
|
|
writeManifest("face-detection", tarPath, sha256);
|
|
|
|
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
|
|
const stderr = result.stderr?.toString() ?? "";
|
|
const progressLines = stderr.split("\n").filter((l) => {
|
|
try {
|
|
const p = JSON.parse(l);
|
|
return typeof p.progress === "number";
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
expect(progressLines.length).toBeGreaterThan(0);
|
|
|
|
const last = JSON.parse(progressLines[progressLines.length - 1]);
|
|
expect(last.progress).toBe(100);
|
|
});
|
|
|
|
it("passes the post-install smoke import check and clears the venv-writing breadcrumb", () => {
|
|
linkVenvPython();
|
|
const { tarPath, sha256 } = createTestTar("face-detection");
|
|
writeManifest("face-detection", tarPath, sha256, { smokeImports: ["json", "sys"] });
|
|
|
|
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
|
expect(installed.bundles["face-detection"]).toBeDefined();
|
|
// The breadcrumb is cleared once the venv write completes cleanly.
|
|
expect(existsSync(join(aiDir, "venv.writing"))).toBe(false);
|
|
});
|
|
|
|
it("fails the install (and does NOT record it) when the smoke import cannot load", () => {
|
|
linkVenvPython();
|
|
const { tarPath, sha256 } = createTestTar("face-detection");
|
|
writeManifest("face-detection", tarPath, sha256, {
|
|
smokeImports: ["snapotter_not_a_real_module_zzz"],
|
|
});
|
|
|
|
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr?.toString()).toContain("verification failed");
|
|
// Not marked installed, so the tool shows as needing install and a retry is clean.
|
|
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
|
expect(installed.bundles["face-detection"]).toBeUndefined();
|
|
});
|
|
|
|
it("honors SNAPOTTER_SKIP_INSTALL_SMOKE=1 as a safety valve for false positives", () => {
|
|
linkVenvPython();
|
|
const { tarPath, sha256 } = createTestTar("face-detection");
|
|
writeManifest("face-detection", tarPath, sha256, {
|
|
smokeImports: ["snapotter_not_a_real_module_zzz"],
|
|
});
|
|
|
|
const result = spawnSync("python3", [scriptPath, "face-detection", manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
SNAPOTTER_SKIP_INSTALL_SMOKE: "1",
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
|
expect(installed.bundles["face-detection"]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
/** Build a tar whose site-packages carries a fake onnxruntime of the given flavor. */
|
|
function createOnnxTar(
|
|
bundleId: string,
|
|
flavor: "cpu" | "gpu",
|
|
): { tarPath: string; sha256: string } {
|
|
const buildDir = join(tempDir, `build-${bundleId}`);
|
|
const capi = join(buildDir, "site-packages", "onnxruntime", "capi");
|
|
mkdirSync(capi, { recursive: true });
|
|
writeFileSync(join(buildDir, "site-packages", "onnxruntime", "__init__.py"), flavor);
|
|
writeFileSync(join(capi, "onnxruntime_pybind11_state.so"), flavor);
|
|
if (flavor === "gpu") {
|
|
writeFileSync(join(capi, "libonnxruntime_providers_cuda.so"), "cuda");
|
|
}
|
|
const distInfo =
|
|
flavor === "gpu" ? "onnxruntime_gpu-1.20.1.dist-info" : "onnxruntime-1.20.1.dist-info";
|
|
mkdirSync(join(buildDir, "site-packages", distInfo), { recursive: true });
|
|
writeFileSync(
|
|
join(buildDir, "site-packages", distInfo, "METADATA"),
|
|
flavor === "gpu" ? "Name: onnxruntime-gpu" : "Name: onnxruntime",
|
|
);
|
|
writeFileSync(
|
|
join(buildDir, "bundle.json"),
|
|
JSON.stringify({
|
|
bundleId,
|
|
version: "1.0.0-test",
|
|
arch: "amd64-gpu",
|
|
imageVersion: "2.0.0",
|
|
pythonVersion: "3.12",
|
|
models: [],
|
|
}),
|
|
);
|
|
|
|
const tarPath = join(tempDir, `${bundleId}-test.tar.gz`);
|
|
execFileSync("tar", ["czf", tarPath, "-C", buildDir, "."]);
|
|
rmSync(buildDir, { recursive: true });
|
|
|
|
const hash = createHash("sha256").update(readFileSync(tarPath)).digest("hex");
|
|
return { tarPath, sha256: hash };
|
|
}
|
|
|
|
function installBundle(bundleId: string, tarPath: string) {
|
|
return spawnSync("python3", [scriptPath, bundleId, manifestPath, modelsDir], {
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: tempDir,
|
|
PYTHON_VENV_PATH: venvDir,
|
|
SNAPOTTER_BUNDLE_LOCAL_PATH: tarPath,
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
describe("onnxruntime flavor reconciliation (#490)", () => {
|
|
const cudaLib = () =>
|
|
join(sitePackagesDir, "onnxruntime", "capi", "libonnxruntime_providers_cuda.so");
|
|
const coreLib = () =>
|
|
join(sitePackagesDir, "onnxruntime", "capi", "onnxruntime_pybind11_state.so");
|
|
|
|
it("a bundle carrying CPU onnxruntime cannot clobber the venv's GPU build", () => {
|
|
const gpu = createOnnxTar("gpu-bundle", "gpu");
|
|
writeManifest("gpu-bundle", gpu.tarPath, gpu.sha256, { models: [] });
|
|
let result = installBundle("gpu-bundle", gpu.tarPath);
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
|
|
const cpu = createOnnxTar("cpu-bundle", "cpu");
|
|
writeManifest("cpu-bundle", cpu.tarPath, cpu.sha256, { models: [] });
|
|
result = installBundle("cpu-bundle", cpu.tarPath);
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
|
|
// The GPU build survives: CUDA provider intact, core lib not downgraded.
|
|
expect(existsSync(cudaLib())).toBe(true);
|
|
expect(readFileSync(coreLib(), "utf-8")).toBe("gpu");
|
|
// Metadata stays truthful: no CPU dist-info shipped in.
|
|
expect(existsSync(join(sitePackagesDir, "onnxruntime-1.20.1.dist-info"))).toBe(false);
|
|
expect(existsSync(join(sitePackagesDir, "onnxruntime_gpu-1.20.1.dist-info"))).toBe(true);
|
|
// Both bundles still recorded as installed.
|
|
const installed = JSON.parse(readFileSync(join(aiDir, "installed.json"), "utf-8"));
|
|
expect(installed.bundles["gpu-bundle"]).toBeDefined();
|
|
expect(installed.bundles["cpu-bundle"]).toBeDefined();
|
|
});
|
|
|
|
it("installing a GPU bundle repairs a venv previously downgraded to the CPU build", () => {
|
|
const cpu = createOnnxTar("cpu-bundle", "cpu");
|
|
writeManifest("cpu-bundle", cpu.tarPath, cpu.sha256, { models: [] });
|
|
let result = installBundle("cpu-bundle", cpu.tarPath);
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
|
|
const gpu = createOnnxTar("gpu-bundle", "gpu");
|
|
writeManifest("gpu-bundle", gpu.tarPath, gpu.sha256, { models: [] });
|
|
result = installBundle("gpu-bundle", gpu.tarPath);
|
|
expect(result.status, `stderr: ${result.stderr?.toString()}`).toBe(0);
|
|
|
|
expect(existsSync(cudaLib())).toBe(true);
|
|
expect(readFileSync(coreLib(), "utf-8")).toBe("gpu");
|
|
// The stale CPU metadata is cleared so pip metadata matches reality.
|
|
expect(existsSync(join(sitePackagesDir, "onnxruntime-1.20.1.dist-info"))).toBe(false);
|
|
expect(existsSync(join(sitePackagesDir, "onnxruntime_gpu-1.20.1.dist-info"))).toBe(true);
|
|
});
|
|
});
|