From 1c40ffe4275d178d6a88f593d263b29d5ca36ce2 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sun, 6 Sep 2026 02:02:06 +0000 Subject: [PATCH] Add Windows MSVC Bazel targets for native voice libraries (#43144) ## What changed - Add explicit x64 and ARM64 targets for native builds, runtime preparation, and linking using the existing voice recipes. Each target requires native Windows execution of the matching architecture. - Declare compiler, SDK, Python, and CMake inputs; require an explicitly supplied Cygwin/pkgconf tool tree and a fixed `SystemRoot`. Validate installed tool selections against the manifest and declared files. - Pair Windows DLLs with SDK import libraries, preserve plugin and receipt runfiles, and omit Unix runtime-search flags. Use Python for portable payload copying. - Correct the MSVC ARM64 tool directory casing to `HostArm64` and document provisioning and build commands. The generic Rust-consumer aliases remain separate; native Windows Bazel execution and consumer validation are still needed to establish complete Windows voice support. ## Testing Add eight unit tests covering tool selection, path anchoring, invalid inputs, and DLL/import-library copying, plus x64 and ARM64 link smoke targets that reference `gst_version`. GitOrigin-RevId: fb2b4998e9ffb5b64ca830ebf4bb4633fc959eb9 --- MODULE.bazel | 2 +- MODULE.bazel.lock | 4 +- patches/windows-support-native-tools.patch | 11 +- third_party/voice/BUILD.bazel | 119 ++++++++++++++ third_party/voice/README.md | 45 ++++++ third_party/voice/bazel_copy.py | 19 +++ third_party/voice/bazel_windows.py | 171 +++++++++++++++++++++ third_party/voice/native_link.bzl | 33 ++-- third_party/voice/runtime.bzl | 28 ++++ third_party/voice/test_bazel_windows.py | 170 ++++++++++++++++++++ third_party/voice/windows_link_smoke.cc | 11 ++ third_party/voice/windows_native.bzl | 129 ++++++++++++++++ 12 files changed, 724 insertions(+), 18 deletions(-) create mode 100644 third_party/voice/bazel_copy.py create mode 100644 third_party/voice/bazel_windows.py create mode 100644 third_party/voice/test_bazel_windows.py create mode 100644 third_party/voice/windows_link_smoke.cc create mode 100644 third_party/voice/windows_native.bzl diff --git a/MODULE.bazel b/MODULE.bazel index 18e32b5460..7b6a1c919c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -174,7 +174,7 @@ use_repo(voice_python, "python_3_12") register_toolchains("@rules_foreign_cc//toolchains:built_make_toolchain") foreign_cc_tools = use_extension("@rules_foreign_cc//foreign_cc:extensions.bzl", "tools") -use_repo(foreign_cc_tools, "pkgconfig_src") +use_repo(foreign_cc_tools, "cmake-3.31.8-windows-x86_64", "pkgconfig_src") single_version_override( module_name = "rules_rs", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 2aca11fcac..321a1b57ff 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -582,7 +582,7 @@ }, "@@windows_support+//windows:extensions.bzl%msvc_runtime": { "general": { - "bzlTransitiveDigest": "IARx/OSheg9jZOotMp87h4ZSH1sYXy2d0otLXaCQdL0=", + "bzlTransitiveDigest": "hSBf9aCBJn12Lq4IKLqoUFuySmukaarmyjhYNGlHMX0=", "usagesDigest": "XZ1DGFYwC06tBdgHxuZLhTfFlPFVKjmYmiBfiY2xe3c=", "recordedInputs": [ "REPO_MAPPING:windows_support+,bazel_tools bazel_tools" @@ -613,7 +613,7 @@ }, "@@windows_support+//windows:extensions.bzl%windows_sdk": { "general": { - "bzlTransitiveDigest": "IARx/OSheg9jZOotMp87h4ZSH1sYXy2d0otLXaCQdL0=", + "bzlTransitiveDigest": "hSBf9aCBJn12Lq4IKLqoUFuySmukaarmyjhYNGlHMX0=", "usagesDigest": "M5xde76Im8TfiBsVtKQIavNOkqfp1QsjwJD4fVrrtFs=", "recordedInputs": [ "REPO_MAPPING:windows_support+,bazel_tools bazel_tools" diff --git a/patches/windows-support-native-tools.patch b/patches/windows-support-native-tools.patch index ef4bcd90ee..8d480d019e 100644 --- a/patches/windows-support-native-tools.patch +++ b/patches/windows-support-native-tools.patch @@ -1,7 +1,7 @@ diff --git a/windows/private/extensions/msvc_runtime.bzl b/windows/private/extensions/msvc_runtime.bzl --- a/windows/private/extensions/msvc_runtime.bzl +++ b/windows/private/extensions/msvc_runtime.bzl -@@ -204,13 +204,19 @@ +@@ -204,13 +204,20 @@ _keep_only_children( repository_ctx, repository_ctx.path("{}/Contents/VC/Tools/MSVC/{}".format(sysroot_dir, msvc_version)), @@ -16,9 +16,10 @@ diff --git a/windows/private/extensions/msvc_runtime.bzl b/windows/private/exten + + # Keep native-host tools and their support files separate from runtime inputs. + bin_dir = "{}/Contents/VC/Tools/MSVC/{}/bin".format(sysroot_dir, msvc_version) -+ _keep_only_children(repository_ctx, repository_ctx.path(bin_dir), ["Host" + arch for arch in architectures]) ++ _keep_only_children(repository_ctx, repository_ctx.path(bin_dir), ["HostArm64" if arch == "arm64" else "Host" + arch for arch in architectures]) + for arch in architectures: -+ _keep_only_children(repository_ctx, repository_ctx.path(bin_dir + "/Host" + arch), [arch]) ++ host = "HostArm64" if arch == "arm64" else "Host" + arch ++ _keep_only_children(repository_ctx, repository_ctx.path(bin_dir + "/" + host), [arch]) def _msvc_runtime_repository_impl(repository_ctx): _check_msvc_license_requirements(repository_ctx) @@ -69,7 +70,7 @@ diff --git a/windows/private/extensions/msvc_runtime.BUILD.bazel b/windows/priva +directory( + name = "msvc_tools_arm64_files", + srcs = glob( -+ ["__MSVC_RUNTIME_DIR__/Contents/VC/Tools/MSVC/__MSVC_VERSION__/bin/Hostarm64/arm64/**"], ++ ["__MSVC_RUNTIME_DIR__/Contents/VC/Tools/MSVC/__MSVC_VERSION__/bin/HostArm64/arm64/**"], + allow_empty = True, + ), +) @@ -77,7 +78,7 @@ diff --git a/windows/private/extensions/msvc_runtime.BUILD.bazel b/windows/priva +subdirectory( + name = "msvc_tools_arm64", + parent = ":msvc_tools_arm64_files", -+ path = "__MSVC_RUNTIME_DIR__/Contents/VC/Tools/MSVC/__MSVC_VERSION__/bin/Hostarm64/arm64", ++ path = "__MSVC_RUNTIME_DIR__/Contents/VC/Tools/MSVC/__MSVC_VERSION__/bin/HostArm64/arm64", +) diff --git a/windows/private/extensions/windows_sdk.bzl b/windows/private/extensions/windows_sdk.bzl --- a/windows/private/extensions/windows_sdk.bzl diff --git a/third_party/voice/BUILD.bazel b/third_party/voice/BUILD.bazel index aed10ec42a..a3a51cda5b 100644 --- a/third_party/voice/BUILD.bazel +++ b/third_party/voice/BUILD.bazel @@ -1,8 +1,10 @@ +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") load("@rules_foreign_cc//toolchains/native_tools:native_tools_toolchain.bzl", "native_tool_toolchain") load(":native.bzl", "native_prefix") load(":native_link.bzl", "native_link") load(":pkg_config.bzl", "pkg_config") load(":runtime.bzl", "native_runtime") +load(":windows_native.bzl", "windows_build_tools", "windows_native_prefix") # All native build consumers must receive the same pinned source manifest. exports_files(["opus-toolchain.cmake"]) @@ -243,3 +245,120 @@ alias( tags = ["manual"], visibility = ["//visibility:public"], ) + +# Explicit opt-in inputs: no private downloader is loaded by the public module. +# CMake and Cygwin use x64 emulation on native ARM64; Python/MSVC/pkgconf do not. +filegroup( + name = "windows_tools_unavailable", + srcs = [], +) + +label_flag( + name = "windows_installed_tools", + build_setting_default = ":windows_tools_unavailable", + visibility = ["//visibility:public"], +) + +[ + config_setting( + name = "windows_" + cpu + "_msvc", + constraint_values = [ + "@platforms//os:windows", + "@platforms//cpu:" + cpu, + "@llvm//constraints/windows/abi:msvc", + ], + ) + for cpu in ("x86_64", "aarch64") +] + +[ + windows_build_tools( + name = "windows_tools_" + cpu, + includes = ["@msvc_runtime//:msvc_include"] + ["@windows_sdk//:winsdk_" + part + "_include" for part in ("ucrt", "shared", "um", "winrt")], + installed_tools = ":windows_installed_tools", + libraries = ["@msvc_runtime//:msvc_lib_" + arch] + ["@windows_sdk//:winsdk_" + part + "_lib_" + arch for part in ("ucrt", "um")], + msvc = "@msvc_runtime//:msvc_tools_" + arch, + sdk = "@windows_sdk//:winsdk_tools_" + arch, + tags = ["manual"], + target = cpu + "-pc-windows-msvc", + target_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:" + cpu, + ], + ) + for cpu, arch in ( + ("x86_64", "x64"), + ("aarch64", "arm64"), + ) +] + +[ + windows_native_prefix( + name = "native_prefix_windows_" + cpu, + archives = [":archives"], + build_tools = ":windows_tools_" + cpu, + exec_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:" + cpu, + ], + tags = ["manual"], + target_compatible_with = select({ + ":windows_" + cpu + "_msvc": [], + "//conditions:default": ["@platforms//:incompatible"], + }), + visibility = ["//visibility:public"], + ) + for cpu in ("x86_64", "aarch64") +] + +[ + native_runtime( + name = "native_runtime_windows_" + cpu, + exec_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:" + cpu, + ], + prefix = ":native_prefix_windows_" + cpu, + tags = ["manual"], + target = cpu + "-pc-windows-msvc", + target_compatible_with = select({ + ":windows_" + cpu + "_msvc": [], + "//conditions:default": ["@platforms//:incompatible"], + }), + visibility = ["//visibility:public"], + windows_tools = ":windows_tools_" + cpu, + ) + for cpu in ("x86_64", "aarch64") +] + +[ + native_link( + name = "native_link_windows_" + cpu, + runtime = ":native_runtime_windows_" + cpu, + tags = ["manual"], + target = cpu + "-pc-windows-msvc", + target_compatible_with = select({ + ":windows_" + cpu + "_msvc": [], + "//conditions:default": ["@platforms//:incompatible"], + }), + visibility = ["//visibility:public"], + ) + for cpu in ("x86_64", "aarch64") +] + +# Link a concrete symbol so CI checks the Windows CcInfo import-library path, +# not only the native runtime and SDK artifact actions. +[ + cc_binary( + name = "windows_link_smoke_" + cpu, + srcs = ["windows_link_smoke.cc"], + tags = ["manual"], + target_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:" + cpu, + "@llvm//constraints/windows/abi:msvc", + ], + deps = [":native_link_windows_" + cpu], + ) + for cpu in ("x86_64", "aarch64") +] diff --git a/third_party/voice/README.md b/third_party/voice/README.md index 3f7d54a863..d79e3de68d 100644 --- a/third_party/voice/README.md +++ b/third_party/voice/README.md @@ -240,3 +240,48 @@ Microsoft Visual C++ runtime (`VCRUNTIME140.dll`) already installed. The latter is not a guaranteed OS component. Release redistribution/licensing, Authenticode policy and actual helper loading remain separate requirements; this script does not install or redistribute Microsoft runtime files or enable voice. +# Windows Bazel inputs and actions + +The named `native_prefix_windows_{x86_64,aarch64}`, +`native_runtime_windows_{x86_64,aarch64}` and +`native_link_windows_{x86_64,aarch64}` targets use the existing native recipe, +runtime inspection and SDK export. These targets are MSVC-only. They require +native Windows execution of the matching architecture; they are not cross builds. +The generic Rust-consumer aliases are connected separately, after these inputs. + +Provide the complete installed Cygwin/pkgconf repository explicitly. The default +`windows_installed_tools` label setting is empty and fails if a Windows action +needs it. This keeps ordinary public dependency queries independent of private +provisioning; it does not silently omit tools from a requested Windows build. +The module does not download that installed tree or accept compiler licenses. + +After provisioning, a native PowerShell invocation is: + +```powershell +bazel build //third_party/voice:native_link_windows_x86_64 ` + --platforms=//:local_windows_msvc ` + --inject_repository="voice_windows_tools=$env:VOICE_WINDOWS_BAZEL_REPOSITORY" ` + --//third_party/voice:windows_installed_tools=@voice_windows_tools//:tools ` + --action_env="SystemRoot=$env:SystemRoot" --host_action_env="SystemRoot=$env:SystemRoot" +``` + +Use `native_link_windows_aarch64` on native ARM64 Windows. Existing MSVC license +acceptance requirements still apply. `SystemRoot` must be a fixed action value, +not the inherited form `--action_env=SystemRoot`; Bazel analysis cannot inspect +that inherited value. The action validates the Windows command directory and +constructs its tool search path from declared inputs, not developer PATH entries. + +Compiler, SDK, Python and CMake inputs use the existing pinned repositories. +CMake and Cygwin execute as x64 under emulation on ARM64; compiler, SDK tools, +Python and pkgconf match the native architecture. Complete support, include and +library files are declared, and installed entrypoints must match the supplied +target/architecture manifest and belong to that declared tree. This does not +turn caller-provided files into authenticated inputs: provisioning retains that +responsibility. These build tools must never enter shipped Codex packages. + +Windows link inputs pair SDK import libraries with the corresponding prepared +DLLs. DLLs, plugins and the receipt remain under the normal native-link runfiles +layout; Windows receives no ELF or Mach-O runtime-search flags. This capability +still needs real native x64/ARM64 Bazel execution and consumer validation before +it establishes complete Windows voice support. The existing direct native recipe +passing on Windows does not prove this new Bazel input and execution path. diff --git a/third_party/voice/bazel_copy.py b/third_party/voice/bazel_copy.py new file mode 100644 index 0000000000..2793f3cd75 --- /dev/null +++ b/third_party/voice/bazel_copy.py @@ -0,0 +1,19 @@ +"""Copy declared library payloads while keeping a real native-search directory.""" + +from pathlib import Path +import shutil +import sys + + +def copy_payloads(locator, pairs): + if len(pairs) % 2: + raise ValueError("library copies require source/destination pairs") + Path(locator).mkdir(parents=True, exist_ok=True) + for source, destination in zip(pairs[::2], pairs[1::2], strict=True): + output = Path(destination) + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, output) + + +if __name__ == "__main__": + copy_payloads(sys.argv[1], sys.argv[2:]) diff --git a/third_party/voice/bazel_windows.py b/third_party/voice/bazel_windows.py new file mode 100644 index 0000000000..f14941d041 --- /dev/null +++ b/third_party/voice/bazel_windows.py @@ -0,0 +1,171 @@ +"""Adapt declared Windows tool paths to the existing native build/runtime recipes.""" + +import hashlib +import json +import os +import re +import shutil +import sys +import tarfile +import tempfile +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_native import NativeBuild +from windows_build_inputs import build_environment + + +def selected_inputs(config, root): + inputs = dict(config["inputs"]) + tools = {name: (root / path).absolute() for name, path in inputs["tools"].items()} + manifest = (root / config["manifest"]).absolute() + declared = {(root / path).absolute() for path in config["installed_files"]} + if manifest.stat().st_size > 65536: + raise ValueError("installed Windows tool metadata exceeds limits") + installed = json.loads(manifest.read_text(encoding="utf-8")) + expected = {"shell", "make", "cygpath", "automake", "pkg_config"} + if ( + installed.get("schemaVersion") != 1 + or installed.get("target") != inputs["target"] + or installed.get("cygwinArchitecture") != "x86_64" + or set(installed.get("tools", {})) != expected + ): + raise ValueError("installed Windows tools do not match the selected target") + for name in expected: + relative = Path(installed["tools"][name]) + source = (manifest.parent / relative).absolute() + if ( + relative.is_absolute() + or ".." in relative.parts + or source not in declared + or (name == "pkg_config" and tools.get(name) != source) + or not source.resolve(strict=True).is_relative_to( + manifest.parent.resolve(strict=True) + ) + ): + raise ValueError(f"installed Windows tool selection differs: {name}") + tools[name] = source + inputs["tools"] = {name: str(path) for name, path in tools.items()} + for name in ("INCLUDE", "LIB"): + inputs[name] = [str((root / path).absolute()) for path in inputs[name]] + return inputs + + +def main(): + operation, config_path = sys.argv[1:] + config = json.loads(Path(config_path).read_text(encoding="utf-8")) + root = Path.cwd() + with tempfile.TemporaryDirectory(prefix="voice-windows-") as temporary: + temporary = Path(temporary) + document = temporary / "windows-inputs.json" + inputs = selected_inputs(config, root) + document.write_text(json.dumps(inputs), encoding="utf-8") + environment, _ = build_environment( + document, inputs["target"], {"python": Path(sys.executable)} + ) + # Keep executor scratch directories, never its developer tool search path. + environment.update( + {name: os.environ[name] for name in ("TMP", "TEMP") if name in os.environ} + ) + home = temporary / "home" + home.mkdir() + environment.update({"HOME": str(home), "USERPROFILE": str(home)}) + os.environ.clear() + os.environ.update(environment) + if operation == "prepare": + from prepare_built_runtime import prepare_archive + + output = root / config["output"] + sdk = root / config["sdk"] + for path in (output, sdk): + if path.exists(): + path.rmdir() + try: + prepare_archive( + root / config["prefix"], + root / config["receipt"], + root / config["status"], + inputs["target"], + output, + sdk_output=sdk, + ) + except ValueError as exc: + if str(exc) != "native build receipt is incomplete or mismatched": + raise + receipt = json.loads((root / config["receipt"]).read_text()) + commits = [ + line + for line in (root / config["status"]).read_text().splitlines() + if line.startswith("STABLE_GIT_COMMIT ") + ] + steps = receipt.get("steps", []) + checks = { + "commit_count": len(commits) == 1, + "commit_format": len(commits) == 1 + and bool( + re.fullmatch(r"STABLE_GIT_COMMIT [0-9a-f]{40}", commits[0]) + ), + "target": receipt.get("target") == inputs["target"], + "manifest": receipt.get("manifest_sha256") + == hashlib.sha256( + Path(__file__).with_name("sources.json").read_bytes() + ).hexdigest(), + "step_count": 1 <= len(steps) <= 128, + "step_exit": all(step.get("exit_code") == 0 for step in steps), + "last_step": bool(steps) + and steps[-1].get("name") == "gst-plugins-good-install", + } + print( + "Receipt checks failed: " + + ", ".join(name for name, valid in checks.items() if not valid), + file=sys.stderr, + ) + raise + return + if operation != "build": + raise ValueError("unknown Windows native action") + archives = temporary / "archives" + archives.mkdir() + for path in config["archives"]: + source = root / path + shutil.copyfile(source, archives / source.name) + tools = inputs["tools"] + builder = NativeBuild( + SimpleNamespace( + target=inputs["target"], + deployment_target=None, + jobs=8, + output=temporary / "build", + archives=archives, + windows_build_inputs=document, + **{ + name: Path(tools[name]) + for name in ( + "cc", + "cxx", + "cmake", + "make", + "pkg_config", + "shell", + "bootstrap_make", + ) + }, + ), + environment, + ) + try: + builder.build() + except Exception: + if builder.record["steps"]: + log = builder.output / (builder.record["steps"][-1]["name"] + ".log") + if log.is_file(): + print(log.read_text(errors="replace"), file=sys.stderr) + raise + with tarfile.open(root / config["prefix"], "w") as archive: + archive.add(builder.prefix, arcname=".") + shutil.copyfile(builder.output / "built.json", root / config["receipt"]) + + +if __name__ == "__main__": + main() diff --git a/third_party/voice/native_link.bzl b/third_party/voice/native_link.bzl index c3931bd2d7..0dc1053a8c 100644 --- a/third_party/voice/native_link.bzl +++ b/third_party/voice/native_link.bzl @@ -3,6 +3,7 @@ load("@bazel_tools//tools/cpp:toolchain_utils.bzl", "find_cpp_toolchain") load("@rules_cc//cc/common:cc_common.bzl", "cc_common") load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") +load("@rules_python//python:py_runtime_info.bzl", "PyRuntimeInfo") # ABI filenames from the pinned native sources. Missing outputs fail the build. _ABI_VERSIONS = { @@ -30,8 +31,10 @@ def _native_link_impl(ctx): runtime = ctx.file.runtime versions = dict(_ABI_VERSIONS) macos = ctx.attr.target.endswith("apple-darwin") - if not macos: + windows = ctx.attr.target.endswith("windows-msvc") + if ctx.attr.target.endswith("unknown-linux-gnu"): versions["gstallocators-1.0"] = "0" + sdk = ctx.attr.runtime[OutputGroupInfo].sdk.to_list()[0] if windows else None locator = ctx.actions.declare_directory(ctx.label.name + "/lib/search-path") originals, aliases, libraries = [], [], [] arguments = [locator.path] @@ -45,39 +48,47 @@ def _native_link_impl(ctx): for name, version in versions.items(): filename = "lib" + name + ("." + version + ".dylib" if macos else ".so." + version) alias = "lib" + name + (".dylib" if macos else ".so") - library = ctx.actions.declare_file(ctx.label.name + "/lib/" + filename) + directory = "lib" + if windows: + # Windows names come from the same pinned recipe's SDK and runtime. + filename = {"ffi": "libffi-8.dll", "intl": "intl-8.dll", "opus": "opus.dll", "pcre2-8": "pcre2-8.dll", "z": "z.dll"}.get(name, name + "-0.dll") + alias = ("libffi" if name == "ffi" else name) + ".lib" + directory = "bin" + library = ctx.actions.declare_file(ctx.label.name + "/" + directory + "/" + filename) development = ctx.actions.declare_file(ctx.label.name + "/lib/" + alias) originals.append(library) aliases.append(development) - arguments.extend([runtime.path + "/lib/" + filename, library.path]) - arguments.extend([runtime.path + "/lib/" + filename, development.path]) + arguments.extend([runtime.path + "/" + directory + "/" + filename, library.path]) + arguments.extend([sdk.path + "/lib/" + alias if windows else runtime.path + "/lib/" + filename, development.path]) libraries.append(cc_common.create_library_to_link( actions = ctx.actions, feature_configuration = features, cc_toolchain = cc, dynamic_library = library, + interface_library = development if windows else None, # @loader_path/$ORIGIN dependencies must stay beside one another. dynamic_library_symlink_path = "voice/" + ctx.label.name + "/" + filename, )) payloads = [] paths = ["runtime.json"] + [ - ("plugins/libgst" + plugin + ".dylib" if macos else "lib/gstreamer-1.0/libgst" + plugin + ".so") + ("bin/gst" + plugin + ".dll" if windows else "plugins/libgst" + plugin + ".dylib" if macos else "lib/gstreamer-1.0/libgst" + plugin + ".so") for plugin in "app audioconvert audioresample coreelements opus rtp rtpmanager".split(" ") ] for path in paths: payload = ctx.actions.declare_file(ctx.label.name + "/" + path) payloads.append(payload) arguments.extend([runtime.path + "/" + path, payload.path]) - ctx.actions.run_shell( - inputs = [runtime], + python = ctx.attr._python[PyRuntimeInfo] + ctx.actions.run( + executable = python.interpreter, + inputs = depset([runtime, ctx.file._copy, python.interpreter] + ([sdk] if windows else []), transitive = [python.files]), outputs = [locator] + originals + aliases + payloads, - arguments = arguments, - command = 'set -eu; /bin/mkdir -p "$1"; shift; while [ "$#" -gt 0 ]; do /bin/mkdir -p "${2%/*}"; /bin/cp "$1" "$2"; shift 2; done', + arguments = [ctx.file._copy.path] + arguments, mnemonic = "VoiceNativeLinkInputs", ) linker = cc_common.create_linker_input( owner = ctx.label, - user_link_flags = depset([ + user_link_flags = depset([] if windows else [ "-Wl,-rpath," + ("@loader_path/../lib" if macos else "$ORIGIN/../lib"), ]), libraries = depset(libraries), @@ -98,6 +109,8 @@ native_link = rule( attrs = { "runtime": attr.label(mandatory = True, allow_single_file = True), "target": attr.string(mandatory = True), + "_copy": attr.label(default = "//third_party/voice:bazel_copy.py", allow_single_file = True), + "_python": attr.label(default = "@python_3_12//:py3_runtime", cfg = "exec"), }, fragments = ["cpp"], toolchains = ["@bazel_tools//tools/cpp:toolchain_type"], diff --git a/third_party/voice/runtime.bzl b/third_party/voice/runtime.bzl index 4c52fc5a6a..880ab5c0b3 100644 --- a/third_party/voice/runtime.bzl +++ b/third_party/voice/runtime.bzl @@ -1,6 +1,7 @@ """Prepare build-produced native libraries with the existing platform policy.""" load("@rules_python//python:py_runtime_info.bzl", "PyRuntimeInfo") +load(":windows_native.bzl", "WindowsBuildToolsInfo") def _native_runtime_impl(ctx): python = ctx.attr._python[PyRuntimeInfo] @@ -8,6 +9,31 @@ def _native_runtime_impl(ctx): receipt = ctx.attr.prefix[OutputGroupInfo].receipt.to_list()[0] output = ctx.actions.declare_directory(ctx.label.name) sdk = ctx.actions.declare_directory(ctx.label.name + "_sdk") + if ctx.attr.windows_tools: + tools = ctx.attr.windows_tools[WindowsBuildToolsInfo] + if tools.inputs["target"] != ctx.attr.target: + fail("Windows runtime target must match its declared native tools") + config = ctx.actions.declare_file(ctx.label.name + ".json") + ctx.actions.write(config, json.encode({ + "inputs": tools.inputs, + "manifest": tools.manifest.path, + "installed_files": [file.path for file in tools.installed_files], + "prefix": prefix.path, + "receipt": receipt.path, + "status": ctx.info_file.path, + "output": output.path, + "sdk": sdk.path, + })) + ctx.actions.run( + executable = tools.python.interpreter, + arguments = [ctx.file._windows_driver.path, "prepare", config.path], + inputs = depset([config, prefix, receipt, ctx.info_file, ctx.file._driver, ctx.file._windows_driver] + ctx.files._preparers, transitive = [tools.files]), + outputs = [output, sdk], + env = tools.environment, + execution_requirements = {"no-remote-exec": "1"}, + mnemonic = "VoiceWindowsRuntime", + ) + return [DefaultInfo(files = depset([output])), OutputGroupInfo(sdk = depset([sdk]))] ctx.actions.run( executable = python.interpreter, arguments = [ @@ -45,6 +71,8 @@ native_runtime = rule( attrs = { "prefix": attr.label(mandatory = True, allow_single_file = True), "target": attr.string(mandatory = True), + "windows_tools": attr.label(cfg = "exec", providers = [WindowsBuildToolsInfo]), + "_windows_driver": attr.label(default = "//third_party/voice:bazel_windows.py", allow_single_file = True), "_driver": attr.label(default = "//third_party/voice:prepare_built_runtime.py", allow_single_file = True), "_preparers": attr.label(default = "//third_party/voice:build_inputs"), "_python": attr.label(default = "@python_3_12//:py3_runtime", cfg = "exec"), diff --git a/third_party/voice/test_bazel_windows.py b/third_party/voice/test_bazel_windows.py new file mode 100644 index 0000000000..945c7e270c --- /dev/null +++ b/third_party/voice/test_bazel_windows.py @@ -0,0 +1,170 @@ +"""Exercise declared tool selection and the platform-independent payload copy action.""" + +import copy +import errno +import json +from pathlib import Path +import tempfile +import unittest + +from bazel_copy import copy_payloads +from bazel_windows import selected_inputs + + +class WindowsInputsTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.repository = self.root / "external/installed tools" + self.names = { + "shell": "cygwin/bin/bash.exe", + "make": "cygwin/bin/make.exe", + "cygpath": "cygwin/bin/cygpath.exe", + "automake": "cygwin/bin/automake-1.18", + "pkg_config": "pkgconf-image/PFiles64/pkgconf 3.0.6/pkgconf.exe", + } + for name in self.names.values(): + path = self.repository / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"synthetic declared executable") + self.manifest = self.repository / "voice-tools.json" + self.metadata = { + "schemaVersion": 1, + "target": "aarch64-pc-windows-msvc", + "cygwinArchitecture": "x86_64", + "tools": self.names, + } + self.manifest.write_text(json.dumps(self.metadata)) + self.config = { + "inputs": { + "schemaVersion": 1, + "target": "aarch64-pc-windows-msvc", + "systemRoot": "C:/Windows", + "tools": { + "cc": "external/msvc/cl.exe", + "pkg_config": str( + (self.repository / self.names["pkg_config"]).relative_to( + self.root + ) + ), + }, + "INCLUDE": ["external/sdk/include"], + "LIB": ["external/sdk/lib"], + }, + "manifest": str(self.manifest.relative_to(self.root)), + "installed_files": [ + str((self.repository / name).relative_to(self.root)) + for name in self.names.values() + ], + } + + def test_paths_are_anchored_before_recipe_changes_directory(self): + before = copy.deepcopy(self.config) + expected = { + **self.config["inputs"], + "tools": { + "cc": str(self.root / "external/msvc/cl.exe"), + **{ + name: str(self.repository / path) + for name, path in self.names.items() + }, + }, + "INCLUDE": [str(self.root / "external/sdk/include")], + "LIB": [str(self.root / "external/sdk/lib")], + } + self.assertEqual(selected_inputs(self.config, self.root), expected) + self.assertEqual(self.config, before) + + def test_manifest_cannot_select_a_file_omitted_from_action_inputs(self): + self.config["installed_files"].pop() + with self.assertRaisesRegex(ValueError, "selection differs: pkg_config"): + selected_inputs(self.config, self.root) + + def test_manifest_cannot_escape_installed_tree(self): + outside = self.repository.parent / "outside.exe" + outside.write_bytes(b"not part of the installed support tree") + inside = self.repository / "cygwin/bin/other-bash.exe" + try: + inside.symlink_to(outside) + except OSError as error: + if error.errno not in (errno.EPERM, errno.EACCES): + raise + self.skipTest("creating symlinks requires OS permission") + self.config["installed_files"].append(str(inside.relative_to(self.root))) + self.metadata["tools"]["shell"] = str(inside.relative_to(self.repository)) + self.manifest.write_text(json.dumps(self.metadata)) + with self.assertRaisesRegex(ValueError, "selection differs: shell"): + selected_inputs(self.config, self.root) + + def test_manifest_cannot_replace_the_build_script_executable(self): + alternative = self.repository / "pkgconf-image/another/pkgconf.exe" + alternative.parent.mkdir(parents=True) + alternative.write_bytes(b"different declared executable") + self.config["installed_files"].append(str(alternative.relative_to(self.root))) + self.metadata["tools"]["pkg_config"] = str( + alternative.relative_to(self.repository) + ) + self.manifest.write_text(json.dumps(self.metadata)) + with self.assertRaisesRegex(ValueError, "selection differs: pkg_config"): + selected_inputs(self.config, self.root) + + def test_wrong_target_or_emulation_contract_is_rejected(self): + for key, value in ( + ("target", "x86_64-pc-windows-msvc"), + ("cygwinArchitecture", "aarch64"), + ): + with self.subTest(key=key): + self.manifest.write_text(json.dumps({**self.metadata, key: value})) + with self.assertRaisesRegex(ValueError, "selected target"): + selected_inputs(self.config, self.root) + + def test_missing_declared_executable_does_not_fall_back_to_path(self): + (self.repository / self.names["shell"]).unlink() + with self.assertRaises(FileNotFoundError): + selected_inputs(self.config, self.root) + + +class LibraryCopiesTests(unittest.TestCase): + def test_import_library_and_dll_bytes_remain_distinct(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + dll = root / "source.dll" + library = root / "source.lib" + dll.write_bytes(b"DLL payload") + library.write_bytes(b"import library payload") + locator = root / "package/lib/search-path" + copy_payloads( + locator, + [ + dll, + root / "package/bin/audio.dll", + library, + root / "package/lib/audio.lib", + ], + ) + self.assertTrue(locator.is_dir()) + self.assertEqual( + { + path.relative_to(root / "package").as_posix(): path.read_bytes() + for path in (root / "package").rglob("*") + if path.is_file() + }, + { + "bin/audio.dll": b"DLL payload", + "lib/audio.lib": b"import library payload", + }, + ) + + def test_missing_payload_fails_the_action(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with self.assertRaises(FileNotFoundError): + copy_payloads( + root / "lib/search-path", + [root / "missing.dll", root / "bin/audio.dll"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/third_party/voice/windows_link_smoke.cc b/third_party/voice/windows_link_smoke.cc new file mode 100644 index 0000000000..e33d3c2e70 --- /dev/null +++ b/third_party/voice/windows_link_smoke.cc @@ -0,0 +1,11 @@ +// Force a real final link against the prepared GStreamer import library. +extern "C" void gst_version(unsigned int*, unsigned int*, unsigned int*, unsigned int*); + +int main() { + unsigned int major = 0; + unsigned int minor = 0; + unsigned int micro = 0; + unsigned int nano = 0; + gst_version(&major, &minor, µ, &nano); + return major == 1 ? 0 : 1; +} diff --git a/third_party/voice/windows_native.bzl b/third_party/voice/windows_native.bzl new file mode 100644 index 0000000000..e1a135319e --- /dev/null +++ b/third_party/voice/windows_native.bzl @@ -0,0 +1,129 @@ +"""Native Windows audio actions with complete, explicitly provisioned tool inputs.""" + +load("@bazel_skylib//rules/directory:providers.bzl", "DirectoryInfo") +load("@rules_python//python:py_runtime_info.bzl", "PyRuntimeInfo") + +WindowsBuildToolsInfo = provider( + doc = "Selected native Windows tools, their support closure and recipe input document.", + fields = { + "files": "Complete declared executable, support, header and library files.", + "inputs": "Existing windows_build_inputs schema, with execroot-relative tool paths.", + "manifest": "Imported installed-tree manifest File.", + "installed_files": "Files supplied by the explicitly selected installed tool repository.", + "python": "Declared PyRuntimeInfo used by the actions.", + "environment": "Explicit Windows OS environment; never a developer PATH.", + }, +) + +def _windows_tools_impl(ctx): + architectures = {"x86_64-pc-windows-msvc": "ml64.exe", "aarch64-pc-windows-msvc": "armasm64.exe"} + if ctx.attr.target not in architectures: + fail("Windows native tools require a supported MSVC target") + python = ctx.attr._python[PyRuntimeInfo] + if not python.interpreter: + fail("Windows native tools require a declared native Python interpreter") + system_root = ctx.configuration.default_shell_env.get("SystemRoot") + if not system_root: + fail("Pass --action_env=SystemRoot= as a fixed value") + msvc = ctx.attr.msvc[DirectoryInfo] + sdk = ctx.attr.sdk[DirectoryInfo] + tools = { + name: msvc.get_file(filename) + for name, filename in { + "cc": "cl.exe", + "cxx": "cl.exe", + "link": "link.exe", + "lib": "lib.exe", + "dumpbin": "dumpbin.exe", + "bootstrap_make": "nmake.exe", + "assembler": architectures[ctx.attr.target], + }.items() + } + tools.update({"rc": sdk.get_file("rc.exe"), "mt": sdk.get_file("mt.exe")}) + tools.update({"cmake": ctx.file.cmake, "python": python.interpreter}) + installed = ctx.files.installed_tools + manifests = [file for file in installed if file.basename == "voice-tools.json"] + if len(manifests) != 1: + fail("Select a complete installed tool repository with --//third_party/voice:windows_installed_tools=") + pkgconf_root = manifests[0].dirname + "/pkgconf-image/" + pkgconf_files = [file for file in installed if file.path.startswith(pkgconf_root)] + pkgconf = [file for file in pkgconf_files if file.basename == "pkgconf.exe"] + if len(pkgconf) != 1: + fail("Installed tool repository must declare exactly one pkgconf-image pkgconf.exe") + tools["pkg_config"] = pkgconf[0] + if ctx.file.cmake not in ctx.files.cmake_data: + fail("CMake executable must belong to its declared support tree") + includes = [target[DirectoryInfo] for target in ctx.attr.includes] + libraries = [target[DirectoryInfo] for target in ctx.attr.libraries] + files = depset( + tools.values() + installed, + transitive = [msvc.transitive_files, sdk.transitive_files, python.files, ctx.attr.cmake_data.files] + + [directory.transitive_files for directory in includes + libraries], + ) + return [DefaultInfo( + files = depset(pkgconf), + runfiles = ctx.runfiles(files = pkgconf_files), + ), WindowsBuildToolsInfo( + files = files, + python = python, + manifest = manifests[0], + installed_files = installed, + environment = {"SystemRoot": system_root, "SYSTEMROOT": system_root}, + inputs = { + "schemaVersion": 1, + "target": ctx.attr.target, + "systemRoot": system_root, + "tools": {name: file.path for name, file in tools.items()}, + "INCLUDE": [directory.path for directory in includes], + "LIB": [directory.path for directory in libraries], + }, + )] + +windows_build_tools = rule( + implementation = _windows_tools_impl, + attrs = { + "target": attr.string(mandatory = True), + "msvc": attr.label(mandatory = True, providers = [DirectoryInfo]), + "sdk": attr.label(mandatory = True, providers = [DirectoryInfo]), + "includes": attr.label_list(mandatory = True, providers = [DirectoryInfo]), + "libraries": attr.label_list(mandatory = True, providers = [DirectoryInfo]), + "installed_tools": attr.label(mandatory = True), + "cmake": attr.label(default = "@cmake-3.31.8-windows-x86_64//:cmake_bin", allow_single_file = True), + "cmake_data": attr.label(default = "@cmake-3.31.8-windows-x86_64//:cmake_data"), + "_python": attr.label(default = "@python_3_12//:py3_runtime", cfg = "exec"), + }, +) + +def _windows_prefix_impl(ctx): + tools = ctx.attr.build_tools[WindowsBuildToolsInfo] + prefix = ctx.actions.declare_file(ctx.label.name + "/prefix.tar") + receipt = ctx.actions.declare_file(ctx.label.name + "/built.json") + config = ctx.actions.declare_file(ctx.label.name + ".json") + ctx.actions.write(config, json.encode({ + "inputs": tools.inputs, + "manifest": tools.manifest.path, + "installed_files": [file.path for file in tools.installed_files], + "archives": [file.path for file in ctx.files.archives], + "prefix": prefix.path, + "receipt": receipt.path, + })) + ctx.actions.run( + executable = tools.python.interpreter, + arguments = [ctx.file._driver.path, "build", config.path], + inputs = depset([config, ctx.file._driver] + ctx.files.archives + ctx.files._recipe, transitive = [tools.files]), + outputs = [prefix, receipt], + env = tools.environment, + execution_requirements = {"no-remote-exec": "1"}, + mnemonic = "VoiceWindowsPrefix", + ) + return [DefaultInfo(files = depset([prefix])), OutputGroupInfo(receipt = depset([receipt]))] + +windows_native_prefix = rule( + implementation = _windows_prefix_impl, + attrs = { + "build_tools": attr.label(mandatory = True, cfg = "exec", providers = [WindowsBuildToolsInfo]), + "archives": attr.label_list(mandatory = True, allow_files = True), + "_driver": attr.label(default = "//third_party/voice:bazel_windows.py", allow_single_file = True), + "_recipe": attr.label(default = "//third_party/voice:native_recipe"), + }, +)