feat(daemon): continue the overlay's last session after hide and show

The daemon now remembers the session its overlay last reported and starts
the next overlay there. The remembered session is two-valued, home or a
named file, lives only in daemon memory, and is resolved when the child is
spawned, so a report read after a request was queued still applies.

The child owner records the start identity proved at readiness and reads
the child's final report inside retirement, before it releases that
identity, on natural exit, on stop and on forced reap. A report must be a
private regular file carrying exactly that generation, PID and start time;
anything else is logged and ignored, and the report is removed either way.
Startup removes reports an earlier daemon left without restoring anything
from them.

Launches carry optional variables: a capability marker, the daemon's home
session file, and the remembered session as the one to continue. The
command line is unchanged, so an older overlay still starts at home. A
--daemon-toggle --session-file request outranks the remembered session for
its activation; a request for home itself starts at home and forgets the
remembered session. The visible-target guard uses the session the running
overlay last reported. The broker allows the new variables and strips them
from helpers that are not wayscriber relaunches.
This commit is contained in:
devmobasa committed 2026-10-05 00:10:52 +02:00
1 parent ac35f7c31d
commit f4109dd5ba
19 files changed
+782 -70

No files matched your search

+1 -1
View File
@@ -1211,7 +1211,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov
- When a fresh launch restores ink onto the transparent overlay board, a toast such as "Restored 7 annotations from last session" offers **Clear** (undoable, like Clear Canvas), because that ink now sits over whatever is on screen. Daemon toggles, empty restores, and solid boards stay quiet.
- Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools.
- `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session.
- `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. The daemon's overlay keeps the session it last opened or saved as across hide and show, while the daemon runs.
- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts.
- The configurator Session tab manages recent named sessions recorded when named-session targets are opened or saved from the CLI, daemon, or overlay. It can rename catalog labels, reveal files, and forget metadata without touching files. Clear Tool State removes only the saved tool layer; Clear Saved Data removes session files. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active.
+4 -2
View File
@@ -2111,8 +2111,10 @@ Use the CLI helpers for quick maintenance:
- `wayscriber --clear-tool-state` removes only the saved tool defaults from the session snapshot, preserving saved boards and history.
- `wayscriber --active --session-file ~/Documents/lecture-04.wayscriber-session` opens and saves a named session file directly.
- `wayscriber --freeze --session-file ~/Documents/lecture-04.wayscriber-session` starts frozen mode with that same named session target.
- `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target.
- `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. If the overlay is already visible with a different target, hide it before switching.
- `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target, the daemon's home session.
- `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. It applies to that activation only. If the overlay is already visible with a different target, hide it before switching.
The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home.
- `wayscriber --session-info --session-file <path>`, `wayscriber --clear-session --session-file <path>`, and `wayscriber --clear-tool-state --session-file <path>` target only that named file.
Config values seed startup defaults. When `restore_tool_state = true`, the saved session tool state is applied after those defaults, so edits such as `[arrow] head_at_end = true` can appear ignored if the session snapshot still stores an older arrow setting. Run `wayscriber --clear-tool-state` (or add `--session-file <path>` for a named session) to make config defaults apply on the next startup without deleting saved boards. In a running overlay, Command Palette -> Reset Tool Defaults clears the saved layer for the active session and immediately applies config defaults to the current tools so the next autosave keeps those defaults.
+1
View File
@@ -8,6 +8,7 @@
- `binary_conflict.rs` warns when another Wayscriber binary exists besides the running daemon.
- `overlay/launch.rs` owns resolved launch options and activation tokens; `overlay/mod.rs` queues and consumes them, preserving token-only retention on existing early-error paths.
- `overlay/lifecycle.rs` owns overlay visibility, active target/flag, backoff, start and retirement; `overlay/lifecycle/stop.rs` owns graceful/forced shutdown. `protocol_v2::OverlayChildOwner` retains child identity, proof records and reaping.
- `protocol_v2/session_target.rs` owns the session reports overlay children write to `daemon-commands/overlay-targets/`, outside the strict v2 tree. `OverlayChildOwner` reads a child's final report before releasing its identity; the daemon keeps the remembered session in memory only and clears stale reports at startup.
- `tray/` owns tray integration and shortcut hint I/O.
- `setup.rs` and `global_shortcuts.rs` support daemon setup workflows.
- `update_watch.rs` owns the background update notice: it publishes to `TrayStatusShared`
+7
View File
@@ -88,6 +88,9 @@ pub struct Daemon {
pub(super) visibility_intents: Arc<VisibilityIntents>,
pub(super) initial_mode: Option<String>,
pub(super) initial_named_session_file: Option<PathBuf>,
/// The session the last overlay reported, which the next one continues;
/// `None` is home. It lives only as long as this daemon.
pub(super) remembered_session_file: Option<PathBuf>,
pub(super) instance_token: String,
pub(super) freeze_on_show: bool,
pub(super) tray_enabled: bool,
@@ -130,6 +133,7 @@ impl Daemon {
visibility_intents: Arc::new(VisibilityIntents::default()),
initial_mode,
initial_named_session_file,
remembered_session_file: None,
instance_token: crate::daemon::generate_daemon_instance_token(),
freeze_on_show: false,
tray_enabled,
@@ -223,6 +227,9 @@ impl Daemon {
err
);
}
if let Err(err) = super::protocol_v2::clear_stale_session_reports() {
warn!("Failed to clear stale overlay session reports on startup: {err:#}");
}
}
fn start_tray(
+29
View File
@@ -183,6 +183,35 @@ fn visible_overlay_rejects_different_named_session_request() {
);
}
#[test]
fn visible_overlay_is_in_the_session_it_switched_to() {
let switched = "/tmp/switched.wayscriber-session";
super::super::overlay::tests::with_reporting_overlay(switched, |daemon| {
let request = |session_file: &str| {
Some(DaemonToggleRequest {
session_file: Some(PathBuf::from(session_file)),
..Default::default()
})
};
// Launched at home, the overlay has since switched away from it.
let err = daemon
.process_single_toggle(request("/tmp/home.wayscriber-session"), None, false)
.expect_err("the session the overlay left is no longer visible");
assert!(
format!("{err:#}")
.contains("cannot switch named session target while overlay is visible"),
"{err:#}"
);
assert_eq!(daemon.test_state(), OverlayState::Visible);
daemon
.process_single_toggle(request(switched), None, false)
.unwrap();
assert_eq!(daemon.test_state(), OverlayState::Hidden);
});
}
#[test]
fn visible_overlay_rejection_writes_daemon_toggle_error_response() {
let temp = crate::test_temp::tempdir().expect("tempdir");
+11 -5
View File
@@ -3,6 +3,7 @@ use super::super::control::{
write_daemon_toggle_command_error, write_daemon_toggle_command_success,
};
use super::super::overlay::overlay_start_backoff_reason;
use super::super::protocol_v2::ReportedSession;
use super::super::types::OverlayState;
use super::{DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW, Daemon};
use crate::tray_action::TrayAction;
@@ -22,11 +23,16 @@ impl Daemon {
if self.overlay.state() != OverlayState::Visible {
return Ok(());
}
if self
.overlay
.active_named_session_file()
.is_some_and(|active| named_session_paths_match(active, requested))
{
// The overlay may have switched session since it was launched.
let active = match self.overlay.reported_session() {
Some(ReportedSession::Home) => self.initial_named_session_file.clone(),
Some(ReportedSession::Named(path)) => Some(path),
None => self
.overlay
.active_named_session_file()
.map(Path::to_path_buf),
};
if active.is_some_and(|active| named_session_paths_match(&active, requested)) {
return Ok(());
}
+88 -8
View File
@@ -2,11 +2,17 @@ use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::daemon::control::DaemonToggleRequest;
use crate::env_vars::{DESKTOP_STARTUP_ID_ENV, NO_DETACH_ENV, XDG_ACTIVATION_TOKEN_ENV};
use crate::env_vars::{
DESKTOP_STARTUP_ID_ENV, NO_DETACH_ENV, OVERLAY_HOME_SESSION_ENV, OVERLAY_PREFERRED_SESSION_ENV,
OVERLAY_SESSION_REPORTS_ENV, XDG_ACTIVATION_TOKEN_ENV,
};
pub(in crate::daemon) struct OverlayLaunchRequest {
mode: Option<String>,
named_session_file: Option<PathBuf>,
/// The session file this request asked for, which outranks any other.
explicit_session_file: Option<PathBuf>,
/// The daemon's startup session file, its overlays' home.
home_session_file: Option<PathBuf>,
freeze: bool,
exit_after_capture: bool,
no_exit_after_capture: bool,
@@ -19,7 +25,7 @@ impl OverlayLaunchRequest {
request: Option<DaemonToggleRequest>,
activation_token: Option<String>,
mode: Option<&str>,
named_session_file: Option<&Path>,
home_session_file: Option<&Path>,
freeze: bool,
) -> Self {
let request = request.unwrap_or_default();
@@ -27,9 +33,8 @@ impl OverlayLaunchRequest {
Self {
mode: request.mode.or_else(|| mode.map(str::to_owned)),
named_session_file: request
.session_file
.or_else(|| named_session_file.map(Path::to_path_buf)),
explicit_session_file: request.session_file,
home_session_file: home_session_file.map(Path::to_path_buf),
freeze: request.freeze || freeze,
exit_after_capture: request.exit_after_capture,
no_exit_after_capture: request.no_exit_after_capture,
@@ -42,8 +47,16 @@ impl OverlayLaunchRequest {
self.mode.as_deref()
}
/// The session file the overlay is launched with: the requested one, else
/// home.
pub(super) fn named_session_file(&self) -> Option<&Path> {
self.named_session_file.as_deref()
self.explicit_session_file
.as_deref()
.or(self.home_session_file.as_deref())
}
pub(super) fn explicit_session_file(&self) -> Option<&Path> {
self.explicit_session_file.as_deref()
}
pub(super) fn activation_token(&self) -> Option<&str> {
@@ -65,10 +78,14 @@ pub(super) struct OverlayLaunch {
pub(super) environment: Vec<(OsString, Option<OsString>)>,
}
/// The launch for `request`. A child `generation` reports its session to the
/// daemon, which passes the session it remembers from the last report as the
/// one to continue, unless the request asked for a session file.
pub(super) fn build_overlay_launch(
request: &OverlayLaunchRequest,
resume_default: Option<bool>,
generation: Option<&str>,
remembered_session_file: Option<&Path>,
) -> OverlayLaunch {
let mut arguments = vec![OsString::from("--active")];
if request.freeze {
@@ -101,6 +118,23 @@ pub(super) fn build_overlay_launch(
.session_resume_override(resume_default)
.map(|enabled| if enabled { "on".into() } else { "off".into() }),
));
// Optional inputs, so an older overlay ignores them and starts as before.
environment.extend([
(
OVERLAY_SESSION_REPORTS_ENV.into(),
generation.map(|_| "1".into()),
),
(
OVERLAY_HOME_SESSION_ENV.into(),
request.home_session_file.as_deref().map(Into::into),
),
(
OVERLAY_PREFERRED_SESSION_ENV.into(),
remembered_session_file
.filter(|_| request.explicit_session_file.is_none())
.map(Into::into),
),
]);
if let Some(mode) = request.mode() {
arguments.push("--mode".into());
@@ -146,7 +180,7 @@ mod tests {
false,
);
let launch = build_overlay_launch(&request, default, generation);
let launch = build_overlay_launch(&request, default, generation, None);
let mut expected = vec![(NO_DETACH_ENV.into(), Some("1".into()))];
if let Some(generation) = generation {
@@ -162,10 +196,56 @@ mod tests {
crate::RESUME_SESSION_ENV.into(),
expected_resume.map(OsString::from),
),
(
OVERLAY_SESSION_REPORTS_ENV.into(),
generation.map(|_| "1".into()),
),
(OVERLAY_HOME_SESSION_ENV.into(), None),
(OVERLAY_PREFERRED_SESSION_ENV.into(), None),
]);
assert_eq!(launch.environment, expected);
}
}
}
}
#[test]
fn launch_offers_the_remembered_session_only_without_a_requested_file() {
let home = "/sessions/home.wayscriber-session";
let remembered = Path::new("/sessions/b.wayscriber-session");
let requested = "/sessions/c.wayscriber-session";
for (session_file, preferred) in [(None, Some(remembered)), (Some(requested), None)] {
let request = OverlayLaunchRequest::new(
session_file.map(|file| DaemonToggleRequest {
session_file: Some(file.into()),
..Default::default()
}),
None,
None,
Some(Path::new(home)),
false,
);
let launch = build_overlay_launch(&request, None, Some("generation"), Some(remembered));
let value = |name: &str| {
launch
.environment
.iter()
.find(|(key, _)| key == name)
.and_then(|(_, value)| value.clone())
};
assert_eq!(value(OVERLAY_SESSION_REPORTS_ENV), Some("1".into()));
assert_eq!(value(OVERLAY_HOME_SESSION_ENV), Some(home.into()));
assert_eq!(
value(OVERLAY_PREFERRED_SESSION_ENV),
preferred.map(Into::into)
);
// The command line stays one an older overlay understands.
assert_eq!(
launch.arguments,
["--active", "--session-file", session_file.unwrap_or(home)].map(OsString::from)
);
}
}
}
+8 -1
View File
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use log::{debug, info, warn};
use super::super::protocol_v2::OverlayChildOwner;
use super::super::protocol_v2::{OverlayChildOwner, ReportedSession};
use super::super::types::{BackendRunner, OverlaySpawnCandidate, OverlayState};
use super::launch::{OverlayLaunchRequest, build_overlay_launch};
@@ -66,11 +66,17 @@ impl OverlayLifecycle {
self.active_named_session_file.as_deref()
}
/// The session the running child last reported, if it reported one.
pub(in crate::daemon) fn reported_session(&self) -> Option<ReportedSession> {
self.child.reported_session()
}
pub(super) fn start(
&mut self,
request: &OverlayLaunchRequest,
candidate: &OverlaySpawnCandidate,
resume_override: &AtomicU8,
remembered_session_file: Option<&Path>,
daemon_token: &str,
) -> std::result::Result<u32, OverlayStartFailure> {
self.child
@@ -87,6 +93,7 @@ impl OverlayLifecycle {
request,
crate::decode_session_override(resume_override.load(Ordering::Acquire)),
self.child.generation(),
remembered_session_file,
);
let attempt = (|| -> Result<u32> {
+25 -16
View File
@@ -6,10 +6,14 @@ use std::thread;
use std::time::{Duration, Instant};
use super::OverlayLifecycle;
use crate::daemon::protocol_v2::{BootClock, open_overlay_pidfd, wait_for_pidfd_exit};
use crate::daemon::protocol_v2::{
BootClock, ReportedSession, open_overlay_pidfd, wait_for_pidfd_exit,
};
impl OverlayLifecycle {
fn terminate(&mut self) -> Result<()> {
/// Stops the child, returning the session it last reported.
fn terminate(&mut self) -> Result<Option<ReportedSession>> {
let mut session = None;
if let Some(pid) = self.child.display_pid() {
let stop_started = Instant::now();
let timeout = Duration::from_secs(2);
@@ -31,12 +35,13 @@ impl OverlayLifecycle {
let deadline = BootClock::now()?.checked_add(timeout)?;
loop {
match self.child.try_wait() {
Ok(Some(status)) => {
Ok(Some(exit)) => {
info!(
"Overlay process exited with status {:?} after {:?}",
status,
exit.status,
stop_started.elapsed()
);
session = exit.session;
break;
}
Ok(None) => {
@@ -45,15 +50,16 @@ impl OverlayLifecycle {
"Overlay process did not exit after {:?}, sending SIGKILL",
stop_started.elapsed()
);
let status = self
let exit = self
.child
.force_kill_and_wait()
.context("lost broker ownership while forcing overlay shutdown")?;
warn!(
"Overlay process killed with status {:?} after {:?}",
status,
exit.status,
stop_started.elapsed()
);
session = exit.session;
break;
}
// Without a pidfd (the child raced us to exit, or the
@@ -86,24 +92,27 @@ impl OverlayLifecycle {
self.active.store(false, Ordering::Release);
self.active_named_session_file = None;
Ok(())
Ok(session)
}
pub(in crate::daemon::overlay) fn hide(&mut self) -> Result<()> {
self.terminate()?;
/// Stops the overlay, returning the session its child last reported.
pub(in crate::daemon::overlay) fn hide(&mut self) -> Result<Option<ReportedSession>> {
let session = self.terminate()?;
self.mark_hidden();
Ok(())
Ok(session)
}
pub(in crate::daemon::overlay) fn poll_exit(&mut self) -> Result<()> {
/// Retires a child that exited on its own, returning the session it last
/// reported.
pub(in crate::daemon::overlay) fn poll_exit(&mut self) -> Result<Option<ReportedSession>> {
match self.child.try_wait() {
Ok(Some(status)) => {
info!("Overlay process exited with status {:?}", status);
Ok(Some(exit)) => {
info!("Overlay process exited with status {:?}", exit.status);
self.mark_hidden();
Ok(exit.session)
}
Ok(None) => {}
Err(err) => return Err(err).context("lost broker ownership of overlay child"),
Ok(None) => Ok(None),
Err(err) => Err(err).context("lost broker ownership of overlay child"),
}
Ok(())
}
}
+28 -1
View File
@@ -6,6 +6,8 @@ use log::{debug, info};
use super::core::Daemon;
use super::types::{OverlaySpawnCandidate, OverlayState};
use crate::daemon::control::DaemonToggleRequest;
use crate::daemon::protocol_v2::ReportedSession;
use crate::session::catalog::session_paths_match;
pub(super) mod launch;
pub(super) mod lifecycle;
@@ -101,6 +103,7 @@ impl Daemon {
match self.spawn_overlay_process(&request, candidates) {
Ok(()) => {
self.clear_overlay_spawn_error();
self.forget_remembered_session_if_started_at_home(&request);
Ok(ShowOutcome::Shown)
}
Err(failure) => {
@@ -187,8 +190,32 @@ impl Daemon {
return Ok(());
}
self.overlay.hide()?;
let session = self.overlay.hide()?;
self.remember_reported_session(session);
self.discard_pending_launch_options();
Ok(())
}
/// Remembers the session a retired overlay last reported, for the next
/// show to continue. Without a report the daemon keeps what it knew.
pub(super) fn remember_reported_session(&mut self, session: Option<ReportedSession>) {
match session {
None => {}
Some(ReportedSession::Home) => self.remembered_session_file = None,
Some(ReportedSession::Named(path)) => self.remembered_session_file = Some(path),
}
}
/// A request for home started an overlay at home. The overlay has no
/// session change to report, so the daemon forgets its remembered session
/// itself rather than return to it on the next show.
fn forget_remembered_session_if_started_at_home(&mut self, request: &OverlayLaunchRequest) {
if let (Some(requested), Some(home)) = (
request.explicit_session_file(),
self.initial_named_session_file.as_deref(),
) && session_paths_match(requested, home)
{
self.remembered_session_file = None;
}
}
}
+3 -1
View File
@@ -8,6 +8,8 @@ impl Daemon {
return Ok(());
}
self.overlay.poll_exit()
let session = self.overlay.poll_exit()?;
self.remember_reported_session(session);
Ok(())
}
}
+2 -1
View File
@@ -108,6 +108,7 @@ impl Daemon {
request,
candidate,
&self.session_resume_override,
self.remembered_session_file.as_deref(),
&self.instance_token,
) {
Ok(pid) => {
@@ -175,7 +176,7 @@ mod tests {
fn build_test_launch(daemon: &mut Daemon) -> OverlayLaunch {
let request = daemon.take_pending_launch();
build_overlay_launch(&request, daemon.session_resume_override(), None)
build_overlay_launch(&request, daemon.session_resume_override(), None, None)
}
fn launch_args(launch: &OverlayLaunch) -> Vec<String> {
+158 -1
View File
@@ -33,6 +33,19 @@ pub(in crate::daemon) fn with_visible_overlay(
});
}
/// Like [`with_visible_overlay`], for an overlay that reports `session`, a
/// path or `"home"`, once shown.
pub(in crate::daemon) fn with_reporting_overlay(session: &str, body: impl FnOnce(&mut Daemon)) {
with_fixture(false, |daemon, _, root| {
instruct(root, Some(session), false);
show(daemon, root);
body(daemon);
daemon.hide_overlay().unwrap();
});
}
pub(in crate::daemon) fn assert_token_only_pending_launch(daemon: &Daemon, token: &str) {
let retained = daemon.pending_launch.as_ref().expect("token must be kept");
@@ -109,6 +122,150 @@ fn assert_retired(daemon: &Daemon, generation: &str) {
for suffix in ["active", "enabled", "ready", "signals"] {
assert!(!proofs.join(format!("{generation}.{suffix}")).exists());
}
assert!(!session_report(generation).exists());
}
fn session_report(generation: &str) -> PathBuf {
crate::paths::daemon_command_dir()
.join("overlay-targets")
.join(format!("{generation}.target"))
}
/// Has the next fake overlay report `report`, `"home"` or a path, and exit
/// afterwards when `exit` is set.
fn instruct(root: &Path, report: Option<&str>, exit: bool) {
fs::write(
root.join(fake_overlay::SESSION_INSTRUCTION),
serde_json::json!({ "report": report, "exit": exit }).to_string(),
)
.unwrap();
}
/// Shows the overlay and returns its launch receipt, removed so the next
/// show's can be told apart.
fn show(daemon: &mut Daemon, root: &Path) -> serde_json::Value {
daemon.show_overlay().unwrap().require_shown().unwrap();
let receipt = receipt(root);
fs::remove_file(root.join(format!(
"{}.receipt",
receipt["generation"].as_str().unwrap()
)))
.unwrap();
receipt
}
fn wait_until_retired(daemon: &mut Daemon) {
let deadline = Instant::now() + Duration::from_secs(3);
while daemon.overlay.state() == OverlayState::Visible {
daemon.update_overlay_process_state().unwrap();
assert!(Instant::now() < deadline, "exited child was not retired");
std::thread::sleep(Duration::from_millis(5));
}
}
const HOME: &str = "/tmp/home.wayscriber-session";
const REMEMBERED: &str = "/tmp/remembered.wayscriber-session";
#[test]
fn the_next_show_continues_the_session_the_overlay_reported() {
with_fixture(false, |daemon, _, root| {
instruct(root, Some(REMEMBERED), false);
let first = show(daemon, root);
assert_eq!(first["reports"], "1");
assert_eq!(first["home"], HOME);
assert!(first["preferred"].is_null());
let generation = first["generation"].as_str().unwrap();
assert!(session_report(generation).exists());
daemon.hide_overlay().unwrap();
assert_retired(daemon, generation);
assert_eq!(
daemon.remembered_session_file.as_deref(),
Some(Path::new(REMEMBERED))
);
// An older overlay still starts at home; a current one continues.
instruct(root, Some("home"), false);
let second = show(daemon, root);
assert_eq!(
second["args"],
serde_json::json!(["--active", "--mode", "transparent", "--session-file", HOME])
);
assert_eq!(second["home"], HOME);
assert_eq!(second["preferred"], REMEMBERED);
// Back home, the overlay exits on its own and the daemon forgets.
daemon.overlay.signal(libc::SIGTERM).unwrap();
wait_until_retired(daemon);
assert_retired(daemon, second["generation"].as_str().unwrap());
assert_eq!(daemon.remembered_session_file, None);
instruct(root, None, false);
let third = show(daemon, root);
assert!(third["preferred"].is_null());
daemon.hide_overlay().unwrap();
});
}
#[test]
fn an_overlay_that_reports_and_exits_at_once_is_still_heard() {
with_fixture(false, |daemon, _, root| {
instruct(root, Some(REMEMBERED), true);
let receipt = show(daemon, root);
wait_until_retired(daemon);
assert_retired(daemon, receipt["generation"].as_str().unwrap());
assert_eq!(
daemon.remembered_session_file.as_deref(),
Some(Path::new(REMEMBERED))
);
});
}
#[test]
fn a_forced_stop_still_reads_the_last_report() {
with_fixture(true, |daemon, _, root| {
instruct(root, Some(REMEMBERED), false);
let receipt = show(daemon, root);
daemon.hide_overlay().unwrap();
assert_retired(daemon, receipt["generation"].as_str().unwrap());
assert_eq!(
daemon.remembered_session_file.as_deref(),
Some(Path::new(REMEMBERED))
);
});
}
#[test]
fn a_requested_session_file_outranks_the_remembered_one_for_that_show() {
with_fixture(false, |daemon, _, root| {
daemon.remembered_session_file = Some(PathBuf::from(REMEMBERED));
let requested = "/tmp/requested.wayscriber-session";
for (session_file, remembered_after) in [(requested, Some(REMEMBERED)), (HOME, None)] {
daemon.queue_overlay_launch(
Some(DaemonToggleRequest {
session_file: Some(PathBuf::from(session_file)),
..Default::default()
}),
None,
);
let receipt = show(daemon, root);
assert_eq!(receipt["args"][4], session_file);
assert!(receipt["preferred"].is_null(), "{session_file}");
// A request for home returns home: nothing remains to continue.
assert_eq!(
daemon.remembered_session_file.as_deref(),
remembered_after.map(Path::new),
"{session_file}"
);
daemon.hide_overlay().unwrap();
}
});
}
#[test]
@@ -311,7 +468,7 @@ fn preparation_error_keeps_only_the_token_and_does_not_replay_options() {
assert!(daemon.start_launch(request, &[candidate]).is_err());
let retained = daemon.take_pending_launch();
let actual = launch::build_overlay_launch(&retained, Some(true), None);
let actual = launch::build_overlay_launch(&retained, Some(true), None, None);
assert_eq!(
actual.arguments,
+48 -8
View File
@@ -3,10 +3,11 @@
//! Spawn candidate discovery tries `current_exe()` first, which under
//! `cargo test` is this test binary. A fixture marks the environment of the
//! broker it starts; when the daemon then launches this binary as an overlay
//! child, the constructor below runs the production child handshake and
//! records how it was launched, before libtest would parse the overlay
//! arguments. Without both the fixture marker and an overlay generation, the
//! constructor returns and the binary runs its tests as usual.
//! child, the constructor below runs the production child handshake, reports a
//! session when the test asks for one, and records how it was launched, before
//! libtest would parse the overlay arguments. Without both the fixture marker
//! and an overlay generation, the constructor returns and the binary runs its
//! tests as usual.
use std::convert::Infallible;
use std::ffi::OsStr;
@@ -17,7 +18,8 @@ use std::time::Duration;
use anyhow::{Context, Result};
use crate::daemon::protocol_v2::{
ActiveGeneration, active_generation_from_environment, publish_ready_from_environment,
ActiveGeneration, ReportedSession, active_generation_from_environment,
publish_ready_from_environment, publish_session_from_environment,
publish_signal_ready_from_environment,
};
@@ -35,6 +37,10 @@ pub(super) const EXITS_BEFORE_READY: &str = "wayscriber-exits-before-ready";
/// Written to the fixture's runtime directory as that child starts, so a test
/// can tell a child that ran and exited from one that never started.
pub(super) const STARTED_THEN_EXITED: &str = "started-then-exited";
/// A test writes this JSON object to the fixture's runtime directory before a
/// show to direct the next fake overlay: `report` is the session it reports
/// once enabled, `"home"` or a path, and `exit` makes it exit after that.
pub(super) const SESSION_INSTRUCTION: &str = "fake-overlay-session.json";
const EXIT_BEFORE_READY_STATUS: i32 = 7;
/// The fake overlay could not serve; the test that launched it then fails.
const FAILURE_STATUS: i32 = 1;
@@ -89,13 +95,40 @@ fn serve(ignore_term: bool) -> Result<Infallible> {
std::thread::sleep(Duration::from_millis(2));
}
// Reported before the receipt, so a test that has the receipt can rely on
// the report.
let exit = report_session()?;
write_receipt()?;
if exit {
std::process::exit(0);
}
loop {
std::thread::sleep(Duration::from_secs(60));
}
}
/// Follows the test's [`SESSION_INSTRUCTION`], returning whether to exit.
fn report_session() -> Result<bool> {
let instruction = match std::fs::read(runtime_root()?.join(SESSION_INSTRUCTION)) {
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error).context("failed to read the session instruction"),
};
if let Some(report) = instruction["report"].as_str() {
let session = match report {
"home" => ReportedSession::Home,
path => ReportedSession::Named(path.into()),
};
anyhow::ensure!(
publish_session_from_environment(&session)?,
"the daemon did not ask for session reports"
);
}
Ok(instruction["exit"].as_bool().unwrap_or(false))
}
fn write_receipt() -> Result<()> {
// As launched: GTK may already have unset the startup-notification variables.
let launched_with = |name: &str| {
@@ -107,6 +140,9 @@ fn write_receipt() -> Result<()> {
"startup": launched_with(crate::env_vars::DESKTOP_STARTUP_ID_ENV),
"resume": launched_with(crate::RESUME_SESSION_ENV),
"detach": launched_with(crate::env_vars::NO_DETACH_ENV),
"reports": launched_with(crate::env_vars::OVERLAY_SESSION_REPORTS_ENV),
"home": launched_with(crate::env_vars::OVERLAY_HOME_SESSION_ENV),
"preferred": launched_with(crate::env_vars::OVERLAY_PREFERRED_SESSION_ENV),
"pid": std::process::id(),
"generation": std::env::var(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV)?,
});
@@ -117,11 +153,9 @@ fn write_receipt() -> Result<()> {
/// Writes `<generation>.<kind>` into the fixture's runtime directory.
fn record(kind: &str, contents: &[u8]) -> Result<()> {
let generation = std::env::var(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV)?;
let root = std::env::var_os(crate::env_vars::XDG_RUNTIME_DIR_ENV)
.context("fake overlay needs the fixture's runtime directory")?;
crate::durable_io::write_atomic(
&Path::new(&root).join(format!("{generation}.{kind}")),
&runtime_root()?.join(format!("{generation}.{kind}")),
contents,
crate::durable_io::AtomicWriteOptions::private_runtime_file(),
)?;
@@ -129,6 +163,12 @@ fn record(kind: &str, contents: &[u8]) -> Result<()> {
Ok(())
}
fn runtime_root() -> Result<std::path::PathBuf> {
std::env::var_os(crate::env_vars::XDG_RUNTIME_DIR_ENV)
.map(Into::into)
.context("fake overlay needs the fixture's runtime directory")
}
/// Whether this process was started as `name`: the broker passes the program
/// path it was given as `argv[0]`, which for a link is the link's own path.
fn launched_as(name: &str) -> bool {
+57 -19
View File
@@ -7,6 +7,9 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use super::session_target::{
ReportedSession, discard_session_report, read_session_report, take_final_session_report,
};
use super::wire::fresh_id;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -25,6 +28,17 @@ struct OwnedOverlayChild {
display_pid: u32,
pidfd: OwnedFd,
child: crate::process_broker::BrokerChild,
/// Captured when its readiness proved its identity, so its reports can be
/// checked after it exits.
process_start_ticks: Option<u64>,
}
/// How an owned overlay child ended.
#[derive(Debug)]
pub(crate) struct OverlayExit {
pub(crate) status: i32,
/// The session it last reported, read before its identity was released.
pub(crate) session: Option<ReportedSession>,
}
#[derive(Debug)]
@@ -127,6 +141,7 @@ impl OverlayChildOwner {
display_pid,
pidfd,
child,
process_start_ticks: None,
});
self.phase = OverlayChildPhase::Starting;
Ok(())
@@ -172,6 +187,9 @@ impl OverlayChildOwner {
{
bail!("overlay readiness identity mismatch");
}
if let Some(owned) = self.owned.as_mut() {
owned.process_start_ticks = Some(record.process_start_ticks);
}
let signals_bytes = match super::linux::read_bounded_regular_file(
&signals_path(&generation),
1024,
@@ -243,6 +261,7 @@ impl OverlayChildOwner {
pub(crate) fn abort_reservation(&mut self) {
if let Some(generation) = self.generation().map(str::to_owned) {
clear_generation_records(&generation);
discard_session_report(&generation);
}
if self.display_pid().is_some_and(|pid| pid != 0) {
let _ = self.force_kill_and_wait();
@@ -265,26 +284,29 @@ impl OverlayChildOwner {
owned.child.signal(signal)
}
pub(crate) fn try_wait(&mut self) -> Result<Option<i32>> {
/// The session the running child last reported. A report that cannot be
/// trusted counts as none.
pub(crate) fn reported_session(&self) -> Option<ReportedSession> {
let owned = self.owned.as_ref()?;
let generation = self.generation()?;
let process_start_ticks = owned.process_start_ticks?;
read_session_report(generation, owned.display_pid, process_start_ticks).unwrap_or_else(
|error| {
log::warn!("Ignoring the session report of overlay child {generation}: {error:#}");
None
},
)
}
pub(crate) fn try_wait(&mut self) -> Result<Option<OverlayExit>> {
let Some(owned) = self.owned.as_mut() else {
return Ok(None);
};
match owned
let status = owned
.child
.try_wait()
.context("failed to query overlay child")?
{
Some(status) => {
if let Some(generation) = self.generation().map(str::to_owned) {
clear_generation_records(&generation);
}
self.owned = None;
self.generation = None;
self.phase = OverlayChildPhase::Stopped;
Ok(Some(status))
}
None => Ok(None),
}
.context("failed to query overlay child")?;
Ok(status.map(|status| self.retire(status)))
}
pub(crate) fn begin_stop(&mut self) -> Result<()> {
@@ -297,7 +319,7 @@ impl OverlayChildOwner {
self.signal(libc::SIGTERM)
}
pub(crate) fn force_kill_and_wait(&mut self) -> Result<i32> {
pub(crate) fn force_kill_and_wait(&mut self) -> Result<OverlayExit> {
let owned = self
.owned
.as_mut()
@@ -306,13 +328,29 @@ impl OverlayChildOwner {
.child
.kill_wait()
.context("broker failed to kill and reap overlay child")?;
if let Some(generation) = self.generation().map(str::to_owned) {
Ok(self.retire(status))
}
/// Ends ownership of the reaped child: reads its final session report while
/// its identity is still held, then clears its proofs and releases it.
fn retire(&mut self, status: i32) -> OverlayExit {
let session = match (self.generation(), self.owned.as_ref()) {
(Some(generation), Some(owned)) => match owned.process_start_ticks {
Some(ticks) => take_final_session_report(generation, owned.display_pid, ticks),
None => {
discard_session_report(generation);
None
}
},
_ => None,
};
if let Some(generation) = self.generation.take() {
clear_generation_records(&generation);
}
self.owned = None;
self.generation = None;
self.phase = OverlayChildPhase::Stopped;
Ok(status)
OverlayExit { status, session }
}
}
+3 -1
View File
@@ -25,7 +25,9 @@ pub(crate) use linux::{
};
pub(crate) use mode::DaemonControlProtocolMode;
pub(crate) use runtime::{ClassifiedRuntimeRecord, read_runtime_record, write_runtime_record_v2};
pub(crate) use session_target::{ReportedSession, publish_session_from_environment};
pub(crate) use session_target::{
ReportedSession, clear_stale_session_reports, publish_session_from_environment,
};
pub(crate) use wire::EffectKind;
pub(crate) use wire::{DaemonRequestV2, DaemonRuntimeRecordV2};
+159 -4
View File
@@ -107,7 +107,7 @@ fn report_path_text(path: &Path) -> Result<String> {
}
fn create_report_dir() -> Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::os::unix::fs::PermissionsExt;
let directory = report_dir();
match std::fs::create_dir(&directory) {
@@ -118,14 +118,169 @@ fn create_report_dir() -> Result<()> {
}
}
let metadata = std::fs::symlink_metadata(&directory)?;
// SAFETY: geteuid has no preconditions and cannot fail.
let owner = unsafe { libc::geteuid() };
if !metadata.is_dir() || metadata.uid() != owner {
if !metadata.is_dir() || !owned_by_this_user(&metadata) {
bail!("{} is not a private report directory", directory.display());
}
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?;
Ok(())
}
/// The session the child `generation` last reported, if it reported one under
/// exactly this identity. The identity is the one the daemon owns, captured
/// while the child ran: an exited child no longer has a `/proc` entry to ask.
pub(crate) fn read_session_report(
generation: &str,
pid: u32,
process_start_ticks: u64,
) -> Result<Option<ReportedSession>> {
super::wire::validate_id(generation)?;
match std::fs::symlink_metadata(report_dir()) {
Ok(metadata) if metadata.is_dir() && is_private(&metadata) => {}
Ok(_) => bail!("the session report directory is not private"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).context("failed to inspect the session reports"),
}
let bytes = match read_private_file(&report_path(generation)) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).context("failed to read the session report"),
};
let record: SessionTargetRecord = super::wire::parse_canonical_json(&bytes, MAX_REPORT_BYTES)?;
if record.schema != REPORT_SCHEMA
|| record.generation != generation
|| record.pid != pid
|| record.process_start_ticks != process_start_ticks
{
bail!("the session report belongs to another overlay child");
}
match record.target.map(PathBuf::from) {
None => Ok(Some(ReportedSession::Home)),
Some(path) if path.is_absolute() => Ok(Some(ReportedSession::Named(path))),
Some(path) => bail!("reported session {} is not absolute", path.display()),
}
}
/// Reads the final report of the exited child `generation`, then removes it
/// along with any temporary its writer left. A report that cannot be trusted
/// is logged and ignored, so it never stands in the way of retiring the child.
pub(crate) fn take_final_session_report(
generation: &str,
pid: u32,
process_start_ticks: u64,
) -> Option<ReportedSession> {
let session =
read_session_report(generation, pid, process_start_ticks).unwrap_or_else(|error| {
log::warn!("Ignoring the session report of overlay child {generation}: {error:#}");
None
});
discard_session_report(generation);
session
}
/// Removes the report of child `generation` and any temporary its writer left.
pub(crate) fn discard_session_report(generation: &str) {
let report = format!("{generation}.target");
// The report itself goes by name, however full the directory is.
let removed = match std::fs::remove_file(report_dir().join(&report)) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
Err(anyhow::Error::new(error).context("failed to remove the report"))
}
_ => remove_reports(|target| target == report),
};
if let Err(error) = removed {
log::warn!("Failed to remove the session report of overlay child {generation}: {error:#}");
}
}
/// Removes every report an earlier daemon left behind, restoring nothing from
/// them: the session a daemon remembered ends with that daemon.
pub(crate) fn clear_stale_session_reports() -> Result<()> {
remove_reports(|target| {
target
.strip_suffix(".target")
.is_some_and(|generation| super::wire::validate_id(generation).is_ok())
})
}
/// Bounds how many entries one cleanup looks at, so a flooded directory costs
/// a fixed amount of work.
const MAX_CLEANUP_ENTRIES: usize = 256;
/// Removes each report, and each temporary left by a report's writer, whose
/// report name satisfies `is_removed`. Anything else in the directory is not
/// this daemon's to remove.
fn remove_reports(is_removed: impl Fn(&str) -> bool) -> Result<()> {
let directory = report_dir();
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error).with_context(|| format!("failed to list {}", directory.display()));
}
};
for entry in entries.take(MAX_CLEANUP_ENTRIES) {
let entry = entry?;
let Ok(name) = entry.file_name().into_string() else {
continue;
};
let report = crate::durable_io::temp_file_target(&name).unwrap_or(&name);
if !is_removed(report) {
continue;
}
match std::fs::remove_file(entry.path()) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to remove {}", entry.path().display()));
}
}
}
Ok(())
}
/// Reads a regular file this user owns and only this user can read or write,
/// without following a symlink.
fn read_private_file(path: &Path) -> std::io::Result<Vec<u8>> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || !is_private(&metadata) || metadata.len() > MAX_REPORT_BYTES as u64 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"session report is not a private regular file within its size bound",
));
}
let mut bytes = Vec::new();
file.take(MAX_REPORT_BYTES as u64 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_REPORT_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"session report exceeds its size bound",
));
}
Ok(bytes)
}
fn is_private(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
owned_by_this_user(metadata) && metadata.mode() & 0o077 == 0
}
fn owned_by_this_user(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
// SAFETY: geteuid has no preconditions and cannot fail.
metadata.uid() == unsafe { libc::geteuid() }
}
#[cfg(test)]
mod tests;
@@ -72,3 +72,146 @@ fn an_overlay_without_its_child_identity_cannot_report() {
assert!(!report_path(generation).exists());
});
}
fn current_identity() -> (u32, u64) {
(
std::process::id(),
super::super::linux::current_process_start_ticks().unwrap(),
)
}
/// Writes `bytes` as `name` in the report directory, private like the writer
/// leaves it unless `mode` says otherwise.
fn write_entry(name: &str, bytes: &[u8], mode: u32) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
// An overlay's identity proof creates this before any report is written.
std::fs::create_dir_all(crate::paths::daemon_command_dir()).unwrap();
create_report_dir().unwrap();
let path = report_dir().join(name);
std::fs::write(&path, bytes).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
}
fn record_bytes(generation: &str, pid: u32, ticks: u64, target: Option<&str>) -> Vec<u8> {
super::super::wire::canonical_json(
&SessionTargetRecord {
schema: REPORT_SCHEMA,
generation: generation.to_owned(),
pid,
process_start_ticks: ticks,
target: target.map(str::to_owned),
},
MAX_REPORT_BYTES,
)
.unwrap()
}
#[test]
fn the_daemon_reads_a_report_only_under_the_identity_it_owns() {
as_daemon_overlay(Some("1"), |generation| {
super::super::publish_ready_from_environment().unwrap();
let session = ReportedSession::Named(PathBuf::from("/sessions/b.wayscriber-session"));
publish_session_from_environment(&session).unwrap();
let (pid, ticks) = current_identity();
assert_eq!(
read_session_report(generation, pid, ticks).unwrap(),
Some(session)
);
assert!(read_session_report(generation, pid + 1, ticks).is_err());
assert!(read_session_report(generation, pid, ticks + 1).is_err());
let other = super::super::ProtocolId::generate().unwrap().to_string();
assert_eq!(read_session_report(&other, pid, ticks).unwrap(), None);
});
}
#[test]
fn an_untrusted_report_is_ignored_and_still_removed() {
as_daemon_overlay(Some("1"), |generation| {
let (pid, ticks) = current_identity();
let name = format!("{generation}.target");
let other = super::super::ProtocolId::generate().unwrap().to_string();
let foreign = record_bytes(&other, pid, ticks, None);
let relative = record_bytes(generation, pid, ticks, Some("b.wayscriber-session"));
let mut newer_schema =
String::from_utf8(record_bytes(generation, pid, ticks, None)).unwrap();
newer_schema = newer_schema.replace(&format!("\"schema\":{REPORT_SCHEMA}"), "\"schema\":2");
let oversize = vec![b' '; MAX_REPORT_BYTES + 1];
let valid = record_bytes(generation, pid, ticks, None);
for (case, bytes, mode) in [
("malformed", b"{".as_slice(), 0o600),
("another generation's record", &foreign, 0o600),
("relative target", &relative, 0o600),
("newer schema", newer_schema.as_bytes(), 0o600),
("oversize", &oversize, 0o600),
("readable by others", &valid, 0o644),
] {
let path = write_entry(&name, bytes, mode);
assert_eq!(
take_final_session_report(generation, pid, ticks),
None,
"{case}"
);
assert!(!path.exists(), "{case}");
}
let target = write_entry("elsewhere", &valid, 0o600);
std::os::unix::fs::symlink(&target, report_path(generation)).unwrap();
assert_eq!(take_final_session_report(generation, pid, ticks), None);
assert!(std::fs::symlink_metadata(report_path(generation)).is_err());
assert!(target.exists(), "only the report's own name is removed");
});
}
#[test]
fn retirement_removes_a_childs_report_and_its_writer_temporaries() {
as_daemon_overlay(Some("1"), |generation| {
let (pid, ticks) = current_identity();
let report = write_entry(
&format!("{generation}.target"),
&record_bytes(
generation,
pid,
ticks,
Some("/sessions/b.wayscriber-session"),
),
0o600,
);
let temporary = write_entry(&format!(".{generation}.target.1.2.3.tmp"), b"{", 0o600);
let other = super::super::ProtocolId::generate().unwrap().to_string();
let other_report = write_entry(&format!("{other}.target"), b"{", 0o600);
assert_eq!(
take_final_session_report(generation, pid, ticks),
Some(ReportedSession::Named(PathBuf::from(
"/sessions/b.wayscriber-session"
)))
);
assert!(!report.exists());
assert!(!temporary.exists());
assert!(other_report.exists());
});
}
#[test]
fn startup_removes_every_stale_report_and_nothing_else() {
as_daemon_overlay(None, |generation| {
let stale = [
write_entry(&format!("{generation}.target"), b"{", 0o600),
write_entry(&format!(".{generation}.target.1.2.3.tmp"), b"{", 0o600),
];
let unrelated = [
write_entry("notes.txt", b"kept", 0o600),
write_entry("not-an-id.target", b"kept", 0o600),
];
clear_stale_session_reports().unwrap();
assert!(stale.iter().all(|path| !path.exists()));
assert!(unrelated.iter().all(|path| path.exists()));
});
}
+7 -1
View File
@@ -98,6 +98,9 @@ pub(super) fn validate(
| "DESKTOP_STARTUP_ID"
| "WAYSCRIBER_RESUME_SESSION"
| "WAYSCRIBER_OVERLAY_CHILD_GENERATION"
| "WAYSCRIBER_OVERLAY_SESSION_REPORTS"
| "WAYSCRIBER_OVERLAY_HOME_SESSION"
| "WAYSCRIBER_OVERLAY_PREFERRED_SESSION"
) {
bail!("environment key {name:?} is not broker-allowed");
}
@@ -276,11 +279,14 @@ fn looks_like_uri_bytes(value: &[u8]) -> bool {
/// spawned, and from there xdg-open's browser, the configurator, tesseract
/// and curl. A wayscriber started anywhere in those trees then took itself
/// for a daemon child.
const INTERNAL_PROCESS_MARKERS: [&str; 4] = [
const INTERNAL_PROCESS_MARKERS: [&str; 7] = [
crate::env_vars::OVERLAY_CHILD_GENERATION_ENV,
crate::env_vars::DETACHED_ENV,
crate::RESUME_SESSION_ENV,
crate::env_vars::DAEMON_WATCHDOG_FD_ENV,
crate::env_vars::OVERLAY_SESSION_REPORTS_ENV,
crate::env_vars::OVERLAY_HOME_SESSION_ENV,
crate::env_vars::OVERLAY_PREFERRED_SESSION_ENV,
];
/// Whether `kind` relaunches wayscriber itself, the only helpers that keep