fix(natives): make Windows-host builds and addon loading work end to end
Runtime: the loader's AVX2 probe used [System.Runtime.Intrinsics.X86.Avx2] via powershell.exe, which only exists on .NET Core — stock Windows PowerShell 5.1 raised TypeNotFound, so every Windows host silently loaded the baseline addon and paid ~270ms for the spawn on the startup path. Ask the kernel via bun:ffi (IsProcessorFeaturePresent(PF_AVX2_INSTRUCTIONS_AVAILABLE), ~0.5ms) and fall back to pwsh-then-powershell for Node embeds. scripts/host-detect.ts shared the same broken probe for build-time variant selection. Build: `bun run build:native` on a win32 host died deep inside the bazel msvc repo rules (linux/mac exec hosts only, by design). The `host` pseudo-target now delegates to the local napi build against real VS Build Tools, and other targets fail fast with guidance. build-bindings.ts resolves the @napi-rs/cli JS entry from its manifest (the node_modules/.bin shim is a PE launcher Bun cannot parse) and auto-appends VS Build Tools' bundled CMake/Ninja to PATH via vswhere so a vcvars prompt is no longer required. Cap maudio at opt-level=1 to dodge a rustc ICE codegenning MaybeUninit<ma_fence> for x86_64-pc-windows-msvc under the pinned nightly (Bazel's older pin is unaffected). Compile: Bun.Glob.scan yields backslash paths on Windows; the legacy Pi virtual module used them verbatim for export keys and generated identifiers, producing invalid JavaScript in compiled-binary builds. Tests: strip ConPTY negotiation escapes in the pty argv test; ignore the bazel-oh-my-pi convenience symlink.
This commit is contained in:
@@ -88,4 +88,5 @@ packages/coding-agent/bench/boot-baseline.json
|
||||
/bazel-out
|
||||
/bazel-testlogs
|
||||
/bazel-pi
|
||||
/bazel-oh-my-pi
|
||||
/.bazelrc.user
|
||||
|
||||
+17
@@ -33,6 +33,20 @@ strip = true
|
||||
# (see crates/pi-natives/src/crash_handler.rs).
|
||||
panic = "unwind"
|
||||
|
||||
# rustc ICEs codegenning `MaybeUninit<maudio_sys::ffi::ma_fence>` — a union with
|
||||
# ScalarPair backend repr on x86_64-pc-windows-msvc — once the MIR opts that run
|
||||
# from opt-level 2 upward const-fold it into an `Uninit` operand
|
||||
# (`rustc_codegen_ssa/src/mir/block.rs`: "codegen_argument: OperandRef(Uninit …)
|
||||
# invalid for pair argument"). Hit by the rustup toolchain this repo pins
|
||||
# (nightly-2026-07-28) on the local napi path (`packages/natives` →
|
||||
# `build:bindings`); Bazel's older pin (nightly/2026-04-29, MODULE.bazel) is
|
||||
# unaffected, which is why CI never saw it. opt-level 1 keeps those MIR opts off.
|
||||
# maudio is a thin FFI shim — miniaudio itself is C compiled by `cc` at its own
|
||||
# optimization level — so the cost is noise. Drop this once the rustup pin moves
|
||||
# past the fix. `ci` and `local` inherit this override from `release`.
|
||||
[profile.release.package.maudio]
|
||||
opt-level = 1
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
@@ -61,6 +75,9 @@ split-debuginfo = "unpacked"
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev.package.maudio]
|
||||
opt-level = 1
|
||||
|
||||
[workspace.lints.rust]
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Rust Lint Levels
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
- Remote MCP transports now enforce header precedence and origin policy: client-generated HTTP/MCP/authorization headers win over configured headers case-insensitively, and Agent Plugins servers never forward configured headers across a redirect to a different origin (method-changing redirects of JSON-RPC POSTs are refused). Agent Plugins stdio `env` values and remote `headers` are likewise exempt from config-value resolution (no ambient env-name lookup, no `!command` execution, empty values preserved).
|
||||
- Added `omp share <session>`: share a saved session by id prefix or `.jsonl` path without launching the agent — same encrypted upload, store selection, and `share.redactSecrets` handling as the `/share` slash command.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the compiled binary build on Windows: `Bun.Glob.scan` yields backslash-separated paths, which the legacy Pi virtual module used verbatim for export keys and generated identifiers, producing invalid JavaScript.
|
||||
|
||||
## [17.2.10] - 2026-08-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -151,7 +151,10 @@ export async function collectBundledPiEntries(): Promise<BundledPiEntry[]> {
|
||||
const glob = new Bun.Glob(`**/*${pattern.sourceSuffix}`);
|
||||
const matches: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: sourceDir, onlyFiles: true })) {
|
||||
matches.push(match);
|
||||
// Bun.Glob yields host separators; the export keys and generated
|
||||
// identifiers below are `/`-shaped. Same normalization as
|
||||
// `generate-docs-index.ts`.
|
||||
matches.push(match.split(path.sep).join("/"));
|
||||
}
|
||||
matches.sort();
|
||||
for (const match of matches) {
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `bun run build` (`scripts/bazel-natives.ts`) now supports Windows hosts: the `host` pseudo-target delegates to the local napi build against VS Build Tools (the bazel msvc cross toolchain remains linux/mac-only), and other targets fail fast with guidance instead of dying deep inside a bazel repo rule. The local build also auto-appends VS Build Tools' bundled CMake/Ninja to `PATH` via vswhere, so it works outside a vcvars prompt.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the native addon loader selecting the `baseline` CPU variant on every Windows host whose `powershell.exe` is Windows PowerShell 5.1: `[System.Runtime.Intrinsics.X86.Avx2]` only exists on .NET Core, so the probe never reported AVX2. Detection now calls `IsProcessorFeaturePresent` through `bun:ffi`, which is both correct and ~270 ms cheaper at startup (the PowerShell spawn is gone); Node embeds fall back to `pwsh` before `powershell.exe`.
|
||||
- Fixed `bun run build:bindings` failing on Windows: the `node_modules/.bin` entry is a `napi.exe` launcher there, which Bun tried to parse as JavaScript. The CLI's JS entry is now resolved from the `@napi-rs/cli` manifest.
|
||||
- Fixed the local (non-Bazel) addon build always producing the `baseline` variant on Windows — `scripts/host-detect.ts` shared the broken PowerShell AVX2 probe.
|
||||
- Fixed a rustc ICE building `maudio` for `x86_64-pc-windows-msvc` under the pinned rustup nightly by capping that package at `opt-level = 1`; MIR const-folding turned `MaybeUninit<ma_fence>` into an `Uninit` operand that codegen rejects for a ScalarPair argument.
|
||||
|
||||
## [17.2.10] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -324,13 +324,39 @@ function detectAvx2Support() {
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const output = runCommand("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"[System.Runtime.Intrinsics.X86.Avx2]::IsSupported",
|
||||
]);
|
||||
return output && output.toLowerCase() === "true";
|
||||
// Under Bun, ask the kernel: PF_AVX2_INSTRUCTIONS_AVAILABLE == 40. Exact,
|
||||
// and ~0.5 ms against ~270 ms for the PowerShell spawn it replaces on the
|
||||
// startup path.
|
||||
if (typeof Bun !== "undefined") {
|
||||
try {
|
||||
const { dlopen, FFIType } = createRequire(import.meta.url)("bun:ffi");
|
||||
const kernel32 = dlopen("kernel32.dll", {
|
||||
IsProcessorFeaturePresent: { args: [FFIType.u32], returns: FFIType.i32 },
|
||||
});
|
||||
try {
|
||||
return kernel32.symbols.IsProcessorFeaturePresent(40) !== 0;
|
||||
} finally {
|
||||
kernel32.close();
|
||||
}
|
||||
} catch {
|
||||
// No FFI (embedder policy, unusual host): fall through to the shell probe.
|
||||
}
|
||||
}
|
||||
// Node embeds have no `bun:ffi`. `[System.Runtime.Intrinsics.X86.Avx2]`
|
||||
// exists only on .NET Core, so `pwsh` (PowerShell 7) answers correctly
|
||||
// while a stock `powershell.exe` (Windows PowerShell 5.1, .NET Framework)
|
||||
// raises TypeNotFound and pins such hosts to the baseline addon.
|
||||
for (const shell of ["pwsh.exe", "powershell.exe"]) {
|
||||
const output = runCommand(shell, [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"[System.Runtime.Intrinsics.X86.Avx2]::IsSupported",
|
||||
]);
|
||||
if (output && output.toLowerCase() === "true") return true;
|
||||
if (output && output.toLowerCase() === "false") return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* (`bun run build:bindings`) only when the Rust API changes its exported
|
||||
* typedefs. Host target only, local cargo profile — no cross-compilation.
|
||||
*/
|
||||
|
||||
import * as fsSync from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { detectHostAvx2Support } from "../../../scripts/host-detect";
|
||||
@@ -20,6 +23,44 @@ process.env.PCRE2_SYS_STATIC ??= "1";
|
||||
// policy override.
|
||||
process.env.CMAKE_POLICY_VERSION_MINIMUM ??= "3.5";
|
||||
|
||||
// Windows: cc-rs and rustc auto-locate cl.exe/link.exe through the VS
|
||||
// registry, but the cmake crate (audiopus_sys' bundled opus) needs cmake —
|
||||
// and its Ninja generator needs ninja — on PATH. VS Build Tools ships both
|
||||
// without exposing them, so outside a vcvars prompt the build dies on
|
||||
// "cmake not found". Resolve the VS install via vswhere and append its
|
||||
// CMake/Ninja dirs, keeping any user-provided tools ahead.
|
||||
if (process.platform === "win32" && (!Bun.which("cmake") || !Bun.which("ninja"))) {
|
||||
const vswhere = path.join(
|
||||
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)",
|
||||
"Microsoft Visual Studio",
|
||||
"Installer",
|
||||
"vswhere.exe",
|
||||
);
|
||||
const probe = Bun.spawnSync(
|
||||
[
|
||||
vswhere,
|
||||
"-latest",
|
||||
"-products",
|
||||
"*",
|
||||
"-requires",
|
||||
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
|
||||
"-property",
|
||||
"installationPath",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
const vsRoot = probe.exitCode === 0 ? probe.stdout.toString("utf-8").trim() : "";
|
||||
if (vsRoot) {
|
||||
const cmakeExt = path.join(vsRoot, "Common7", "IDE", "CommonExtensions", "Microsoft", "CMake");
|
||||
const extraDirs = [path.join(cmakeExt, "CMake", "bin"), path.join(cmakeExt, "Ninja")].filter(dir =>
|
||||
fsSync.existsSync(dir),
|
||||
);
|
||||
if (extraDirs.length > 0) {
|
||||
process.env.PATH = [process.env.PATH ?? "", ...extraDirs].filter(Boolean).join(path.delimiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repoRoot = path.join(import.meta.dir, "../../..");
|
||||
const rustDir = path.join(repoRoot, "crates/pi-natives");
|
||||
const nativeDir = path.join(import.meta.dir, "../native");
|
||||
@@ -142,18 +183,28 @@ const buildOutputDir = await fs.mkdtemp(
|
||||
path.join(nativeDir, ".build", `${process.platform}-${process.arch}-${effectiveVariant ?? "default"}-local-`),
|
||||
);
|
||||
|
||||
// Resolve napi bin directly: `bunx @napi-rs/cli` can pick up the wrong bin on
|
||||
// systems where `cli` exists on PATH (e.g. Mono's /usr/bin/cli on Ubuntu).
|
||||
const napiBin = Bun.which("napi", {
|
||||
PATH: [
|
||||
path.join(import.meta.dir, "..", "node_modules", ".bin"),
|
||||
path.join(repoRoot, "node_modules", ".bin"),
|
||||
process.env.PATH ?? "",
|
||||
].join(path.delimiter),
|
||||
});
|
||||
if (!napiBin) {
|
||||
throw new Error("Could not locate @napi-rs/cli `napi` binary in node_modules/.bin");
|
||||
// Resolve the CLI's JS entry from the package manifest rather than the
|
||||
// `node_modules/.bin` shim: `bunx @napi-rs/cli` can pick up the wrong bin on
|
||||
// systems where `cli` exists on PATH (e.g. Mono's /usr/bin/cli on Ubuntu), and
|
||||
// on Windows the shim is a `napi.exe` launcher that Bun would try to parse as
|
||||
// JavaScript.
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const napiManifestPath = require_.resolve("@napi-rs/cli/package.json");
|
||||
const napiManifest: unknown = require_(napiManifestPath);
|
||||
const napiBinEntry =
|
||||
typeof napiManifest === "object" &&
|
||||
napiManifest !== null &&
|
||||
"bin" in napiManifest &&
|
||||
typeof napiManifest.bin === "object" &&
|
||||
napiManifest.bin !== null &&
|
||||
"napi" in napiManifest.bin &&
|
||||
typeof napiManifest.bin.napi === "string"
|
||||
? napiManifest.bin.napi
|
||||
: null;
|
||||
if (!napiBinEntry) {
|
||||
throw new Error(`@napi-rs/cli manifest at ${napiManifestPath} declares no string \`bin.napi\` entry`);
|
||||
}
|
||||
const napiBin = path.join(path.dirname(napiManifestPath), napiBinEntry);
|
||||
|
||||
const napiArgs = [
|
||||
"build",
|
||||
|
||||
@@ -640,7 +640,13 @@ describe("pi-natives", () => {
|
||||
expect(callbackError).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBeFalse();
|
||||
expect(JSON.parse(output.trim())).toEqual(expected);
|
||||
// ConPTY interleaves terminal negotiation with the child's own bytes
|
||||
// (`ESC[6n`, SGR reset, an OSC 0 title set, cursor show), so strip the
|
||||
// escape sequences before parsing the payload.
|
||||
const payload = output
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)|\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
||||
.trim();
|
||||
expect(JSON.parse(payload)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("reports the child PID as soon as the PTY process starts", async () => {
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
* Extra args after `--` are passed to bazel verbatim (cache configs, endpoints,
|
||||
* headers — see .bazelrc for the cache-rw/cache-ro policy configs).
|
||||
*
|
||||
* Windows hosts: the msvc cc toolchain in bazel/toolchains/msvc only supports
|
||||
* linux/mac exec hosts (its clang-cl+xwin wrappers replace the MSVC a Windows
|
||||
* box already has), so a win32 host cannot run any bazel addon build. The
|
||||
* `host` pseudo-target instead delegates to the local napi build
|
||||
* (packages/natives/scripts/build-bindings.ts) against the installed VS Build
|
||||
* Tools; every other target on a win32 host fails fast with guidance.
|
||||
*
|
||||
* Note: musl addons intentionally reuse the plain linux-<arch> filenames, so a
|
||||
* `linux-all` copy overwrites the gnu addon with the musl one (and vice versa);
|
||||
* CI jobs that ship files always request an explicit disjoint target set.
|
||||
@@ -214,10 +221,47 @@ async function installAddon(sourcePath: string, destPath: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* win32-host path for the `host` pseudo-target: the bazel msvc cross toolchain
|
||||
* cannot run here, but real MSVC can — build the addon via the napi local
|
||||
* build and install it into destDir like the bazel path would.
|
||||
*/
|
||||
async function buildWindowsHostAddon(host: HostInfo, destDir: string): Promise<void> {
|
||||
const script = path.join(repoRoot, "packages/natives/scripts/build-bindings.ts");
|
||||
console.log(`win32 host: bazel msvc toolchain is linux/mac-only; building via ${path.relative(repoRoot, script)}`);
|
||||
const proc = Bun.spawn([process.execPath, script], {
|
||||
cwd: repoRoot,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) process.exit(exitCode || 1);
|
||||
|
||||
const filename = `pi_natives.win32-x64-${host.avx2 ? "modern" : "baseline"}.node`;
|
||||
const builtPath = path.join(repoRoot, "packages/natives/native", filename);
|
||||
if (path.dirname(builtPath) !== destDir) {
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await installAddon(builtPath, path.join(destDir, filename));
|
||||
}
|
||||
console.log(`installed ${filename} → ${path.join(destDir, filename)}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
const host: HostInfo = { platform: process.platform, arch: process.arch, avx2: detectHostAvx2Support() };
|
||||
const destDir = options.dest ? path.resolve(options.dest) : path.join(repoRoot, "packages/natives/native");
|
||||
|
||||
if (host.platform === "win32" && !options.source) {
|
||||
if (options.targets.length !== 1 || options.targets[0] !== "host") {
|
||||
throw new Error(
|
||||
`Cannot bazel-build [${options.targets.join(", ")}] on a Windows host: the msvc cross ` +
|
||||
"toolchain (bazel/toolchains/msvc) only runs on linux/mac exec hosts. Use `host` here " +
|
||||
"(local napi build via VS Build Tools), or run this script from WSL/linux for cross targets.",
|
||||
);
|
||||
}
|
||||
await buildWindowsHostAddon(host, destDir);
|
||||
return;
|
||||
}
|
||||
let outputs: string[];
|
||||
|
||||
if (options.source) {
|
||||
|
||||
+18
-7
@@ -1,3 +1,4 @@
|
||||
import { dlopen, FFIType } from "bun:ffi";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
function runCommand(command: string, args: string[]): string | null {
|
||||
@@ -30,13 +31,23 @@ export function detectHostAvx2Support(): boolean {
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const output = runCommand("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"[System.Runtime.Intrinsics.X86.Avx2]::IsSupported",
|
||||
]);
|
||||
return output?.toLowerCase() === "true";
|
||||
// `[System.Runtime.Intrinsics.X86.Avx2]` only exists on .NET Core, so the
|
||||
// PowerShell probe reported `false` on every host whose `powershell.exe`
|
||||
// is Windows PowerShell 5.1 (.NET Framework) — i.e. a stock Windows box —
|
||||
// silently downgrading AVX2 machines to the baseline ISA. Ask the kernel
|
||||
// instead: PF_AVX2_INSTRUCTIONS_AVAILABLE == 40.
|
||||
try {
|
||||
const kernel32 = dlopen("kernel32.dll", {
|
||||
IsProcessorFeaturePresent: { args: [FFIType.u32], returns: FFIType.i32 },
|
||||
});
|
||||
try {
|
||||
return kernel32.symbols.IsProcessorFeaturePresent(40) !== 0;
|
||||
} finally {
|
||||
kernel32.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user