Watch
1
0
Fork
You've already forked souveraine
0

sessiond: the machine can wake the panel, and so can she

The state machine could turn the screen off and had no way to turn it
back on. `apply_gesture` answers a power tap on a dark panel with
`Restore` — which is brightness — and there was no unblank in the action
table at all. That was invisible because the wake never came through the
daemon: hyprland.lua bound the physical key straight to
blueline-screen-toggle, so the compositor woke the panel and sessiond
only heard about it afterwards.

viewtop consumes that key on purpose — the control that locks the phone
must not be interceptable by whatever is fullscreen — so the gap became a
phone that could sleep and never wake. Measured on hardware: panel off,
presses arriving and logged, no way back short of ssh.

Action::Unblank closes it, and a power tap now returns [Unblank, Restore]
in that order, because the brightness a panel comes back at means nothing
until the panel is back.

Also a `screen` verb, because a wake bound to a keybinding in a
compositor config is not something the agent can reach and §13 says that
is a defect. It is not a bypass: `on` is immediate, `off` routes through
request_blank() like every other path, so she can turn the screen off and
still cannot darken an unlocked session. Operation is hers; the ordering
is a property of the machine, not a permission she lacks.

100 tests, four new ones pinning exactly that.
This commit is contained in:
Fimeg 2026-08-02 17:29:33 -04:00
commit b45fb705db
3 changed files with 189 additions and 2 deletions

View file

