77from __future__ import annotations
88
99from datetime import datetime , timezone , timedelta
10+ import json
1011import re
1112import subprocess
1213
@@ -59,11 +60,48 @@ def _entry(key,state,activity,reason,evidence,when="",href="/logs",
5960 code = code ,evidence = evidence ,updated_at = _time (when ),
6061 href = href ,next_scheduled = next_run )
6162
63+ def _timer_list_next (unit , * , run ):
64+ """Fixed-unit, read-only fallback: systemctl list-timers JSON if realtime
65+ timer property is unavailable (for example a monotonic timer).
66+ Never derive a wall-clock estimate from monotonic microseconds.
67+ """
68+ try :
69+ res = run (["systemctl" ,"--user" ,"list-timers" ,"--all" ,"--no-pager" ,
70+ "--output=json" ,unit ],capture_output = True ,text = True ,
71+ timeout = 1.5 ,check = False )
72+ if res .returncode != 0 or len (res .stdout )> 8192 :
73+ return ""
74+ rows = json .loads (res .stdout )
75+ if not isinstance (rows ,list ) or len (rows )> 12 :
76+ return ""
77+ for item in rows :
78+ if not isinstance (item ,dict ) or item .get ("unit" )!= unit :
79+ continue
80+ value = item .get ("next" )
81+ if isinstance (value ,str ):
82+ value = value .strip ()
83+ if value not in ("" ,"n/a" ,"-" ,"0" ) and TIMESTAMP .fullmatch (value ):
84+ return value
85+ elif type (value )== int and 10 ** 14 < value < 10 ** 17 :
86+ # systemd JSON MAY use Unix microseconds; these bounds are
87+ # intentionally conservative, otherwise we show unknown.
88+ try :
89+ return datetime .fromtimestamp (
90+ value / 1000000 ,timezone .utc ).isoformat (timespec = "seconds" )
91+ except (OverflowError ,ValueError ,OSError ):
92+ pass
93+ except (OSError ,subprocess .TimeoutExpired ,TypeError ,ValueError ,
94+ AttributeError ,json .JSONDecodeError ):
95+ pass
96+ return ""
97+
98+
6299def _timer_schedules (* ,run = subprocess .run ):
63- """Read only the two known user timers. Return raw systemd wall-clock text .
100+ """Observe exactly two allowlisted user timers, never control units .
64101
65- These are provider-reported scheduled triggers, NOT rate eligibility;
66- invalid/unavailable response becomes unknown rather than an invented time.
102+ Prefer systemd's realtime next trigger; fall back to systemd's own
103+ list-timers reported next wall time if no realtime property is provided.
104+ Missing schedule is distinct from an inactive timer or publisher fault.
67105 """
68106 times = {}
69107 for key ,unit in TIMERS .items ():
@@ -74,10 +112,13 @@ def _timer_schedules(*,run=subprocess.run):
74112 value = res .stdout .strip () if res .returncode == 0 else ""
75113 times [key ]= value if (value not in ("" ,"n/a" ,"0" ) and
76114 TIMESTAMP .fullmatch (value )) else ""
77- except (OSError , subprocess .TimeoutExpired , TypeError , AttributeError ):
115+ except (OSError ,subprocess .TimeoutExpired ,TypeError ,AttributeError ):
78116 times [key ]= ""
117+ if not times [key ]:
118+ times [key ]= _timer_list_next (unit ,run = run )
79119 return times
80120
121+
81122def _eligible_time (metrics ,key ,now ):
82123 values = metrics .get ("next_possible_utc" ) if isinstance (metrics ,dict ) else None
83124 raw = values .get (key ) if isinstance (values ,dict ) else None
0 commit comments