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:
zero
2026-08-07 16:50:52 +08:00
parent ab78d3091e
commit 82436b0e93
10 changed files with 201 additions and 27 deletions
+1
View File
@@ -88,4 +88,5 @@ packages/coding-agent/bench/boot-baseline.json
/bazel-out
/bazel-testlogs
/bazel-pi
/bazel-oh-my-pi
/.bazelrc.user
+17
View File
@@ -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
+4
View File
@@ -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) {
+11
View File
@@ -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
+33 -7
View File
@@ -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;
+62 -11
View File
@@ -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",
+7 -1
View File
@@ -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 () => {
+44
View File
@@ -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
View File
@@ -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;