1use planarian_schema::{
16 Backends, Cadence, Clock, Condition, Disruption, DriverRef, Evidence, GenerationBackend, GenerationId, Hash,
17 InventoryBackend, Millis, Observed, Override, PowerSource, ProbeBackend, ProbeCost, ProbeDef, ProbeId, ProbeKind,
18 ProbeOrigin, ProbeResult, ProbeRole, Reason, Scope, SessionBackend, SessionInfo, SystemState, Timestamp,
19 UnitBackend, UnitDef, UnitDefs, UnitId, UnitKind,
20};
21use std::collections::{BTreeMap, BTreeSet};
22
23pub const QUIET_INTERVAL: Millis = Millis::secs(60);
25
26pub const TICK: Millis = Millis::secs(5);
27pub const MIN_STALE: Millis = Millis::secs(30);
28pub const HARDWARE_CADENCE: Millis = Millis::secs(60);
29pub const HARDWARE_CADENCE_BATTERY: Millis = Millis::secs(300);
30pub const TICK_BUDGET: Millis = Millis(500);
32
33pub use planarian_schema::Manifest;
34
35pub type Canonical = BTreeMap<String, Vec<ProbeDef>>;
37
38pub fn apply_canonical(def: &mut UnitDef, canonical: &Canonical) {
41 let core = canonical.get(&def.id.name).cloned().unwrap_or_default();
42 let core_ids: BTreeSet<&ProbeId> = core.iter().map(|p| &p.id).collect();
43 def.probes.retain(|p| !core_ids.contains(&p.id));
44 for p in def.probes.iter_mut() {
45 p.origin = ProbeOrigin::User;
46 }
47 def.probes.extend(core);
48}
49
50#[derive(Clone, Debug, Default)]
52pub struct Context {
53 pub known_good: GenerationId,
54 pub live_tested: Option<GenerationId>,
55 pub accepted_baseline: Option<GenerationId>,
56 pub security_floor: Option<GenerationId>,
58 pub overrides: Vec<Override>,
59 pub core_hash: Hash,
60}
61
62#[derive(Clone, Debug, Default)]
63pub struct Assembler {
64 pub quiet: bool,
67 manifest: Manifest,
68 canonical: Canonical,
69 evidence: BTreeMap<(UnitId, ProbeId), Evidence>,
70 last_run: BTreeMap<(UnitId, ProbeId), Timestamp>,
71 on_demand: BTreeSet<(UnitId, ProbeId)>,
72 pub failed_since: BTreeMap<UnitId, Timestamp>,
73 pub known_drivers: BTreeMap<UnitId, DriverRef>,
74 pub sessions: Vec<SessionInfo>,
75 session_seen: BTreeMap<String, Timestamp>,
77 pub on_battery: bool,
78 pub slow_ticks: u64,
80 pub slow_tick: Option<(Millis, Vec<String>)>,
83}
84
85impl Assembler {
86 pub fn new(manifest: Manifest, canonical: Canonical) -> Assembler {
87 let mut a = Assembler { canonical, ..Default::default() };
88 a.set_manifest(manifest);
89 a
90 }
91
92 pub fn set_manifest(&mut self, mut manifest: Manifest) {
94 for def in manifest.units.values_mut() {
95 apply_canonical(def, &self.canonical);
96 }
97 for def in manifest.user_units.iter_mut() {
98 apply_canonical(def, &self.canonical);
99 }
100 self.manifest = manifest;
101 }
102
103 pub fn manifest(&self) -> &Manifest {
104 &self.manifest
105 }
106
107 pub fn defs(&self) -> UnitDefs {
111 let mut defs = self.manifest.units.clone();
112 let uids: BTreeSet<u32> = self.sessions.iter().filter(|s| s.establishes()).map(|s| s.uid).collect();
113 let bound: Vec<UnitDef> = defs.values().filter(|d| session_bound(d)).cloned().collect();
116 for t in bound {
117 defs.remove(&t.id);
118 let seated = t.desired.when.needs_seat();
121 for s in self.sessions.iter().filter(|s| s.establishes() && (s.seat.is_some() || !seated)) {
122 let id = UnitId {
123 kind: t.id.kind,
124 name: t.id.name.clone(),
125 scope: Scope::User { uid: s.uid, session: Some(s.id.clone()) },
126 };
127 let mut d = t.clone();
128 d.id = id.clone();
129 d.desired.when = Condition::All(vec![
130 t.desired.when.clone(),
131 Condition::SessionEstablished { uid: Some(s.uid), seated },
132 ]);
133 for e in d.edges.iter_mut() {
134 e.from = id.clone();
135 }
136 defs.insert(id, d);
137 }
138 }
139 for t in &self.manifest.user_units {
140 for uid in &uids {
141 let id = UnitId {
142 kind: t.id.kind,
143 name: t.id.name.clone(),
144 scope: Scope::User { uid: *uid, session: None },
145 };
146 let mut d = t.clone();
147 d.id = id.clone();
148 d.desired.when = Condition::All(vec![
149 t.desired.when.clone(),
150 Condition::SessionEstablished { uid: Some(*uid), seated: false },
151 ]);
152 for e in d.edges.iter_mut() {
153 e.from = id.clone();
154 if let Scope::User { .. } = e.to.scope {
155 e.to.scope = Scope::User { uid: *uid, session: None };
156 }
157 }
158 defs.insert(id, d);
159 }
160 }
161 defs
162 }
163
164 pub fn request(&mut self, unit: &UnitId, probe: &ProbeId) {
166 self.on_demand.insert((unit.clone(), probe.clone()));
167 }
168
169 pub fn reprobe(&mut self, unit: &UnitId) {
171 self.last_run.retain(|(u, _), _| u != unit);
172 }
173
174 fn interval(&self, p: &ProbeDef) -> Option<Millis> {
175 let base = match p.cadence {
176 Cadence::EveryTick | Cadence::OnEvent => TICK,
177 Cadence::Every(d) => d,
178 Cadence::OnDemand => return None,
179 };
180 let base = if p.cost == ProbeCost::WakesHardware {
181 let floor = if self.on_battery { HARDWARE_CADENCE_BATTERY } else { HARDWARE_CADENCE };
182 Millis(base.0.max(floor.0))
183 } else {
184 base
185 };
186 Some(Millis(base.0.max(per(p).map_or(0, |m| m.0))))
190 }
191
192 fn stale_after(&self, p: &ProbeDef) -> Millis {
193 let interval = self.interval(p).unwrap_or(TICK);
194 Millis((interval.0 * 3).max(p.timeout.0 * 2 + TICK.0).max(MIN_STALE.0))
195 }
196
197 fn quieted(&self, def: &UnitDef, p: &ProbeDef, now: Timestamp) -> bool {
201 if !self.quiet || self.on_demand.contains(&(def.id.clone(), p.id.clone())) {
202 return false;
203 }
204 let depended_on = matches!(def.id.scope, Scope::User { .. })
205 || def.id.kind == UnitKind::Device
206 || matches!(def.restart_disruption, Disruption::SessionLoss | Disruption::ConnectivityLoss);
207 !depended_on
208 && self.last_run.get(&(def.id.clone(), p.id.clone())).is_some_and(|last| now.0 < last.0 + QUIET_INTERVAL.0)
209 }
210
211 fn due(&self, unit: &UnitId, p: &ProbeDef, now: Timestamp) -> bool {
212 let key = (unit.clone(), p.id.clone());
213 if self.on_demand.contains(&key) {
214 return true;
215 }
216 if p.role == ProbeRole::Diagnostic || p.check_contract().is_err() {
219 return false;
220 }
221 match (self.interval(p), self.last_run.get(&key)) {
222 (None, _) => false,
223 (Some(_), None) => true,
224 (Some(i), Some(last)) => now.0 >= last.0 + i.0,
225 }
226 }
227
228 fn observe_unit<B: Backends>(
229 &mut self,
230 b: &B,
231 def: &UnitDef,
232 devices: &BTreeMap<UnitId, Option<DriverRef>>,
233 ) -> Observed {
234 let id = &def.id;
235 let now = b.clock().now();
236 if id.kind == UnitKind::Device {
237 if id.name.starts_with("fs-") {
238 return Observed::Active; }
240 return match devices.get(id) {
241 None => Observed::Absent,
242 Some(None) => Observed::Detected,
243 Some(Some(d)) => {
244 self.known_drivers.insert(id.clone(), d.clone());
245 Observed::Bound
246 }
247 };
248 }
249 if id.name.starts_with("external:") {
250 return Observed::Active; }
252 if let Some((target, within)) = def.probes.iter().find_map(|p| match &p.kind {
253 ProbeKind::SessionTarget { target, within } => Some((target.clone(), *within)),
254 _ => None,
255 }) {
256 return match self.session_of(id) {
257 Some(s) => self.observe_session_target(b, &target, within, now, s),
258 None => Observed::Absent,
259 };
260 }
261 match b.units().observe(id) {
262 Ok(o) => o,
263 Err(e) => Observed::Unknown { since: now, reason: format!("backend: {e:?}") },
264 }
265 }
266
267 fn session_of(&self, id: &UnitId) -> Option<&SessionInfo> {
269 match &id.scope {
270 Scope::User { uid, session: Some(sid) } => {
271 self.sessions.iter().find(|s| &s.id == sid && s.uid == *uid && s.establishes())
272 }
273 _ => None,
274 }
275 }
276
277 fn session_up<B: Backends>(&self, b: &B, target: &str, s: &SessionInfo) -> bool {
282 if let Some(up) = b.sessions().compositor_running(s) {
283 return up;
284 }
285 let tid = UnitId {
286 kind: UnitKind::Target,
287 name: target.into(),
288 scope: Scope::User { uid: s.uid, session: Some(s.id.clone()) },
289 };
290 matches!(b.units().observe(&tid), Ok(Observed::Active))
291 }
292
293 fn observe_session_target<B: Backends>(
297 &self,
298 b: &B,
299 target: &str,
300 within: Millis,
301 now: Timestamp,
302 s: &SessionInfo,
303 ) -> Observed {
304 if self.session_up(b, target, s) {
305 Observed::Active
306 } else if now.0 < s.created.0 + within.0 {
307 Observed::Transitioning { from: Box::new(Observed::Bound), since: now }
308 } else {
309 Observed::Bound
310 }
311 }
312
313 fn session_target_result<B: Backends>(
314 &self,
315 b: &B,
316 id: &UnitId,
317 target: &str,
318 within: Millis,
319 now: Timestamp,
320 ) -> ProbeResult {
321 let Some(s) = self.session_of(id) else { return ProbeResult::Error("no such session".into()) };
322 if self.session_up(b, target, s) || now.0 < s.created.0 + within.0 {
323 ProbeResult::Pass
324 } else {
325 ProbeResult::Fail(format!(
326 "the desktop did not come up within {}s of session {} (uid {})",
327 within.0 / 1000,
328 s.id,
329 s.uid
330 ))
331 }
332 }
333
334 pub fn observe<B: Backends>(&mut self, b: &B, ctx: &Context) -> SystemState {
336 let started = b.clock().now();
337 let now = started;
338
339 self.sessions = b.sessions().sessions().unwrap_or_default();
340 for s in self.sessions.iter_mut() {
341 let seen = *self.session_seen.entry(s.id.clone()).or_insert(now);
342 if s.created.0 == 0 {
343 s.created = seen;
344 }
345 }
346 let live: BTreeSet<String> = self.sessions.iter().map(|s| s.id.clone()).collect();
347 self.session_seen.retain(|id, _| live.contains(id));
348 let power = b.sessions().power();
349 self.on_battery = power == Some(PowerSource::Battery);
350 let devices: BTreeMap<UnitId, Option<DriverRef>> =
351 b.inventory().devices().unwrap_or_default().into_iter().map(|d| (d.id, d.driver)).collect();
352
353 let mut conditions = BTreeSet::new();
354 for s in self.sessions.iter().filter(|s| s.establishes()) {
355 conditions.insert(Condition::SessionEstablished { uid: Some(s.uid), seated: s.seat.is_some() });
356 }
357 for sid in b.sessions().foreground() {
359 if self.sessions.iter().any(|s| s.id == sid && s.establishes()) {
360 conditions.insert(Condition::Foreground { session: sid });
361 }
362 }
363 match power {
364 Some(PowerSource::Ac) => {
365 conditions.insert(Condition::OnAcPower);
366 }
367 Some(PowerSource::Battery) => {
368 conditions.insert(Condition::OnBattery);
369 }
370 None => {}
371 }
372 for d in devices.keys() {
373 conditions.insert(Condition::DeviceConnected(d.clone()));
374 }
375
376 let defs = self.defs();
377 let mut observed: BTreeMap<UnitId, Observed> = BTreeMap::new();
378 for def in defs.values() {
379 let o = self.observe_unit(b, def, &devices);
380 match &o {
381 Observed::Failed(_) => {
382 self.failed_since.entry(def.id.clone()).or_insert(now);
383 }
384 _ => {
385 self.failed_since.remove(&def.id);
386 }
387 }
388 observed.insert(def.id.clone(), o);
389 }
390
391 let mut batch: Vec<(UnitId, ProbeDef)> = vec![];
394 let mut local: Vec<(UnitId, ProbeDef, ProbeResult)> = vec![];
395 for def in defs.values() {
396 for p in &def.probes {
397 if self.quieted(def, p, now) {
398 if let Some(e) = self.evidence.get_mut(&(def.id.clone(), p.id.clone())) {
401 e.stale_after = Millis(e.stale_after.0.max(QUIET_INTERVAL.0 * 3));
402 }
403 continue;
404 }
405 if !self.due(&def.id, p, now) {
406 continue;
407 }
408 match &p.kind {
409 ProbeKind::SystemdActive => {
410 let r = match &observed[&def.id] {
411 Observed::Active | Observed::Bound if def.id.kind == UnitKind::Device => ProbeResult::Pass,
412 Observed::Active => ProbeResult::Pass,
413 Observed::Unknown { reason, .. } => ProbeResult::Error(reason.clone()),
414 other => ProbeResult::Fail(format!("{other:?}")),
415 };
416 local.push((def.id.clone(), p.clone(), r));
417 }
418 ProbeKind::SessionTarget { target, within } => {
419 let r = self.session_target_result(b, &def.id, target, *within, now);
420 local.push((def.id.clone(), p.clone(), r));
421 }
422 _ => batch.push((def.id.clone(), p.clone())),
423 }
424 }
425 }
426 let results = if batch.is_empty() { vec![] } else { b.probes().run_batch(&batch) };
427 for ((unit, p), r) in batch.into_iter().zip(results) {
428 local.push((unit, p, r));
429 }
430 let ran: Vec<String> = local.iter().map(|(u, p, _)| format!("{u}/{}", p.id.0)).collect();
431 for (unit, p, result) in local {
432 let key = (unit.clone(), p.id.clone());
433 self.on_demand.remove(&key);
434 self.last_run.insert(key.clone(), now);
435 let stale_after = self.stale_after(&p);
436 self.evidence.insert(key, Evidence { probe: p.id.clone(), result, at: now, stale_after });
437 }
438 self.evidence.retain(|(u, pid), _| defs.get(u).is_some_and(|d| d.probes.iter().any(|p| &p.id == pid)));
440
441 let units = observed
442 .into_iter()
443 .map(|(id, o)| {
444 let ev: Vec<Evidence> =
445 self.evidence.iter().filter(|((u, _), _)| *u == id).map(|(_, e)| e.clone()).collect();
446 (id, (o, ev))
447 })
448 .collect();
449
450 let generation = b.generations().current().unwrap_or(ctx.known_good);
451 let resources = b.inventory().resources().unwrap_or_default();
452 let finished = b.clock().now();
453 let took = finished.0.saturating_sub(started.0);
454 if took > TICK_BUDGET.0 {
455 self.slow_ticks += 1;
456 self.slow_tick = Some((Millis(took), ran));
457 }
458 SystemState {
459 generation,
460 known_good: ctx.known_good,
461 live_tested: ctx.live_tested,
462 accepted_baseline: ctx.accepted_baseline,
463 security_floor: ctx.security_floor,
464 units,
465 overrides: ctx.overrides.clone(),
466 conditions,
467 resources,
468 core_hash: ctx.core_hash,
469 holds: Default::default(),
471 observed_at: now,
472 }
473 }
474}
475
476fn per(p: &ProbeDef) -> Option<Millis> {
479 match p.side_effects {
480 planarian_schema::SideEffects::Measurement { per, .. } if per.0 > 0 => Some(per),
481 _ => None,
482 }
483}
484
485pub fn backend_reason(msg: &str) -> Reason {
487 Reason::Backend(msg.into())
488}
489
490#[cfg(test)]
491mod tests;
492
493fn session_bound(d: &UnitDef) -> bool {
495 d.id.scope == Scope::System && d.probes.iter().any(|p| matches!(p.kind, ProbeKind::SessionTarget { .. }))
496}