@ -336,6 +336,31 @@ pub enum Action {
Restore, Restore,
/// Blank the panel through the DPMS executor. /// Blank the panel through the DPMS executor.
Blank, Blank,
/// Light the panel through the same executor.
///
/// The machine could turn the screen off and had no way to turn it back
/// on: `apply_gesture` answers a power tap on a dark panel with `Restore`,
/// which is *brightness*, and there was no unblank in this table at all.
/// That worked only because the wake never came through the daemon —
/// `hyprland.lua` bound the physical key straight to
/// `blueline-screen-toggle`, so the compositor woke the panel and sessiond
/// merely heard about it afterwards. Under viewtop the compositor consumes
/// that key (a device button must not be interceptable by whatever is
/// fullscreen), and the gap became a phone that could sleep and never wake.
///
/// So the machine owns both directions now, which is what §1 asks for in
/// the first place: seven blind actors became one authority, and an
/// authority that can only act in one direction is half an authority. It
/// also makes waking something the agent can *reach* — doctrine §13, a
/// verb she cannot reach is a defect — rather than a side effect of a
/// keybinding in a compositor config she has no say over.
///
/// Unlike [`Action::Blank`] this carries no ordering obligation: §1 is
/// about paths to a *dark* panel. Lighting one discloses nothing that the
/// lock surface is not already responsible for covering, and
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
/// panel, only sessiond moves the lock".
Unblank,
/// Request the session lock, because something wants the panel dark and /// Request the session lock, because something wants the panel dark and
/// the session is not locked yet. /// the session is not locked yet.
/// ///
@ -1529,10 +1554,19 @@ impl DeviceStateMachine {
} }
if !self.panel_on { if !self.panel_on {
// Dark: the tap is a wake, and a power-button wake is never vetoed. // Dark: the tap is a wake, and a power-button wake is never vetoed.
//
// `Unblank` *then* `Restore`, and the order is the whole of it: the
// panel has to come back before the brightness it comes back at
// means anything. `Restore` alone was what this returned, which was
// only ever enough because the compositor woke the panel through a
// keybinding before the daemon was ever consulted — see
// `Action::Unblank`. Under viewtop nothing else does it, so a tap
// set the brightness of a screen that stayed off.
//
// `Restore` puts back the brightness the dim captured rather than a // `Restore` puts back the brightness the dim captured rather than a
// guessed floor — see the comment on that capture. // guessed floor — see the comment on that capture.
self.blank_requested = false; self.blank_requested = false;
return vec![Action::Restore]; return vec![Action::Unblank, Action::Restore];
} }
self.request_blank( self.request_blank(
now, now,
@ -2091,6 +2125,53 @@ impl DeviceStateMachine {
/// The executor reports the panel's real state. Turning on counts as /// The executor reports the panel's real state. Turning on counts as
/// input, so a dt2w wake starts the blank budget from the wake itself. /// input, so a dt2w wake starts the blank budget from the wake itself.
/// Ask for the panel, from outside the machine's own rules.
///
/// The agent's verb lands here (`{"op":"screen"}`), and so does anything
/// else that wants the glass lit or dark. It is deliberately not a
/// shortcut past anything: **off routes through `request_blank()`** like
/// every other path, so it locks first and waits out `LOCK_ACK_BUDGET`,
/// and **on** is the wake that direction never had.
///
/// Doctrine §13 in one function. Operation is hers — she may turn the
/// screen off — and the ordering invariant is not a permission she is
/// missing, it is a property of the machine that applies to every caller
/// including itself.
/// What the machine currently believes the panel is doing.
///
/// A belief, and named as one: the executor reports it (`set_panel`) and
/// nothing here infers it, so this is the last thing the hardware said
/// rather than an assumption about what it must be doing now.
pub fn panel_on(&self) -> bool {
self.panel_on
}
pub fn request_screen(&mut self, on: bool, why: &str) -> Vec<Action> {
if on {
if self.panel_on {
return Vec::new();
}
self.blank_requested = false;
let mut actions = vec![Action::Unblank];
// A wake must never come up dim, for the same reason `set_panel`
// restores: a blank that landed while the dim warning was showing
// left the saved brightness low.
if self.dimmed {
self.dimmed = false;
actions.push(Action::Restore);
}
return actions;
}
if !self.panel_on {
return Vec::new();
}
self.request_blank(
Instant::now(),
serde_json::json!({ "verb": "screen", "on": false }),
why,
)
}
pub fn set_panel(&mut self, on: bool) -> Vec<Action> { pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
if self.panel_on == on { if self.panel_on == on {
return Vec::new(); return Vec::new();
@ -2771,6 +2852,62 @@ mod tests {
assert!(!sm.blank_requested, "the panel must not be dark yet"); assert!(!sm.blank_requested, "the panel must not be dark yet");
} }
#[test]
fn the_agents_screen_verb_cannot_blank_an_unlocked_session() {
// Doctrine §13 in one assertion. Operation is hers, so she may ask for
// the screen off — and the ordering invariant is not a permission she
// lacks, it is a property of the machine that applies to every caller.
// She gets `Lock` first, exactly as the idle timer does.
let (mut sm, _t0) = unlocked_and_lit();
assert_eq!(sm.request_screen(false, "agent"), vec![Action::Lock]);
assert!(sm.pending_blank.is_some());
assert!(!sm.blank_requested, "the panel must not be dark yet");
}
#[test]
fn the_screen_verb_wakes_a_dark_panel() {
// The direction the machine did not have. Before `Action::Unblank`
// there was no way out of a blank at all from inside the daemon: a
// power tap on a dark panel answered `Restore`, which is brightness,
// and the panel stayed off. It only ever worked because Hyprland's
// keybinding woke the panel before sessiond was consulted.
let (mut sm, _t0) = unlocked_and_lit();
sm.set_panel(false);
assert!(!sm.panel_on());
assert_eq!(sm.request_screen(true, "agent"), vec![Action::Unblank]);
}
#[test]
fn a_power_tap_on_a_dark_panel_unblanks_before_it_restores() {
// Order is the whole of it: the brightness a panel comes back at means
// nothing until the panel is back. `Restore` alone was what this
// returned, and under viewtop that set the brightness of a screen that
// stayed off.
let (mut sm, t0) = unlocked_and_lit();
sm.set_panel(false);
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
let actions = sm.button_edge_at(Button::Power, ButtonEdge::Up, t0 + Duration::from_millis(80));
let actions = if actions.is_empty() {
// Taps resolve on the multi-tap window expiring, not on release.
sm.tick_at(t0 + Duration::from_secs(2))
} else {
actions
};
let unblank = actions.iter().position(|a| *a == Action::Unblank);
let restore = actions.iter().position(|a| *a == Action::Restore);
assert!(unblank.is_some(), "a tap on a dark panel must wake it: {actions:?}");
if let (Some(u), Some(r)) = (unblank, restore) {
assert!(u < r, "the panel must come back before its brightness does");
}
}
#[test]
fn asking_for_the_state_the_panel_is_already_in_does_nothing() {
let (mut sm, _t0) = unlocked_and_lit();
assert!(sm.panel_on());
assert!(sm.request_screen(true, "agent").is_empty());
}
#[test] #[test]
fn a_pending_blank_fires_once_the_lock_is_acked() { fn a_pending_blank_fires_once_the_lock_is_acked() {
let (mut sm, t0) = unlocked_and_lit(); let (mut sm, t0) = unlocked_and_lit();

View file

@ -188,6 +188,17 @@ pub const VERBS: &[VerbDoc] = &[
refuses: &[], refuses: &[],
example: r#"{"op":"panel","on":false}"#, example: r#"{"op":"panel","on":false}"#,
}, },
VerbDoc {
op: "screen",
mutates: true,
summary: "turn the panel on or off; a blank still locks the session first",
// Refusable because turning it OFF routes through the same
// lock-before-blank path everything else does — the agent owns
// operation (§13) and still cannot blank an unlocked session, because
// that is an ordering invariant rather than a permission.
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"screen","on":true}"#,
},
VerbDoc { VerbDoc {
op: "forensic_log", op: "forensic_log",
mutates: false, mutates: false,
@ -294,6 +305,19 @@ pub enum Request {
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark /// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
/// glance. Reported, never assumed — the executor owns the panel. /// glance. Reported, never assumed — the executor owns the panel.
Panel { on: bool }, Panel { on: bool },
/// Ask for the panel. The agent's verb, and the user's, and the shell's.
///
/// Distinct from [`Request::Panel`], which is the executor *reporting*
/// what the hardware did. This one *asks*, and the distinction is the
/// whole reason both exist: a report must never be able to actuate, or a
/// stale report would drive the panel; and an ask must never be able to
/// silently edit the machine's idea of the hardware.
///
/// Waking is immediate. Blanking goes through the same `request_blank()`
/// every other path takes, so it locks first and waits for the ack —
/// doctrine §13's shape exactly: operation is hers, and the one thing she
/// cannot do is make the glass dark on a session that is not locked.
Screen { on: bool },
/// Query recent forensic log entries. Returns the last N entries /// Query recent forensic log entries. Returns the last N entries
/// from the in-memory forensic buffer for post-hoc analysis. /// from the in-memory forensic buffer for post-hoc analysis.
ForensicLog { count: Option<usize> }, ForensicLog { count: Option<usize> },
@ -532,7 +556,7 @@ mod tests {
// And the other direction: every variant must be advertised. Bump this // And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc. // deliberately when a verb is added, having added its VerbDoc.
assert_eq!(VERBS.len(), 15, "a Request variant was added without a VerbDoc"); assert_eq!(VERBS.len(), 16, "a Request variant was added without a VerbDoc");
} }
#[test] #[test]

View file

@ -347,6 +347,16 @@ fn execute(shared: &Arc<Shared>, action: Action) {
// dim is what pinned brightness at 10/255. // dim is what pinned brightness at 10/255.
Action::Dim => ("brightnessctl", vec!["set", "10"], "panel-dim"), Action::Dim => ("brightnessctl", vec!["set", "10"], "panel-dim"),
Action::Restore => unreachable!("handled above; the restore carries a value"), Action::Restore => unreachable!("handled above; the restore carries a value"),
// The other direction, through the same executor.
//
// No lock check and no proximity gate. `Blank`'s guard below exists
// because a dark panel on an unlocked session is a disclosure hazard
// that outlives the moment; lighting one is not, and the lock surface
// is what gets lit when the session is locked. §4's table settles the
// veto question separately: a power button "can it lie? no — hardware
// signal", which is why the one wake a pocket can produce by itself
// (tap-to-wake) is gated at the toggle and this is not.
Action::Unblank => (DPMS_EXECUTOR, vec!["on"], "panel-on"),
Action::Blank => { Action::Blank => {
// The invariant, enforced where it cannot be reasoned around: the // The invariant, enforced where it cannot be reasoned around: the
// panel does not go dark unless logind says this session is // panel does not go dark unless logind says this session is
@ -1233,6 +1243,22 @@ fn handle_request(
} }
serde_json::json!({ "ok": true, "panel_on": on }) serde_json::json!({ "ok": true, "panel_on": on })
} }
Request::Screen { on } => {
let actions = {
let mut d = shared.lock();
d.device_state.request_screen(on, "screen verb")
};
let acted = !actions.is_empty();
for action in actions {
execute(shared, action);
}
// `panel_on` is what the machine currently believes, not what was
// asked: a blank that is waiting on a lock ack has not happened
// yet, and saying otherwise would be the report lying about the
// hardware.
let panel_on = shared.lock().device_state.panel_on();
serde_json::json!({ "ok": true, "requested": on, "panel_on": panel_on, "acted": acted })
}
Request::Bearer => { Request::Bearer => {
let d = shared.lock(); let d = shared.lock();
let b = &d.device_state.bearer; let b = &d.device_state.bearer;