gardesk/gardisplay / f967e25

Browse files

add watchdog process for robust auto-revert and file logging

- Add watchdog module that spawns separate bash process to revert
- Watchdog survives main process crash by using xrandr commands
- Add file logging to ~/.cache/gardisplay/gardisplay.log
- Cancel watchdog when user confirms or manually reverts
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f967e25b7327b1c41a31208244a341edc1a871ad
Parents
8545cd9
Tree
05824e4

5 changed files

StatusFile+-
M .gitignore 2 1
M gardisplay/Cargo.toml 1 0
M gardisplay/src/app.rs 31 0
M gardisplay/src/main.rs 31 2
A gardisplay/src/watchdog.rs 163 0
.gitignoremodified
@@ -3,4 +3,5 @@ docs/
33
 .fackr/
44
 CLAUDE.md
55
 target/
6
-Cargo.lock
6
+Cargo.lock
7
+nohup.out
gardisplay/Cargo.tomlmodified
@@ -22,5 +22,6 @@ thiserror = "2.0"
2222
 anyhow = "1.0"
2323
 tracing = "0.1"
2424
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
25
+tracing-appender = "0.2"
2526
 clap = { version = "4.5", features = ["derive"] }
2627
 dirs = "6.0"
gardisplay/src/app.rsmodified
@@ -1,5 +1,7 @@
11
 //! Main application state and event loop.
22
 
3
+use std::process::Child;
4
+
35
 use anyhow::Result;
46
 use gartk_core::{Color, InputEvent, Key, Rect, Size, Theme};
57
 use gartk_render::{copy_surface_to_window, Renderer};
@@ -15,6 +17,7 @@ use crate::ui::{
1517
     Button, ConfirmOverlay, ConfirmResult, DisplayPanel, DisplayPanelResult, Dropdown,
1618
     DropdownAction, EventResult, MonitorView, TextInput,
1719
 };
20
+use crate::watchdog;
1821
 
1922
 /// Window dimensions.
2023
 const WINDOW_WIDTH: u32 = 800;
@@ -55,6 +58,9 @@ pub struct App {
5558
     // Confirmation state for display changes
5659
     confirm_overlay: Option<ConfirmOverlay>,
5760
     pre_change_config: Option<Vec<MonitorConfig>>,
61
+    // Watchdog process for auto-revert (independent of main process)
62
+    #[allow(dead_code)] // Child kept alive for watchdog process
63
+    watchdog_child: Option<Child>,
5864
 }
5965
 
6066
 impl App {
@@ -225,6 +231,7 @@ impl App {
225231
             save_as_input: None,
226232
             confirm_overlay: None,
227233
             pre_change_config: None,
234
+            watchdog_child: None,
228235
         })
229236
     }
230237
 
@@ -720,6 +727,22 @@ impl App {
720727
             return;
721728
         }
722729
 
730
+        // Start the watchdog process for auto-revert
731
+        // This is more robust than relying on the event loop, which may crash
732
+        // if the display change causes X connection issues
733
+        if let Some(ref pre_config) = self.pre_change_config {
734
+            match watchdog::start_watchdog(pre_config) {
735
+                Ok(child) => {
736
+                    tracing::info!("started watchdog process for auto-revert");
737
+                    self.watchdog_child = Some(child);
738
+                }
739
+                Err(e) => {
740
+                    tracing::error!("failed to start watchdog: {}", e);
741
+                    // Continue anyway - we still have the in-process timeout
742
+                }
743
+            }
744
+        }
745
+
723746
         // Show confirmation overlay
724747
         let size = self.window.size();
725748
         let window_rect = Rect::new(0, 0, size.width, size.height);
