1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! Persisted preferences: `<store_dir>/settings.json`. Deliberately tiny - one
//! flat file, every field optional, an unreadable or half-written file falling
//! back to defaults rather than failing a command. Nothing here is a credential.
use crate::paths::Paths;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings {
/// Let proxy mode continue the session on another account when one is spent.
/// `None` = never set, treated as off; `swapdex proxy --auto` overrides it for
/// one run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_auto: Option<bool>,
/// Seconds a turn may be HELD when every account is spent, waiting for the
/// earliest window to reset instead of failing with a 429.
///
/// The turn used to die there and an unattended run ended with it, even
/// though the windows state their own reset times - the wall's length was
/// known and simply not used. 0 or unset means never hold, because a caller
/// that would rather see the error than wait must be able to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hold_seconds: Option<i64>,
/// Accounts kept OUT of automatic rotation. They can still be switched to by
/// hand - this only says "do not pick this one for me", which is the useful
/// meaning when an account is shared, billed elsewhere, or being saved.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
/// Explicit rotation order, lowest first. Accounts absent from this list keep
/// the automatic order and are tried after the ranked ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub priority: Vec<String>,
/// Step off an account once a window reaches this fraction, instead of
/// waiting for it to refuse a turn. `None` = wait for the refusal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_threshold: Option<f64>,
/// Which account to reach for when the current one is full: `roomiest` (the
/// most left) or `consume-first` (the window about to reset, so nothing
/// lapses unused). `None` = roomiest, the behaviour swapdex has always had.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_strategy: Option<String>,
/// A cheaper model to ask for when EVERY account is past the threshold and
/// there is nowhere left to rotate. Off unless set: changing the model gives
/// the user something other than what they asked for, so it is the last
/// thing swapdex does before a turn fails, never the first.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_model: Option<String>,
}
impl Settings {
pub fn auto(&self) -> bool {
self.proxy_auto.unwrap_or(false)
}
/// The threshold to step off an account at, if one is set. Clamped to a range
/// that means something: below 5% every account looks full, and above 1.0 is
/// unreachable.
pub fn threshold(&self) -> Option<f64> {
self.proxy_threshold.map(|t| t.clamp(0.05, 1.0))
}
pub fn is_disabled(&self, name: &str) -> bool {
self.disabled.iter().any(|d| d == name)
}
/// Toggle an account's participation in rotation; returns the new state.
pub fn toggle_disabled(&mut self, name: &str) -> bool {
if let Some(i) = self.disabled.iter().position(|d| d == name) {
self.disabled.remove(i);
false
} else {
self.disabled.push(name.to_string());
true
}
}
/// Rank for rotation: ranked accounts first in their listed order, everything
/// else after, so a partial ranking is still meaningful.
/// The rotation strategy, defaulting to the long-standing one. An
/// unrecognised value in the file is ignored rather than fatal: a settings
/// file is a convenience and must never fail a command.
pub fn strategy(&self) -> crate::proxy::pick::Strategy {
self.proxy_strategy
.as_deref()
.and_then(crate::proxy::pick::Strategy::parse)
.unwrap_or_default()
}
pub fn rank(&self, name: &str) -> usize {
self.priority
.iter()
.position(|p| p == name)
.unwrap_or(usize::MAX)
}
}
fn file(paths: &Paths) -> std::path::PathBuf {
paths.store_dir().join("settings.json")
}
/// Read the settings. A missing, unreadable, or corrupt file yields defaults: a
/// preference is never worth failing a switch over.
pub fn load(paths: &Paths) -> Settings {
std::fs::read(file(paths))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
/// Read, modify and write the settings as ONE operation.
///
/// Every caller used to do load -> modify -> save on its own, with nothing
/// between them, so two overlapping changes each read the same file and the
/// second write erased the first. teamclaude hit this as "concurrent
/// token-refresh loss": a freshly refreshed token clobbered by an unrelated
/// preference write seconds later.
///
/// The store's own lock serialises it. A lock that cannot be taken is not worth
/// failing a preference over - the write still happens, exactly as it did
/// before, so this is never worse than the old behaviour.
pub fn update(paths: &Paths, edit: impl FnOnce(&mut Settings)) -> Result<()> {
// The store's own lock. If it cannot be taken the write still happens -
// never worse than before - but say so rather than lose a change in
// silence, since that silence is what made this hard to see at all.
// The store's own lock, waited for rather than skipped. Contention here is
// brief - a read, an edit, an atomic write - and giving up on the first
// `Busy` is what let one change silently erase another.
let store = crate::store::Store::open(paths);
let mut _guard = None;
if let Ok(s) = &store {
for attempt in 0..50 {
match s.lock() {
Ok(g) => {
_guard = Some(g);
break;
}
Err(crate::store::LockError::Busy) => {
if attempt == 49 {
eprintln!(
"swapdex: settings stayed locked - a concurrent change may be lost"
);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
// An unwritable store is not contention: waiting cannot help,
// and the write below will report the real problem.
Err(_) => break,
}
}
}
let mut cfg = load(paths);
edit(&mut cfg);
save(paths, &cfg)
}
/// Write the settings atomically, so a crash mid-write cannot leave a half file
/// that then reads as "no preferences".
pub fn save(paths: &Paths, s: &Settings) -> Result<()> {
let path = file(paths);
std::fs::create_dir_all(paths.store_dir()).context("create store dir")?;
let bytes = serde_json::to_vec_pretty(s)?;
// Not a secret, but reuse the atomic 0600 path: the store is 0700 anyway, and
// this is the writer that cannot leave a half file behind.
crate::atomic::write_secret(&path, &bytes).context("write settings.json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_threshold_persists_and_is_clamped_to_a_meaningful_range() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
assert_eq!(load(&paths).threshold(), None, "off until asked for");
save(
&paths,
&Settings {
proxy_threshold: Some(0.9),
..Default::default()
},
)
.unwrap();
assert_eq!(load(&paths).threshold(), Some(0.9));
// Nonsense values are pulled back rather than making every account look
// full (or the setting unreachable).
let low = Settings {
proxy_threshold: Some(0.0),
..Default::default()
};
assert_eq!(low.threshold(), Some(0.05));
let high = Settings {
proxy_threshold: Some(5.0),
..Default::default()
};
assert_eq!(high.threshold(), Some(1.0));
}
#[test]
fn disabled_accounts_toggle_and_persist() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = load(&paths);
assert!(!s.is_disabled("rnd"));
assert!(s.toggle_disabled("rnd"), "first toggle disables");
assert!(s.is_disabled("rnd"));
save(&paths, &s).unwrap();
let mut back = load(&paths);
assert!(back.is_disabled("rnd"), "the choice persists");
assert!(!back.toggle_disabled("rnd"), "toggling again re-enables");
assert!(!back.is_disabled("rnd"));
}
#[test]
fn ranked_accounts_sort_before_unranked_ones() {
let s = Settings {
priority: vec!["work".into(), "rnd".into()],
..Default::default()
};
assert!(s.rank("work") < s.rank("rnd"), "listed order is the order");
assert!(
s.rank("rnd") < s.rank("anything-else"),
"ranked beats unranked"
);
assert_eq!(
s.rank("a"),
s.rank("b"),
"unranked accounts keep their existing order"
);
}
#[test]
fn defaults_when_absent_and_round_trips_when_set() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
assert_eq!(load(&paths), Settings::default());
assert!(!load(&paths).auto(), "auto is off until asked for");
save(
&paths,
&Settings {
proxy_auto: Some(true),
..Default::default()
},
)
.unwrap();
assert!(load(&paths).auto(), "the preference persists");
save(
&paths,
&Settings {
proxy_auto: Some(false),
..Default::default()
},
)
.unwrap();
assert!(!load(&paths).auto(), "and can be turned back off");
}
#[test]
fn a_corrupt_file_reads_as_defaults_rather_than_failing() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
std::fs::create_dir_all(paths.store_dir()).unwrap();
std::fs::write(super::file(&paths), b"{ not json").unwrap();
assert_eq!(load(&paths), Settings::default());
}
}
#[cfg(test)]
mod concurrent_update_tests {
use super::*;
/// Two settings changes at once must not lose one of them.
///
/// Every caller did load -> modify -> save on its own, with nothing between
/// them. Two overlapping changes each read the same file and the second
/// write erased the first - the pattern teamclaude hit as "concurrent
/// token-refresh loss", where a refreshed token was clobbered by an
/// unrelated preference write seconds later.
#[test]
fn overlapping_updates_both_survive() {
let d = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(d.path());
save(&paths, &Settings::default()).unwrap();
// Two writers, each changing a DIFFERENT field, interleaved the way two
// processes would.
let a = std::thread::spawn({
let p = paths.clone();
move || update(&p, |s| s.hold_seconds = Some(111)).unwrap()
});
let b = std::thread::spawn({
let p = paths.clone();
move || update(&p, |s| s.proxy_auto = Some(true)).unwrap()
});
a.join().unwrap();
b.join().unwrap();
let got = load(&paths);
assert_eq!(got.hold_seconds, Some(111), "one writer's change was lost");
assert_eq!(
got.proxy_auto,
Some(true),
"the other writer's change was lost"
);
}
}