Skip to content

Commit dfc7246

Browse files
MagicalTuxclaude
andcommitted
Fix Windows tray stuck on "Waiting"; make idle reason visible
The Windows GUI build has no console (windows_subsystem = "windows"), so every eprintln is discarded — a worker that dies or can't find work looks identical to a healthy idle one: a permanent "Status: Waiting" with no clue why. The likely trigger: the default workdir was CWD-relative ("decryptd-data"), and a tray app launched from Explorer/login often has a non-writable CWD (System32, Program Files), so create_dir_all failed and the worker thread died instantly while the (disk-free) GPU list still rendered. - GUI builds now default the workdir to a stable per-user data dir (%LOCALAPPDATA%\decryptd on Windows, ~/.local/share/decryptd on Linux), so launch CWD no longer matters. Console builds keep ./decryptd-data. Overridable via --workdir / DECRYPTD_WORKDIR. - Surface why the worker is idle in the tray: "no open work", "pull error: …", or "stopped: …" instead of a bare "Waiting". A fatal worker stop also pops a desktop notification (the only channel that survives the GUI subsystem). Tests for workdir resolution. Both feature sets build; verified the GUI build now writes ~/.local/share/decryptd from any CWD. Bump to 0.1.12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a5beef7 commit dfc7246

4 files changed

Lines changed: 113 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "decryptd"
3-
version = "0.1.11"
3+
version = "0.1.12"
44
edition = "2024"
55
license = "Proprietary"
66
authors = ["Karpeles Lab Inc"]

