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
//! The create-path name race (issue #3707).
//!
//! Why: `tmux new-session -A` attaches to an existing session of the same name
//! instead of failing, so two creators that computed the same name both
//! returned success and two `SessionRecord`s were persisted against ONE
//! physical pane — aliasing, with no error at any step and every subsequent
//! `send-keys` typed into a terminal another session was driving. These tests
//! pin the fix: the managed create path creates WITHOUT `-A`, so a lost race
//! refuses, and the refusal is retried under a fresh name.
//! What: two fake drivers modelling the two halves of the race — one whose
//! listing is stale while tmux already holds the name, one that refuses every
//! name — driven through `SessionManager::create_with_reserved_name`.
//! Test: this file IS the test module.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use super::manager::{ManagedError, ManagedTmuxDriver, SessionManager};
use super::record::ManagedSessionId;
/// A driver that reproduces the race: the snapshot the name dedupe reads is
/// STALE, while tmux itself already holds the name.
///
/// Why: the aliasing bug lives entirely in the gap between those two views.
/// `list_sessions` is what `dedupe_session_name` consults a moment before the
/// create; the live set is what `tmux new-session` decides against. Modelling
/// them as one map would close the very window these tests open.
/// `create_session` succeeds on a name already live — that is what `-A` does,
/// and it is the behaviour under test rather than a shortcut.
/// What: `list_sessions` answers from `snapshot`; `session_exists_checked`
/// answers from `live`; `create_session` records the name and inserts it into
/// `live`, never failing.
/// Test: `two_creators_of_one_name_never_share_a_pane`.
struct RacingTmuxDriver {
/// The stale live-session snapshot the name dedupe reads.
snapshot: Mutex<Vec<String>>,
/// The names tmux actually holds.
live: Mutex<HashSet<String>>,
/// Every name `create_session` was asked to create, in order.
creates: Mutex<Vec<String>>,
}
impl RacingTmuxDriver {
/// A driver whose tmux already holds `live` but whose listing shows
/// `snapshot`.
fn new(snapshot: &[&str], live: &[&str]) -> Arc<Self> {
Arc::new(Self {
snapshot: Mutex::new(snapshot.iter().map(|s| (*s).to_string()).collect()),
live: Mutex::new(live.iter().map(|s| (*s).to_string()).collect()),
creates: Mutex::new(Vec::new()),
})
}
}
impl ManagedTmuxDriver for RacingTmuxDriver {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.creates.lock().unwrap().push(name.to_string());
self.live.lock().unwrap().insert(name.to_string());
Ok(())
}
fn session_exists_checked(&self, name: &str) -> Result<bool, ManagedError> {
Ok(self.live.lock().unwrap().contains(name))
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.live.lock().unwrap().remove(name);
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.snapshot.lock().unwrap().clone())
}
}
/// A driver whose tmux refuses every name, so the retry has to terminate.
///
/// Why: the retry re-dedupes after each refusal; without a bound a server that
/// refuses everything turns one create into a spin.
/// What: `session_exists_checked` always says taken; `create_session` records
/// the call so the test can prove nothing was created.
/// Test: `create_gives_up_after_repeated_name_refusals`.
struct AlwaysTakenTmuxDriver {
/// Every name `create_session` was asked to create — expected to stay empty.
creates: Mutex<Vec<String>>,
}
impl ManagedTmuxDriver for AlwaysTakenTmuxDriver {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.creates.lock().unwrap().push(name.to_string());
Ok(())
}
fn session_exists_checked(&self, _name: &str) -> Result<bool, ManagedError> {
Ok(true)
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(Vec::new())
}
}
/// #3707: a creator that loses the name race gets its own pane, never a share
/// of the winner's.
///
/// What: the dedupe's snapshot is empty while tmux already holds
/// `tm-race-01`. Asserts the persisted record does NOT carry the taken name,
/// that exactly one pane was created under the record's own name, and that the
/// winner's pane was never touched.
/// Test: this function IS the test.
#[tokio::test]
async fn two_creators_of_one_name_never_share_a_pane() {
let dir = TempDir::new().unwrap();
let fake = RacingTmuxDriver::new(&[], &["tm-race-01"]);
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let record = mgr
.create_with_reserved_name(
ManagedSessionId::new(),
"tm-race-01".into(),
"loser".into(),
Some(PathBuf::from("/tmp/wt-race")),
None,
None,
None,
Default::default(),
false,
false,
)
.await
.expect("a lost name race must retry under a fresh name, not fail");
assert_ne!(
record.tmux_name, "tm-race-01",
"the losing creator must not persist a record against the winner's pane"
);
let creates = fake.creates.lock().unwrap().clone();
assert!(
!creates.contains(&"tm-race-01".to_string()),
"the winner's live session must never be re-created or attached: {creates:?}"
);
assert_eq!(
creates,
vec![record.tmux_name.clone()],
"exactly one pane must be created, under the record's own name"
);
}
/// #3707: sustained refusals end in a bounded error, not a spin.
///
/// Test: this function IS the test.
#[tokio::test]
async fn create_gives_up_after_repeated_name_refusals() {
let dir = TempDir::new().unwrap();
let fake = Arc::new(AlwaysTakenTmuxDriver {
creates: Mutex::new(Vec::new()),
});
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let err = mgr
.create_with_reserved_name(
ManagedSessionId::new(),
"tm-wedged-01".into(),
"loser".into(),
Some(PathBuf::from("/tmp/wt-wedged")),
None,
None,
None,
Default::default(),
false,
false,
)
.await
.expect_err("a name that can never be claimed must fail, not loop");
assert!(
matches!(err, ManagedError::NameCollision(_)),
"a sustained refusal is a name collision, not a tmux outage: {err:?}"
);
assert!(
fake.creates.lock().unwrap().is_empty(),
"nothing may be created when every name is refused"
);
}