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
//! Filesystem migration with caller-owned diagnostic delivery.
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::tty::{Level, Notice};
use super::{migrate_config_text, migrate_persona_text, Migration};
/// A config lock-contention warning fires at most once per process. A single
/// command (e.g. `mcp import`, which re-reads the same authoritative config
/// while already holding its transaction lock) can hit lock contention on
/// the same target more than once; the operator needs to be told once that
/// this run's config changes were not durably saved, not the identical
/// sentence per attempt.
static LOCK_CONTENTION_REPORTED: AtomicBool = AtomicBool::new(false);
/// Reset the once-per-process dedupe. Test-only: the real "once per process"
/// promise needs a fresh process to observe, but the unit suite runs many
/// scenarios in one process and each must see its own first occurrence.
#[cfg(test)]
pub(crate) fn reset_lock_contention_warned_for_test() {
LOCK_CONTENTION_REPORTED.store(false, Ordering::Relaxed);
}
/// Read and atomically migrate a persona. The caller owns presentation of the
/// report, including when a later persona parse fails.
pub fn read_persona_file(
path: &Path,
report: &mut dyn FnMut(Notice<'static>),
) -> std::io::Result<String> {
read_migrating(
MigratingPath {
shown: path,
operated: path,
},
"persona",
migrate_persona_text,
true,
|p| std::fs::read_to_string(p),
|p, text| crate::atomic_fs::atomic_write(p, text.as_bytes()),
report,
)
}
/// Read config under the same resolved-destination lock as Config::save.
/// A lock failure and the resulting in-memory load form one report. The lock
/// remains held through revalidation and replacement, exactly as before.
pub fn read_config_file(
path: &Path,
rewrite: bool,
report: &mut dyn FnMut(Notice<'static>),
) -> std::io::Result<String> {
let locked = rewrite.then(|| {
let destination = crate::atomic_fs::ResolvedPath::resolve(path)?;
let lock = crate::atomic_fs::acquire_lock(&destination.lock_path())?;
anyhow::Ok((destination, lock))
});
let (locked, lock_failure) = match locked.transpose() {
Ok(locked) => (locked, None),
// Surface the lock-contention text at most once per process: a
// single command that re-reads the same already-locked config more
// than once (e.g. `mcp import`'s namespace-sanitization pass
// re-loading a target it already holds the transaction lock on)
// would otherwise repeat the identical sentence per attempt,
// whether it lands in the fallback notice below or merged into a
// migration notice above.
Err(error) if !LOCK_CONTENTION_REPORTED.swap(true, Ordering::Relaxed) => (
None,
Some(format!(
"cannot lock config {} for migration: {error:#}",
path.display()
)),
),
Err(_) => (None, None),
};
let destination = locked.as_ref().map(|(destination, _)| destination);
// Display the caller's path; operate on the resolved one. Canonicalization
// can add a verbatim `\\?\` prefix (Windows), expand 8.3 short names, or
// follow a symlinked directory, and the operator should be shown the path
// they actually named.
let operated = destination.map_or(path, crate::atomic_fs::ResolvedPath::as_path);
let mut reported = false;
let result = read_migrating(
MigratingPath {
shown: path,
operated,
},
"config",
migrate_config_text,
destination.is_some(),
|p| std::fs::read_to_string(p),
|_, text| {
destination
.expect("rewrite holds the config lock")
.atomic_write(text.as_bytes())
},
&mut |mut notice| {
if let Some(error) = &lock_failure {
notice.text = format!("{}; {error}", notice.text).into();
}
reported = true;
report(notice);
},
);
if let Some(error) = lock_failure.filter(|_| !reported) {
let loaded = match &result {
Ok(_) => {
"loaded original text in memory; config changes from this command were not saved"
.to_string()
}
Err(read_error) => format!(
"could not read original text: {read_error}; config changes from this command were not saved"
),
};
report(Notice::new(
Level::Warn,
"",
format!("newt: warning: {error}; {loaded}"),
));
}
result
}
/// The path shown to the operator and the path actually read/written.
/// Canonicalization can add a verbatim `\\?\` prefix (Windows), expand 8.3
/// short names, or follow a symlinked directory; `shown` is what the
/// operator named, `operated` is what the filesystem calls resolved to.
pub(super) struct MigratingPath<'a> {
pub(super) shown: &'a Path,
pub(super) operated: &'a Path,
}
/// Injected filesystem seam. Reporting does not own stdout, stderr, a tracing
/// subscriber, or a terminal. A host may retain the value until a safe point.
pub(super) fn read_migrating(
path: MigratingPath<'_>,
kind: &str,
migrate: fn(&str) -> Option<Migration>,
rewrite: bool,
mut read: impl FnMut(&Path) -> std::io::Result<String>,
write: impl FnOnce(&Path, &str) -> anyhow::Result<()>,
report: &mut dyn FnMut(Notice<'static>),
) -> std::io::Result<String> {
let MigratingPath { shown, operated } = path;
let raw = read(operated)?;
let Some(migration) = migrate(&raw) else {
return Ok(raw);
};
let changes = migration.changes.join(", ");
let shown = shown.display();
if !rewrite {
report(Notice::new(
Level::Warn,
"",
format!(
"newt: warning: {kind} {shown} uses old psyche labels ({changes}); \
loading translated original text in memory, the file is unchanged"
),
));
return Ok(migration.text);
}
// Preserve the changed-source check: a non-cooperating editor may write
// despite the lock. The latest bytes always win over our original snapshot.
match read(operated) {
Ok(current) if current != raw => {
let latest = migrate(¤t);
let changes = latest.as_ref().map(|m| m.changes.join(", "));
let detail = changes.map(|c| format!(" ({c})")).unwrap_or_default();
report(Notice::new(
Level::Warn,
"",
format!(
"newt: warning: {kind} {shown} changed during migration; \
loading latest text in memory{detail}, the file is unchanged"
),
));
return Ok(latest.map_or(current, |latest| latest.text));
}
Err(error) => {
report(Notice::new(
Level::Warn,
"",
format!(
"newt: warning: cannot re-read {kind} {shown} before migration: {error}; \
loading translated original text ({changes}), the file is unchanged"
),
));
return Ok(migration.text);
}
Ok(_) => {}
}
let notice = match write(operated, &migration.text) {
Ok(()) => Notice::new(
Level::Ok,
"",
format!("newt: migrated {kind} {shown}: {changes}; loaded translated original text"),
),
Err(error) => Notice::new(
Level::Warn,
"",
format!(
"newt: warning: {kind} {shown} uses old psyche labels ({changes}); \
loading translated original text, but migration write did not complete: {error:#}"
),
),
};
report(notice);
Ok(migration.text)
}
#[cfg(test)]
#[path = "psyche_import_notice_tests.rs"]
mod tests;