diff --git a/docs/features/qwen38-rtx3090.md b/docs/features/qwen38-rtx3090.md index 13c8d38c96..b8d6635c53 100644 --- a/docs/features/qwen38-rtx3090.md +++ b/docs/features/qwen38-rtx3090.md @@ -94,11 +94,22 @@ appended, so your `GPU_UTIL`, `DFLASH_TOKENS` or your own `VLLM_API_KEY` survive The API key it generates is written straight onto the two seeded providers, so step 2 below is just "enable it". -Two things it deliberately will not do. On Windows it refuses unless -`VLLM_QWEN_PROJECT_DIR` is set (§1c): the default `~/qwen-serving` resolves to a -*Windows* home, and 20 GB of weights reached from the WSL2 VM across a 9p share is -a mistake that costs 20 GB to discover. And it never raises the WSL2 memory -ceiling — see §1a for why that one stays manual. +**On Windows it places the project inside WSL2 for you.** The default +`~/qwen-serving` resolves to a *Windows* home, and 20 GB of weights reached from +the WSL2 VM across a 9p share is a mistake that costs 20 GB to discover — so +before it clones anything, PortOS asks WSL for the default distro's name and home +(`wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'`), checks that +`\\wsl.localhost\\home\` is readable from Windows, and records +the result as `VLLM_QWEN_PROJECT_DIR` in PortOS's own `.env` so the readiness +check, the Start button and the next server boot all resolve the same directory. +It refuses only where it genuinely cannot answer the question — no WSL on the +host, no distro but a container engine's own (`docker-desktop` is recreated on a +reset), or a `\\wsl.localhost` share Windows cannot read — and each refusal +names that host's fix. Setting `VLLM_QWEN_PROJECT_DIR` yourself still overrides +the whole decision (§1c). + +The one thing it deliberately will not do is raise the WSL2 memory ceiling — see +§1a for why that one stays manual. #### By hand @@ -227,19 +238,22 @@ On Windows, confirm the same URL answers from the PortOS side too — Docker Desktop / WSL2 localhost forwarding is what makes a container in the VM reachable at `127.0.0.1` on the host. -### 1c. Point PortOS at the project (Windows only) +### 1c. Point PortOS at the project (Windows, optional) A native-Win32 PortOS resolves the default `~/qwen-serving` to a *Windows* home -directory, where the project is not. Set `VLLM_QWEN_PROJECT_DIR` to the distro's -UNC path so the readiness checklist can see the project and its `models/`: +directory, where the project is not. **You do not normally have to fix this +yourself** — the provisioning button and the Start button both detect the distro +and record the UNC path (see above). Set `VLLM_QWEN_PROJECT_DIR` only to overrule +that, or when you cloned by hand somewhere other than the default distro's home: ``` VLLM_QWEN_PROJECT_DIR=\\wsl.localhost\\home\\qwen-serving ``` Node reads that path, and `docker compose` accepts it as a working directory, so -both the checklist and the Start button work from it. On Linux the default is -already correct. +both the checklist and the Start button work from it. An exported value wins over +anything PortOS detected on an earlier run. On Linux the default is already +correct. **`DFLASH_TOKENS=15` is deliberately not a default.** It is the setting behind the headline throughput number, but it costs KV cache: 56k context across 4 @@ -283,9 +297,10 @@ explicitly-named provisioning action above. `prepare` writes them into the proje `models/` directory (compose bind-mounts `${MODELS_DIR:-./models}`), which is what PortOS looks at; the `qwen-cache` docker volume alongside it holds only the torch.compile / Triton / FlashInfer JIT caches. If PortOS cannot read that -directory at all — the normal case on Windows before `VLLM_QWEN_PROJECT_DIR` is -set to the UNC path above — the button says so, and `VLLM_QWEN_WEIGHTS_DIR` is -the escape hatch for weights kept somewhere else entirely. +directory at all, the button says so, and `VLLM_QWEN_WEIGHTS_DIR` is the escape +hatch for weights kept somewhere else entirely. On Windows the Start button first +resolves the WSL2 placement described above, so a project prepared by hand inside +the distro is found without any configuration. ## What the numbers mean @@ -319,7 +334,7 @@ concurrent server result. | Variable | Default | Purpose | |---|---|---| -| `VLLM_QWEN_PROJECT_DIR` | `~/qwen-serving` | Where the compose project was cloned. Required on Windows, as the `\\wsl.localhost\…` UNC path. | +| `VLLM_QWEN_PROJECT_DIR` | `~/qwen-serving`, or the `\\wsl.localhost\…` path PortOS detected and recorded in its own `.env` on Windows | Where the compose project was cloned. Set it only to overrule the detected placement — an exported value wins over the record. | | `VLLM_QWEN_WEIGHTS_DIR` | *(unset)* | The directory holding the model weights, when it is not the project's own `models/` — e.g. a `MODELS_DIR` pointed elsewhere, or a HuggingFace hub cache shared with another stack. | ## Related diff --git a/server/lib/README.md b/server/lib/README.md index 99822177fa..cd8284e1e3 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -177,9 +177,10 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. | | `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export the two names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` is the only registrant: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. | | `mtplxModels.js` | `listMtplxCachedModels({command?})` → `{models, error}` from `mtplx models --json` (a local directory listing — no network, no model load) and `pickMtplxCachedModel(models)` → the repo id to hand `mtplx serve --model`. `models: null` means the cache could not be READ (no binary, command failed, unparseable) and is deliberately distinct from `[]` (read, and empty), because `services/localRuntimeSetup.js` starts MTPLX on its own default in the first case and refuses with the `mtplx pull` command in the second. Exists because `mtplx serve` defaults `--model` to one hard-coded checkpoint and exits 1 before binding when that repo is not cached — even on a host holding a different MTP model that serves fine. Picks only entries MTPLX itself calls complete (`validation.ok !== false`, so a half-finished pull is not served), preferring one with a recorded `mtplx_runtime.json` exactness contract. `describeMtplxCache(cache)` → `{state: 'unknown'\|'empty'\|'partial'\|'ready', model, count, error}` folds both into the one value `services/providerReadiness.js` puts on the checklist and `describeRuntimeSetup` picks a button from — so an empty cache is named up front instead of only inside the failure of a Start that could never work. | -| `vllmQwenProject.js` | `resolveVllmProjectDir()` / `inspectVllmQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the operator-cloned syv-ai/qwen38-27b-rtx3090 compose project, plus `vllmStartBlockedReason(project)` → the prose refusal (or `null`), and `vllmProjectSetupState(project)` → `ready`/`empty`/`unknown` for the readiness checklist. Directory reads only — never runs docker or touches a registry. `hasWeights` is tri-state: `true` found / `false` caches read and empty / `null` no cache readable (a docker-volume cache is invisible from a native-Win32 PortOS), and `services/localRuntimeSetup.js` refuses to `docker compose up` on anything but `true` so the start button can never kick off the ~20 GB prepare. Overrides: `VLLM_QWEN_PROJECT_DIR`, `VLLM_QWEN_WEIGHTS_DIR`. | +| `vllmQwenProject.js` | `resolveVllmProjectDir()` / `inspectVllmQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the syv-ai/qwen38-27b-rtx3090 compose project, plus `vllmStartBlockedReason(project)` → the prose refusal (or `null`), and `vllmProjectSetupState(project)` → `ready`/`empty`/`unknown` for the readiness checklist. `hasWeights` is tri-state: `true` found / `false` caches read and empty / `null` no cache readable (a docker-volume cache is invisible from a native-Win32 PortOS), and `services/localRuntimeSetup.js` refuses to `docker compose up` on anything but `true` so the start button can never kick off the ~20 GB prepare. Also owns WHERE the project lives: `readRecordedVllmProjectDir(envPath?)` / `recordVllmProjectDir(dir, envPath?)` keep PortOS's auto-detected directory as one line in the INSTALL's `.env` (`PATHS.installRoot`, so a worktree-booted server writes where the real install reads), and `vllmProjectDirIsSettled()` answers "is detection still worth a subprocess?" so the manager never re-lists the precedence. That order is `VLLM_QWEN_PROJECT_DIR` (this run's decision) → the record → `~/qwen-serving`; `envPath` is a parameter so a test's sandbox answers instead of the developer's install. Directory reads plus that one config line — never docker, never a registry. Other override: `VLLM_QWEN_WEIGHTS_DIR`. | +| `wslDistro.js` | Which WSL2 distro a native-Win32 PortOS should put Linux-side work in. `detectWslProjectDir(leaf)` → `{dir, distro, home}` or `{dir: null, reason}` (`no-wsl` / `no-distro` / `internal-distro` / `unreadable-share`), built from `wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'` — the distro's OWN shell, because `wsl --list` prints UTF-16LE that a UTF-8 reader mangles while an executed program's stdout passes through byte for byte. Verifies the derived `\\wsl.localhost\…` path is readable from Windows before returning it (WSL running and its share answering are separate facts) and refuses a container engine's own distro (`docker-desktop` and friends are recreated on a reset). `parseWslProbe` / `parseWslDistroList` (NUL-stripping the UTF-16 bytes, for an error message only) are exported for their own tests; `WSL_UNC_PREFIX` names the share root for callers writing refusal prose. Exists so the vLLM stack places its ~20 GB of weights on the distro filesystem instead of asking a human to fill in a UNC template — every read from a `C:\` checkout would cross a 9p share. | | `qwenAgentParsers.js` | The tool-call / reasoning parser flags a local runtime MUST carry to serve a Qwen3-family model to a coding agent — one table PortOS owns instead of three docs. `QWEN_AGENT_PARSERS` maps runtime → `{toolCallParser, reasoningParser, enableAutoToolChoice}` (vLLM `qwen3_xml` + `--enable-auto-tool-choice`, SGLang `qwen3_coder` + `--reasoning-parser qwen3`, llama both `null` — a positive "no such flag today", not a placeholder). `parserFlagsFor(runtime)` returns the argv fragment (always an array, so no caller type-checks) and `vllmExtraArgs()` its string form for the compose project's `.env` `EXTRA_ARGS`; an unknown runtime THROWS, because the failure it prevents is silent — a parser-less server answers fluently and returns tool markup as ordinary text with `tool_calls: null`, so the agent never touches a file. Spellings are empirical (`docs/research/2026-08-21-qwen38-rtx3090-vllm.md`, `…-sglang-qwen38-27b.md`); never auto-detect one from the chat template — that is how `hermes` got picked. Pure. | -| `vllmQwenProvision.js` | The `.env` half of provisioning that same project: `generateVllmApiKey()`, `isWsl2Engine()` (win32 counts — Docker Desktop's engine IS a WSL2 VM), `vllmEnvDefaults({apiKey, wsl2})` → the load-bearing tool-parser/pin-memory/alloc-conf settings, and `mergeEnvFileContents(existing, defaults)` → `{contents, added, kept, effective}`. **Additive only** — an operator's existing key or tuning is never overwritten, and `effective` reports what the container will actually read. Plus `WSL2_PREPARE_MIN_BYTES` / `WSL2_PREPARE_CONFIG_HINT` for the ceiling `prepare` needs (detected and warned about, never raised). | +| `vllmQwenProvision.js` | The `.env` half of provisioning that same project: `generateVllmApiKey()`, `isWsl2Engine()` (win32 counts — Docker Desktop's engine IS a WSL2 VM), `vllmEnvDefaults({apiKey, wsl2})` → the load-bearing tool-parser/pin-memory/alloc-conf settings, `parseEnvContents(contents)` → a key→value Map (keyed on *mention*, so a commented-out key reads as absent and `KEY=` as an intentional empty), and the two writers over one shared newline guard: `mergeEnvFileContents(existing, defaults)` → `{contents, added, kept, effective}` is **additive only** — an operator's existing key or tuning is never overwritten, and `effective` reports what the container will actually read — while `upsertEnvLine(contents, key, value)` REPLACES one key's line, for a value PortOS owns and re-derives (`vllmQwenProject.js`'s recorded project directory); its replacement is a function, not a string, so a `$`-sequence in the value is written literally. Plus `WSL2_PREPARE_MIN_BYTES` / `WSL2_PREPARE_CONFIG_HINT` for the ceiling `prepare` needs (detected and warned about, never raised). | | `sglangQwenProject.js` | `resolveSglangProjectDir()` / `inspectSglangQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the operator's SGLang Qwen3.8-27B project, plus `sglangStartBlockedReason(project)` → the prose refusal (or `null`). Sibling of `vllmQwenProject.js` with the same tri-state `hasWeights` contract (`true` found / `false` caches read and empty / `null` no cache readable) and the same directory-reads-only rule — never docker, never a registry. Differs in that PortOS OWNS this launch line (SGLang publishes an image but no compose project), so the refusals point at the compose file in `docs/features/sglang-qwen38.md` rather than at a `git clone`. Overrides: `SGLANG_QWEN_PROJECT_DIR`, `SGLANG_QWEN_WEIGHTS_DIR`. | | `sglangQwenRecipe.js` | The `sglang serve` launch line PortOS owns, per NVIDIA card class. `buildSglangQwenRecipe({hw, contextLength, ssmDtype, spec, radixStrategy, host, port})` → `{image, modelName, modelPath, cell, mambaRatio, stateSlots, flags, env}`, and `sglangComposeYaml(recipe)` renders the `docker-compose.yml` from it (one source of truth; a test pins the doc against it). `mambaFullMemoryRatio(...)` derives `--mamba-full-memory-ratio` from the cookbook formula `(S + D) × state_bytes / (L × kv_bytes_per_token)` — load-bearing, because the cookbook default `0.9` under-sizes the GDN state pool at CoS prompt lengths and silently clamps `max_running_requests`. Both Qwen parsers (`--reasoning-parser qwen3`, `--tool-call-parser qwen3_coder` — NOT vLLM's `qwen3_xml`) are baked into every cell: getting them wrong fails silently, with the model emitting raw markup and the agent never calling a tool. `sglangCellForGpu(gpu)` maps a `cudaCapability.js` compute-cap row to a cell (SM 9.x → `h200`, SM ≥10 → `rtx6000`/`rtx5090` by VRAM, Ampere → `null`, which is a refusal: 24 GB stays on vLLM), and `sglangUnsupportedReason({platform, status, gpus})` is the prose for every no — never collapsing a probe `'unknown'` into "no GPU". Pure: no filesystem, no docker, no network. | | `opencodeStream.js` | OpenCode's `--format json` event stream — ONE parser, shared by `services/localModelAgentBenchmark.js` (which wants chars/tokens) and `services/modelCapabilityTests.js` (which wants a readable transcript). `eventPart` (accepts the flat `{part}` and nested `{properties.part}` envelopes OpenCode has both used), `isToolEvent` / `eventText`, `parseAgentLine` / `parseAgentEvents` (blank and unparsable lines yield nothing rather than throwing), `formatAgentEvent` (one frame → a transcript line a person can read, with the path or command the tool acted on), and `summarizeOpenCodeEvents` (assistant chars, tool calls, and output tokens — `null`, never 0, when OpenCode reported no usage; tool ARGUMENTS are deliberately not counted as answer text). Pure. The runner that produces the stream is `services/opencodeTask.js`. | diff --git a/server/lib/index.js b/server/lib/index.js index 247a15809a..719a39a1e2 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -184,6 +184,7 @@ export * from './localProviderRuntime.js'; export * from './mtplxModels.js'; export * from './managedDaemon.js'; export * from './vllmQwenProject.js'; +export * from './wslDistro.js'; export * from './qwenAgentParsers.js'; export * from './vllmQwenProvision.js'; export * from './sglangQwenProject.js'; diff --git a/server/lib/vllmQwenProject.js b/server/lib/vllmQwenProject.js index b6d0c25261..0996b1bb8d 100644 --- a/server/lib/vllmQwenProject.js +++ b/server/lib/vllmQwenProject.js @@ -10,9 +10,9 @@ * a directory that has never been prepared would kick off exactly the multi-tens- * of-gigabytes download PortOS promises never to start on its own. * - * So the button asks first, and this module is the question. It only reads - * directory entries — it never runs docker, contacts a registry, or reads a - * weight file. + * So the button asks first, and this module is the question. It reads directory + * entries and records where it looked — it never runs docker, contacts a + * registry, or reads a weight file. * * **Sentinels matter here.** `hasWeights` is a tri-state: `true` (a Qwen model * directory was found), `false` (every candidate root was readable and none held @@ -28,15 +28,46 @@ * working directory. `VLLM_QWEN_WEIGHTS_DIR` covers the rarer case of a cache * kept somewhere else entirely; otherwise the operator simply runs compose * themselves, which is the documented path anyway. + * + * **The operator no longer types that UNC path themselves.** On Windows, + * `services/vllmQwenManager.js` asks WSL for it (`lib/wslDistro.js`) and records + * the answer through `recordVllmProjectDir` below, so every later read — the + * once-a-minute readiness inspection, the Start button, a restarted server — + * resolves the same directory the provisioning run actually used. The record is + * one line in PortOS's own `.env`, kept HERE because "where does this project + * live" is one question and one module should answer it. */ +import { readFileSync } from 'fs'; import { readdir, stat } from 'fs/promises'; import { homedir } from 'os'; import { join } from 'path'; +// From the leaf modules, not the `fileUtils.js` aggregate: several route suites +// replace that whole aggregate with a small literal, and a module-level +// `PATHS.root` read through it explodes at import time in a suite that never +// touches this file. +import { atomicWrite } from './fileCore.js'; +import { PATHS } from './paths.js'; +import { parseEnvContents, upsertEnvLine } from './vllmQwenProvision.js'; + /** Operator override for where the compose project was cloned. */ export const VLLM_PROJECT_DIR_ENV = 'VLLM_QWEN_PROJECT_DIR'; +/** The directory name upstream's README uses, inside whichever home holds it. */ +export const VLLM_PROJECT_LEAF = 'qwen-serving'; + +/** + * PortOS's own `.env` — where an auto-detected project directory is recorded. + * + * `installRoot`, not `root`: what is recorded here is machine-local runtime state + * ("where this machine's WSL project lives"), so it belongs to the install and + * not to whichever checkout loaded the code. A server booted from a CoS agent + * worktree has no `.env` in its own tree (`lib/paths.js`, #1947), and anchoring + * to `root` there would write a throwaway file the real install never reads. + */ +export const PORTOS_ENV_PATH = join(PATHS.installRoot, '.env'); + /** * Operator override for the HuggingFace cache holding the weights — the answer * for a stack whose cache is a docker named volume PortOS cannot see. @@ -52,7 +83,44 @@ const resolveHome = (env) => String(env?.HOME || env?.USERPROFILE || '').trim() || homedir(); /** Where the upstream README tells the operator to clone it. */ -export const vllmDefaultProjectDir = (env = process.env) => join(resolveHome(env), 'qwen-serving'); +export const vllmDefaultProjectDir = (env = process.env) => join(resolveHome(env), VLLM_PROJECT_LEAF); + +/** + * The project directory PortOS recorded for itself, or `''` when there is none. + * + * Read from the file on every call rather than cached: the provisioning run + * writes it, and the readiness poll that must start seeing the new directory + * lives in the same process without a restart between them. PortOS has no + * dotenv, so `.env` reaches `process.env` for nobody — a module that wants a + * value out of it reads the file, the same way `services/localLlm.js` reads its + * `LLM_BACKEND` marker. + * + * @param {string} [envPath] + * @returns {string} + */ +export function readRecordedVllmProjectDir(envPath = PORTOS_ENV_PATH) { + let contents = ''; + try { contents = readFileSync(envPath, 'utf8'); } catch { return ''; } + return parseEnvContents(contents).get(VLLM_PROJECT_DIR_ENV) || ''; +} + +/** + * Remember where this project was placed, so nothing has to detect it twice. + * + * `upsertEnvLine` rather than an append: a file accumulating one line per + * provisioning run is a config whose meaning depends on which reader opens it + * (some take the first mention, some the last). Atomic, because PortOS's `.env` + * also carries the database password and a half-written truncate is readable by + * a concurrent boot. + * + * @param {string} dir + * @param {string} [envPath] + */ +export async function recordVllmProjectDir(dir, envPath = PORTOS_ENV_PATH) { + let contents = ''; + try { contents = readFileSync(envPath, 'utf8'); } catch { /* no .env yet */ } + await atomicWrite(envPath, upsertEnvLine(contents, VLLM_PROJECT_DIR_ENV, dir)); +} /** Compose file names the upstream project may ship under. */ const COMPOSE_FILENAMES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; @@ -87,10 +155,31 @@ const LOCAL_WEIGHT_MARKERS = ['model.safetensors.index.json', 'model.safetensors const isDirectory = (path) => stat(path).then((s) => s.isDirectory(), () => false); const isFile = (path) => stat(path).then((s) => s.isFile(), () => false); -/** The configured project directory, or upstream's documented default. */ -export function resolveVllmProjectDir(env = process.env) { +/** + * The configured project directory, what PortOS recorded, or upstream's + * documented default — in that order. + * + * The process environment outranks the recorded value deliberately: an operator + * who exports this variable (in their shell, or in `ecosystem.config.cjs`) is + * making a decision for this run, and a directory PortOS auto-detected on some + * earlier run must not quietly outlive it. + */ +export function resolveVllmProjectDir(env = process.env, envPath = PORTOS_ENV_PATH) { const configured = String(env?.[VLLM_PROJECT_DIR_ENV] || '').trim(); - return configured || vllmDefaultProjectDir(env); + if (configured) return configured; + return readRecordedVllmProjectDir(envPath) || vllmDefaultProjectDir(env); +} + +/** + * Whether anything already answers "where does this project live", so a caller + * knows whether detecting it is still worth a subprocess. + * + * Exported so `services/vllmQwenManager.js` asks THIS module rather than + * re-listing the two sources above — a precedence change made in one place and + * not the other is invisible on any non-Windows machine. + */ +export function vllmProjectDirIsSettled(env = process.env, envPath = PORTOS_ENV_PATH) { + return Boolean(String(env?.[VLLM_PROJECT_DIR_ENV] || '').trim() || readRecordedVllmProjectDir(envPath)); } /** @@ -135,11 +224,15 @@ async function rootHoldsQwenWeights(root, entries) { * Inspect the operator's vLLM project without touching docker. * * @param {NodeJS.ProcessEnv} [env] + * @param {string} [envPath] - which `.env` holds the recorded directory; a + * parameter for the same reason `resolveHome` reads the passed env — every + * path this module derives has to be answerable by a test's sandbox rather + * than by whatever the developer's own install happens to have recorded. * @returns {Promise<{dir:string, hasProject:boolean, composeFile:string|null, * hasWeights:boolean|null, weightsRoot:string|null}>} */ -export async function inspectVllmQwenProject(env = process.env) { - const dir = resolveVllmProjectDir(env); +export async function inspectVllmQwenProject(env = process.env, envPath = PORTOS_ENV_PATH) { + const dir = resolveVllmProjectDir(env, envPath); const hasProject = await isDirectory(dir); let composeFile = null; @@ -181,16 +274,16 @@ export async function inspectVllmQwenProject(env = process.env) { */ export function vllmStartBlockedReason(project) { if (!project?.hasProject) { - return `the compose project was not found at ${project?.dir}. Clone https://github.com/syv-ai/qwen38-27b-rtx3090 there (or set ${VLLM_PROJECT_DIR_ENV}) and run its prepare step once — PortOS never downloads the image or the weights.`; + return `the compose project was not found at ${project?.dir}. Use the checklist's “Clone, build & prepare” button — it does the whole ~30 GB sequence for you, and a plain Start never downloads an image or a weight.`; } if (!project.composeFile) { return `${project.dir} exists but holds no docker-compose file. Point ${VLLM_PROJECT_DIR_ENV} at the cloned syv-ai/qwen38-27b-rtx3090 checkout.`; } if (project.hasWeights === false) { - return `the project is cloned but no Qwen weights are cached yet. Run its prepare step in a terminal — starting compose now would pull roughly 20 GB, which PortOS will not do for you.`; + return `the project is cloned but no Qwen weights are cached yet. Use the checklist's “Clone, build & prepare” button, which runs that step and names the ~20 GB before it starts — a plain Start will not spend it for you.`; } if (project.hasWeights === null) { - return `PortOS cannot read a models directory for this project, so it cannot confirm the weights are already downloaded. On Windows the project usually lives inside WSL2 — point ${VLLM_PROJECT_DIR_ENV} at its UNC path (\\\\wsl.localhost\\\\home\\\\qwen-serving). Otherwise set ${VLLM_WEIGHTS_DIR_ENV} to the directory holding the weights, or start it yourself with \`docker compose --profile single up -d\` in ${project.dir}.`; + return `PortOS cannot read a models directory for this project, so it cannot confirm the weights are already downloaded. On Windows it places the project inside WSL2 and records the UNC path for itself — if that record is stale, or the weights live somewhere else entirely, set ${VLLM_PROJECT_DIR_ENV} or ${VLLM_WEIGHTS_DIR_ENV} to where they actually are. Failing that, start it yourself with \`docker compose --profile single up -d\` in ${project.dir}.`; } return null; } diff --git a/server/lib/vllmQwenProject.test.js b/server/lib/vllmQwenProject.test.js index 81ac7a4fe6..f61a8503f8 100644 --- a/server/lib/vllmQwenProject.test.js +++ b/server/lib/vllmQwenProject.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -7,14 +7,22 @@ import { VLLM_PROJECT_DIR_ENV, VLLM_WEIGHTS_DIR_ENV, inspectVllmQwenProject, + readRecordedVllmProjectDir, + recordVllmProjectDir, resolveVllmProjectDir, vllmDefaultProjectDir, + vllmProjectDirIsSettled, vllmProjectSetupState, vllmStartBlockedReason, } from './vllmQwenProject.js'; let root; const projectDir = () => join(root, 'qwen-serving'); +/** + * The `.env` PortOS records a detected directory in — inside the sandbox, so + * the developer's own install can never answer one of these assertions. + */ +const envPath = () => join(root, '.env'); /** * An env with HOME/USERPROFILE/HF_HOME pointed inside the sandbox. Without it, @@ -42,21 +50,58 @@ describe('resolveVllmProjectDir', () => { it('ignores a blank override rather than resolving to an empty path', () => { const home = { HOME: '/home/example', USERPROFILE: '/home/example' }; - expect(resolveVllmProjectDir({ ...home, [VLLM_PROJECT_DIR_ENV]: ' ' })) + expect(resolveVllmProjectDir({ ...home, [VLLM_PROJECT_DIR_ENV]: ' ' }, envPath())) .toBe(vllmDefaultProjectDir(home)); }); + + it('falls back to the directory PortOS recorded for itself', async () => { + const home = { HOME: '/home/example', USERPROFILE: '/home/example' }; + const recorded = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\qwen-serving'; + await recordVllmProjectDir(recorded, envPath()); + + expect(readRecordedVllmProjectDir(envPath())).toBe(recorded); + expect(resolveVllmProjectDir(home, envPath())).toBe(recorded); + // An exported override still outranks it — that is this run's decision, + // and a directory detected on some earlier run must not outlive it. + expect(resolveVllmProjectDir({ ...home, [VLLM_PROJECT_DIR_ENV]: '/srv/qwen' }, envPath())).toBe('/srv/qwen'); + }); + + it('rewrites its own record instead of appending a second line', async () => { + writeFileSync(envPath(), 'PGPASSWORD=portos'); + await recordVllmProjectDir('/srv/first', envPath()); + await recordVllmProjectDir('/srv/second', envPath()); + + const contents = readFileSync(envPath(), 'utf8'); + expect(contents.match(/^VLLM_QWEN_PROJECT_DIR=/gm)).toHaveLength(1); + expect(readRecordedVllmProjectDir(envPath())).toBe('/srv/second'); + // The line it was appended after had no trailing newline — splicing onto it + // would have corrupted both settings. + expect(contents).toContain('PGPASSWORD=portos\n'); + }); + + it('reports nothing settled until something answers', async () => { + const bare = { HOME: '/home/example', USERPROFILE: '/home/example' }; + expect(vllmProjectDirIsSettled(bare, envPath())).toBe(false); + + expect(vllmProjectDirIsSettled({ ...bare, [VLLM_PROJECT_DIR_ENV]: '/srv/qwen' }, envPath())).toBe(true); + + await recordVllmProjectDir('/srv/recorded', envPath()); + expect(vllmProjectDirIsSettled(bare, envPath())).toBe(true); + }); }); describe('inspectVllmQwenProject', () => { it('reports no project when the directory is absent', async () => { - const project = await inspectVllmQwenProject(env()); + const project = await inspectVllmQwenProject(env(), envPath()); expect(project).toMatchObject({ hasProject: false, composeFile: null }); - expect(vllmStartBlockedReason(project)).toContain('syv-ai/qwen38-27b-rtx3090'); + // Points at the checklist button that does the whole sequence, not at a + // `git clone` the operator has to run themselves. + expect(vllmStartBlockedReason(project)).toContain('Clone, build & prepare'); }); it('reports a directory with no compose file as not a project', async () => { mkdirSync(projectDir(), { recursive: true }); - const project = await inspectVllmQwenProject(env()); + const project = await inspectVllmQwenProject(env(), envPath()); expect(project).toMatchObject({ hasProject: true, composeFile: null }); expect(vllmStartBlockedReason(project)).toContain('no docker-compose file'); }); @@ -65,7 +110,7 @@ describe('inspectVllmQwenProject', () => { mkdirSync(projectDir(), { recursive: true }); writeFileSync(join(projectDir(), 'docker-compose.yml'), 'services: {}\n'); - const project = await inspectVllmQwenProject(env()); + const project = await inspectVllmQwenProject(env(), envPath()); expect(project.composeFile).toBe('docker-compose.yml'); // `null`, not `false` — nothing was READ, which is a different fix than an // empty cache. The distinction is the point of the sentinel. @@ -78,7 +123,7 @@ describe('inspectVllmQwenProject', () => { writeFileSync(join(projectDir(), 'compose.yaml'), 'services: {}\n'); mkdirSync(join(projectDir(), 'models'), { recursive: true }); - const project = await inspectVllmQwenProject(env()); + const project = await inspectVllmQwenProject(env(), envPath()); expect(project).toMatchObject({ composeFile: 'compose.yaml', hasWeights: false }); expect(vllmStartBlockedReason(project)).toContain('no Qwen weights are cached'); }); @@ -88,7 +133,7 @@ describe('inspectVllmQwenProject', () => { writeFileSync(join(projectDir(), 'docker-compose.yml'), 'services: {}\n'); mkdirSync(join(projectDir(), 'models', 'models--syv-ai--Qwen3.8-27B-w4a16'), { recursive: true }); - const project = await inspectVllmQwenProject(env()); + const project = await inspectVllmQwenProject(env(), envPath()); expect(project.hasWeights).toBe(true); expect(project.weightsRoot).toBe(join(projectDir(), 'models')); expect(vllmStartBlockedReason(project)).toBeNull(); @@ -103,7 +148,7 @@ describe('inspectVllmQwenProject', () => { mkdirSync(model, { recursive: true }); writeFileSync(join(model, 'model.safetensors.index.json'), '{"weight_map":{}}\n'); - const project = await inspectVllmQwenProject(env()); + const project = await inspectVllmQwenProject(env(), envPath()); expect(project.hasWeights).toBe(true); expect(project.weightsRoot).toBe(join(projectDir(), 'models')); expect(vllmStartBlockedReason(project)).toBeNull(); @@ -116,7 +161,7 @@ describe('inspectVllmQwenProject', () => { mkdirSync(model, { recursive: true }); writeFileSync(join(model, 'model.safetensors'), 'tensors\n'); - expect((await inspectVllmQwenProject(env())).hasWeights).toBe(true); + expect((await inspectVllmQwenProject(env(), envPath())).hasWeights).toBe(true); }); it('does not count a qwen-named directory that holds no weight file', async () => { @@ -127,7 +172,7 @@ describe('inspectVllmQwenProject', () => { mkdirSync(notes, { recursive: true }); writeFileSync(join(notes, 'README.md'), 'not weights\n'); - expect((await inspectVllmQwenProject(env())).hasWeights).toBe(false); + expect((await inspectVllmQwenProject(env(), envPath())).hasWeights).toBe(false); }); it('ignores a cache holding only unrelated models', async () => { @@ -135,7 +180,7 @@ describe('inspectVllmQwenProject', () => { writeFileSync(join(projectDir(), 'docker-compose.yml'), 'services: {}\n'); mkdirSync(join(projectDir(), 'models', 'models--meta-llama--Llama-3.1-8B'), { recursive: true }); - expect((await inspectVllmQwenProject(env())).hasWeights).toBe(false); + expect((await inspectVllmQwenProject(env(), envPath())).hasWeights).toBe(false); }); it('honors the weights-directory override, for a cache PortOS cannot otherwise see', async () => { @@ -144,7 +189,7 @@ describe('inspectVllmQwenProject', () => { const cache = join(root, 'elsewhere', 'hub'); mkdirSync(join(cache, 'models--syv-ai--qwen3.8-27b'), { recursive: true }); - const project = await inspectVllmQwenProject(env({ [VLLM_WEIGHTS_DIR_ENV]: cache })); + const project = await inspectVllmQwenProject(env({ [VLLM_WEIGHTS_DIR_ENV]: cache }), envPath()); expect(project).toMatchObject({ hasWeights: true, weightsRoot: cache }); }); @@ -154,7 +199,7 @@ describe('inspectVllmQwenProject', () => { const hfHome = join(root, 'hf'); mkdirSync(join(hfHome, 'hub', 'models--Qwen--Qwen3.8-27B'), { recursive: true }); - const project = await inspectVllmQwenProject(env({ HF_HOME: hfHome })); + const project = await inspectVllmQwenProject(env({ HF_HOME: hfHome }), envPath()); expect(project.hasWeights).toBe(true); }); }); diff --git a/server/lib/vllmQwenProvision.js b/server/lib/vllmQwenProvision.js index 36874e42e2..13cd2a336d 100644 --- a/server/lib/vllmQwenProvision.js +++ b/server/lib/vllmQwenProvision.js @@ -37,6 +37,7 @@ import { randomBytes } from 'crypto'; import { vllmExtraArgs } from './qwenAgentParsers.js'; +import { escapeRegExp } from './textUtils.js'; /** Bytes of entropy in a generated key — matches the doc's `openssl rand -hex 24`. */ const API_KEY_BYTES = 24; @@ -128,6 +129,51 @@ export function parseEnvContents(contents) { return found; } +/** + * Add lines to the end of a `.env`, or return it unchanged when there are none. + * + * The separator is the whole point: a file not ending in a newline would splice + * the first new key onto the operator's last line and silently corrupt both. + * Shared by the two writers below so that guard is written once. + * + * @param {string} base + * @param {string[]} lines + * @returns {string} + */ +function appendEnvLines(base, lines) { + const text = String(base || ''); + if (lines.length === 0) return text; + const separator = text.length === 0 || text.endsWith('\n') ? '' : '\n'; + return `${text}${separator}${lines.join('\n')}\n`; +} + +/** + * Set ONE key, replacing the line that already declares it. + * + * The complement of `mergeEnvFileContents`: that one is additive by contract and + * never overrules the operator, which is exactly wrong for a value PortOS owns + * and re-derives (`vllmQwenProject.js`'s recorded project directory). Everything + * else in the file is left byte for byte. + * + * The replacement is a FUNCTION, not a string. A value carrying one of + * String.replace's special $-patterns would otherwise be expanded into the + * surrounding text instead of written literally — `scripts/lib/envFile.js` + * learned that on a password, and this is the same fix kept next to the parser + * it belongs with. + * + * @param {string} contents + * @param {string} key + * @param {string} value + * @returns {string} + */ +export function upsertEnvLine(contents, key, value) { + const text = String(contents || ''); + const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm'); + return pattern.test(text) + ? text.replace(pattern, () => `${key}=${value}`) + : appendEnvLines(text, [`${key}=${value}`]); +} + /** * Append the missing defaults to an existing `.env`, changing nothing else. * @@ -155,13 +201,7 @@ export function mergeEnvFileContents(existing, defaults) { lines.push(`${key}=${value}`); } - const base = String(existing || ''); - if (lines.length === 0) return { contents: base, added, kept, effective }; - - // A file that does not end in a newline would otherwise splice the first new - // key onto the operator's last line and silently corrupt both. - const separator = base.length === 0 || base.endsWith('\n') ? '' : '\n'; - return { contents: `${base}${separator}${lines.join('\n')}\n`, added, kept, effective }; + return { contents: appendEnvLines(existing, lines), added, kept, effective }; } /** diff --git a/server/lib/vllmQwenProvision.test.js b/server/lib/vllmQwenProvision.test.js index cae886076d..8682068a39 100644 --- a/server/lib/vllmQwenProvision.test.js +++ b/server/lib/vllmQwenProvision.test.js @@ -5,6 +5,7 @@ import { isWsl2Engine, mergeEnvFileContents, parseEnvContents, + upsertEnvLine, vllmEnvDefaults, VLLM_API_KEY_VAR, } from './vllmQwenProvision.js'; @@ -127,3 +128,25 @@ describe('mergeEnvFileContents', () => { expect(result.added).toEqual([]); }); }); + +describe('upsertEnvLine', () => { + it('replaces the line a key already declares, leaving the rest byte for byte', () => { + const existing = 'PGPASSWORD=portos\nVLLM_QWEN_PROJECT_DIR=/srv/old\nGPU_UTIL=0.93\n'; + expect(upsertEnvLine(existing, 'VLLM_QWEN_PROJECT_DIR', '/srv/new')) + .toBe('PGPASSWORD=portos\nVLLM_QWEN_PROJECT_DIR=/srv/new\nGPU_UTIL=0.93\n'); + }); + + it('appends a key the file does not mention, without splicing onto its last line', () => { + expect(upsertEnvLine('PGPASSWORD=portos', 'VLLM_QWEN_PROJECT_DIR', '/srv/qwen')) + .toBe('PGPASSWORD=portos\nVLLM_QWEN_PROJECT_DIR=/srv/qwen\n'); + expect(upsertEnvLine('', 'SPEC', 'dflash2')).toBe('SPEC=dflash2\n'); + }); + + it('writes a value containing a $-pattern literally', () => { + // A string replacement would expand `$&` into the matched line and `$\`` into + // everything before it, corrupting the file and every key around it. + const written = upsertEnvLine('A=1\nSECRET=old\nB=2\n', 'SECRET', 'p$&w$`d'); + expect(written).toBe('A=1\nSECRET=p$&w$`d\nB=2\n'); + expect(parseEnvContents(written).get('SECRET')).toBe('p$&w$`d'); + }); +}); diff --git a/server/lib/wslDistro.js b/server/lib/wslDistro.js new file mode 100644 index 0000000000..77a5d13491 --- /dev/null +++ b/server/lib/wslDistro.js @@ -0,0 +1,172 @@ +/** + * Where a native-Win32 PortOS should put Linux-side work, and what that + * directory is called from Windows. + * + * Docker Desktop's engine IS a WSL2 VM. Anything the engine reads out of a + * Windows path is reached from inside that VM over a 9p share, so a + * multi-tens-of-gigabytes model checkout placed on `C:\` is slow in a way that + * is invisible until it has already cost the download. The documented layout is + * the distro's own filesystem, reached from Windows as + * `\\wsl.localhost\\home\` — a path Node can stat and + * `CreateProcess` accepts as a working directory. + * + * PortOS used to make the operator work that out: it refused, printed that UNC + * shape with `` and `` left as literal angle brackets, and told + * them to set an environment variable and click again. Every value in that + * template is something the machine can answer, so this module answers it. + * + * **The probe runs the distro's own shell, not `wsl --list`.** `wsl.exe`'s own + * UI output is UTF-16LE, which any UTF-8 reader mangles; the stdout of a program + * it EXECUTES is passed through byte for byte, so `wsl.exe -e sh -c 'echo …'` + * comes back as plain UTF-8. `WSL_DISTRO_NAME` and `HOME` are both set inside + * the distro, which is exactly the pair the UNC path needs. The UTF-16 read is + * confined to the failure path, where the distro list only decorates an error + * message. + * + * Nothing here decides anything: it reports what WSL says, and the caller + * decides whether to act on it (`services/vllmQwenManager.js`). + */ + +import { bufferedSpawn } from './bufferedSpawn.js'; +import { pathExists } from './fileCore.js'; + +/** The Windows-visible root of every WSL distro's filesystem. */ +export const WSL_UNC_PREFIX = '\\\\wsl.localhost\\'; + +/** + * A probe that must answer in seconds or not at all. A distro that is not yet + * running is started by this call, which is the slow case; an unresponsive one + * must not hold a setup click open forever. + */ +const PROBE_TIMEOUT_MS = 60 * 1000; + +/** + * Distros that exist to run somebody's container engine, not to hold a user's + * files. Docker Desktop's pair is recreated from scratch on a reset and its + * data volume is not a filesystem to clone into; the others are the same shape + * from the engines that copied the design. + */ +const INTERNAL_DISTRO_RE = /^(docker-desktop(-data)?|rancher-desktop(-data)?|podman-machine.*)$/i; + +/** Whether a distro name belongs to a container engine rather than to the user. */ +const isInternalWslDistro = (name) => INTERNAL_DISTRO_RE.test(String(name || '').trim()); + +/** + * Ask the DEFAULT distro what it is called and where its home is. `-e` execs + * without a login shell, so a noisy `.bashrc` cannot prepend a banner to the + * two lines this parses. + */ +const WSL_PROBE_ARGS = Object.freeze(['-e', 'sh', '-c', 'echo "$WSL_DISTRO_NAME"; echo "$HOME"']); + +/** + * The two lines `WSL_PROBE_ARGS` prints, or `null` when the output is not that. + * + * A distro answering with an empty or relative `HOME` is not usable, and reads + * as a failure here rather than deriving a path at the root of somebody's + * distro. + * + * @param {string} stdout + * @returns {{distro: string, home: string}|null} + */ +export function parseWslProbe(stdout) { + const [distro = '', home = ''] = String(stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!distro || !home.startsWith('/')) return null; + return { distro, home }; +} + +/** + * `wsl.exe --list --quiet`, decoded. + * + * Its output is UTF-16LE, and the buffer arrives here already coerced through + * UTF-8 by the spawn helper — for the ASCII a distro name is in practice, that + * leaves each character followed by a NUL, so dropping NULs recovers the name. + * Used ONLY to name the alternatives in an error message; a name this mangles + * costs a less specific sentence, never a wrong placement. + * + * @param {string} stdout + * @returns {string[]} user distros, engine plumbing removed + */ +export function parseWslDistroList(stdout) { + return String(stdout || '') + .replace(/\0/g, '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((name) => name && !isInternalWslDistro(name)); +} + +/** + * The Windows path for a POSIX path inside one distro. + * + * @param {string} distro + * @param {string} posixPath - absolute, e.g. `/home/alice` + * @returns {string} e.g. `\\wsl.localhost\Ubuntu\home\alice` + */ +function wslUncPath(distro, posixPath) { + const segments = String(posixPath || '').split('/').filter(Boolean); + return [`${WSL_UNC_PREFIX}${distro}`, ...segments].join('\\'); +} + +/** + * Run `wsl.exe`, never throwing. + * + * `launched` separates "WSL is not on this machine" from "WSL answered and said + * no": the first is a feature the operator installs, the second is a + * configuration they change, and collapsing the two would send half of each + * group to the wrong fix. + * + * @param {string[]} args + * @returns {Promise<{launched: boolean, ok: boolean, stdout: string, error: string}>} + */ +async function runWsl(args) { + // `wsl.exe` spelled with its extension: under `shell: false` a bare name is + // matched literally, and System32 is on PATH everywhere this can work. + const result = await bufferedSpawn('wsl.exe', args, { timeoutMs: PROBE_TIMEOUT_MS, shell: false }); + return { + launched: !result.error, + ok: result.success, + stdout: result.stdout || '', + error: String(result.error?.message || result.stderr || '').replace(/\0/g, '').trim(), + }; +} + +/** The distro names to offer in a failure, or `[]` when even that is unavailable. */ +async function listUserDistros(run) { + return parseWslDistroList((await run(['--list', '--quiet'])).stdout); +} + +/** + * Find the WSL2 home directory a Windows PortOS should place `leaf` in. + * + * Verifies the derived UNC path is actually readable before returning it: WSL + * being installed and its `\\wsl.localhost` share answering are separate facts, + * and a path only the container can see would send the caller back to the same + * 9p mistake by a longer route. + * + * @param {string} leaf - the directory name to place inside the distro's home + * @param {{run?: (args: string[]) => Promise<{launched: boolean, ok: boolean, stdout: string, error: string}>, + * exists?: (path: string) => Promise}} [deps] + * @returns {Promise<{dir: string|null, distro?: string, home?: string, reason?: string, error?: string, + * distros?: string[]}>} `dir` is the answer; `reason` names which question + * failed (`no-wsl` | `no-distro` | `internal-distro` | `unreadable-share`). + */ +export async function detectWslProjectDir(leaf, { run = runWsl, exists = pathExists } = {}) { + const probe = await run(WSL_PROBE_ARGS); + if (!probe.launched) return { dir: null, reason: 'no-wsl', error: probe.error }; + + const parsed = probe.ok ? parseWslProbe(probe.stdout) : null; + if (!parsed) return { dir: null, reason: 'no-distro', error: probe.error, distros: await listUserDistros(run) }; + + if (isInternalWslDistro(parsed.distro)) { + // A container engine's own distro answered because it is the default one. + // Its filesystem is disposable, so this is a refusal even though WSL works. + return { dir: null, reason: 'internal-distro', distro: parsed.distro, distros: await listUserDistros(run) }; + } + + const homeUnc = wslUncPath(parsed.distro, parsed.home); + if (!(await exists(homeUnc))) return { dir: null, reason: 'unreadable-share', distro: parsed.distro, home: homeUnc }; + + return { dir: `${homeUnc}\\${leaf}`, distro: parsed.distro, home: homeUnc }; +} diff --git a/server/lib/wslDistro.test.js b/server/lib/wslDistro.test.js new file mode 100644 index 0000000000..8532a0f7a5 --- /dev/null +++ b/server/lib/wslDistro.test.js @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { detectWslProjectDir, parseWslDistroList, parseWslProbe } from './wslDistro.js'; + +/** The probe args, recognised by shape rather than by the module's own constant. */ +const isProbe = (args) => args[0] === '-e'; + +/** A `wsl.exe` that answers the probe and nothing else. */ +const wslThatAnswers = (stdout) => vi.fn(async (args) => ( + isProbe(args) + ? { launched: true, ok: true, stdout, error: '' } + : { launched: true, ok: true, stdout: '', error: '' } +)); + +describe('parseWslProbe', () => { + it('reads the distro name and home the probe prints', () => { + expect(parseWslProbe('Ubuntu\n/home/alice\n')).toEqual({ distro: 'Ubuntu', home: '/home/alice' }); + }); + + it('tolerates CRLF and a blank trailing line', () => { + expect(parseWslProbe('Ubuntu\r\n/home/alice\r\n\r\n')).toEqual({ distro: 'Ubuntu', home: '/home/alice' }); + }); + + it('rejects a home that is not an absolute POSIX path', () => { + // Otherwise the caller derives a path at the ROOT of somebody's distro. + expect(parseWslProbe('Ubuntu\n\n')).toBeNull(); + expect(parseWslProbe('Ubuntu\nrelative/home\n')).toBeNull(); + expect(parseWslProbe('')).toBeNull(); + }); +}); + +describe('parseWslDistroList', () => { + it('recovers names from the UTF-16LE bytes a UTF-8 reader leaves NULs in', () => { + const utf16 = [...'Ubuntu-24.04'].map((ch) => `${ch}\u0000`).join(''); + expect(parseWslDistroList(`${utf16}\r\n`)).toEqual(['Ubuntu-24.04']); + }); + + it('drops the container engines own distros, and keeps everything else', () => { + const listed = 'Ubuntu\ndocker-desktop\ndocker-desktop-data\nrancher-desktop-data\npodman-machine-default\nDebian\n'; + expect(parseWslDistroList(listed)).toEqual(['Ubuntu', 'Debian']); + }); +}); + +describe('detectWslProjectDir', () => { + it('derives the project directory from what the default distro says', async () => { + const run = wslThatAnswers('Ubuntu\n/home/alice\n'); + + const found = await detectWslProjectDir('qwen-serving', { run, exists: async () => true }); + + expect(found).toMatchObject({ + dir: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\qwen-serving', + distro: 'Ubuntu', + }); + // The probe runs the distro's OWN shell — `wsl --list` prints UTF-16LE that + // a UTF-8 reader mangles, while an executed program's stdout passes through. + expect(run).toHaveBeenCalledWith(['-e', 'sh', '-c', expect.stringContaining('WSL_DISTRO_NAME')]); + }); + + it('separates a host with no WSL from a WSL with no distro', async () => { + const missing = vi.fn(async () => ({ launched: false, ok: false, stdout: '', error: 'ENOENT' })); + expect(await detectWslProjectDir('qwen-serving', { run: missing })).toMatchObject({ dir: null, reason: 'no-wsl' }); + + const refused = vi.fn(async () => ({ launched: true, ok: false, stdout: '', error: 'no installed distributions' })); + expect(await detectWslProjectDir('qwen-serving', { run: refused })).toMatchObject({ dir: null, reason: 'no-distro' }); + }); + + it('refuses a container engines own distro, and names the real ones instead', async () => { + const run = vi.fn(async (args) => (isProbe(args) + ? { launched: true, ok: true, stdout: 'docker-desktop\n/root\n', error: '' } + : { launched: true, ok: true, stdout: 'Ubuntu\ndocker-desktop\n', error: '' })); + + const found = await detectWslProjectDir('qwen-serving', { run, exists: async () => true }); + + // Its filesystem is recreated on a Docker Desktop reset — not a home for + // 20 GB of weights, even though WSL itself is working fine. + expect(found).toMatchObject({ dir: null, reason: 'internal-distro', distro: 'docker-desktop', distros: ['Ubuntu'] }); + }); + + it('refuses a distro whose share Windows cannot actually read', async () => { + const run = wslThatAnswers('Ubuntu\n/home/alice\n'); + + const found = await detectWslProjectDir('qwen-serving', { run, exists: async () => false }); + + // WSL answering and \\wsl.localhost answering are separate facts; returning + // a path only the container can see would repeat the 9p mistake. + expect(found).toMatchObject({ dir: null, reason: 'unreadable-share', home: '\\\\wsl.localhost\\Ubuntu\\home\\alice' }); + }); +}); diff --git a/server/services/localRuntimeSetup.js b/server/services/localRuntimeSetup.js index f94bea573e..faf6bab6b0 100644 --- a/server/services/localRuntimeSetup.js +++ b/server/services/localRuntimeSetup.js @@ -134,6 +134,7 @@ const PROVISION_STEPS = Object.freeze({ 'pull-start': Object.freeze({ // Names the download so the click IS the consent. label: (label) => `Download the default model & start ${label}`, + begun: (label) => `${label} is already installed — downloading its default checkpoint before starting it.`, refused: (label) => `PortOS cannot download model weights for ${label}.`, failed: (label, error) => `${label} model download failed: ${error}`, done: (label) => `${label}'s default checkpoint is cached.`, @@ -145,6 +146,7 @@ const PROVISION_STEPS = Object.freeze({ // PROJECT on a host whose docker / NVIDIA / WSL2 setup is already the // operator's own decision. label: (label) => `Clone, build & prepare ${label} (~30 GB), then start`, + begun: (label) => `Docker is installed — preparing ${label}'s compose project before starting it.`, refused: (label) => `PortOS cannot provision a compose project for ${label}.`, failed: (label, error) => `${label} provisioning failed: ${error}`, done: (label) => `${label}'s compose project is built and prepared.`, @@ -566,7 +568,13 @@ export async function runLocalRuntimeSetup(kind, { endpoint, emit = () => {}, is emit(`${runtime.label} is installed.`); if (result.note) emit(result.note); } else { - emit(`${runtime.label} is already installed — starting it.`); + // "already installed — starting it" was a lie whenever the click was a + // provisioning one: the next thing to happen is a clone or a multi-gigabyte + // download, and reporting a start makes every line after it read as a + // failure of the start. Each provisioning step says what it is about to do. + emit(resolved === provision + ? PROVISION_STEPS[provision].begun(runtime.label) + : `${runtime.label} is already installed — starting it.`); } if (!row.start) { diff --git a/server/services/vllmQwenManager.js b/server/services/vllmQwenManager.js index 2a7a660bff..ef91594aaf 100644 --- a/server/services/vllmQwenManager.js +++ b/server/services/vllmQwenManager.js @@ -34,10 +34,14 @@ import { localEndpointPort } from '../lib/localProviderRuntime.js'; import { PORTS } from '../lib/ports.js'; import { inspectVllmQwenProject, + recordVllmProjectDir, + vllmProjectDirIsSettled, vllmProjectSetupState, vllmStartBlockedReason, VLLM_PROJECT_DIR_ENV, + VLLM_PROJECT_LEAF, } from '../lib/vllmQwenProject.js'; +import { detectWslProjectDir, WSL_UNC_PREFIX } from '../lib/wslDistro.js'; import { generateVllmApiKey, isWsl2Engine, @@ -85,24 +89,91 @@ export async function readVllmQwenSetupState() { return vllmProjectSetupState(await inspectVllmQwenProject()); } +/** The real distros to offer, when there are any. */ +const nameDistros = (distros) => (distros?.length + ? ` PortOS can see ${distros.join(', ')} — \`wsl --set-default \` picks one; otherwise` + : ' Install one with'); + +/** + * The lead sentence for each way the WSL question can go unanswered. + * + * A table rather than four returns so the tail below is appended exactly once. + * That tail is the part that must never go missing — a fifth reason added to + * `detectWslProjectDir` would otherwise ship a refusal that neither rules out + * `C:\` nor names the override. + */ +const PLACEMENT_REFUSALS = Object.freeze({ + 'internal-distro': (f) => `the default WSL distro is \`${f.distro}\`, which is a container engine's own plumbing — it is recreated from scratch on a reset, so ~20 GB of weights must not live there.${nameDistros(f.distros)} \`wsl --install -d Ubuntu\`.`, + 'unreadable-share': (f, detail) => `the \`${f.distro}\` distro answered, but Windows cannot read ${f.home} — the ${WSL_UNC_PREFIX} share is not responding${detail}. \`wsl --shutdown\` restarts it (that takes the whole VM down, so stop your containers first).`, + 'no-distro': (f, detail) => `WSL is present but no distro answered${detail}, so there is no Linux filesystem to put this project on.${nameDistros(f.distros)} \`wsl --install -d Ubuntu\`.`, + 'no-wsl': (_f, detail) => `this stack runs inside WSL2 — Docker Desktop's own engine IS a WSL2 VM — and \`wsl.exe\` did not run on this host${detail}. Install a distro with \`wsl --install -d Ubuntu\`, then click this again and PortOS will place the project inside it for you.`, +}); + /** - * Why PortOS must not clone this project to a Windows path. + * Why PortOS could not find a Linux-side home for this project on a Windows + * host — prose the checklist renders verbatim, one fix per case. * - * Docker Desktop's engine is a WSL2 VM. A project cloned onto the Windows - * filesystem is reached from inside that VM over a 9p share, so `prepare` would - * write ~20 GB of weights across it and the server would page them back the same - * way — the documented layout is the Linux filesystem, and this is a mistake that - * costs 20 GB to discover and 20 GB to undo. An operator who genuinely wants a - * Windows path can still have one: setting the override IS that decision, so the - * refusal only fires when nothing was configured at all. + * There is no case for "the operator did not configure a directory" any more. + * That was the old refusal, and it asked a person to look up two values + * (``, ``) that WSL will state on request. What survives is the + * set of answers PortOS genuinely cannot supply for itself: a machine with no + * WSL, a default distro that belongs to a container engine, and a share Windows + * cannot read. * - * @returns {string|null} the refusal, or `null` when the placement is the - * operator's own explicit choice (or the host is not Windows). + * @param {{reason?: string, distro?: string, home?: string, error?: string, distros?: string[]}} found + * @returns {string} */ -export function vllmWindowsPlacementRefusal(dir, env = process.env, platform = process.platform) { - if (platform !== 'win32') return null; - if (String(env?.[VLLM_PROJECT_DIR_ENV] || '').trim()) return null; - return `on Windows this project and its ~20 GB of weights belong on the WSL2 filesystem, not the Windows one — cloning to ${dir} would leave every weight read crossing a 9p share. Set ${VLLM_PROJECT_DIR_ENV} to the distro's UNC path (\\\\wsl.localhost\\\\home\\\\qwen-serving) and click this again.`; +export function wslPlacementRefusal(found) { + const lead = PLACEMENT_REFUSALS[found?.reason] || PLACEMENT_REFUSALS['no-wsl']; + return `${lead(found || {}, found?.error ? ` (${found.error})` : '')} PortOS will not fall back to the Windows filesystem, where every one of those weight reads would cross a 9p share. To place the project somewhere of your own choosing instead, set ${VLLM_PROJECT_DIR_ENV} and click this again.`; +} + +/** + * Settle where this project goes before anything is written to it. + * + * On Windows the answer is never the default `%USERPROFILE%\qwen-serving`: + * Docker Desktop's engine is a WSL2 VM, so a project on the Windows filesystem + * is reached from inside that VM over a 9p share, and the ~20 GB of weights + * would be written across it once and paged back across it forever. PortOS used + * to refuse and hand the operator a UNC template to fill in by hand; it now asks + * WSL for the same two values (`lib/wslDistro.js`) and records the answer, so + * the readiness poll, the Start button, and the next server boot all resolve the + * directory this run actually used. + * + * Detection runs ONLY when nothing already answers the question — an exported + * `VLLM_QWEN_PROJECT_DIR` or an earlier recording both win, and neither costs a + * subprocess. Off Windows there is nothing to detect: the default home is a + * Linux filesystem already. + * + * @param {{emit?: (line: string) => void}} [ctx] + * @returns {Promise} the refusal, or `null` once the directory is + * settled — the same shape as `vllmStartBlockedReason`, and read back the same + * way, through `inspectVllmQwenProject()`. + */ +export async function ensureVllmProjectDir({ emit = () => {} } = {}) { + if (process.platform !== 'win32') return null; + if (vllmProjectDirIsSettled()) return null; + + emit('Windows host — asking WSL where this project belongs, so its ~20 GB of weights land on the distro filesystem rather than on the Windows one.'); + const found = await detectWslProjectDir(VLLM_PROJECT_LEAF); + if (!found.dir) return wslPlacementRefusal(found); + + emit(`Placing it in the \`${found.distro}\` distro, at ${found.dir}.`); + // This process FIRST, the file second. `resolveVllmProjectDir` reads the env + // ahead of the record, so every later read in this run — the re-inspection + // below, the readiness poll, the Start button — resolves the detected + // directory even when the write fails. Without it, a failed write silently + // sends the very next inspection back to `%USERPROFILE%\qwen-serving`: the + // C:\ placement this whole path exists to refuse. + process.env[VLLM_PROJECT_DIR_ENV] = found.dir; + // Recording is only what makes the choice outlive this run, so a failed write + // costs exactly that — not a ~30 GB provision. + const recordedOk = await recordVllmProjectDir(found.dir).then(() => true, (err) => { + emit(`Could not record ${VLLM_PROJECT_DIR_ENV} in PortOS's .env (${err.message}) — this run still uses that directory, but set it there yourself or the next restart will look on the Windows filesystem again.`); + return false; + }); + if (recordedOk) emit(`Recorded ${VLLM_PROJECT_DIR_ENV} in PortOS's .env, so the readiness check and the Start button find it too.`); + return null; } /** @@ -214,13 +285,16 @@ export async function provisionVllmQwenProject({ emit, isCancelled }) { return { success: false, error: `the Docker daemon is not answering (${docker.error}). Start Docker Desktop (or dockerd) and try again — PortOS does not install it.` }; } + // Before the first inspection, because on Windows this is what decides which + // directory gets inspected at all. + const misplaced = await ensureVllmProjectDir({ emit }); + if (misplaced) return { success: false, error: misplaced }; + let project = await inspectVllmQwenProject(); const dir = project.dir; const alreadyPrepared = vllmProjectSetupState(project) === 'ready'; if (!project.hasProject) { - const misplaced = vllmWindowsPlacementRefusal(dir); - if (misplaced) return { success: false, error: misplaced }; const git = findCommandOnPath('git'); if (!git) return { success: false, error: '`git` was not found on PortOS\'s PATH, so the compose project cannot be cloned.' }; emit(`Cloning ${VLLM_UPSTREAM_REPO} into ${dir}…`); @@ -286,6 +360,14 @@ export async function provisionVllmQwenProject({ emit, isCancelled }) { * @returns {Promise<{success: boolean, error?: string}>} */ export async function startVllmQwenProject({ emit, endpoint, isCancelled }) { + // A Windows host whose project was prepared inside WSL — by an earlier + // provisioning run on another install, or by hand from the feature doc — has + // it at a UNC path this PortOS has never been told about. Resolving that here + // as well as in provisioning is what turns "cannot read a models directory" + // into a start that works. + const misplaced = await ensureVllmProjectDir({ emit }); + if (misplaced) return { success: false, error: misplaced }; + const project = await inspectVllmQwenProject(); const blocked = vllmStartBlockedReason(project); if (blocked) return { success: false, error: blocked }; diff --git a/server/services/vllmQwenManager.test.js b/server/services/vllmQwenManager.test.js index d7078d32f9..af4d4dd2de 100644 --- a/server/services/vllmQwenManager.test.js +++ b/server/services/vllmQwenManager.test.js @@ -16,9 +16,21 @@ vi.mock('../lib/streamingSpawn.js', () => streaming); // PARTIAL for the rest of the module: `vllmProjectSetupState` is a pure // classifier, and stubbing it would let a wrong reading of a real inspection // pass here. -const project = vi.hoisted(() => ({ inspectVllmQwenProject: vi.fn(), vllmStartBlockedReason: vi.fn(() => null) })); +const project = vi.hoisted(() => ({ + inspectVllmQwenProject: vi.fn(), + vllmStartBlockedReason: vi.fn(() => null), + // PortOS's own `.env`, which the real pair reads and writes — mocked so a + // suite can never be answered by (or write into) the developer's install. + vllmProjectDirIsSettled: vi.fn(() => false), + recordVllmProjectDir: vi.fn(async () => {}), +})); vi.mock('../lib/vllmQwenProject.js', async (importOriginal) => ({ ...(await importOriginal()), ...project })); +// `wsl.exe`. Mocked because the real one answers on the developer's Windows box +// — the placement tests would then assert against THAT machine's distro. +const wsl = vi.hoisted(() => ({ detectWslProjectDir: vi.fn() })); +vi.mock('../lib/wslDistro.js', async (importOriginal) => ({ ...(await importOriginal()), ...wsl })); + // The two side effects: the `.env` write and the provider records the generated // key lands on. `tryReadFile`/`formatBytes` stay real — they are pure readers. const files = vi.hoisted(() => ({ atomicWrite: vi.fn(async () => {}), tryReadFile: vi.fn(async () => null) })); @@ -33,6 +45,8 @@ vi.mock('./providers.js', () => providers); import { provisionVllmQwenProject, readVllmQwenSetupState, startVllmQwenProject, VLLM_UPSTREAM_REPO } from './vllmQwenManager.js'; const DIR = '/home/example/qwen-serving'; +/** What `wsl.exe` says on a Windows host with an ordinary distro. */ +const WSL_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\example\\qwen-serving'; const emptyProject = { dir: DIR, hasProject: false, composeFile: null, hasWeights: null, weightsRoot: null }; const clonedProject = { ...emptyProject, hasProject: true, composeFile: 'docker-compose.yml', hasWeights: false }; const preparedProject = { ...clonedProject, hasWeights: true, weightsRoot: `${DIR}/models` }; @@ -68,6 +82,8 @@ beforeEach(() => { pathLookup.findCommandOnPath.mockImplementation((cmd) => (cmd === 'docker' || cmd === 'git' ? `/usr/bin/${cmd}` : null)); project.inspectVllmQwenProject.mockResolvedValue(preparedProject); project.vllmStartBlockedReason.mockReturnValue(null); + project.vllmProjectDirIsSettled.mockImplementation(() => Boolean(process.env.VLLM_QWEN_PROJECT_DIR)); + wsl.detectWslProjectDir.mockResolvedValue({ dir: WSL_DIR, distro: 'Ubuntu', home: '\\\\wsl.localhost\\Ubuntu\\home\\example' }); files.tryReadFile.mockResolvedValue(null); providers.getAllProviders.mockResolvedValue({ activeProvider: null, providers: [] }); streaming.runStreamingCommand.mockResolvedValue({ success: true }); @@ -234,17 +250,74 @@ describe('provisionVllmQwenProject', () => { expect(files.atomicWrite).toHaveBeenCalled(); }); - it('refuses to clone onto the Windows filesystem when no project dir was configured', async () => { + it('asks WSL where to put the project on Windows, and records the answer', async () => { + repin('win32'); + stageFreshProject(); + const lines = []; + + const result = await provision(lines); + + // The whole point: no refusal, no UNC template for a human to fill in. + expect(result.success).toBe(true); + expect(project.recordVllmProjectDir).toHaveBeenCalledWith(WSL_DIR); + expect(emitted(lines)).toContain(WSL_DIR); + // Recording is what lets the readiness poll and the Start button resolve the + // same directory this run used. + expect(emitted(lines)).toMatch(/VLLM_QWEN_PROJECT_DIR/); + }); + + it('never spends a WSL probe when the directory is already settled', async () => { + repin('win32'); + process.env.VLLM_QWEN_PROJECT_DIR = WSL_DIR; + stageFreshProject(); + + await provision(); + expect(wsl.detectWslProjectDir).not.toHaveBeenCalled(); + + // …and the same for a directory an earlier run already recorded. + delete process.env.VLLM_QWEN_PROJECT_DIR; + project.vllmProjectDirIsSettled.mockReturnValue(true); + wsl.detectWslProjectDir.mockClear(); + stageFreshProject(); + + await provision(); + expect(wsl.detectWslProjectDir).not.toHaveBeenCalled(); + }); + + it('refuses before cloning when WSL cannot offer a home, naming that host\'s fix', async () => { repin('win32'); project.inspectVllmQwenProject.mockResolvedValue(emptyProject); + wsl.detectWslProjectDir.mockResolvedValue({ dir: null, reason: 'internal-distro', distro: 'docker-desktop', distros: ['Ubuntu'] }); const result = await provision(); - expect(result).toMatchObject({ success: false, error: expect.stringMatching(/VLLM_QWEN_PROJECT_DIR/) }); + // Docker Desktop's own distro is wiped on a reset — and the refusal names + // the distro that ISN'T, rather than a `` placeholder. + expect(result).toMatchObject({ success: false, error: expect.stringMatching(/docker-desktop/) }); + expect(result.error).toMatch(/wsl --set-default/); + expect(result.error).toContain('Ubuntu'); expect(dockerCalls()).toEqual(['version --format {{.Server.Version}}']); expect(files.atomicWrite).not.toHaveBeenCalled(); }); + it('carries on with the detected directory when recording it fails', async () => { + repin('win32'); + project.recordVllmProjectDir.mockRejectedValue(new Error('.env is read-only')); + stageFreshProject(); + const lines = []; + + const result = await provision(lines); + + // A ~30 GB run must not be lost to a failed one-line config write — but the + // consequence (a restart looks on C: again) has to be said out loud. + expect(result.success).toBe(true); + expect(emitted(lines)).toMatch(/Could not record VLLM_QWEN_PROJECT_DIR/); + // And the run itself still uses the detected directory: the process env is + // the highest-precedence input `resolveVllmProjectDir` reads, so a failed + // file write cannot send the very next inspection back to C:. + expect(process.env.VLLM_QWEN_PROJECT_DIR).toBe(WSL_DIR); + }); + it('refuses before cloning anything when the Docker daemon is not answering', async () => { project.inspectVllmQwenProject.mockResolvedValue(emptyProject); streaming.runStreamingCommand.mockResolvedValueOnce({ success: false, error: 'exit 1: cannot connect to the Docker daemon' }); @@ -358,6 +431,28 @@ describe('startVllmQwenProject', () => { expect(streaming.runStreamingCommand).not.toHaveBeenCalled(); }); + it('settles a Windows host\'s project directory before looking for the project', async () => { + // A checkout prepared inside WSL — by hand, or by another install — sits at + // a UNC path this PortOS has never been told about. Without this the start + // looks on C:, finds nothing, and reports "cannot read a models directory". + repin('win32'); + + const result = await startVllmQwenProject({ emit: () => {}, isCancelled: () => false }); + + expect(result.success).toBe(true); + expect(project.recordVllmProjectDir).toHaveBeenCalledWith(WSL_DIR); + }); + + it('refuses without running compose when Windows has no WSL to place it in', async () => { + repin('win32'); + wsl.detectWslProjectDir.mockResolvedValue({ dir: null, reason: 'no-wsl', error: 'ENOENT' }); + + const result = await startVllmQwenProject({ emit: () => {}, isCancelled: () => false }); + + expect(result).toMatchObject({ success: false, error: expect.stringMatching(/wsl --install/) }); + expect(streaming.runStreamingCommand).not.toHaveBeenCalled(); + }); + it('does not start a container nobody is waiting for', async () => { const result = await startVllmQwenProject({ emit: () => {}, isCancelled: () => true });