Skip to main content

clawft_kernel/node_facts/
macos.rs

1//! macOS probes: CPU, OS version, unified memory, storage, Metal GPU and
2//! the Apple Neural Engine.
3//!
4//! Sources: `sysctl`, `sw_vers`, `vm_stat`, `df`, and
5//! `system_profiler SPDisplaysDataType -json`. The ANE has no public query
6//! API; its presence is inferred from an Apple-silicon chip plus
7//! `CoreML.framework`, so it is advertised as `claimed` with a note, never
8//! `probed` or `measured`, and its busy state is not known.
9
10use clawft_types::placement::{AttrValue, Provenance};
11
12use super::host::ProbeHost;
13use super::probe::{Collected, bytes_attr, cap, note_external_withheld, parse_df, public_mount, str_list};
14
15const COREML: &str = "/System/Library/Frameworks/CoreML.framework";
16
17fn sysctl(host: &dyn ProbeHost, key: &str) -> Option<String> {
18    host.run("sysctl", &["-n", key])
19        .map(|s| s.trim().to_string())
20        .filter(|s| !s.is_empty())
21}
22
23/// Free memory from `vm_stat`: (free + inactive + speculative) pages.
24/// Current free memory from `vm_stat` alone (cheap live probe).
25pub fn live_free(host: &dyn ProbeHost) -> Option<u64> {
26    host.run("vm_stat", &[]).as_deref().and_then(parse_vm_stat)
27}
28
29pub fn parse_vm_stat(out: &str) -> Option<u64> {
30    let page: u64 = out
31        .lines()
32        .next()?
33        .split("page size of ")
34        .nth(1)?
35        .split_whitespace()
36        .next()?
37        .parse()
38        .ok()?;
39    let pages = |label: &str| -> u64 {
40        out.lines()
41            .find(|l| l.starts_with(label))
42            .and_then(|l| l.split(':').nth(1))
43            .and_then(|v| v.trim().trim_end_matches('.').parse().ok())
44            .unwrap_or(0)
45    };
46    let n = pages("Pages free") + pages("Pages inactive") + pages("Pages speculative");
47    Some(n.saturating_mul(page))
48}
49
50/// Run the macOS probes.
51pub fn probe(host: &dyn ProbeHost, arch: &str, c: &mut Collected) {
52    let brand = sysctl(host, "machdep.cpu.brand_string");
53    let apple_silicon =
54        arch == "aarch64" && brand.as_deref().is_some_and(|b| b.starts_with("Apple"));
55
56    if let Some(mut cp) = cap(&format!("cpu.arch.{arch}"), Provenance::Probed) {
57        if let Some(n) = sysctl(host, "hw.ncpu").and_then(|s| s.parse::<i64>().ok()) {
58            cp = cp.with_attr("cores", n);
59        }
60        if let Some(b) = &brand {
61            cp = cp.with_attr("model", b.as_str());
62        }
63        c.push(cp);
64    }
65
66    if let Some(mut os) = cap("os.macos", Provenance::Probed) {
67        if let Some(v) = host.run("sw_vers", &["-productVersion"]) {
68            os = os.with_attr("version", v.trim());
69        }
70        c.push(os);
71    }
72
73    let total = sysctl(host, "hw.memsize").and_then(|s| s.parse::<u64>().ok());
74    let free = host.run("vm_stat", &[]).as_deref().and_then(parse_vm_stat);
75    if let Some(total) = total {
76        let ids: &[&str] = if apple_silicon {
77            &["mem.unified", "mem.system"]
78        } else {
79            &["mem.system"]
80        };
81        for id in ids {
82            if let Some(mut m) = cap(id, Provenance::Probed) {
83                m = m.with_attr("total", bytes_attr(total));
84                if let Some(f) = free {
85                    m = m.with_attr("free", bytes_attr(f.min(total)));
86                }
87                c.push(m);
88            }
89        }
90        if apple_silicon {
91            c.note(
92                "mem.unified",
93                "sysctl hw.memsize; Apple silicon: CPU, GPU and ANE share one pool",
94            );
95        }
96    }
97
98    storage(host, c);
99    metal(host, apple_silicon, total, c);
100    if apple_silicon {
101        ane(host, brand.as_deref().unwrap_or("Apple silicon"), c);
102    }
103    if let Some(dm) = cap("node.class.dev-mac", Provenance::Claimed) {
104        c.push(dm);
105    }
106}
107
108fn storage(host: &dyn ProbeHost, c: &mut Collected) {
109    if let Some((free, mount)) = host.run("df", &["-kP", "/"]).as_deref().and_then(parse_df)
110        && let Some(s) = cap("store.tier.internal", Provenance::Probed)
111    {
112        let mut s = s.with_attr("free", bytes_attr(free));
113        if let Some(m) = public_mount(&mount) {
114            s = s.with_attr("mount", m);
115        }
116        c.push(s);
117    }
118    let mut external = 0;
119    for vol in host.list_dir("/Volumes").unwrap_or_default() {
120        let path = format!("/Volumes/{vol}");
121        let Some((free, mount)) = host
122            .run("df", &["-kP", &path])
123            .as_deref()
124            .and_then(parse_df)
125        else {
126            continue;
127        };
128        // `/Volumes/Macintosh HD` is an alias of `/`, not a drive.
129        if mount != path {
130            continue;
131        }
132        // The label is the user's own name for the drive: it stays local.
133        if let Some(s) = cap("store.tier.external", Provenance::Probed) {
134            c.push(
135                s.with_attr("mounted", true)
136                    .with_attr("free", bytes_attr(free)),
137            );
138            external += 1;
139        }
140    }
141    note_external_withheld(c, external);
142}
143
144fn metal(host: &dyn ProbeHost, apple_silicon: bool, total: Option<u64>, c: &mut Collected) {
145    let Some(json) = host.run("system_profiler", &["SPDisplaysDataType", "-json"]) else {
146        c.note(
147            "accel.gpu.metal",
148            "system_profiler unavailable; GPU not advertised",
149        );
150        return;
151    };
152    let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
153        c.note("accel.gpu.metal", "system_profiler output was not JSON");
154        return;
155    };
156    let gpus = v["SPDisplaysDataType"]
157        .as_array()
158        .cloned()
159        .unwrap_or_default();
160    for g in gpus {
161        let Some(family) = g["spdisplays_mtlgpufamilysupport"].as_str() else {
162            continue; // no Metal support reported: do not advertise
163        };
164        let Some(mut m) = cap("accel.gpu.metal", Provenance::Probed) else {
165            continue;
166        };
167        let version = family.trim_start_matches("spdisplays_metal");
168        m = m
169            .with_attr("sdk", "metal")
170            .with_attr("sdk_version", version)
171            .with_attr("unified", apple_silicon);
172        if let Some(model) = g["sppci_model"].as_str() {
173            m = m.with_attr("device", model);
174        }
175        if let Some(vendor) = g["spdisplays_vendor"].as_str() {
176            let vendor = vendor
177                .trim_start_matches("sppci_vendor_")
178                .to_ascii_lowercase();
179            m = m.with_attr("vendor", vendor.as_str());
180        }
181        if let Some(cores) = g["sppci_cores"]
182            .as_str()
183            .and_then(|s| s.parse::<i64>().ok())
184        {
185            m = m.with_attr("cores", cores);
186        }
187        if let (true, Some(t)) = (apple_silicon, total) {
188            m = m.with_attr("mem_bytes", bytes_attr(t));
189        }
190        c.push(m);
191        c.note(
192            "accel.gpu.metal",
193            "system_profiler SPDisplaysDataType (Metal family, cores); mem_bytes is the unified pool, not extra memory",
194        );
195    }
196}
197
198fn ane(host: &dyn ProbeHost, chip: &str, c: &mut Collected) {
199    if !host.exists(COREML) {
200        c.note(
201            "accel.npu.ane",
202            "CoreML.framework not found; ANE not advertised",
203        );
204        return;
205    }
206    if let Some(a) = cap("accel.npu.ane", Provenance::Claimed) {
207        c.push(
208            a.with_attr("device", chip)
209                .with_attr("unified", true)
210                .with_attr("formats", str_list(&["coreml"]))
211                .with_attr("utilisation_known", AttrValue::Bool(false)),
212        );
213        c.note(
214            "accel.npu.ane",
215            "claimed: inferred from Apple-silicon chip + CoreML.framework; no public API reports ANE busy state or throughput",
216        );
217    }
218}