src/gui.rs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@ pub fn run_with_tray(args: RunArgs, status: Status) -> Result<()> {
3131

3232
let worker_status = status.clone();
3333
let worker = std::thread::spawn(move || {
34-
if let Err(e) = crate::run_worker(args, worker_status) {
35-
eprintln!("[decryptd] worker error: {e:#}");
34+
if let Err(e) = crate::run_worker(args, worker_status.clone()) {
35+
let msg = format!("{e:#}");
36+
eprintln!("[decryptd] worker error: {msg}");
37+
// The GUI subsystem swallows stderr, so make a fatal stop visible:
38+
// record it in the tray status and pop a desktop notification.
39+
worker_status.set_note(format!("stopped: {msg}"));
40+
notify_error(&msg);
3641
}
3742
});
3843

@@ -74,15 +79,26 @@ fn load_icon() -> Result<Icon> {
7479
}
7580

7681
/// The status line, refreshed each tick. Paused wins over running: while a paused
77-
/// fragment sits parked between tiles it still counts as "active".
78-
fn status_label(status: &Status) -> &'static str {
82+
/// fragment sits parked between tiles it still counts as "active". When idle, the
83+
/// worker's note (why it's waiting) is shown so a silent stall is legible.
84+
fn status_label(status: &Status) -> String {
7985
if status.is_paused() {
80-
"Status: Paused"
81-
} else if status.is_running() {
82-
"Status: Running"
83-
} else {
84-
"Status: Waiting"
86+
return "Status: Paused".to_string();
87+
}
88+
if status.is_running() {
89+
return "Status: Running".to_string();
8590
}
91+
let note = status.note();
92+
if note.is_empty() {
93+
return "Status: Waiting".to_string();
94+
}
95+
// Keep the menu from ballooning on a long error message.
96+
let note = if note.chars().count() > 60 {
97+
format!("{}…", note.chars().take(60).collect::<String>())
98+
} else {
99+
note
100+
};
101+
format!("Status: {note}")
86102
}
87103

88104
/// The Pause/Resume toggle's label, refreshed each tick to match the flag.
@@ -158,6 +174,16 @@ fn notify_running() {
158174
}
159175
}
160176

177+
/// Pop a desktop notification for a fatal worker stop. The GUI subsystem has no
178+
/// console, so this is the only way such an error reaches the user (the tray
179+
/// status line also carries it, but a notification is what draws the eye).
180+
fn notify_error(msg: &str) {
181+
let _ = notify_rust::Notification::new()
182+
.summary(&format!("decryptd v{VERSION} stopped"))
183+
.body(msg)
184+
.show();
185+
}
186+
161187
/// Kick off a one-shot self-update from the tray. Reuses [`crate::build_updater`]
162188
/// — the same signed updater the hourly background task uses — so it shares the
163189
/// trust anchor and transport; `update()` fetches the latest signed manifest,

src/main.rs

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ struct RunArgs {
5656
/// KarpelesLab platform host (pullOne/submitFragment are anonymous — no key needed).
5757
#[arg(long, env = "DECRYPTD_HOST", default_value = "www.atonline.com")]
5858
host: String,
59-
/// Working directory for the blob cache and scratch.
60-
#[arg(long, env = "DECRYPTD_WORKDIR", default_value = "decryptd-data")]
61-
workdir: PathBuf,
59+
/// Working directory for the blob cache, worker id, and scratch. Defaults to a
60+
/// per-user data dir for the GUI build (it may be launched from a non-writable
61+
/// CWD like System32) and to `./decryptd-data` for the console build.
62+
#[arg(long, env = "DECRYPTD_WORKDIR")]
63+
workdir: Option<PathBuf>,
6264
/// Claim a single fragment then exit (default: loop forever).
6365
#[arg(long)]
6466
once: bool,
@@ -81,6 +83,39 @@ struct RunArgs {
8183
cache_max_gb: u64,
8284
}
8385

86+
impl RunArgs {
87+
/// The resolved working directory: the `--workdir`/`DECRYPTD_WORKDIR` value if
88+
/// given, else [`default_workdir`].
89+
fn workdir(&self) -> PathBuf {
90+
self.workdir.clone().unwrap_or_else(default_workdir)
91+
}
92+
}
93+
94+
/// Default working directory. The GUI build can be started from any CWD (an
95+
/// Explorer double-click, a login item, a service), and a CWD-relative folder may
96+
/// not be creatable there — a silent failure that strands the tray on "Waiting".
97+
/// So the GUI build defaults to a stable per-user data dir; the console build,
98+
/// run from an operator-chosen directory, keeps `./decryptd-data`.
99+
fn default_workdir() -> PathBuf {
100+
#[cfg(feature = "gui")]
101+
{
102+
#[cfg(windows)]
103+
if let Some(base) = std::env::var_os("LOCALAPPDATA") {
104+
return PathBuf::from(base).join("decryptd");
105+
}
106+
#[cfg(not(windows))]
107+
{
108+
if let Some(base) = std::env::var_os("XDG_DATA_HOME") {
109+
return PathBuf::from(base).join("decryptd");
110+
}
111+
if let Some(home) = std::env::var_os("HOME") {
112+
return PathBuf::from(home).join(".local/share/decryptd");
113+
}
114+
}
115+
}
116+
PathBuf::from("decryptd-data")
117+
}
118+
84119
// ------------------------------------------------------------- pullOne response
85120
/// `Decrypt/Job:pullOne` payload — a claimed fragment, its parent job's blobs, and
86121
/// the single-use key that authenticates this fragment's result submission.
@@ -228,7 +263,7 @@ fn fetch_blob(args: &RunArgs, downloads: &Downloads, d: &DataRef) -> Result<Vec<
228263
return Ok(bytes);
229264
}
230265

231-
let cache = args.workdir.join("cache");
266+
let cache = args.workdir().join("cache");
232267
std::fs::create_dir_all(&cache)?;
233268
// `Hash` is the blob's content SHA-256, so it doubles as a cache key + checksum.
234269
let cache_path = (!d.hash.is_empty()).then(|| cache.join(&d.hash));
@@ -473,6 +508,10 @@ struct Status {
473508
active: Arc<AtomicUsize>,
474509
/// Set by the tray's Pause item; the worker parks at its gates while true.
475510
paused: Arc<AtomicBool>,
511+
/// Why the worker is idle when not running — "no open work", a pull error, or
512+
/// a fatal stop. Surfaced in the tray so a Windows user (whose console logs are
513+
/// swallowed by the GUI subsystem) can tell an idle worker from a broken one.
514+
note: Arc<Mutex<String>>,
476515
}
477516

478517
impl Status {
@@ -513,6 +552,21 @@ impl Status {
513552
}
514553
}
515554

555+
/// Record why the worker is idle (shown in the tray). Set by the pipeline on
556+
/// pull errors / no-work and by the GUI on a fatal worker stop.
557+
fn set_note(&self, note: impl Into<String>) {
558+
*self.note.lock().unwrap() = note.into();
559+
}
560+
561+
/// The current idle note, or empty. Only read by the GUI tray.
562+
#[cfg_attr(
563+
not(all(feature = "gui", any(target_os = "linux", target_os = "windows"))),
564+
allow(dead_code)
565+
)]
566+
fn note(&self) -> String {
567+
self.note.lock().unwrap().clone()
568+
}
569+
516570
/// Mark a GPU run as started; the returned guard marks it finished on drop
517571
/// (so a panic in `run_on_gpu` can't strand the counter above zero).
518572
fn run_guard(&self) -> RunGuard {
@@ -777,16 +831,19 @@ fn prefetch_loop(
777831
status.wait_while_paused();
778832
match claim_and_fetch(&args, &downloads, &worker_id, &ctx, &inflight) {
779833
Ok(Some(job)) => {
834+
status.set_note(""); // got work; clear any idle note
780835
if ready.send(job).is_err() {
781836
return; // pipeline shut down
782837
}
783838
}
784839
Ok(None) => {
785840
eprintln!("[decryptd] no work; sleeping {}s", args.idle_secs);
841+
status.set_note("no open work");
786842
thread::sleep(Duration::from_secs(args.idle_secs));
787843
}
788844
Err(e) => {
789845
eprintln!("[decryptd] pull error: {e:#}");
846+
status.set_note(format!("pull error: {e}"));
790847
thread::sleep(Duration::from_secs(args.idle_secs));
791848
}
792849
}
@@ -952,11 +1009,13 @@ fn load_or_create_worker_id(workdir: &Path) -> Result<String> {
9521009
}
9531010

9541011
fn run_worker(args: RunArgs, status: Status) -> Result<()> {
955-
std::fs::create_dir_all(&args.workdir)?;
1012+
let workdir = args.workdir();
1013+
std::fs::create_dir_all(&workdir)
1014+
.with_context(|| format!("creating workdir {}", workdir.display()))?;
9561015
let ctx = RestContext::with_config(Config::new("https".to_string(), args.host.clone()))
9571016
.with_debug(std::env::var("DECRYPTD_DEBUG").is_ok());
9581017
let jobs = args.jobs.max(1);
959-
let worker_id = load_or_create_worker_id(&args.workdir)?;
1018+
let worker_id = load_or_create_worker_id(&workdir)?;
9601019

9611020
let count = cuda::device_count().map_err(|e| anyhow!("enumerating GPUs: {e}"))?;
9621021
if count < 1 {
@@ -1073,6 +1132,17 @@ mod tests {
10731132
assert_eq!(bytes, b"hello");
10741133
}
10751134

1135+
#[test]
1136+
fn workdir_override_beats_default() {
1137+
// Explicit --workdir is always honored verbatim.
1138+
let a = RunArgs::parse_from(["decryptd", "--workdir", "/tmp/custom-wd"]);
1139+
assert_eq!(a.workdir(), PathBuf::from("/tmp/custom-wd"));
1140+
// In the (non-gui) test build the default is the CWD-relative folder.
1141+
let b = RunArgs::parse_from(["decryptd"]);
1142+
assert_eq!(b.workdir(), default_workdir());
1143+
assert_eq!(default_workdir(), PathBuf::from("decryptd-data"));
1144+
}
1145+
10761146
#[test]
10771147
fn worker_id_persists_and_is_reused() {
10781148
let dir = std::env::temp_dir().join(format!("decryptd-wid-{}", std::process::id()));

0 commit comments

Comments
 (0)