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
//! The standing guard on #4743 — a test process reaches no trusty-search daemon
//! on any destructive path — and the sweep's fail-safe reads (#6285).
//!
//! Why a real, live, accepting daemon rather than a dead socket: a dead socket
//! makes the guarded and the unguarded code look identical — both end with "no
//! index was deleted", one because the request was refused at the source and one
//! because nothing was there to answer it. The daemon here WOULD have accepted
//! the delete and answered success, so the call counter staying at zero is
//! evidence about trusty-mpm's behaviour rather than about the machine.
//!
//! Why these fixtures reproduce the reported hazard exactly: the issue names
//! `decommission_full_still_terminates_the_runtime`, whose workspace basename is
//! `full`. `disposable_workspace_index_id` derives the index id from a bare
//! `file_name()`, so that test asks a daemon to destroy an index called `full`.
//! `sess`, `live` and `proj` appear the same way elsewhere in this suite, and
//! the operator's daemon carried a real index named `proj` when this was
//! written. Nothing about the fixture names is fixed here — the point of the fix
//! is that the id no longer matters, because the request is never issued.
//!
//! Why the sweep's READ path is pinned in the same file: #6285 moved these calls
//! onto the socket, and the sweep's decision to delete rests on what those reads
//! return. A read that fails must not resolve to a value that licenses a delete.
//!
//! Test: the four functions below.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use serde_json::{Value, json};
use super::record::{ManagedSessionId, ManagedSessionState};
use super::tests::make_manager;
use crate::daemon::search_rpc;
use crate::test_support::isolated_daemon_home;
use crate::uds_mock::{self, MockFuture, MockUdsDaemon, RpcError};
/// Generous next to the client's own 5 s budget — this only has to outlast a
/// loopback round-trip on a loaded machine.
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// A stand-in for the operator's trusty-search daemon that answers every method
/// with a successful delete body and counts what it was asked.
///
/// Why it binds where production resolution looks, rather than taking
/// `TRUSTY_SEARCH_SOCKET`: the hazard #4743 describes is the daemon a process
/// finds on its own. `daemon_socket_path` under an overridden data directory is
/// that lookup, run against a socket that is not the operator's.
struct CountingDaemon {
calls: Arc<AtomicUsize>,
daemon: MockUdsDaemon,
}
impl CountingDaemon {
/// Bind at the path `search_rpc::search_socket` derives under the active
/// [`isolated_daemon_home`] override.
async fn start() -> Self {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let socket = search_rpc::search_socket().expect("derive the trusty-search socket");
let daemon = uds_mock::spawn_at(socket, move |_method, _params| {
counter.fetch_add(1, Ordering::SeqCst);
Box::pin(
async move { Ok(json!({ "ok": true, "removed": true, "data_deleted": true })) },
)
})
.await;
Self { calls, daemon }
}
/// Confirm the fixture's own premise: this daemon really does accept and
/// answer. Without it, a zero count could mean the daemon was broken.
async fn self_check(&self) {
search_rpc::call_at(
self.daemon.socket(),
search_rpc::METHOD_HEALTH,
json!({}),
PROBE_TIMEOUT,
)
.await
.expect("the stand-in daemon must answer — the fixture's premise");
// The self-check's own call does not count against the assertion.
self.calls.store(0, Ordering::SeqCst);
}
fn seen(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
/// The regression test for #4743: a full decommission from a test process must
/// put nothing on the wire, even though a daemon is discoverable and willing.
///
/// Why serial: the daemon-home override is process-global.
/// What: reproduces the issue's own fixture — an SM-owned workspace whose
/// basename is `full`, so `disposable_workspace_index_id` yields the index id
/// `full` and `decommission_with_root` reaches its effect-4 index delete. Points
/// daemon resolution at a daemon that would accept the delete, decommissions for
/// real, and asserts it was called zero times. Reverting
/// `DestructiveIndexDelete::acquire`'s `running_under_test_harness` branch makes
/// this fail with a count of 1.
/// Test: this function IS the test.
#[serial_test::serial]
#[tokio::test]
async fn decommission_issues_no_request_to_a_live_daemon_under_test() {
let dir = crate::test_support::hermetic_temp_dir();
// #8935: the record's pane proves the live session is its own.
let (mgr, _fake) = super::tests::make_manager_with_pane(&dir).await;
let managed_root = crate::test_support::hermetic_temp_dir();
// The issue's own fixture name. A bare `file_name()` becomes the index id.
let workspace_path = managed_root.path().join("owner").join("repo").join("full");
std::fs::create_dir_all(&workspace_path).expect("create the fixture workspace");
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(workspace_path.clone()),
None,
Some(workspace_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true, // owned — reaches the index-delete effect
)
.await
.expect("create");
let (_home, _override) = isolated_daemon_home(false);
let daemon = CountingDaemon::start().await;
daemon.self_check().await;
let outcome = mgr
.decommission_with_root(&record.id, managed_root.path(), None)
.await;
let (tombstone, _removed) = outcome.expect("full decommission");
assert_eq!(
tombstone.state,
ManagedSessionState::Decommissioned,
"the guard must not change what decommission does to the record"
);
assert_eq!(
daemon.seen(),
0,
"a test process must reach the trusty-search daemon zero times — this delete \
carries the delete_data opt-in and destroys a real index named `full` (#4743)"
);
}
/// The same guarantee for the second destructive site, which before #4743 had
/// no guard of its own at all.
///
/// Why separate: `sweep_orphaned_search_indexes` built its own destructive
/// request in a loop rather than calling the decommission-time helper, so a
/// guard placed only on that helper would have left this path fully exposed.
/// Asserting on the sweep directly is what keeps the two from diverging again.
/// What: with a willing daemon resolvable, a non-dry-run sweep must return empty
/// having made no call — not even the index listing, which the capability is
/// acquired ahead of.
/// Test: this function IS the test.
#[serial_test::serial]
#[tokio::test]
async fn sweep_makes_no_request_under_a_test_harness() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let (_home, _override) = isolated_daemon_home(false);
let daemon = CountingDaemon::start().await;
daemon.self_check().await;
let swept = mgr.sweep_orphaned_search_indexes(false).await;
assert_eq!(
swept.expect("sweep must not error").len(),
0,
"a refused sweep reports nothing removed"
);
assert_eq!(
daemon.seen(),
0,
"the orphan sweep must not even LIST indexes from a test process (#4743)"
);
}
/// No daemon is a no-op, not a failure and not a sweep of nothing.
///
/// Why: the orphan-GC loop calls this every 60 s on every machine, including
/// ones with no trusty-search installed. Before #6285 the absent discovery file
/// answered that question; now the resolver returns a path whether or not
/// anything is bound to it, so the no-op has to come from the unanswered call.
/// What: dry-run, so no capability is needed and the read path really runs, with
/// resolution pointed at a data directory holding no socket.
/// Test: this function IS the test.
#[serial_test::serial]
#[tokio::test]
async fn sweep_orphaned_search_indexes_noop_when_daemon_unreachable() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let (_home, _override) = isolated_daemon_home(false);
let swept = mgr
.sweep_orphaned_search_indexes(true)
.await
.expect("an unreachable daemon is a no-op, never an error");
assert!(
swept.is_empty(),
"nothing is collectable when nothing answered: {swept:?}"
);
}
/// The index id both halves of the status-probe test use.
const ORPHAN_ID: &str = "gone-session";
/// A daemon that lists exactly one `.worktrees`-rooted index and answers
/// `status` for it — or refuses the status call when `status` is `None`.
fn one_index_daemon(
root: PathBuf,
status: Option<Value>,
) -> impl Fn(&str, Value) -> MockFuture + Send + Sync {
move |method, _params| {
let answer = if method == search_rpc::METHOD_INDEXES_LIST {
Ok(json!({
"indexes": [{ "id": ORPHAN_ID, "root_path": root.to_string_lossy() }]
}))
} else if method == search_rpc::METHOD_INDEX_STATUS {
match &status {
Some(body) => Ok(body.clone()),
None => Err(RpcError::new(
crate::daemon::error::CODE_UNAVAILABLE,
"index status unavailable",
)),
}
} else {
Err(RpcError::new(
-32601,
format!("unexpected method: {method}"),
))
};
Box::pin(async move { answer })
}
}
/// Run one dry-run sweep against a daemon that lists `ORPHAN_ID` rooted at
/// `root` and answers `status` for it.
///
/// Why a fresh [`isolated_daemon_home`] per call: the socket path is derived
/// from the override, and a bound socket file outlives the daemon that bound it
/// — two daemons in one home would collide on the second bind.
async fn dry_sweep_against(
mgr: &super::manager::SessionManager,
root: PathBuf,
status: Option<Value>,
) -> Vec<String> {
let (_home, _override) = isolated_daemon_home(false);
let socket = search_rpc::search_socket().expect("derive the trusty-search socket");
let _daemon = uds_mock::spawn_at(socket, one_index_daemon(root, status)).await;
mgr.sweep_orphaned_search_indexes(true)
.await
.expect("a dry-run sweep against an answering daemon must not error")
}
/// A status probe that failed must not resolve to a collectable index (#6285).
///
/// Why: `probe_chunk_count` used to answer `0` for a failed probe, and `0` is
/// precisely the reading that makes an unclaimed `.worktrees` index collectable.
/// A wedged daemon, a timed-out call, or a one-off refusal therefore looked
/// exactly like "this index is empty" — the sweep deleted on evidence it never
/// gathered. An unreachable daemon must never read as an absent index.
/// What: one fixture, driven twice against the SAME candidate — a `.worktrees`
/// root that no longer exists on disk, which `is_orphan_index` reclaims at any
/// chunk count. With the status probe refused the sweep must report nothing;
/// with it answered the sweep must report the id. The second half is what proves
/// the first is the probe failing rather than the fixture never qualifying.
/// Dry-run throughout: the decision is what is under test, and a test process
/// could not acquire the delete capability anyway (#4743).
/// Test: this function IS the test.
#[serial_test::serial]
#[tokio::test]
async fn sweep_skips_a_candidate_whose_status_probe_failed() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
// Never existed on disk: an unclaimed worktree index whose root is gone is
// an orphan at any chunk count, so only the probe's outcome can change the
// verdict between the two halves below.
let vanished_root = PathBuf::from("/nonexistent/owner/repo/.worktrees/gone-session");
let skipped = dry_sweep_against(&mgr, vanished_root.clone(), None).await;
assert!(
skipped.is_empty(),
"an index whose chunk count could not be read must not be collectable: {skipped:?}"
);
let collected = dry_sweep_against(&mgr, vanished_root, Some(json!({ "chunk_count": 0 }))).await;
assert_eq!(
collected,
vec![ORPHAN_ID.to_string()],
"fixture premise: the same candidate IS collectable once the daemon answers"
);
}