clawft_kernel/node_facts/
macos.rs1use 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
23pub 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
50pub 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 if mount != path {
130 continue;
131 }
132 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; };
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}