diff --git a/package-lock.json b/package-lock.json index 7f19db4..cbedae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,433 @@ "name": "@gamecult/vili", "version": "0.1.0", "dependencies": { - "cultcache-ts": "file:../CultLib/packages/cultcache-ts", - "ws": "^8.18.0" + "cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts" } }, "../CultLib/packages/cultcache-ts": { + "name": "@gamecult/cultcache-ts", + "version": "0.14.2", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "koffi": "3.3.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.0.tgz", + "integrity": "sha512-lHXE2mq+uG6CVeX1iepn2ATcAWMZPagr+J0ICYge7wTMDYc5pPRQMq0Rr/16775i6QlUqQqpk8c5Gu66+u3gGA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.0.tgz", + "integrity": "sha512-7EeVK/KS9XNbOsUfy0B3uuSjPQZenjJ35y9QSi6R6eK4Fv0oGvREQaBzIJRJwonxp7sJfGObnwnQTnKTVK2DWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.0.tgz", + "integrity": "sha512-2R9bihYmYFyRP98wRG2m5WQVKMONf7DoPJI8RkGCOUB2afOXJEVXFepiSKxBZGPml8i1USwAxMUwkzbJ5uMG1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.0.tgz", + "integrity": "sha512-OecYCQfzhUx+XyAtatIHlk0jIz87wKNjtYvwlwgMwgaGQwO/TxFeReNo+s3167Rh1a9c8rUIOjq1jfzAPBxbKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.0.tgz", + "integrity": "sha512-6ytHzV1DuyxTP5LtFmQ3imBk71j/F/Nb0Hcwhjcn35esnknm5rQbgRl6tNRldrkGnM4GIAJpsagooUBh1xAzYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.0.tgz", + "integrity": "sha512-hINo5z1Iz2u3Rx3oHfzcmi5A03gYAoYf4nCJJYaY0bb5IuGdIC7noQTyyI9YxfyTJgoqw3wKFWWYtvsAmEo9Hw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.0.tgz", + "integrity": "sha512-7XQYAZh828nW6+y3IdyUNdo2VbVHL5ZC8Qb+yqHrlDjTjVXSyGYZJHAyJZ5c7eSTTKBhtIMTX238rIsdexxrog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.0.tgz", + "integrity": "sha512-NbahRKKKFkHoFN/ZpLY+w4F6eYSqV4KMN40PmjZ1xQjG/XjYb6PIYgE41c7/acAPxOYqujrcaI3B4BAgWYm5/A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.0.tgz", + "integrity": "sha512-Eo/+wGeawGICzJkUlkzi/KerAJ6U1L/Hf7cnJKH7aGgCOY5RRnjUqXs/eL+UDpRcFHzGeUn+Fq+8q2VxYxvAfw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.0.tgz", + "integrity": "sha512-u9tKuj42RFWJU8ZgZqcBhkFCkIZE/SE6oR1ljpukttJUhOVuaf/lmsWxyOxiVu3U8T1gtD658FlLgrbMVIn6aQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.0.tgz", + "integrity": "sha512-2hKlNZ6OXkTUp6fcueZcP0sQ6yhpdfANg9/mWxDKzT1o8qIj4qJD7UcZLCcRIC8JXiP8JQwZBtlPuyXIqofDQg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ppc64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.0.tgz", + "integrity": "sha512-bIVAoHgzyRN7xs529BK7rF3BbZUTJgIr0t/wvBvAgaIpreKsdZ/QWxhedKRbTN7ljlDHkPZFmu5XcCjb/vqUag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.0.tgz", + "integrity": "sha512-2niYAe+TBXbvf7yog/aiHiDUrKQn+RkMSH5x3ZYo65OQyII1CY3Jk2kP3Pe735gq0LzIYJsDuGMW+02ighSadQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.0.tgz", + "integrity": "sha512-apU8ZMOrOh1eqMP/Pp0sDZ/WY6A4r6JWph26h63VM9nZ+5KCTYuPgojfoLPNvrfI/bn1PY3+bpXKRhZHI3gxbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.0.tgz", + "integrity": "sha512-iTyHMCSSs+X579sWJv7XVFMp9gOfROI8IUEwp09YxJpGvmiSoipI0J7kytFuFq6dyuwO0Ymy4JdlmKnCM0qkxg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.0.tgz", + "integrity": "sha512-xCvMV+4VNHjTxgVPEsBzU0k8dlA7aoFO5QjRF9lVU/NzGhUedlYvFgaF6IWsGOoWEaNt9hgP4KK/wxMbm9v8EQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.0.tgz", + "integrity": "sha512-xItIwx1elM8k1pTudEfuV/1U2gQ06+rFfxCVXrZkyWYYGTzmA5To00f8garJFR+E9gbjQJbn8QDFvWMI+i2sMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.0.tgz", + "integrity": "sha512-fD41LgKBx5i1YlJDuds4tRHAaG5vV55tvagzJwKFDR/5BDeaFvH9QFMUMDoNl2LkmVyeug0dIElHr/oxejh3IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.0.tgz", + "integrity": "sha512-BfDbDVEuQwPZOvWXawc3JT/7i9cN7QHYotgo014jjXb9pNv46Ykhb0RRglkcHg+SdytW/7pxYn18YwcQP6LLvQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.0.tgz", + "integrity": "sha512-D+ORp73rTmvGVfq01+4uBmgVZqrPqU9N9xCZCfexaCDlVk0WQsaqid1dGsi6eScJuHt4Cp8s1i5/4CsecxFTKQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/cultcache-ts": { + "resolved": "vendor/CultLib/packages/cultcache-ts", + "link": true + }, + "node_modules/koffi": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.0.tgz", + "integrity": "sha512-UeyQtptuPeCP0BqKD+Q8ftTQhIUBHO7bXyK9DVIaW9hJSg0BrICm4K5N8Qtn7zKk20sb5vpnES9KBqVksM2kpQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.3.0", + "@koromix/koffi-android-x64": "3.3.0", + "@koromix/koffi-darwin-arm64": "3.3.0", + "@koromix/koffi-darwin-x64": "3.3.0", + "@koromix/koffi-freebsd-arm64": "3.3.0", + "@koromix/koffi-freebsd-ia32": "3.3.0", + "@koromix/koffi-freebsd-x64": "3.3.0", + "@koromix/koffi-linux-arm": "3.3.0", + "@koromix/koffi-linux-arm64": "3.3.0", + "@koromix/koffi-linux-ia32": "3.3.0", + "@koromix/koffi-linux-loong64": "3.3.0", + "@koromix/koffi-linux-ppc64": "3.3.0", + "@koromix/koffi-linux-riscv64": "3.3.0", + "@koromix/koffi-linux-x64": "3.3.0", + "@koromix/koffi-openbsd-arm64": "3.3.0", + "@koromix/koffi-openbsd-ia32": "3.3.0", + "@koromix/koffi-openbsd-x64": "3.3.0", + "@koromix/koffi-win32-arm64": "3.3.0", + "@koromix/koffi-win32-ia32": "3.3.0", + "@koromix/koffi-win32-x64": "3.3.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "vendor/CultLib/packages/cultcache-ts": { "name": "@gamecult/cultcache-ts", "version": "0.14.2", "license": "MIT", @@ -28,31 +450,6 @@ "engines": { "node": ">=20" } - }, - "node_modules/cultcache-ts": { - "resolved": "../CultLib/packages/cultcache-ts", - "link": true - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index 25f6a94..8a7a4a6 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,9 @@ "scripts": { "start": "node scripts/vili-daemon.mjs", "health": "node scripts/vili-daemon.mjs --health", - "smoke": "node scripts/smoke-vili.mjs", - "provider": "node scripts/vili-daemon.mjs --print-provider-advertisement" + "test": "node --test scripts/test-vili-daemon.mjs" }, "dependencies": { - "cultcache-ts": "file:../CultLib/packages/cultcache-ts", - "ws": "^8.18.0" + "cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts" } } diff --git a/scripts/fixtures/stub-worker.mjs b/scripts/fixtures/stub-worker.mjs new file mode 100644 index 0000000..24d2c9a --- /dev/null +++ b/scripts/fixtures/stub-worker.mjs @@ -0,0 +1,57 @@ +// Stands in for kimodo-resident-worker.py in the daemon's tests. It binds port 0, prints the ready line the +// daemon waits for, and answers /health and /generate with what the daemon gave it. +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { appendFileSync } from "node:fs"; + +const argv = process.argv.slice(2); +const flag = (name) => argv[argv.indexOf(name) + 1]; +const host = flag("--host"); +let requests = 0; + +appendFileSync(path.join(process.env.VILI_EMBEDDINGS_DIR, "stub-spawns.log"), `${process.pid}\n`); + +const server = http.createServer((request, response) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + const send = (body) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + if (request.method === "GET" && request.url === "/health") { + send({ ok: true, model: "stub-model", displayModel: "Stub Model", pid: process.pid, requests }); + } else if (request.method === "POST" && request.url === "/generate") { + requests += 1; + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + send({ + ok: true, + pid: process.pid, + requests, + output: payload.output, + priority: os.getPriority(), + env: { + OMP_NUM_THREADS: process.env.OMP_NUM_THREADS, + MKL_NUM_THREADS: process.env.MKL_NUM_THREADS, + VILI_THREADS: process.env.VILI_THREADS, + HF_HUB_OFFLINE: process.env.HF_HUB_OFFLINE, + TRANSFORMERS_OFFLINE: process.env.TRANSFORMERS_OFFLINE, + HF_HOME: process.env.HF_HOME, + PYTHONPATH: process.env.PYTHONPATH, + VILI_KIMODO_COMMIT: process.env.VILI_KIMODO_COMMIT, + VILI_PYTHON_ENV: process.env.VILI_PYTHON_ENV, + LEAK_CHECK: process.env.LEAK_CHECK, + }, + argv, + }); + } else { + response.writeHead(404); + response.end(); + } + }); +}); + +server.listen(Number(flag("--port")), host, () => { + console.log(JSON.stringify({ event: "kimodo-worker-ready", port: server.address().port })); +}); diff --git a/scripts/test-vili-daemon.mjs b/scripts/test-vili-daemon.mjs new file mode 100644 index 0000000..0d2e5e5 --- /dev/null +++ b/scripts/test-vili-daemon.mjs @@ -0,0 +1,218 @@ +// node --test scripts/test-vili-daemon.mjs +// Runs the real daemon against scripts/fixtures/stub-worker.mjs as its --python/--worker-entry. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const daemonPath = path.join(here, "vili-daemon.mjs"); +const stubPath = path.join(here, "fixtures", "stub-worker.mjs"); +const THREADS = 5; + +async function until(condition, what) { + const deadline = Date.now() + 15000; + for (;;) { + const value = await condition(); + if (value) return value; + assert.ok(Date.now() < deadline, `timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +async function startDaemon({ configured = true } = {}) { + const stateRoot = mkdtempSync(path.join(os.tmpdir(), "vili-test-")); + const modelsRoot = path.join(stateRoot, "models"); + const argv = [ + daemonPath, "--port", "0", "--state-root", stateRoot, "--python", process.execPath, "--worker-entry", stubPath, + "--threads", String(THREADS), "--python-env", "test-env", + ]; + if (configured) argv.push("--models-root", modelsRoot, "--kimodo-commit", "abc1234"); + const proc = spawn(process.execPath, argv, { env: { ...process.env, LEAK_CHECK: "leaked" }, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + proc.stdout.on("data", (chunk) => { output += chunk; }); + const port = await until(() => /listening on http:\/\/127\.0\.0\.1:(\d+)/.exec(output)?.[1], "the daemon to listen"); + return { + stateRoot, + modelsRoot, + proc, + port: Number(port), + spawns: () => { + const log = path.join(stateRoot, "embeddings", "stub-spawns.log"); + return existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).map(Number) : []; + }, + async stop() { + if (proc.exitCode === null && proc.signalCode === null) { + proc.kill("SIGTERM"); + await until(() => proc.exitCode !== null || proc.signalCode !== null, "the daemon to exit"); + } + rmSync(stateRoot, { recursive: true, force: true }); + }, + }; +} + +function request(port, method, urlPath, { body, headers = {} } = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, method, path: urlPath, headers }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch { + // not JSON + } + resolve({ status: res.statusCode, headers: res.headers, raw, body: parsed }); + }); + }); + req.on("error", reject); + req.end(body); + }); +} + +const generate = (daemon, payload = { prompt: "a person waves", duration: 2 }, headers = {}) => + request(daemon.port, "POST", "/motion/generate", { + body: typeof payload === "string" ? payload : JSON.stringify(payload), + headers: { "content-type": "application/json", ...headers }, + }); +const operatorState = async (daemon) => (await request(daemon.port, "GET", "/operator-state")).body; + +test("the worker's port comes from its ready line", async () => { + const daemon = await startDaemon(); + try { + const answer = await generate(daemon); + assert.equal(answer.status, 200); + const backend = (await operatorState(daemon)).backend; + assert.equal(backend.state, "ready"); + assert.ok(Number.isInteger(backend.port) && backend.port > 0 && backend.port !== 8825); + assert.equal(answer.body.worker.pid, backend.pid); + assert.deepEqual(answer.body.worker.argv.slice(-4), ["--host", "127.0.0.1", "--port", "0"]); + // the request reached the stub on the port the stub printed + assert.equal((await request(backend.port, "GET", "/health")).body.requests, 1); + } finally { + await daemon.stop(); + } +}); + +test("one child, respawned after exit", async () => { + const daemon = await startDaemon(); + try { + const [first, second] = await Promise.all([generate(daemon), generate(daemon)]); + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.equal(daemon.spawns().length, 1); + const [pid] = daemon.spawns(); + process.kill(pid, "SIGKILL"); + await until(async () => (await operatorState(daemon)).backend.state === "stopped", "the daemon to see the exit"); + const third = await generate(daemon); + assert.equal(third.status, 200); + const spawns = daemon.spawns(); + assert.equal(spawns.length, 2); + assert.notEqual(spawns[1], pid); + assert.equal(third.body.worker.pid, spawns[1]); + } finally { + await daemon.stop(); + } +}); + +test("environment and nice", async () => { + const daemon = await startDaemon(); + try { + const { env, priority } = (await generate(daemon)).body.worker; + assert.equal(env.OMP_NUM_THREADS, String(THREADS)); + assert.equal(env.MKL_NUM_THREADS, String(THREADS)); + assert.equal(env.VILI_THREADS, String(THREADS)); + assert.equal(env.HF_HUB_OFFLINE, "1"); + assert.equal(env.TRANSFORMERS_OFFLINE, "1"); + assert.equal(env.HF_HOME, daemon.modelsRoot); + assert.equal(env.VILI_KIMODO_COMMIT, "abc1234"); + assert.equal(env.VILI_PYTHON_ENV, "test-env"); + assert.ok(env.PYTHONPATH.endsWith(path.join("vendor", "CultLib", "packages", "cultcache-py", "src"))); + assert.equal(env.LEAK_CHECK, undefined, "the daemon's own environment does not reach the worker"); + assert.equal(priority, 10); + } finally { + await daemon.stop(); + } +}); + +test("SIGTERM leaves no child", async () => { + const daemon = await startDaemon(); + try { + assert.equal((await generate(daemon)).status, 200); + const [pid] = daemon.spawns(); + process.kill(pid, 0); + daemon.proc.kill("SIGTERM"); + await until(() => daemon.proc.exitCode !== null, "the daemon to exit"); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + } finally { + await daemon.stop(); + } +}); + +test("cross-site is refused before the worker", async () => { + const daemon = await startDaemon(); + try { + assert.equal((await generate(daemon)).status, 200); + const withOrigin = await generate(daemon, undefined, { origin: "https://example.test" }); + assert.equal(withOrigin.status, 403); + assert.equal(withOrigin.body.code, "cross-site"); + const plain = await generate(daemon, undefined, { "content-type": "text/plain" }); + assert.equal(plain.status, 403); + assert.equal(plain.body.code, "cross-site"); + const options = await request(daemon.port, "OPTIONS", "/motion/generate", { headers: { origin: "https://example.test" } }); + assert.equal(options.status, 404); + const health = await request(daemon.port, "GET", "/health"); + for (const reply of [withOrigin, plain, options, health]) { + assert.equal(Object.keys(reply.headers).filter((name) => name.startsWith("access-control-")).length, 0); + } + const backend = (await operatorState(daemon)).backend; + assert.equal((await request(backend.port, "GET", "/health")).body.requests, 1, "only the same-site request reached the worker"); + } finally { + await daemon.stop(); + } +}); + +test("unconfigured refuses before spawning", async () => { + const daemon = await startDaemon({ configured: false }); + try { + const answer = await generate(daemon); + assert.equal(answer.status, 503); + assert.equal(answer.body.code, "worker-unconfigured"); + assert.deepEqual(daemon.spawns(), []); + assert.equal((await operatorState(daemon)).backend.state, "stopped"); + } finally { + await daemon.stop(); + } +}); + +test("an error never echoes the request", async () => { + const daemon = await startDaemon(); + try { + const malformed = await generate(daemon, "{\"prompt\": \"the launch code is swordfish\", "); + assert.equal(malformed.status, 400); + assert.ok(!malformed.raw.includes("swordfish")); + const missing = await generate(daemon, { texts: ["the launch code is swordfish"] }); + assert.equal(missing.status, 400); + assert.ok(!missing.raw.includes("swordfish")); + } finally { + await daemon.stop(); + } +}); + +test("a thread count outside 1 to 16 stops the daemon without echoing it", async () => { + for (const threads of ["0", "17", "99", "8x"]) { + const proc = spawn(process.execPath, [daemonPath, "--threads", threads], { stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + proc.stdout.on("data", (chunk) => { output += chunk; }); + proc.stderr.on("data", (chunk) => { output += chunk; }); + const code = await new Promise((resolve) => proc.on("exit", resolve)); + assert.equal(code, 2); + assert.ok(!output.includes(threads), `the refusal repeated ${threads}`); + } +}); diff --git a/scripts/vili-daemon.mjs b/scripts/vili-daemon.mjs index e75e0ac..9b9eb4c 100644 --- a/scripts/vili-daemon.mjs +++ b/scripts/vili-daemon.mjs @@ -1,11 +1,13 @@ #!/usr/bin/env node import http from "node:http"; +import os from "node:os"; +import { closeSync, openSync, writeSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; +import readline from "node:readline"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import { execFile } from "node:child_process"; -import { WebSocketServer } from "ws"; +import { spawn } from "node:child_process"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); @@ -16,37 +18,9 @@ const { defineDocumentType, } = require("cultcache-ts"); const projectRoot = path.resolve(__dirname, ".."); -const ravenBaseUrl = process.env.VILI_PUBLIC_BASE_URL || "http://10.77.0.4:8824"; -const ravenDeckUrl = process.env.VILI_PUBLIC_DECK_URL || "ws://10.77.0.4:8824/eve/deck"; const providerId = "vili.animation"; const viliStoreFileName = "vili.service.cc"; -const kimodoWorkerName = process.env.VILI_KIMODO_WORKER_NAME || "vili-kimodo-worker"; -const kimodoWorkerPort = Number(process.env.VILI_KIMODO_WORKER_PORT || 8825); -const kimodoWorkerUrl = `http://127.0.0.1:${kimodoWorkerPort}`; -const dockerHuggingFaceEnv = [ - "export HF_TOKEN=\"$(cat /root/.cache/huggingface/token 2>/dev/null || true)\"", - "export HUGGING_FACE_HUB_TOKEN=\"$HF_TOKEN\"", -].join("; "); -const dockerHuggingFaceArgs = [ - "-e HF_TOKEN", - "-e HUGGING_FACE_HUB_TOKEN", - "-v /root/.cache/huggingface:/root/.cache/huggingface", -].join(" "); const objectSchema = { parse: (value) => value }; -const providerAdvertisementDefinition = defineDocumentType({ - type: "gamecult.eve.provider_advertisement", - schemaName: "gamecult.eve.provider_advertisement", - schemaId: "gamecult.eve.provider_advertisement.v1", - schemaVersion: "gamecult.eve.provider_advertisement.v1", - name: (value) => value?.providerId || "provider", - schema: objectSchema, - members: [ - { slot: 0, memberName: "providerId", typeName: "string", isName: true }, - { slot: 1, memberName: "title", typeName: "string" }, - { slot: 2, memberName: "verseId", typeName: "string" }, - { slot: 3, memberName: "updatedAt", typeName: "string" }, - ], -}); const operatorStateDefinition = defineDocumentType({ type: "gamecult.vili.operator_state", schemaName: "gamecult.vili.operator_state", @@ -61,20 +35,6 @@ const operatorStateDefinition = defineDocumentType({ { slot: 3, memberName: "backend", typeName: "object" }, ], }); -const eveSurfaceDefinition = defineDocumentType({ - type: "gamecult.eve.surface_state", - schemaName: "gamecult.eve.surface_state", - schemaId: "gamecult.eve.surface_state.v1", - schemaVersion: "gamecult.eve.surface_state.v1", - name: (value) => value?.providerId || "surface", - schema: objectSchema, - members: [ - { slot: 0, memberName: "providerId", typeName: "string", isName: true }, - { slot: 1, memberName: "title", typeName: "string" }, - { slot: 2, memberName: "updatedAt", typeName: "string" }, - { slot: 3, memberName: "surface", typeName: "object" }, - ], -}); const commandBoundaryDefinition = defineDocumentType({ type: "gamecult.vili.command_boundary", schemaName: "gamecult.vili.command_boundary", @@ -106,20 +66,26 @@ const transportProfileDefinition = defineDocumentType({ ], }); const viliDocumentRegistry = defineDocumentRegistry( - providerAdvertisementDefinition, operatorStateDefinition, - eveSurfaceDefinition, commandBoundaryDefinition, transportProfileDefinition, ); +const MAX_THREADS = 16; + function parseArgs(argv) { + const env = process.env; const args = { - host: process.env.VILI_DAEMON_HOST || "127.0.0.1", - port: Number(process.env.VILI_DAEMON_PORT || 8824), - stateRoot: process.env.VILI_STATE_ROOT || path.join(projectRoot, ".vili"), + host: env.VILI_DAEMON_HOST || "127.0.0.1", + port: Number(env.VILI_DAEMON_PORT || 8824), + stateRoot: path.resolve(env.VILI_STATE_ROOT || path.join(projectRoot, ".vili")), + python: env.VILI_PYTHON || "python3", + workerEntry: path.resolve(env.VILI_WORKER_ENTRY || path.join(__dirname, "kimodo-resident-worker.py")), + modelsRoot: env.VILI_MODELS_ROOT || "", + threads: env.VILI_THREADS || "8", + kimodoCommit: env.VILI_KIMODO_COMMIT || "", + pythonEnv: env.VILI_PYTHON_ENV || "", health: false, - printProviderAdvertisement: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -127,53 +93,29 @@ function parseArgs(argv) { if (arg === "--host") args.host = argv[++index]; else if (arg === "--port") args.port = Number(argv[++index]); else if (arg === "--state-root") args.stateRoot = path.resolve(argv[++index]); + else if (arg === "--python") args.python = argv[++index]; + else if (arg === "--worker-entry") args.workerEntry = path.resolve(argv[++index]); + else if (arg === "--models-root") args.modelsRoot = argv[++index]; + else if (arg === "--threads") args.threads = argv[++index]; + else if (arg === "--kimodo-commit") args.kimodoCommit = argv[++index]; + else if (arg === "--python-env") args.pythonEnv = argv[++index]; else if (arg === "--health") args.health = true; - else if (arg === "--print-provider-advertisement") args.printProviderAdvertisement = true; } + if (!/^[0-9]{1,2}$/.test(String(args.threads)) || Number(args.threads) < 1 || Number(args.threads) > MAX_THREADS) { + console.error(`threads must be an integer from 1 to ${MAX_THREADS}`); + process.exit(2); + } + args.threads = Number(args.threads); + if (args.modelsRoot) args.modelsRoot = path.resolve(args.modelsRoot); return args; } const args = parseArgs(process.argv.slice(2)); const startedAt = new Date().toISOString(); -let backendStatus = { - checkedAt: null, - dockerImage: "unknown", - nvidiaRuntime: "unknown", - huggingFaceToken: "unknown", - kimodoHelp: "unknown", - kimodoWorker: "unknown", - kimodoWorkerDetail: null, - lastError: null, -}; -function run(command, commandArgs, options = {}) { - return new Promise((resolve) => { - execFile(command, commandArgs, { - cwd: projectRoot, - timeout: options.timeout ?? 20000, - env: { ...process.env, ...(options.env || {}) }, - windowsHide: true, - maxBuffer: 1024 * 1024 * 4, - }, (error, stdout, stderr) => { - resolve({ - ok: !error, - code: error?.code ?? 0, - signal: error?.signal ?? null, - stdout: String(stdout || "").trim(), - stderr: String(stderr || "").trim(), - error: error ? String(error.message || error) : null, - }); - }); - }); -} - -function wsl(script, options = {}) { - return run("wsl.exe", ["-d", "Ubuntu-24.04", "-u", "root", "--", "bash", "-lc", script], options); -} - -function shQuote(value) { - return `'${String(value).replaceAll("'", "'\\''")}'`; -} +// The worker child. Display state only: nothing reads `worker` to decide anything but ensureWorker. +const worker = { state: "stopped", proc: null, pid: null, port: null, model: null, servedModels: [], lastError: null }; +let starting = null; function firstDefined(...values) { return values.find((value) => value !== undefined && value !== null); @@ -183,22 +125,11 @@ function boolValue(value) { return value === true || String(value).toLowerCase() === "true" || String(value) === "1"; } -async function wslJson(script, options = {}) { - const result = await wsl(script, options); - if (!result.ok) return { ...result, body: null }; - try { - return { ...result, body: JSON.parse(result.stdout) }; - } catch (error) { - return { ...result, ok: false, body: null, error: `Invalid JSON: ${error.message}`, stderr: result.stderr }; - } -} - function json(response, statusCode, body) { const payload = JSON.stringify(body, null, 2); response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", - "access-control-allow-origin": "*", }); response.end(payload); } @@ -207,38 +138,31 @@ function text(response, statusCode, body) { response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", - "access-control-allow-origin": "*", }); response.end(body); } -function providerAdvertisement() { +// A browser page can POST to localhost; a browser cannot send application/json cross-site without a preflight, +// and no route answers a preflight. So a POST that carries an Origin, or is not JSON, never reaches the body. +function refuseCrossSite(request, response) { + const contentType = String(request.headers["content-type"] || "").split(";")[0].trim().toLowerCase(); + if (request.headers.origin !== undefined || contentType !== "application/json") { + json(response, 403, { ok: false, code: "cross-site" }); + return true; + } + return false; +} + +function backendStatus() { return { - schema: "gamecult.eve.provider_advertisement.v1", - providerId, - title: "Vili Animation", - description: "Persona animation and Kimodo motion generation daemon on Raven.", - canonicalService: "asgard.vili", - locatedService: "asgard.raven.vili", - verseId: "raven.local", - cultMeshAddress: "asgard.raven.vili/eve/operator", - updatedAt: new Date().toISOString(), - endpoints: { - http: ravenBaseUrl, - health: `${ravenBaseUrl}/health`, - smoke: `${ravenBaseUrl}/smoke`, - providerAdvertisement: `${ravenBaseUrl}/provider-advertisement`, - operatorState: `${ravenBaseUrl}/operator-state`, - operatorSurface: `${ravenBaseUrl}/eve/operator`, - deckBroker: ravenDeckUrl, - providerDeck: `${ravenDeckUrl}/${providerId}`, - }, - capabilities: [ - "kimodo.motion.generate", - "persona.performance.intent", - "eve.operator.surface", - "cultmesh.provider.discovery", - ], + state: worker.state, + pid: worker.pid, + port: worker.port, + threads: args.threads, + pythonEnv: args.pythonEnv || null, + kimodoCommit: args.kimodoCommit || null, + modelsRoot: args.modelsRoot || null, + lastError: worker.lastError, }; } @@ -247,18 +171,15 @@ function operatorState() { schema: "gamecult.vili.operator_state.v0", service: "vili", providerId, - verseId: "raven.local", - host: "raven", + host: os.hostname(), startedAt, updatedAt: new Date().toISOString(), - backend: backendStatus, + backend: backendStatus(), authority: { owns: [ "animation job intake", - "Kimodo runtime checks", - "Kimodo resident worker lifecycle", + "worker process lifecycle", "motion artifact references", - "Vili provider and operator surfaces", ], doesNotOwn: [ "Persona speech generation", @@ -269,11 +190,7 @@ function operatorState() { }, routes: { health: "/health", - providerAdvertisement: "/provider-advertisement", operatorState: "/operator-state", - operatorSurface: "/eve/operator", - deckProviders: "/eve/deck/providers", - smoke: "/smoke", generate: "/motion/generate", }, }; @@ -293,16 +210,11 @@ function commandBoundary() { owns: [ "animation job identity", "request persistence", - "Kimodo resident worker lifecycle", + "worker process lifecycle", "timeout handling", "motion artifact references", ], }, - smoke: { - method: "GET", - path: "/smoke", - owns: ["backend readiness proof", "resident worker warmup"], - }, liveness: { method: "GET", path: "/health", @@ -311,7 +223,7 @@ function commandBoundary() { }, }, forbiddenAuthority: - "HTTP health, WebSocket decks, and JSON sidecars are compatibility/operator lowerings; they do not own daemon health or provider truth when CultCache/CultNet records exist.", + "HTTP health and the operator-state projection are compatibility lowerings; they do not own daemon health or provider truth when CultCache/CultNet records exist.", }; } @@ -321,178 +233,168 @@ function transportProfile() { profileId: "vili", daemonId: "vili", targetTransport: "cultnet.transport.rudp.v0", - currentTransport: "CultCache local state + compatibility HTTP/WS lowerings", + currentTransport: "CultCache local state + compatibility HTTP lowerings", state: "local-cultcache-only", - providerAdvertisementSchema: "gamecult.eve.provider_advertisement.v1", operatorStateSchema: "gamecult.vili.operator_state.v1", commandBoundarySchema: "gamecult.vili.command_boundary.v1", - compatibilityMechanisms: [ - `${ravenBaseUrl}/health`, - `${ravenBaseUrl}/provider-advertisement`, - `${ravenDeckUrl}/${providerId}`, - ], + compatibilityMechanisms: ["/health", "/operator-state"], cutLine: - "Vili owns typed provider, operator, command, and transport state. HTTP/WS/JSON projections are lowerings until Odin consumes Vili provider records over CultNet/RUDP.", + "Vili owns typed operator, command, and transport state. HTTP projections are lowerings until Odin consumes Vili records over CultNet/RUDP.", updatedAt: new Date().toISOString(), }; } -function eveSurface() { - const state = operatorState(); - return { - schema: "gamecult.eve.surface.v1", - providerId, - title: "Vili Animation", - updatedAt: state.updatedAt, - surface: { - root: { - type: "panel", - title: "Vili Animation", - children: [ - { type: "stat", label: "Host", value: "Raven" }, - { type: "stat", label: "Verse", value: "raven.local" }, - { type: "stat", label: "Docker image", value: state.backend.dockerImage }, - { type: "stat", label: "NVIDIA runtime", value: state.backend.nvidiaRuntime }, - { type: "stat", label: "Kimodo", value: state.backend.kimodoHelp }, - { type: "stat", label: "Kimodo worker", value: state.backend.kimodoWorker }, - { type: "stat", label: "HF token", value: state.backend.huggingFaceToken }, - { type: "command", label: "Smoke", method: "GET", href: `${ravenBaseUrl}/smoke` }, - { type: "command", label: "Generate motion", method: "POST", href: `${ravenBaseUrl}/motion/generate` }, - ], - }, - }, - state, - }; -} - async function persistSurfaces() { await mkdir(args.stateRoot, { recursive: true }); - await persistCultCacheSurfaces(); - await writeFile(path.join(args.stateRoot, "vili-daemon.pid"), `${process.pid}\n`); - await writeFile(path.join(args.stateRoot, "provider-advertisement.json"), JSON.stringify(providerAdvertisement(), null, 2)); - await writeFile(path.join(args.stateRoot, "operator-state.json"), JSON.stringify(operatorState(), null, 2)); - await writeFile(path.join(args.stateRoot, "eve-operator-surface.json"), JSON.stringify(eveSurface(), null, 2)); - await writeFile(path.join(args.stateRoot, "command-boundary.json"), JSON.stringify(commandBoundary(), null, 2)); - await writeFile(path.join(args.stateRoot, "transport-profile.json"), JSON.stringify(transportProfile(), null, 2)); -} - -async function persistCultCacheSurfaces() { const cache = CultCache.builder() .withRegistry(viliDocumentRegistry) .withGenericStore(new SingleFileMessagePackBackingStore(path.join(args.stateRoot, viliStoreFileName))) .build(); - await cache.put(providerAdvertisementDefinition, providerId, providerAdvertisement()); await cache.put(operatorStateDefinition, "vili", operatorState()); - await cache.put(eveSurfaceDefinition, providerId, eveSurface()); await cache.put(commandBoundaryDefinition, "vili", commandBoundary()); await cache.put(transportProfileDefinition, "vili", transportProfile()); } -async function refreshBackendStatus({ includeKimodoHelp = false } = {}) { - const docker = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - "docker images gamecult/kimodo:latest --format '{{.Repository}}:{{.Tag}}' | head -n 1", - ].join("; "), { timeout: 20000 }); - - let nvidia = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - "docker info --format '{{json .Runtimes}}' | grep -q nvidia && echo configured || echo missing", - ].join("; "), { timeout: 20000 }); - - const token = await wsl("test -f /root/.cache/huggingface/token && echo present || echo missing", { timeout: 10000 }); - const worker = await kimodoWorkerHealth(20000); - let help = { ok: true, stdout: backendStatus.kimodoHelp === "ok" ? "ok" : "not checked", stderr: "" }; - if (includeKimodoHelp) { - nvidia = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - "docker run --rm --runtime=nvidia --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi --query-gpu=name --format=csv,noheader | head -n 1", - ].join("; "), { timeout: 45000 }); - help = await wsl([ - "set -e", - "service docker start >/dev/null 2>&1 || true", - dockerHuggingFaceEnv, - `docker run --rm --runtime=nvidia --gpus all ${dockerHuggingFaceArgs} gamecult/kimodo:latest kimodo_gen --help | sed -n '1,6p'`, - ].join("; "), { timeout: 60000 }); - } - - backendStatus = { - checkedAt: new Date().toISOString(), - dockerImage: docker.ok && docker.stdout ? docker.stdout : "missing", - nvidiaRuntime: nvidia.ok && nvidia.stdout ? nvidia.stdout : "unavailable", - huggingFaceToken: token.ok ? token.stdout : "unknown", - kimodoHelp: includeKimodoHelp ? (help.ok ? "ok" : "failed") : backendStatus.kimodoHelp, - kimodoWorker: worker.ok && worker.body?.ok ? "resident" : "not running", - kimodoWorkerDetail: worker.body || null, - lastError: [docker, nvidia, token, help].find((result) => !result.ok)?.stderr || null, +// The whole child environment, in one place. Nothing else from process.env reaches the worker. +function childEnv() { + const threads = String(args.threads); + const env = { + HF_HOME: args.modelsRoot, + HF_HUB_OFFLINE: "1", + TRANSFORMERS_OFFLINE: "1", + VILI_EMBEDDINGS_DIR: path.join(args.stateRoot, "embeddings"), + VILI_THREADS: threads, + OMP_NUM_THREADS: threads, + MKL_NUM_THREADS: threads, + PYTHONPATH: path.join(projectRoot, "vendor", "CultLib", "packages", "cultcache-py", "src"), + PYTHONDONTWRITEBYTECODE: "1", + PYTHONUNBUFFERED: "1", + VILI_KIMODO_COMMIT: args.kimodoCommit, + VILI_PYTHON_ENV: args.pythonEnv, }; - await persistSurfaces(); - return { docker, nvidia, token, help, backendStatus }; + for (const name of ["PATH", "HOME", "LANG"]) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + return env; } -async function kimodoWorkerHealth(timeout = 10000) { - return wslJson(`curl -fsS --max-time ${Math.ceil(timeout / 1000)} ${shQuote(`${kimodoWorkerUrl}/health`)}`, { timeout }); +function workerUnconfigured() { + return !args.modelsRoot || !args.kimodoCommit || !args.pythonEnv; } -async function startKimodoWorker({ model = null } = {}) { - const script = [ - "set -e", - "service docker start >/dev/null 2>&1 || true", - dockerHuggingFaceEnv, - "mkdir -p /mnt/e/Projects/Vili/.vili/artifacts /mnt/e/Projects/Vili/.vili/logs /mnt/e/Projects/Vili/.vili/embeddings", - "export VILI_KIMODO_COMMIT=\"$(git -C /opt/gamecult/kimodo rev-parse HEAD)\"", - "export VILI_KIMODO_IMAGE=\"$(docker image inspect --format '{{.Id}}' gamecult/kimodo:latest)\"", - "if ! kill -0 $(cat /tmp/vili-wsl-keepalive.pid 2>/dev/null) >/dev/null 2>&1; then nohup bash -c 'while true; do sleep 3600; done' >/tmp/vili-wsl-keepalive.log 2>&1 & echo $! >/tmp/vili-wsl-keepalive.pid; fi", - `if docker ps --format '{{.Names}}' | grep -qx ${shQuote(kimodoWorkerName)}; then echo running; exit 0; fi`, - `docker rm -f ${shQuote(kimodoWorkerName)} >/dev/null 2>&1 || true`, - [ - "docker run -d", - `--name ${shQuote(kimodoWorkerName)}`, - "--runtime=nvidia --gpus all", - `-p 127.0.0.1:${kimodoWorkerPort}:${kimodoWorkerPort}`, - dockerHuggingFaceArgs, - "-e VILI_KIMODO_COMMIT -e VILI_KIMODO_IMAGE", - "-e PYTHONPATH=/opt/cultlib/cultcache-py", - "-v /mnt/e/Projects/Vili/scripts:/opt/vili:ro", - "-v /mnt/e/Projects/CultLib/packages/cultcache-py/src:/opt/cultlib/cultcache-py:ro", - "-v /mnt/e/Projects/Vili/.vili/embeddings:/vili-embeddings", - "-v /mnt/e/Projects/Vili/.vili/artifacts:/outputs", - "gamecult/kimodo:latest", - "python", - "/opt/vili/kimodo-resident-worker.py", - `--port ${kimodoWorkerPort}`, - model ? `--model ${shQuote(model)}` : "", - ].join(" "), - ].join("; "); - return wsl(script, { timeout: 30000 }); +function waitForExit(proc, timeoutMs) { + return new Promise((resolve) => { + if (proc.exitCode !== null || proc.signalCode !== null) return resolve(true); + const timer = setTimeout(() => resolve(false), timeoutMs); + proc.once("exit", () => { + clearTimeout(timer); + resolve(true); + }); + }); } -async function ensureKimodoWorker({ timeoutMs = 1000 * 60 * 8, model = null } = {}) { - const health = await kimodoWorkerHealth(10000); - if (health.ok && health.body?.ok) { - if (!model || health.body.model === model || health.body.displayModel === model) return health; - await wsl(`docker rm -f ${shQuote(kimodoWorkerName)} >/dev/null 2>&1 || true`, { timeout: 30000 }); +// The worker leads its own process group, so the encoder child it spawns dies with it. +async function stopWorker() { + const proc = worker.proc; + if (!proc || proc.pid === undefined) return; + worker.state = "stopped"; + for (const signal of ["SIGTERM", "SIGKILL"]) { + try { + process.kill(-proc.pid, signal); + } catch { + // the group is already gone + } + if (await waitForExit(proc, signal === "SIGTERM" ? 10000 : 5000)) break; + } +} + +async function startWorker(model, timeoutMs) { + await mkdir(path.join(args.stateRoot, "logs"), { recursive: true }); + await mkdir(path.join(args.stateRoot, "embeddings"), { recursive: true }); + const logFd = openSync(path.join(args.stateRoot, "logs", "worker.log"), "a"); + const argv = [args.workerEntry, "--host", "127.0.0.1", "--port", "0"]; + if (model) argv.push("--model", model); + const proc = spawn(args.python, argv, { + cwd: __dirname, + env: childEnv(), + stdio: ["ignore", "pipe", logFd], + shell: false, + detached: true, + }); + closeSync(logFd); + Object.assign(worker, { state: "starting", proc, pid: proc.pid ?? null, port: null, model: model || null, servedModels: [], lastError: null }); + const exited = new Promise((resolve) => { + proc.once("error", () => resolve("the worker could not be spawned")); + proc.once("exit", (code, signal) => resolve(`the worker exited (${signal ?? `code ${code}`})`)); + }); + exited.then((reason) => { + if (worker.proc !== proc) return; + Object.assign(worker, { state: "stopped", proc: null, pid: null, port: null, lastError: worker.state === "stopped" ? worker.lastError : reason }); + }); + + const fail = async (reason) => { + worker.lastError = reason; + await stopWorker(); + Object.assign(worker, { state: "failed", proc: null, pid: null, port: null, lastError: reason }); + throw new Error(reason); + }; + + if (proc.pid === undefined) await fail(await exited); + try { + os.setPriority(proc.pid, 10); + } catch { + await fail("the worker priority could not be lowered"); } - const started = await startKimodoWorker({ model }); - if (!started.ok) return started; - + // Stdout lines are the worker's log, until the one that says it is ready. const deadline = Date.now() + timeoutMs; - let last = started; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 5000)); - last = await kimodoWorkerHealth(10000); - if (last.ok && last.body?.ok) return last; + let readyTimer; + const ready = await Promise.race([ + new Promise((resolve) => { + const lines = readline.createInterface({ input: proc.stdout }); + const sink = openSync(path.join(args.stateRoot, "logs", "worker.log"), "a"); + lines.on("line", (line) => { + try { + const event = JSON.parse(line); + if (event?.event === "kimodo-worker-ready" && Number.isInteger(event.port)) return resolve(event); + } catch { + // not JSON: an ordinary log line + } + writeSync(sink, `${line}\n`); + }); + lines.on("close", () => closeSync(sink)); + }), + exited.then((reason) => ({ failed: reason })), + new Promise((resolve) => { readyTimer = setTimeout(() => resolve({ failed: "the worker was not ready in time" }), timeoutMs); }), + ]); + clearTimeout(readyTimer); + if (ready.failed) await fail(ready.failed); + worker.port = ready.port; + + for (;;) { + try { + const health = await fetch(`http://127.0.0.1:${worker.port}/health`, { signal: AbortSignal.timeout(10000) }); + const body = await health.json(); + if (health.ok && body?.ok) { + worker.servedModels = [body.model, body.displayModel].filter(Boolean); + worker.state = "ready"; + return; + } + } catch { + // not answering yet + } + if (worker.proc !== proc) throw new Error(worker.lastError || "the worker exited"); + if (Date.now() > deadline) await fail("the worker did not become healthy in time"); + await new Promise((resolve) => setTimeout(resolve, 500)); } - return { - ok: false, - stdout: last.stdout || "", - stderr: last.stderr || "", - error: `Kimodo worker did not become healthy within ${timeoutMs}ms.`, - body: last.body || null, - }; +} + +async function ensureWorker({ model = null, timeoutMs = 1000 * 60 * 8 } = {}) { + if (starting) await starting; + if (worker.state === "ready" && model && !worker.servedModels.includes(model)) await stopWorker(); + if (worker.state === "ready") return; + if (!starting) starting = startWorker(model, timeoutMs).finally(() => { starting = null; }); + await starting; } async function readRequestBody(request) { @@ -503,11 +405,15 @@ async function readRequestBody(request) { } async function generateMotion(request, response) { + if (workerUnconfigured()) { + json(response, 503, { ok: false, code: "worker-unconfigured" }); + return; + } let body; try { body = await readRequestBody(request); - } catch (error) { - json(response, 400, { ok: false, error: `Invalid JSON: ${error.message}` }); + } catch { + json(response, 400, { ok: false, error: "Invalid JSON." }); return; } @@ -560,7 +466,7 @@ async function generateMotion(request, response) { if (typeof body.constraints === "string") return body.constraints; if (body.constraints_path) return body.constraints_path; if (body.constraintsPath) return body.constraintsPath; - if (body.constraints !== undefined) return `/outputs/${jobId}/constraints.json`; + if (body.constraints !== undefined) return path.join(artifactDir, "constraints.json"); return undefined; })(); if (body.constraints !== undefined && typeof body.constraints !== "string") { @@ -588,12 +494,13 @@ async function generateMotion(request, response) { inputFolder: firstDefined(body.inputFolder, body.input_folder), }; await writeFile(path.join(artifactDir, "meta.json"), JSON.stringify(meta, null, 2)); - const worker = await ensureKimodoWorker({ - timeoutMs: Number(firstDefined(body.workerTimeoutMs, body.worker_timeout_ms, 1000 * 60 * 8)), - model: body.model || null, - }); - let result = worker; - if (worker.ok) { + + let result; + try { + await ensureWorker({ + timeoutMs: Number(firstDefined(body.workerTimeoutMs, body.worker_timeout_ms, 1000 * 60 * 8)), + model: body.model || null, + }); const workerRequest = { model: body.model, prompt: prompt || undefined, @@ -613,20 +520,19 @@ async function generateMotion(request, response) { bvhStandardTpose: boolValue(firstDefined(body.bvhStandardTpose, body.bvh_standard_tpose)), noPostprocess: boolValue(firstDefined(body.noPostprocess, body.no_postprocess)), saveExampleDir: boolValue(firstDefined(body.saveExampleDir, body.save_example_dir)), - output: `/outputs/${jobId}/motion`, + output: path.join(artifactDir, "motion"), }; await writeFile(path.join(artifactDir, "request.json"), JSON.stringify(workerRequest, null, 2)); - const curlCommand = [ - `curl -sS --max-time ${Math.ceil(Number(body.timeoutMs || 1000 * 60 * 30) / 1000)}`, - "-H 'content-type: application/json'", - `--data-binary @${shQuote(`/mnt/e/Projects/Vili/.vili/artifacts/${jobId}/request.json`)}`, - shQuote(`${kimodoWorkerUrl}/generate`), - ].join(" "); - result = await wslJson([ - "set -e", - curlCommand, - ].join("; "), { timeout: Number(body.timeoutMs || 1000 * 60 * 30) }); - if (result.body?.ok === false) result = { ...result, ok: false }; + const answer = await fetch(`http://127.0.0.1:${worker.port}/generate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(workerRequest), + signal: AbortSignal.timeout(Number(body.timeoutMs || 1000 * 60 * 30)), + }); + const workerBody = await answer.json(); + result = { ok: answer.ok && workerBody?.ok !== false, error: answer.ok ? null : `the worker answered ${answer.status}`, body: workerBody }; + } catch (error) { + result = { ok: false, error: `the worker request failed (${error?.name || "Error"})`, body: null }; } const job = { @@ -641,54 +547,21 @@ async function generateMotion(request, response) { seed, artifactDir, ok: result.ok, - stdout: result.stdout, - stderr: result.stderr, error: result.error, worker: result.body, }; - await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)).catch(async () => { - await mkdir(path.join(args.stateRoot, "jobs"), { recursive: true }); - await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)); - }); + await writeFile(path.join(args.stateRoot, "jobs", `${jobId}.json`), JSON.stringify(job, null, 2)); json(response, result.ok ? 200 : 502, job); } async function handleRequest(request, response) { const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); - if (request.method === "OPTIONS") { - response.writeHead(204, { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET,POST,OPTIONS", - "access-control-allow-headers": "content-type", - }); - response.end(); - return; - } + if (request.method === "POST" && refuseCrossSite(request, response)) return; if (url.pathname === "/" || url.pathname === "/health") { - await refreshBackendStatus({ includeKimodoHelp: false }); json(response, 200, { ok: true, service: "vili", providerId, state: operatorState() }); - } else if (url.pathname === "/provider-advertisement") { - json(response, 200, providerAdvertisement()); } else if (url.pathname === "/operator-state") { json(response, 200, operatorState()); - } else if (url.pathname === "/eve/operator") { - json(response, 200, eveSurface()); - } else if (url.pathname === "/eve/deck/providers") { - json(response, 200, { - providers: [{ - id: providerId, - title: "Vili Animation", - description: "Persona animation and Kimodo motion generation daemon.", - endpoint: `${ravenDeckUrl}/${providerId}`, - }], - }); - } else if (url.pathname === "/smoke") { - const result = await refreshBackendStatus({ includeKimodoHelp: true }); - const worker = await ensureKimodoWorker(); - await refreshBackendStatus({ includeKimodoHelp: false }); - const ok = result.backendStatus.kimodoHelp === "ok" && worker.ok && worker.body?.ok; - json(response, ok ? 200 : 502, { ok, ...result, worker: worker.body || worker }); } else if (url.pathname === "/motion/generate" && request.method === "POST") { await generateMotion(request, response); } else { @@ -704,11 +577,6 @@ async function healthCheck() { if (!body.ok) throw new Error("health returned ok=false"); } -if (args.printProviderAdvertisement) { - console.log(JSON.stringify(providerAdvertisement(), null, 2)); - process.exit(0); -} - if (args.health) { healthCheck().then(() => process.exit(0), (error) => { console.error(error.message); @@ -720,32 +588,25 @@ if (args.health) { const server = http.createServer((request, response) => { handleRequest(request, response).catch((error) => { - json(response, 500, { ok: false, error: error.message, stack: process.env.NODE_ENV === "development" ? error.stack : undefined }); + // The error's message can carry the request; name only its class. + console.error(`request failed: ${error?.name || "Error"}`); + if (!response.headersSent) json(response, 500, { ok: false, code: "internal" }); + else response.end(); }); }); - const wss = new WebSocketServer({ noServer: true }); - wss.on("connection", (socket) => { - const sendSurface = () => { - if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(eveSurface())); - }; - sendSurface(); - const timer = setInterval(sendSurface, 5000); - socket.on("close", () => clearInterval(timer)); - }); - - server.on("upgrade", (request, socket, head) => { - const url = new URL(request.url, `http://${request.headers.host || `${args.host}:${args.port}`}`); - if (url.pathname === `/eve/deck/${providerId}` || url.pathname === "/eve/deck") { - wss.handleUpgrade(request, socket, head, (ws) => wss.emit("connection", ws, request)); - } else { - socket.destroy(); - } - }); - server.listen(args.port, args.host, () => { - console.log(`Vili listening on http://${args.host}:${args.port}`); + console.log(`Vili listening on http://${args.host}:${server.address().port}`); }); - setInterval(() => persistSurfaces().catch((error) => console.error(error)), 5000); + const surfaceTimer = setInterval(() => persistSurfaces().catch((error) => console.error(`persist failed: ${error?.name || "Error"}`)), 5000); + + const shutdown = async () => { + clearInterval(surfaceTimer); + await stopWorker(); + server.close(); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); } diff --git a/vendor/CultLib b/vendor/CultLib index 02cf04b..b549d73 160000 --- a/vendor/CultLib +++ b/vendor/CultLib @@ -1 +1 @@ -Subproject commit 02cf04b5c9266772947a69a6c25218e0d157507d +Subproject commit b549d73183aa36c6ebb8163ebc68cad60b09c85d