Include GIO in voice SDKs and native runtimes (#43102)

## Why

The Rust GStreamer bindings link GLib's GIO library even when none of the selected plugins imports it. Following only plugin dependencies can omit a library the bindings need.

## What changed

- Export `gio-2.0` pkg-config metadata in the voice SDK.
- Require GIO as a runtime dependency root on Linux, macOS, and Windows, including its transitive dependencies under the existing import checks.
- Reject runtime packages that omit the required GIO library.

## Testing

Extend fixtures to cover GIO inclusion and missing-library rejection across platforms, loading GIO and its transitive dependency after moving a Linux runtime, and resolving `gio-2.0` through pkg-config after moving an SDK.

GitOrigin-RevId: d12e45d387d56c54d94d788cf07d82bb22c2b4a3
This commit is contained in:
Benjamin Carlsson
2026-09-05 21:18:44 +00:00
committed by copyberry
parent 64b482500d
commit 47ca4619be
12 changed files with 150 additions and 11 deletions
+6
View File
@@ -68,6 +68,12 @@ separate runtime projection and are never copied into users' Codex packages.
Cargo/Bazel native providers, final helper linkage and moved-package execution
remain separate integration work; exporting an SDK does not enable voice.
The Rust GStreamer bindings also link GLib's GIO library. Its pkg-config metadata
is included in the SDK, and runtime preparation treats its loader identity as an
explicit dependency root alongside the seven plugins. GIO comes from the same
pinned GLib build; it is not another source package or a GStreamer plugin. Its
transitive imports must satisfy the same private-library and system-import checks.
## Native build recipe
`build_native.py` runs the unmodified upstream build systems in a new output
+4 -1
View File
@@ -9,7 +9,7 @@ import sys
# Import only this script's siblings, including under PYTHONSAFEPATH.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from runtime import Binary, PLUGINS, RuntimeFormat, prepare
from runtime import Binary, PLUGINS, RuntimeFormat, prepare, required_library_paths
SYSTEM_IMPORTS = frozenset(
{
@@ -138,6 +138,9 @@ def project(prefix, receipts, target, output):
inspect,
finalize_copy,
plugin_dir="lib/gstreamer-1.0",
required_libraries=tuple(
Path(path).name for path in required_library_paths(target)
),
)
prepare(prefix, receipts, target, output, format)
+4 -1
View File
@@ -10,7 +10,7 @@ import sys
# Import only this script's siblings, including under PYTHONSAFEPATH.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from runtime import Binary as MachO
from runtime import PLUGINS, RuntimeFormat, digest, prepare
from runtime import PLUGINS, RuntimeFormat, digest, prepare, required_library_paths
SYSTEM_IMPORTS = frozenset(
{
@@ -150,6 +150,9 @@ def project(prefix, receipts, target, output):
SYSTEM_IMPORTS,
inspect,
finalize_copy,
required_libraries=tuple(
Path(path).name for path in required_library_paths(target)
),
)
prepare(prefix, receipts, target, output, format)
+3 -1
View File
@@ -9,7 +9,7 @@ import json
from pathlib import Path
import re
from runtime import PLUGINS, digest
from runtime import PLUGINS, digest, required_library_paths
def runtime_files(root: Path, target: str) -> dict[str, str]:
@@ -71,5 +71,7 @@ def runtime_files(root: Path, target: str) -> dict[str, str]:
files
):
raise ValueError("runtime must include exactly the selected plugins")
if not set(required_library_paths(target)).issubset(files):
raise ValueError("runtime must include the required libraries")
files["runtime.json"] = hashlib.sha256(data).hexdigest()
return files
+20 -1
View File
@@ -20,6 +20,17 @@ PLUGINS = (
)
def required_library_paths(target: str) -> tuple[str, ...]:
"""Libraries needed by the bindings even when no selected plugin imports them."""
if target.endswith("-apple-darwin"):
return ("lib/libgio-2.0.0.dylib",)
if target.endswith("-unknown-linux-gnu"):
return ("lib/libgio-2.0.so.0",)
if target.endswith("-pc-windows-msvc"):
return ("bin/gio-2.0-0.dll",)
raise ValueError("unsupported native runtime target")
@dataclass(frozen=True)
class Binary:
identity: str
@@ -37,6 +48,7 @@ class RuntimeFormat:
finalize_copy: Callable
library_dir: str = "lib"
plugin_dir: str = "plugins"
required_libraries: tuple[str, ...] = ()
def digest(path):
@@ -118,7 +130,14 @@ def prepare(prefix, receipts, target, output, format):
binaries[relative] = (record, metadata)
identities[metadata.identity] = relative
destinations[relative] = destination
pending, selected = list(format.plugins), set()
libraries = {path.name: relative for relative, path in destinations.items()}
if any(name not in libraries for name in format.required_libraries):
raise ValueError("missing required native library")
pending = [
*format.plugins,
*(libraries[name] for name in format.required_libraries),
]
selected = set()
while pending:
relative = pending.pop()
if relative in selected:
+1
View File
@@ -15,6 +15,7 @@ from runtime import digest
MODULES = (
"glib-2.0",
"gobject-2.0",
"gio-2.0",
"gmodule-no-export-2.0",
"gstreamer-1.0",
"gstreamer-base-1.0",
+33 -2
View File
@@ -12,7 +12,8 @@ import unittest
from unittest.mock import patch
from assemble_package import assemble
from runtime import PLUGINS, digest
from package_runtime import runtime_files
from runtime import PLUGINS, digest, required_library_paths
class AssembleTests(unittest.TestCase):
@@ -54,13 +55,19 @@ class AssembleTests(unittest.TestCase):
libraries.append(
{"path": path.relative_to(root).as_posix(), "sha256": digest(path)}
)
plugins = [record["path"] for record in libraries]
for name in required_library_paths(target):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"prepared required library")
libraries.append({"path": name, "sha256": digest(path)})
manifest = {
"schemaVersion": 1,
"developmentOnly": True,
"target": target,
"sourceCommit": "b" * 40,
"sourceManifestSha256": digest(Path(__file__).with_name("sources.json")),
"plugins": [record["path"] for record in libraries],
"plugins": plugins,
"libraries": libraries,
}
(root / "runtime.json").write_text(json.dumps(manifest))
@@ -129,6 +136,30 @@ class AssembleTests(unittest.TestCase):
},
)
def test_rejects_stale_runtime_without_gio_for_each_platform(self):
for target, plugin, gio in (
(
"aarch64-unknown-linux-gnu",
"lib/gstreamer-1.0/libgst{}.so",
"lib/libgio-2.0.so.0",
),
(
"aarch64-apple-darwin",
"plugins/libgst{}.dylib",
"lib/libgio-2.0.0.dylib",
),
("aarch64-pc-windows-msvc", "bin/gst{}.dll", "bin/gio-2.0-0.dll"),
):
with self.subTest(target=target):
root, receipt = self.make_runtime(target, plugin)
receipt["libraries"] = [
r for r in receipt["libraries"] if r["path"] != gio
]
(root / gio).unlink()
(root / "runtime.json").write_text(json.dumps(receipt))
with self.assertRaisesRegex(ValueError, "required libraries"):
runtime_files(root.resolve(), target)
def test_rejects_invalid_runtime_receipts_before_creating_package(self):
runtime, original = self.make_runtime()
changes = [
+50 -3
View File
@@ -46,6 +46,40 @@ class RuntimeTests(unittest.TestCase):
check=True,
capture_output=True,
)
source.write_text("int voice_gio_dependency(void) { return 84; }\n")
gio_dependency = self.prefix / "lib/libgiofixture.so.1.2"
subprocess.run(
[
"cc",
"-shared",
"-fPIC",
str(source),
"-o",
str(gio_dependency),
"-Wl,-soname,libgiofixture.so.1",
],
check=True,
capture_output=True,
)
source.write_text(
"extern int voice_gio_dependency(void); "
"int voice_gio_fixture(void) { return voice_gio_dependency(); }\n"
)
subprocess.run(
[
"cc",
"-shared",
"-fPIC",
str(source),
str(gio_dependency),
"-Wl,-rpath,$ORIGIN",
"-o",
str(self.prefix / "lib/libgio-2.0.so.0.8800.3"),
"-Wl,-soname,libgio-2.0.so.0",
],
check=True,
capture_output=True,
)
source.write_text(
"extern int voice_fixture(void); int voice_plugin(void) { return voice_fixture(); }\n"
)
@@ -122,7 +156,8 @@ class RuntimeTests(unittest.TestCase):
"-c",
"import ctypes,json,pathlib,sys; root=pathlib.Path(sys.argv[1]); "
"manifest=json.loads((root/'runtime.json').read_text()); "
"print([ctypes.CDLL(str(root/p)).voice_plugin() for p in manifest['plugins']])",
"print([ctypes.CDLL(str(root/p)).voice_plugin() for p in manifest['plugins']] + "
"[ctypes.CDLL(str(root/'lib/libgio-2.0.so.0')).voice_gio_fixture()])",
str(moved),
],
check=True,
@@ -131,9 +166,17 @@ class RuntimeTests(unittest.TestCase):
env=environment,
cwd=self.root,
)
self.assertEqual(json.loads(result.stdout), [42] * len(PLUGINS))
self.assertEqual(json.loads(result.stdout), [42] * len(PLUGINS) + [84])
manifest = json.loads((moved / "runtime.json").read_text())
self.assertEqual(len(manifest["libraries"]), len(PLUGINS) + 1)
self.assertEqual(
{record["path"] for record in manifest["libraries"]},
{
"lib/libfixture.so.1",
"lib/libgio-2.0.so.0",
"lib/libgiofixture.so.1",
*(f"lib/gstreamer-1.0/libgst{name}.so" for name in PLUGINS),
},
)
for record in manifest["libraries"]:
path = moved / record["path"]
expected = (
@@ -141,6 +184,8 @@ class RuntimeTests(unittest.TestCase):
if path.parent.name == "gstreamer-1.0"
else ()
)
if path.name == "libgio-2.0.so.0":
expected = ("29=$ORIGIN",)
self.assertEqual(inspect(path, self.target).rpaths, expected)
self.assertEqual(digest(path), record["sourceSha256"])
self.assertEqual(record["sha256"], record["sourceSha256"])
@@ -161,7 +206,9 @@ class RuntimeTests(unittest.TestCase):
def test_missing_dependency_and_digest_mismatch_leave_no_output(self):
for records in (
[r for r in self.inventory if "libgio" not in r["path"]],
[r for r in self.inventory if "libfixture" not in r["path"]],
[r for r in self.inventory if "libgiofixture" not in r["path"]],
[{**r, "sha256": "b" * 64} for r in self.inventory],
):
with self.subTest(records=records), self.assertRaises(ValueError):
+14
View File
@@ -47,6 +47,13 @@ class RuntimeTests(unittest.TestCase):
capture_output=True,
)
(self.prefix / "lib/libfixture.1.dylib").symlink_to(self.library.name)
gio = self.prefix / "lib/libgio-2.0.0.dylib"
shutil.copy2(self.library, gio)
subprocess.run(
["/usr/bin/install_name_tool", "-id", "@rpath/" + gio.name, str(gio)],
check=True,
capture_output=True,
)
source.write_text(
"extern int voice_fixture(void); int voice_plugin(void) { return voice_fixture(); }\n"
)
@@ -120,6 +127,7 @@ class RuntimeTests(unittest.TestCase):
{r["path"] for r in manifest["libraries"]},
{
"lib/libfixture.1.dylib",
"lib/libgio-2.0.0.dylib",
*(f"plugins/libgst{name}.dylib" for name in PLUGINS),
},
)
@@ -205,6 +213,12 @@ class RuntimeTests(unittest.TestCase):
def test_missing_dependency_and_duplicate_identity_fail_before_output_creation(
self,
):
self.inventory_path.write_text(
json.dumps([r for r in self.inventory if "libgio" not in r["path"]])
)
with self.assertRaisesRegex(ValueError, "missing required native library"):
project(self.prefix, self.receipts, self.target, self.output)
self.assertFalse(self.output.exists())
self.inventory_path.write_text(
json.dumps([r for r in self.inventory if "libfixture" not in r["path"]])
)
+1
View File
@@ -93,6 +93,7 @@ class SdkTests(unittest.TestCase):
"--cflags",
"--libs",
"gstreamer-audio-1.0",
"gio-2.0",
],
env={
**os.environ,
+10 -1
View File
@@ -53,6 +53,7 @@ class RuntimeTests(unittest.TestCase):
capture_output=True,
cwd=self.root,
)
shutil.copy2(self.library, self.prefix / "bin/gio-2.0-0.dll")
source = self.root / "plugin.c"
source.write_text(
"__declspec(dllimport) int voice_fixture(void); "
@@ -166,12 +167,20 @@ class RuntimeTests(unittest.TestCase):
)
self.assertEqual(json.loads(result.stdout), [42] * len(PLUGINS))
manifest = json.loads((moved / "runtime.json").read_text())
self.assertEqual(len(manifest["libraries"]), len(PLUGINS) + 1)
self.assertEqual(
{record["path"] for record in manifest["libraries"]},
{
"bin/fixture.dll",
"bin/gio-2.0-0.dll",
*(f"bin/gst{name}.dll" for name in PLUGINS),
},
)
for record in manifest["libraries"]:
self.assertEqual(digest(moved / record["path"]), record["sourceSha256"])
def test_missing_dependency_and_digest_mismatch_leave_no_output(self):
for records in (
[r for r in self.inventory if "gio-2.0" not in r["path"]],
[r for r in self.inventory if "fixture.dll" not in r["path"]],
[{**r, "sha256": "b" * 64} for r in self.inventory],
):
+4 -1
View File
@@ -8,7 +8,7 @@ import sys
# Import only this script's siblings, including under PYTHONSAFEPATH.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from runtime import Binary, PLUGINS, RuntimeFormat, prepare
from runtime import Binary, PLUGINS, RuntimeFormat, prepare, required_library_paths
# VCRUNTIME140 is a development prerequisite, not a guaranteed Windows component.
EXTERNAL_IMPORTS = frozenset(
@@ -131,6 +131,9 @@ def project(prefix, receipts, target, output):
finalize_copy,
library_dir="bin",
plugin_dir="bin",
required_libraries=tuple(
Path(path).name for path in required_library_paths(target)
),
)
prepare(prefix, receipts, target, output, format)