From d155d8dd3413cc034b1e6b283ef938f0938cb10a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" <70015+atomantic@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:38:35 +0000 Subject: [PATCH 1/2] Place the vLLM Qwen project inside WSL2 automatically on Windows Setting up vLLM (Qwen3.8-27B) on a Windows host used to dead-end: PortOS refused to clone, printed a UNC template with `` and `` left as literal angle brackets, and told the operator to set VLLM_QWEN_PROJECT_DIR and click the button again. Every value in that template is something the machine can answer. PortOS now asks WSL for them. Before cloning, it runs the default distro's own shell (`wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'` -- the distro's stdout is UTF-8, while `wsl --list` prints UTF-16LE that a UTF-8 reader mangles), checks the derived \wsl.localhost path is readable from Windows, and records it as VLLM_QWEN_PROJECT_DIR in PortOS's own .env so the readiness poll, the Start button, and the next server boot all resolve the same directory. Start does the same, so a project prepared by hand inside the distro is found with no configuration at all. It refuses only where it genuinely cannot answer: no WSL on the host, no distro but a container engine's own (docker-desktop is wiped on a reset), or a \wsl.localhost share Windows cannot read -- and each refusal names that host's fix instead of a placeholder. Setting the env var still overrules the whole decision. Also fixes a misleading progress line: a provisioning click reported "already installed - starting it" immediately before a clone and a ~30 GB download, which made every line after it read as a failed start. - server/lib/wslDistro.js: new; distro/home detection and UNC derivation - server/lib/vllmQwenProject.js: record and resolve the project directory, anchored to the install root so a worktree boot writes where the install reads - server/lib/vllmQwenProvision.js: upsertEnvLine, sharing one newline guard with mergeEnvFileContents - server/services/vllmQwenManager.js: settle placement before provision and start - server/services/localRuntimeSetup.js: each provisioning step says what it is about to do --- docs/features/qwen38-rtx3090.md | 43 ++++-- server/lib/README.md | 5 +- server/lib/index.js | 1 + server/lib/vllmQwenProject.js | 117 ++++++++++++++-- server/lib/vllmQwenProject.test.js | 73 ++++++++-- server/lib/vllmQwenProvision.js | 53 +++++++- server/lib/wslDistro.js | 172 ++++++++++++++++++++++++ server/lib/wslDistro.test.js | 88 ++++++++++++ server/services/localRuntimeSetup.js | 10 +- server/services/vllmQwenManager.js | 114 +++++++++++++--- server/services/vllmQwenManager.test.js | 101 +++++++++++++- 11 files changed, 708 insertions(+), 69 deletions(-) create mode 100644 server/lib/wslDistro.js create mode 100644 server/lib/wslDistro.test.js 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..42db3294bd 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,50 @@ 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 +200,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/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 }); From 693e5c8fe273c9e843ad09c1e12aea32b3674c86 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" <70015+atomantic@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:39:56 +0000 Subject: [PATCH 2/2] Test upsertEnvLine's replacement, including a value carrying a $-pattern --- server/lib/vllmQwenProvision.js | 1 + server/lib/vllmQwenProvision.test.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/server/lib/vllmQwenProvision.js b/server/lib/vllmQwenProvision.js index 42db3294bd..13cd2a336d 100644 --- a/server/lib/vllmQwenProvision.js +++ b/server/lib/vllmQwenProvision.js @@ -173,6 +173,7 @@ export function upsertEnvLine(contents, key, value) { ? text.replace(pattern, () => `${key}=${value}`) : appendEnvLines(text, [`${key}=${value}`]); } + /** * Append the missing defaults to an existing `.env`, changing nothing else. * 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'); + }); +});