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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
//! 0063 §6.6 production correspondence: SearchLifecycle.tla's transition
//! vocabulary replayed through the actual admission/publication
//! handlers — `handle_picker_event`, `handle_lsp_event`'s
//! workspace-symbols merge and the warm-queue drain — asserting the
//! model's named invariants (RowsCurrent, CompletionHonest,
//! WarmBounded) at each step, including the negative traces the model
//! rejects (a retired generation's late publish, a truncated
//! completion). This is correspondence, not a re-test of the e2e: the
//! seams are driven directly, streams hand-armed like the established
//! worker-lifecycle tests (deterministic — no rg, no sleeps).
//!
//! The publication decisions under test are the verified kernel's
//! (`strop_core::searchguard`): each guard site below calls it, so a
//! trace that passes here exercises the same function Verus proves.
#[cfg(test)]
mod tests {
use super::super::*;
use crate::editor::lsp::attach::AttachKey;
use std::path::PathBuf;
use strop_core::worker::{Outcome, Ticket, WorkerId};
use strop_core::Buffer;
use strop_picker::{Item, Payload, PickerMsg};
use strop_workspace::Filesystem;
fn item(text: &str) -> Item {
Item {
badge: None,
text: text.into(),
payload: Payload::File(PathBuf::from(text)),
}
}
/// Model `TypeQuery`: a fresh generation's request registers before
/// launch and takes ownership of the stream (the query path's row
/// purge is its own action, covered by the query tests; the seam
/// under replay here is the publication guard).
fn arm_stream(e: &mut Editor) -> Ticket<PickerKey> {
let picker = e.picker.as_ref().map(|glue| glue.id).unwrap();
let request = e.worker_ids.allocate().unwrap();
let ticket = Ticket {
request,
key: PickerKey {
picker,
cwd: e.cwd.clone(),
},
};
let glue = e.picker.as_mut().unwrap();
glue.active = Some(ticket.clone());
glue.picker.streaming = true;
ticket
}
/// Settle the ranking actor only: hand-armed streams hold no
/// retained worker channel, so `wait_picker`'s stream loop does
/// not apply (the established worker-lifecycle tests settle the
/// same way once their injected terminal lands).
fn settle_ranking(e: &mut Editor) {
while e
.picker
.as_ref()
.is_some_and(|glue| glue.rank_pending.is_some())
{
let event = e
.picker_ranking
.rx
.as_ref()
.expect("local ranking channel")
.recv_timeout(std::time::Duration::from_secs(5))
.expect("ranking settled");
e.handle_picker_ranking(event);
}
}
fn row_texts(e: &Editor) -> Vec<String> {
e.picker
.as_ref()
.unwrap()
.picker
.rows
.iter()
.map(|row| {
e.picker.as_ref().unwrap().picker.items[row.item]
.text
.clone()
})
.collect()
}
/// The merge seam's publication set: every item the picker holds,
/// before ranking narrows the presentation (the ranked `rows` view
/// would hide a wrongly-merged row that fails the query's text
/// match — the mutant survives there).
fn item_texts(e: &Editor) -> Vec<String> {
e.picker
.as_ref()
.unwrap()
.picker
.items
.iter()
.map(|item| item.text.clone())
.collect()
}
/// Trace: OpenPicker, TypeQuery (g1), ProviderPartial(g1),
/// TypeQuery (g2 retires g1), late ProviderPartial/ProviderDone(g1)
/// — refused — ProviderPartial(g2), ProviderDone(g2).
/// Invariant: RowsCurrent (no retired-query publication).
#[test]
fn retired_stream_generation_never_publishes() {
let mut e = Editor::new(Buffer::from_text("x\n"));
e.open_picker(Kind::Search);
let g1 = arm_stream(&mut e);
// ProviderPartial(g1): the live generation publishes.
e.handle_picker_event(PickerEvent {
ticket: g1.clone(),
msg: PickerMsg::Items(vec![item("gen1.rs")].into()),
});
settle_ranking(&mut e);
assert_eq!(
row_texts(&e),
vec!["gen1.rs".to_string()],
"RowsCurrent: the live generation's rows publish"
);
// TypeQuery: generation 2 retires generation 1.
let g2 = arm_stream(&mut e);
// Late arrivals from the retired generation, in every shape the
// stream can take: the model rejects each transition and so
// must the handler.
for msg in [
PickerMsg::Items(vec![item("retired.rs")].into()),
PickerMsg::Warning("retired truncation".into()),
PickerMsg::Finished(Outcome::Success(())),
] {
e.handle_picker_event(PickerEvent {
ticket: g1.clone(),
msg,
});
}
settle_ranking(&mut e);
let glue = e.picker.as_ref().unwrap();
assert_eq!(
row_texts(&e),
vec!["gen1.rs".to_string()],
"RowsCurrent: a retired generation never appends"
);
assert!(
glue.picker.warning.is_none(),
"a retired generation's truncation never surfaces"
);
assert!(
glue.picker.streaming,
"ProviderDone(g1) cannot publish generation 2's completion"
);
// ProviderPartial(g2) + ProviderDone(g2): publish, then an
// honest completion (no truncation on record).
e.handle_picker_event(PickerEvent {
ticket: g2.clone(),
msg: PickerMsg::Items(vec![item("gen2.rs")].into()),
});
e.handle_picker_event(PickerEvent {
ticket: g2.clone(),
msg: PickerMsg::Finished(Outcome::Success(())),
});
settle_ranking(&mut e);
let glue = e.picker.as_ref().unwrap();
assert_eq!(
row_texts(&e),
vec!["gen1.rs".to_string(), "gen2.rs".to_string()],
"the owning generation publishes"
);
assert!(!glue.picker.streaming, "the live terminal settles");
assert!(
glue.picker.error.is_none() && glue.picker.warning.is_none(),
"CompletionHonest: the untruncated live terminal completes clean"
);
}
/// Trace: OpenPicker (workspace symbols), ProviderPartial(g),
/// TypeQuery (g+1 retires g), late ProviderPartial(g) — refused —
/// ProviderPartial(g+1). Same RowsCurrent invariant, second guard
/// site: `merge_workspace_symbols` via `handle_lsp_event`.
#[test]
fn retired_wsymbols_generation_never_merges() {
use strop_lsp::protocol::{ProtoSymbol, ServerColumn, ServerLocation, ServerPosition};
use strop_lsp::{LspEvent, ServerId};
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "fn wrap() {}\n").unwrap();
let mut e = Editor::new_in(Buffer::from_text(""), dir.path().to_path_buf());
e.open_picker(Kind::WorkspaceSymbols);
e.wait_picker();
let g = e.picker.as_ref().unwrap().wsymbols_generation;
let symbol = |name: &str| ProtoSymbol {
name: name.into(),
container: String::new(),
kind: "Function".into(),
location: ServerLocation {
doc: strop_workspace::ResourceLocation::local(dir.path().join("lib.rs")),
position: ServerPosition {
line: strop_core::id::LineIndex::new(0),
column: ServerColumn::new(3),
},
},
};
// ProviderPartial(g): the live generation merges.
e.handle_lsp_event(LspEvent::WorkspaceSymbols {
server: ServerId::new(9),
generation: g,
symbols: vec![symbol("semantic_hit")],
});
e.wait_picker();
assert!(
item_texts(&e)
.iter()
.any(|text| text.starts_with("semantic_hit ")),
"RowsCurrent: the live generation merges"
);
// TypeQuery: typing retires generation g.
e.paste_bracketed("wrap");
e.wait_picker();
let retired = g;
let g = e.picker.as_ref().unwrap().wsymbols_generation;
assert_eq!(g, retired + 1, "the query change bumped the generation");
// ProviderPartial(retired) landing late: the model rejects it.
e.handle_lsp_event(LspEvent::WorkspaceSymbols {
server: ServerId::new(9),
generation: retired,
symbols: vec![symbol("retired_hit")],
});
e.wait_picker();
assert!(
!item_texts(&e)
.iter()
.any(|text| text.starts_with("retired_hit")),
"RowsCurrent: a retired generation's reply never merges"
);
// ProviderPartial(g) for the new live generation: merges.
e.handle_lsp_event(LspEvent::WorkspaceSymbols {
server: ServerId::new(9),
generation: g,
symbols: vec![symbol("wrap_semantic")],
});
e.wait_picker();
assert!(
item_texts(&e)
.iter()
.any(|text| text.starts_with("wrap_semantic ")),
"the new live generation merges"
);
}
/// Trace: TypeQuery (g), TruncateIndex, ProviderDone(g).
/// Invariant: CompletionHonest — the model keeps completeF false on
/// a truncated terminal; the handler's correspondence is that the
/// bound hit stays visibly on the settled picker (never a silent
/// empty success), which the kernel's decision mirrors.
#[test]
fn truncated_completion_stays_visibly_incomplete() {
let mut e = Editor::new(Buffer::from_text("x\n"));
e.open_picker(Kind::Search);
let g1 = arm_stream(&mut e);
// TruncateIndex: the source reports its bounds were hit.
e.handle_picker_event(PickerEvent {
ticket: g1.clone(),
msg: PickerMsg::Warning(
"workspace symbols: syntax tier truncated at its bounds; narrow the scope for full coverage".into(),
),
});
// ProviderDone(g): the stream settles, the claim does not.
e.handle_picker_event(PickerEvent {
ticket: g1.clone(),
msg: PickerMsg::Finished(Outcome::Success(())),
});
let glue = e.picker.as_ref().unwrap();
assert!(!glue.picker.streaming, "the terminal settles the stream");
assert!(
glue.picker.warning.is_some(),
"CompletionHonest: a truncated completion stays visibly incomplete"
);
assert!(glue.picker.error.is_none(), "a bound hit is not a failure");
// The kernel decision agrees with the model's completeF clause:
// honest iff the live generation's terminal AND no truncation.
let gen = g1.request.get();
assert!(!strop_core::searchguard::completion_is_honest(
gen, gen, true
));
assert!(strop_core::searchguard::completion_is_honest(
gen, gen, false
));
assert!(!strop_core::searchguard::completion_is_honest(
gen - 1,
gen,
false
));
}
/// Trace: WarmStart ×3 (pre-filled), WarmEnqueue ×3 — exactly one
/// further WarmStart, the rest wait — WarmDone, one refill
/// WarmStart, ClosePopup (the queue dies with the surface).
/// Invariant: WarmBounded (warm-up concurrency within the bound,
/// started only while the symbols surface is open).
#[test]
fn warm_drain_holds_the_bound_and_dies_with_the_surface() {
let dir = tempfile::tempdir().unwrap();
let mut e = Editor::new_in(Buffer::from_text(""), dir.path().to_path_buf());
e.lsp_state.attach.enabled = true;
e.open_picker(Kind::WorkspaceSymbols);
e.wait_picker();
// WarmStart ×3 already in flight.
for n in 0..3u64 {
e.lsp_state.attach.pending.insert(
AttachKey {
target: Filesystem::Local,
language: "rust".into(),
path: dir.path().join(format!("inflight{n}")),
},
WorkerId::new(90 + n),
);
}
// WarmEnqueue ×3 with one slot free: exactly one WarmStart.
e.lsp_warm_scope_projects(
(0..3)
.map(|n| (dir.path().join(format!("p{n}")), "Cargo.toml".to_string()))
.collect(),
);
assert_eq!(
e.lsp_state.attach.pending.len(),
4,
"WarmBounded: the one free slot fills, no more"
);
assert_eq!(
e.lsp_state.attach.warm_queue.len(),
2,
"the rest of the queue waits"
);
// WarmDone frees a slot: the drain starts exactly one more.
let freed = e.lsp_state.attach.pending.keys().next().unwrap().clone();
e.lsp_state.attach.pending.remove(&freed);
e.lsp_warm_scope_projects(Vec::new());
assert_eq!(
e.lsp_state.attach.pending.len(),
4,
"WarmBounded: the refill holds the bound"
);
assert_eq!(e.lsp_state.attach.warm_queue.len(), 1);
// ClosePopup: warm-up is on demand — the queue dies with the
// symbols surface.
e.open_picker(Kind::Files);
assert!(
e.lsp_state.attach.warm_queue.is_empty(),
"installing another surface ends warm-up"
);
}
}