@@ -732,6 +755,10 @@ impl App {
732755
         self.confirm_overlay = None;
733756
         self.pre_change_config = None;
734757
 
758
+        // Cancel the watchdog by removing its config file
759
+        watchdog::cancel_watchdog();
760
+        self.watchdog_child = None;
761
+
735762
         // Update original_monitors to current state
736763
         let primary_name = self.monitor_view.primary_name().map(|s| s.to_string());
737764
         self.original_monitors = self
@@ -760,6 +787,10 @@ impl App {
760787
     fn revert_to_pre_change(&mut self) {
761788
         self.confirm_overlay = None;
762789
 
790
+        // Cancel the watchdog - we're reverting manually
791
+        watchdog::cancel_watchdog();
792
+        self.watchdog_child = None;
793
+
763794
         if let Some(ref configs) = self.pre_change_config.take() {
764795
             if let Some(ref randr) = self.randr {
765796
                 // IMPORTANT: Prepare screen size BEFORE reverting
gardisplay/src/main.rsmodified
@@ -4,6 +4,9 @@ mod app;
44
 mod config;
55
 mod randr;
66
 mod ui;
7
+mod watchdog;
8
+
9
+use std::fs;
710
 
811
 use clap::Parser;
912
 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@@ -21,18 +24,44 @@ struct Args {
2124
     demo: bool,
2225
 }
2326
 
27
+/// Get the log file path.
28
+fn log_file_path() -> std::path::PathBuf {
29
+    let cache_dir = dirs::cache_dir()
30
+        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
31
+        .join("gardisplay");
32
+
33
+    // Ensure directory exists
34
+    let _ = fs::create_dir_all(&cache_dir);
35
+
36
+    cache_dir.join("gardisplay.log")
37
+}
38
+
2439
 fn main() -> anyhow::Result<()> {
2540
     let args = Args::parse();
2641
 
42
+    // Set up logging to both console and file
43
+    let log_path = log_file_path();
44
+    let file = fs::OpenOptions::new()
45
+        .create(true)
46
+        .append(true)
47
+        .open(&log_path)?;
48
+
49
+    let file_layer = tracing_subscriber::fmt::layer()
50
+        .with_writer(file)
51
+        .with_ansi(false);
52
+
53
+    let console_layer = tracing_subscriber::fmt::layer();
54
+
2755
     tracing_subscriber::registry()
2856
         .with(
2957
             EnvFilter::try_from_default_env()
3058
                 .unwrap_or_else(|_| EnvFilter::new("info,gardisplay=debug")),
3159
         )
32
-        .with(tracing_subscriber::fmt::layer())
60
+        .with(console_layer)
61
+        .with(file_layer)
3362
         .init();
3463
 
35
-    tracing::info!("starting gardisplay");
64
+    tracing::info!("starting gardisplay (log file: {:?})", log_path);
3665
 
3766
     let config = config::load_config(args.config.as_deref())?;
3867
     let mut app = app::App::new(config, args.demo)?;
gardisplay/src/watchdog.rsadded
@@ -0,0 +1,163 @@
1
+//! Watchdog process for auto-reverting display changes.
2
+//!
3
+//! This module spawns a separate process that will revert display changes
4
+//! if not canceled within a timeout. This is more robust than relying on the
5
+//! main process's event loop, which may crash or lose its X connection when
6
+//! display settings change.
7
+
8
+use std::fs;
9
+use std::io::Write;
10
+use std::path::PathBuf;
11
+use std::process::{Child, Command, Stdio};
12
+
13
+use crate::config::MonitorConfig;
14
+
15
+/// Timeout in seconds for auto-revert.
16
+const REVERT_TIMEOUT_SECS: u32 = 15;
17
+
18
+/// Get the path to the revert config file.
19
+fn revert_config_path() -> PathBuf {
20
+    let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
21
+    PathBuf::from(runtime_dir).join("gardisplay-revert.json")
22
+}
23
+
24
+/// Start the watchdog process that will revert display settings after timeout.
25
+/// Returns the child process handle if successful.
26
+pub fn start_watchdog(configs: &[MonitorConfig]) -> std::io::Result<Child> {
27
+    let config_path = revert_config_path();
28
+
29
+    // Serialize the revert config to JSON
30
+    let json = serde_json::to_string(configs).map_err(|e| {
31
+        std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
32
+    })?;
33
+
34
+    // Write to temp file
35
+    let mut file = fs::File::create(&config_path)?;
36
+    file.write_all(json.as_bytes())?;
37
+    file.sync_all()?;
38
+
39
+    tracing::info!(
40
+        "watchdog: wrote revert config to {:?} ({} monitors)",
41
+        config_path,
42
+        configs.len()
43
+    );
44
+
45
+    // Get the DISPLAY environment variable
46
+    let display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());
47
+
48
+    // Spawn the watchdog script as a detached process
49
+    // We use bash to create a simple watchdog that:
50
+    // 1. Sleeps for the timeout
51
+    // 2. Checks if the config file still exists
52
+    // 3. If so, applies xrandr commands to revert
53
+    // 4. Deletes the config file
54
+    let script = format!(
55
+        r#"
56
+        sleep {timeout}
57
+        if [ -f "{config_path}" ]; then
58
+            echo "gardisplay watchdog: reverting display settings..."
59
+            {xrandr_commands}
60
+            rm -f "{config_path}"
61
+            echo "gardisplay watchdog: revert complete"
62
+        fi
63
+        "#,
64
+        timeout = REVERT_TIMEOUT_SECS,
65
+        config_path = config_path.display(),
66
+        xrandr_commands = generate_xrandr_commands(configs),
67
+    );
68
+
69
+    tracing::debug!("watchdog script:\n{}", script);
70
+
71
+    let child = Command::new("bash")
72
+        .arg("-c")
73
+        .arg(&script)
74
+        .env("DISPLAY", display)
75
+        .stdin(Stdio::null())
76
+        .stdout(Stdio::null())
77
+        .stderr(Stdio::null())
78
+        .spawn()?;
79
+
80
+    tracing::info!("watchdog: started with PID {}", child.id());
81
+    Ok(child)
82
+}
83
+
84
+/// Cancel the watchdog by deleting the config file.
85
+/// The watchdog process will check for this and exit without reverting.
86
+pub fn cancel_watchdog() {
87
+    let config_path = revert_config_path();
88
+    if config_path.exists() {
89
+        if let Err(e) = fs::remove_file(&config_path) {
90
+            tracing::warn!("watchdog: failed to remove config file: {}", e);
91
+        } else {
92
+            tracing::info!("watchdog: canceled (removed config file)");
93
+        }
94
+    }
95
+}
96
+
97
+/// Generate xrandr commands to restore the given configurations.
98
+fn generate_xrandr_commands(configs: &[MonitorConfig]) -> String {
99
+    let mut commands = Vec::new();
100
+
101
+    for config in configs {
102
+        if !config.enabled {
103
+            commands.push(format!("xrandr --output {} --off", config.name));
104
+            continue;
105
+        }
106
+
107
+        let rotation = match config.rotation {
108
+            90 => "left",
109
+            180 => "inverted",
110
+            270 => "right",
111
+            _ => "normal",
112
+        };
113
+
114
+        commands.push(format!(
115
+            "xrandr --output {} --mode {}x{} --pos {}x{} --rotate {}",
116
+            config.name,
117
+            config.width,
118
+            config.height,
119
+            config.x,
120
+            config.y,
121
+            rotation
122
+        ));
123
+    }
124
+
125
+    commands.join("\n            ")
126
+}
127
+
128
+#[cfg(test)]
129
+mod tests {
130
+    use super::*;
131
+
132
+    #[test]
133
+    fn test_generate_xrandr_commands() {
134
+        let configs = vec![
135
+            MonitorConfig {
136
+                name: "eDP-1".to_string(),
137
+                enabled: true,
138
+                x: 0,
139
+                y: 0,
140
+                width: 2880,
141
+                height: 1800,
142
+                refresh: 60.0,
143
+                scale: 1.0,
144
+                rotation: 0,
145
+            },
146
+            MonitorConfig {
147
+                name: "HDMI-1".to_string(),
148
+                enabled: true,
149
+                x: 2880,
150
+                y: 0,
151
+                width: 1920,
152
+                height: 1080,
153
+                refresh: 60.0,
154
+                scale: 1.0,
155
+                rotation: 90,
156
+            },
157
+        ];
158
+
159
+        let commands = generate_xrandr_commands(&configs);
160
+        assert!(commands.contains("xrandr --output eDP-1 --mode 2880x1800 --pos 0x0 --rotate normal"));
161
+        assert!(commands.contains("xrandr --output HDMI-1 --mode 1920x1080 --pos 2880x0 --rotate left"));
162
+    }
163
+}