Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions .github/workflows/no-gpu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
101 changes: 101 additions & 0 deletions tools/zig/host_tests_test.py
Original file line number Diff line number Diff line change
@@ -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()
73 changes: 73 additions & 0 deletions zig/build/host_tests.zig
Original file line number Diff line number Diff line change
@@ -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;
}
Loading