diff --git a/.github/workflows/no-gpu.yml b/.github/workflows/no-gpu.yml index da66a63747..ea2dc3f8fa 100644 --- a/.github/workflows/no-gpu.yml +++ b/.github/workflows/no-gpu.yml @@ -55,7 +55,7 @@ jobs: "$RUNNER_TEMP/pytest/bin/python" -m pytest -q -p no:cacheprovider zig: runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -121,10 +121,26 @@ jobs: exit 0 fi python3 -m unittest discover -s tools/zig -p zig_cache_restat_test.py -v - # zig build test is the host unit tests. No GPU. The Python suite is not this check. - # A unit test that hangs fails by name after 10 minutes instead of holding the runner until the job's limit. - - name: zig build test - run: zig build -Dfixed-cpu test --test-timeout 10m + - name: Host test graph regressions + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + PUSH_BASE: ${{ github.event.before }} + run: | + if [ -n "$BASE_REF" ]; then + base=$(git merge-base HEAD "origin/$BASE_REF" 2> /dev/null || true) + else + base=$(git rev-parse --verify "$PUSH_BASE^{commit}" 2> /dev/null || true) + fi + if [ -n "$base" ] && git diff --quiet "$base" HEAD -- \ + build.zig zig/build/host_tests.zig tools/zig/host_tests_test.py; then + echo "Host test graph inputs are unchanged." + exit 0 + fi + python3 -m unittest discover -s tools/zig -p host_tests_test.py -v + # Keep per-test timeouts while allowing the DeepSeek AOT enumeration to finish on hosted runners. + - name: zig build test-host + run: zig build -Dfixed-cpu test-host --test-timeout 20m # The kernel contract's limits gate: every registered launch against M1-M5 and sm_80/86/89/120/121, controls red. - name: zig build test-kernel-contract run: zig build -Dfixed-cpu test-kernel-contract diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a335060e3d..b2adc009fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,15 @@ zig build test-cuda-host This subset does not replace the complete `test` suite. +Native Linux no-GPU CI runs `zig build -Dfixed-cpu test-host --test-timeout 20m`. +It includes `test` and every configured `test-*` and `check-*` step, including compile-only checks. +New named checks join automatically, even when a build module registers them last. +The reviewed exclusions in `zig/build/host_tests.zig` cover the optional xgrammar tests, +the separately scheduled `check-release-paths` gate, manual large-state timing commands, and temporary failures tracked in #2031. +An empty named check, a stale exclusion, or an alias that brings excluded work back into the aggregate fails it. +When a fix lands, remove its temporary exclusion and the matching fixture entry in `tools/zig/host_tests_test.py`. +This aggregate is only registered for native Linux; other hosts keep their existing `test` and device steps. + HIP is an explicit build opt-in. Add `-Dhip=true` to enable its host tests and named HIP targets: ```sh diff --git a/build.zig b/build.zig index fe8202515f..bd7fe268e8 100644 --- a/build.zig +++ b/build.zig @@ -76,6 +76,7 @@ pub fn build(b: *std.Build) void { @import("zig/build/dsv41_draft.zig").steps(b); @import("zig/build/dsv41_assets.zig").steps(b); const target = b.standardTargetOptions(.{}); + defer @import("zig/build/host_tests.zig").wire(b, target); @import("zig/build/copy_source.zig").steps(b); @import("zig/build/checkpoint_io_host.zig").steps(b, target); if (target.result.os.tag == .macos) @import("zig/build/masked_values.zig").steps(b, target, .ReleaseSafe); diff --git a/tools/zig/host_tests_test.py b/tools/zig/host_tests_test.py new file mode 100644 index 0000000000..a728cdc76a --- /dev/null +++ b/tools/zig/host_tests_test.py @@ -0,0 +1,101 @@ +"""Real Zig build graphs must include new host checks and keep reviewed external requirements outside CI.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +POLICY = ROOT / "zig/build/host_tests.zig" +EXCLUDED = ( + "test-grammar-budget", + "test-grammar-tools", + "check-release-paths", + "test-startup-states-timing", + "test-imprint-ops-timing", + "test-metal-residency-owners-host", + "test-qwen35-draft-frame-host", + "test-qwen35-frame-invalidation-host", + "test-gradient-serving", +) + + +@unittest.skipUnless(sys.platform.startswith("linux"), "test-host is a native Linux CI aggregate") +class HostTestGraphTests(unittest.TestCase): + def setUp(self): + self.zig = os.environ.get("ZIG") or shutil.which("zig") + if not self.zig: + self.skipTest("no Zig compiler found (set ZIG, or put zig on PATH)") + temporary = tempfile.TemporaryDirectory(prefix="tensorfold-host-graph-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + shutil.copyfile(POLICY, self.root / "host_tests.zig") + + def build(self, extra="", omit=None, args=None): + excluded = "\n".join( + f' b.step("{name}", "external requirement").dependOn(&b.addFail("external gate ran").step);' + for name in EXCLUDED if name != omit + ) + source = '''const std = @import("std"); +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + defer @import("host_tests.zig").wire(b, target); +''' + excluded + "\n" + extra + "\n}\n" + (self.root / "build.zig").write_text(source) + return subprocess.run([self.zig, "build", *(args or ["test-host", "--summary", "all"])], + cwd=self.root, capture_output=True, text=True, timeout=120) + + def assert_failed_with(self, result, message): + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(message, result.stdout + result.stderr) + + def test_new_named_host_test_is_included_after_the_collector_is_declared(self): + result = self.build(' b.step("test-new-host", "new host test").dependOn(&b.addFail("new host test reached").step);') + self.assert_failed_with(result, "new host test reached") + + def test_existing_unnamed_unit_tests_are_preserved(self): + result = self.build(' b.step("test", "existing units").dependOn(&b.addFail("existing units reached").step);') + self.assert_failed_with(result, "existing units reached") + + def test_new_compile_only_check_is_included(self): + (self.root / "broken.zig").write_text('comptime { @compileError("new compile check reached"); }\n') + result = self.build(''' const check = b.addObject(.{ .name = "compile-only", .root_module = b.createModule(.{ + .root_source_file = b.path("broken.zig"), .target = target, + }) }); + b.step("check-new-host", "compile only").dependOn(&check.step);''') + self.assert_failed_with(result, "new compile check reached") + + def test_reviewed_external_requirements_are_not_executed(self): + result = self.build() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("external gate ran", result.stdout + result.stderr) + + def test_alias_to_an_excluded_check_is_rejected(self): + result = self.build(''' b.step("test-alias", "alias").dependOn(&b.top_level_steps.get("test-grammar-budget").?.step);''') + self.assert_failed_with(result, "test-host reaches excluded test-grammar-budget") + self.assertNotIn("external gate ran", result.stdout + result.stderr) + + def test_direct_alias_to_an_excluded_action_is_rejected(self): + result = self.build(''' const external = b.top_level_steps.get("test-grammar-tools").?; + b.step("test-action-alias", "alias").dependOn(external.step.dependencies.items[0]);''') + self.assert_failed_with(result, "test-host reaches excluded test-grammar-tools") + self.assertNotIn("external gate ran", result.stdout + result.stderr) + + def test_empty_named_check_is_rejected(self): + result = self.build(' _ = b.step("test-empty", "accidentally disconnected");') + self.assert_failed_with(result, "named check test-empty has no build steps") + + def test_removed_external_step_requires_review_of_its_exclusion(self): + result = self.build(omit="test-grammar-tools") + self.assert_failed_with(result, "stale exclusion test-grammar-tools") + + def test_cross_target_does_not_acquire_a_native_host_aggregate(self): + result = self.build(args=["--help", "-Dtarget=x86_64-windows-gnu"]) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("test-host", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/zig/build/host_tests.zig b/zig/build/host_tests.zig new file mode 100644 index 0000000000..ed6e7b1a4e --- /dev/null +++ b/zig/build/host_tests.zig @@ -0,0 +1,73 @@ +//! Native Linux CI collects named host tests and compile checks after every build module has registered its steps. +const std = @import("std"); +const Step = std.Build.Step; +const Set = std.AutoHashMapUnmanaged(*Step, void); + +const Exclusion = struct { name: []const u8, reason: []const u8 }; +const exclusions = [_]Exclusion{ + .{ .name = "test-grammar-budget", .reason = "Requires the optional xgrammar checkout and -Dxgrammar=DIR" }, + .{ .name = "test-grammar-tools", .reason = "Requires the optional xgrammar checkout and -Dxgrammar=DIR" }, + .{ .name = "check-release-paths", .reason = "The release-paths CI job checks the separately built release server" }, + .{ .name = "test-startup-states-timing", .reason = "Manual disk startup timing seeds up to 100,000 states" }, + .{ .name = "test-imprint-ops-timing", .reason = "Manual Imprint timing seeds up to 100,000 learned states" }, + .{ .name = "test-metal-residency-owners-host", .reason = "red on main, #2031 (Qwen27 recording fixture leaves Runner.kv unset)" }, + .{ .name = "test-qwen35-draft-frame-host", .reason = "red on main, #2031 (recording fixture leaves seg_fn uninitialized)" }, + .{ .name = "test-qwen35-frame-invalidation-host", .reason = "red on main, #2031 (same recording fixture as test-qwen35-draft-frame-host)" }, + .{ .name = "test-gradient-serving", .reason = "red on main, #2031 (Flash Next hyper-connection source pin drifted)" }, +}; + +pub fn wire(b: *std.Build, target: std.Build.ResolvedTarget) void { + const host = b.graph.host.result; + if (host.os.tag != .linux or target.result.os.tag != host.os.tag or + target.result.cpu.arch != host.cpu.arch or target.result.abi != host.abi) return; + + const all = b.step("test-host", "Native Linux host tests and compile checks with reviewed exclusions"); + var rejected: std.ArrayList(*Step) = .empty; + for (b.top_level_steps.values()) |top| { + const name = top.step.name; + if (&top.step == all or !isCheck(name) or exclusion(name) != null) continue; + if (top.step.dependencies.items.len == 0) + reject(b, &rejected, b.fmt("test-host: named check {s} has no build steps", .{name})); + all.dependOn(&top.step); + } + + var included: Set = .empty; + collect(b.allocator, all, &included); + for (exclusions) |entry| { + const top = b.top_level_steps.get(entry.name) orelse { + reject(b, &rejected, b.fmt("test-host: stale exclusion {s}: {s}", .{ entry.name, entry.reason })); + continue; + }; + var visited: Set = .empty; + if (includedBoundary(b.allocator, &top.step, &included, &visited)) + reject(b, &rejected, b.fmt("test-host reaches excluded {s}: {s}", .{ entry.name, entry.reason })); + } + if (rejected.items.len != 0) all.dependencies = rejected; +} + +fn reject(b: *std.Build, rejected: *std.ArrayList(*Step), message: []const u8) void { + rejected.append(b.allocator, &b.addFail(message).step) catch @panic("OOM"); +} + +fn isCheck(name: []const u8) bool { + return std.mem.eql(u8, name, "test") or std.mem.startsWith(u8, name, "test-") or + std.mem.startsWith(u8, name, "check-"); +} + +fn exclusion(name: []const u8) ?Exclusion { + for (exclusions) |entry| if (std.mem.eql(u8, name, entry.name)) return entry; + return null; +} + +fn collect(a: std.mem.Allocator, root: *Step, seen: *Set) void { + if ((seen.getOrPut(a, root) catch @panic("OOM")).found_existing) return; + for (root.dependencies.items) |dep| collect(a, dep, seen); +} + +fn includedBoundary(a: std.mem.Allocator, root: *Step, included: *const Set, seen: *Set) bool { + if ((seen.getOrPut(a, root) catch @panic("OOM")).found_existing) return false; + if (included.contains(root)) return true; + if (root.tag != .top_level) return false; + for (root.dependencies.items) |dep| if (includedBoundary(a, dep, included, seen)) return true; + return false; +}