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
//! Tmux session-name resolution, extracted from `manager.rs` (issue #2032).
//!
//! Why: `manager.rs` was at the 500-SLOC production cap (same constraint
//! documented in `reactivate.rs`/`hook_sync.rs`), and issue #2032 needs the
//! semantic tmux name resolved BEFORE the in-project per-session git worktree
//! is created — the worktree directory and branch are now named after the
//! tmux name instead of the raw session UUID, so the name has to exist first.
//! Splitting the derivation/serial-allocation/collision-retry loop out of
//! `create_with_id` (moved here verbatim, then generalised) lets BOTH
//! `create_with_id` (post-hoc derivation, for the cloned/local spawn paths)
//! and the in-project spawn routing layer (pre-hoc reservation, via
//! `SessionManager::create_with_reserved_name`) share the exact same logic —
//! a session's tmux name is derived in exactly ONE place, never twice.
//! What: `SessionManager::resolve_session_name` runs the existing
//! priority-ordered derivation (`name_hint` > GitHub-parseable `repo_url`-
//! derived project > `cwd` basename), the per-project serial allocation
//! (issue #1955), and the bounded collision-retry loop (#1966 review
//! follow-up). The `extra_collision` predicate lets a caller reject a
//! candidate for reasons `resolve_session_name` cannot see itself — the
//! in-project spawn path uses it to also refuse names whose
//! `.worktrees/<name>` directory or `session/<name>` branch already exists on
//! disk (a collision surface the plain tmux-name check knows nothing about;
//! see `daemon::managed_routes::inproject::create_session_worktree`).
//! Also home to the #3692 auto-suffix-never-reject dedupe surface, for names
//! that are operator-supplied or already resolved rather than derived fresh
//! via `resolve_session_name`'s own per-project serial allocator:
//! [`SessionManager::taken_name_set`] + [`SessionManager::dedupe_name_against`]
//! (the composable pieces `rename`/`adopt_existing` call UNDER their held
//! store write guard) and [`SessionManager::dedupe_session_name`] (the
//! lock-free snapshot wrapper `create_with_resolved_name` uses as the create
//! path's final pre-tmux-create safety net).
//! Test: `naming_tests.rs`.
use std::collections::HashSet;
use std::path::Path;
use super::manager::{ManagedError, SessionManager};
use super::record::ManagedSessionId;
impl SessionManager {
/// Resolve a fresh, collision-free tmux session name (issues #1955, #2032).
///
/// Why: both [`Self::create_with_id`] (deriving the name itself) and the
/// in-project spawn path (reserving the name BEFORE the worktree that will
/// be named after it) need identical priority/serial/collision-retry
/// semantics. Centralising it here means neither caller can silently drift
/// from the other's naming behaviour.
/// What: name selection priority —
/// 1. Explicit `name_hint` → leaf = sanitized hint (treated as a
/// directory-like basename) → `tm-<hint-slug>-NN`.
/// 2. GitHub-parseable `github_project` (a bare repo name, already
/// extracted by the caller from `repo_url`) → `tm-<repo>-NN`.
/// 3. Otherwise → leaf = basename(`cwd`), or `"local"` if that sanitizes
/// to empty.
///
/// Every branch allocates a per-project serial so concurrent sessions for
/// the same leaf get distinguishable `-01`, `-02`, `-03` names. A candidate
/// is rejected (and retried with the next free serial) when it collides
/// with a live tmux session OR when `extra_collision(candidate)` returns
/// `true` — the latter lets the in-project path fold in its
/// worktree-dir/branch existence check without this function needing to
/// know anything about git worktrees. Returns [`ManagedError::NameCollision`]
/// if every attempt in the bounded window (`MAX_NAME_ALLOCATION_ATTEMPTS`)
/// collides.
///
/// Residual race window (KNOWN, and narrowed twice since #2032): there is
/// still a gap between THIS check and the caller's actual
/// `create_session`/`git worktree add` a few lines later.
/// `create_with_resolved_name`'s final pre-create dedupe (#3692) narrows
/// it to a few instructions, and #3707 stopped a lost race from aliasing:
/// that path now creates without `-A`, so tmux refuses a name it does not
/// win and the create re-dedupes and retries instead of attaching to the
/// winner's pane. The `git worktree add` half of the window is unchanged.
///
/// Server-up guarantee (#3823 contract, moved here by #3886): this
/// function's very first action is a `list-sessions` probe
/// ([`Self::names_for_serial_allocation`]). On a host where tmux has
/// never run the socket does not exist yet and that probe is a hard
/// error, so #3823 made every caller responsible for calling
/// `ensure_server_up()` first. Two callers never got the memo — the
/// in-project launch routes `spawn_managed_on_main` and
/// `reserve_inproject_worktree`, which deliberately bypass
/// `create_with_id` to reserve a name BEFORE the worktree exists (see
/// this module's doc) — and #3886 was the resulting cold-host failure.
/// The guard therefore lives HERE now, adjacent to the probe it
/// protects, so a third bypassing caller cannot reintroduce it. It is a
/// single idempotent `tmux start-server` round-trip and a no-op when the
/// server is already up.
/// Test: `manager_serial_reuses_decommissioned_gap`,
/// `resolve_session_name_rejects_extra_collision` in `naming_tests.rs`;
/// the cold-host guarantee in `server_up_tests.rs`
/// (`resolve_session_name_ensures_server_up_before_listing_sessions`,
/// `resolve_session_name_succeeds_on_a_cold_tmux_host`).
pub(crate) async fn resolve_session_name(
&self,
name_hint: Option<&str>,
github_project: Option<&str>,
cwd: &Path,
extra_collision: impl Fn(&str) -> bool,
) -> Result<String, ManagedError> {
// #3886: guard the `list-sessions` probe below, not each caller —
// the in-project launch routes bypass `create_with_id`'s guard.
self.tmux.ensure_server_up()?;
let mut existing_names = self.names_for_serial_allocation().await?;
let leaf_hint = name_hint.map(|h| crate::core::names::leaf_slug_from_dir(Path::new(h)));
const MAX_NAME_ALLOCATION_ATTEMPTS: u8 = 5;
for _ in 0..MAX_NAME_ALLOCATION_ATTEMPTS {
let candidate = if let Some(ref leaf) = leaf_hint {
crate::core::names::build_session_name(leaf, &existing_names)?
} else {
crate::core::names::build_managed_session_name(
github_project,
cwd,
&existing_names,
)?
};
if self.tmux.session_exists(&candidate) || extra_collision(&candidate) {
existing_names.push(candidate);
continue;
}
return Ok(candidate);
}
Err(ManagedError::NameCollision(format!(
"no free `tm-<leaf>-NN` name after {MAX_NAME_ALLOCATION_ATTEMPTS} attempts \
(sustained concurrent creates for the same project)"
)))
}
/// Build the daemon-wide "taken" name set: a PRE-FETCHED live tmux
/// snapshot ∪ every NON-terminal store record's `tmux_name` (issue #3692).
///
/// Why: split from [`Self::dedupe_session_name`] so callers that already
/// hold the store WRITE guard (`rename`, `adopt_existing` — which must
/// keep collision-check/dedupe/persist atomic, see the #3692 review's
/// HIGH-3 finding) can pass the records they read THROUGH that guard,
/// instead of calling `self.list()` (which takes the same lock →
/// deadlock) or using a lock-free snapshot (which reintroduces the exact
/// two-writers-pick-the-same-ordinal race this fixes). `live_tmux_names`
/// is a parameter — NOT fetched here — because `list_sessions` is a
/// blocking tmux subprocess and `store.write()` gates every session-
/// manager mutation daemon-wide (#3698 round-2 HIGH-A): callers must
/// fetch the snapshot BEFORE taking the guard, never under it.
/// What: unions `live_tmux_names` with `records`' non-terminal
/// `tmux_name`s (a `Decommissioned`/`Deleted` tombstone's name is NOT
/// taken — reusable immediately, unsuffixed), excluding `exclude_id`'s
/// own current name so a record renaming to a name only it holds is
/// never suffixed against itself.
/// Test: exercised via every `dedupe_session_name` /
/// `rename_suffixes_*` / `manager_adopt_existing_suffixes_*` test.
pub(crate) fn taken_name_set(
live_tmux_names: &[String],
records: &[super::record::SessionRecord],
exclude_id: Option<&ManagedSessionId>,
) -> HashSet<String> {
let mut taken: HashSet<String> = live_tmux_names.iter().cloned().collect();
taken.extend(
records
.iter()
.filter(|r| exclude_id != Some(&r.id) && !r.state.is_terminal())
.map(|r| r.tmux_name.clone()),
);
taken
}
/// Disambiguate `candidate` against a pre-built taken set — auto-suffix
/// on collision, NEVER reject (owner decision, issue #3692), respecting
/// the 64-char session-name cap.
///
/// Why: the pure tail shared by BOTH dedupe surfaces (the lock-free
/// [`Self::dedupe_session_name`] snapshot and the under-write-guard
/// `rename`/`adopt_existing` paths). Also owns the length guard the
/// #3692 review's LOW finding flagged: `validate_session_name` enforces
/// the 64-char cap on the BARE candidate, so a suffix appended here
/// could silently push past it.
/// What: [`dedupe_two_digit`] (candidate unchanged when free; else the
/// smallest free two-digit `-NN` ordinal, #9238). If
/// the suffixed result would exceed [`MAX_SESSION_NAME_LEN`], truncates
/// the candidate to reserve [`SUFFIX_HEADROOM`] chars (enough for the
/// worst-case `-<millis>` fallback suffix) and re-dedupes — the result
/// is then guaranteed to fit.
/// Test: `dedupe_name_against_caps_suffixed_length_at_64` in
/// `super::naming_tests`; every `rename_suffixes_*` test transitively.
pub(crate) fn dedupe_name_against(candidate: &str, taken: &HashSet<String>) -> String {
let result = dedupe_two_digit(candidate, taken);
if result.chars().count() <= MAX_SESSION_NAME_LEN {
return result;
}
// The appended ordinal pushed a near-cap candidate over 64 chars —
// shorten the base to reserve suffix headroom, then re-dedupe.
let short: String = candidate
.chars()
.take(MAX_SESSION_NAME_LEN - SUFFIX_HEADROOM)
.collect();
dedupe_two_digit(&short, taken)
}
/// Disambiguate `candidate` against every live/tracked name — auto-suffix
/// on collision, NEVER reject (owner decision, issue #3692: two distinct
/// managed sessions were found sharing one name, making name-based resume
/// ambiguous and the picker show duplicate rows).
///
/// Why: [`Self::resolve_session_name`] already guarantees a fresh,
/// collision-free `tm-<leaf>-NN` name for the derive-from-scratch create
/// path via its own per-project serial allocator — but its snapshot and
/// the eventual `tmux new-session` are separated by real work (clone,
/// worktree provisioning), the documented TOCTOU window. This method is
/// the create path's FINAL safety net, called by
/// `create_with_resolved_name` immediately before the tmux create to
/// re-check-and-suffix the name at the last possible moment, narrowing
/// (not fully closing — that would need a lock spanning the external
/// tmux/store processes) that window.
/// NOTE for other callers: `rename` and `adopt_existing` must NOT use
/// this snapshot wrapper — they serialize their check/dedupe/persist
/// against the store write guard (tmux calls kept OUTSIDE it, with a
/// re-verify before persisting) and go through [`Self::taken_name_set`]
/// plus [`Self::dedupe_name_against`] directly (calling this here would
/// deadlock on the store lock, and a snapshot would reintroduce the
/// #3692 race for two concurrent stopped-session renames).
/// A snapshot this lock-free can still go stale between the read and the
/// tmux create, which is why `create_with_resolved_name` no longer trusts
/// it alone: since #3707 that path creates WITHOUT `-A`, so a name it
/// loses comes back as a refusal rather than as a silent attach to
/// somebody else's pane. `also_taken` is how that refusal is remembered —
/// feeding only the snapshot back in would re-propose the same name and
/// spin, so the caller passes every name tmux has already refused it.
/// What: [`Self::taken_name_set`] over a fresh tmux `list_sessions`
/// snapshot (fetched BEFORE `self.list()` takes the store lock — never
/// under it, #3698 round-2 HIGH-A) + a fresh `self.list()` snapshot, union
/// `also_taken`, then [`Self::dedupe_name_against`].
/// Test: `create_with_reserved_name_suffixes_stale_reservation` in
/// `super::naming_tests`; `two_creators_of_one_name_never_share_a_pane` in
/// `super::create_race_tests` covers the `also_taken` path.
pub(crate) async fn dedupe_session_name(
&self,
candidate: &str,
exclude_id: Option<&ManagedSessionId>,
also_taken: &HashSet<String>,
) -> Result<String, ManagedError> {
// #3886: propagate, never re-wrap — `list_sessions` already returns a
// `ManagedError`, so `TmuxUnavailable(e.to_string())` doubled its own
// `tmux error:` prefix.
let live_names = self.tmux.list_sessions()?;
let records = self.list().await;
let mut taken = Self::taken_name_set(&live_names, &records, exclude_id);
taken.extend(also_taken.iter().cloned());
Ok(Self::dedupe_name_against(candidate, &taken))
}
}
/// Cap on a managed session name's length — must match the 64-char rule
/// [`super::rename::validate_session_name`] enforces on operator input, so an
/// auto-appended collision suffix (#3692) can never push a validated name
/// back over the limit tmux/we accept.
pub(crate) const MAX_SESSION_NAME_LEN: usize = 64;
/// Headroom [`SessionManager::dedupe_name_against`] reserves when truncating
/// a near-cap candidate: covers the worst-case `-<millis>` timestamp fallback
/// suffix (1 + 13 digits) with margin.
const SUFFIX_HEADROOM: usize = 16;
/// [`trusty_common::session_naming::dedupe_by_ordinal`] with one suffix
/// format: a taken name with no trailing ordinal becomes `<name>-02`, then
/// `-03`, and so on (#9238).
///
/// Why: the per-project serial allocator names sessions `tm-<leaf>-01`, while
/// the shared dedupe suffixed an unnumbered name `-2`, so one fleet carried
/// both `tm-localizer-2` and `tm-i8n-01`. Two digits is the form most records
/// already use, and `-02` reads as the second session of the name. Existing
/// names are opaque strings and stay valid; a taken name that already ends in
/// an ordinal keeps that ordinal's width, as before.
/// What: returns `candidate` when it is free. Otherwise, for a candidate with
/// no trailing `-<digits>`, dedupes `<candidate>-02`, which the shared
/// function returns when free or increments at width 2; any other candidate
/// goes to the shared function unchanged.
/// Test: `dedupe_suffixes_an_unnumbered_name_with_two_digits`.
fn dedupe_two_digit(candidate: &str, taken: &HashSet<String>) -> String {
if !taken.contains(candidate) {
return candidate.to_string();
}
// The shared function's own reading of a trailing ordinal: 1-6 digits.
let numbered = candidate.rsplit_once('-').is_some_and(|(_, tail)| {
(1..=6).contains(&tail.len()) && tail.bytes().all(|b| b.is_ascii_digit())
});
let seed = if numbered {
candidate.to_string()
} else {
format!("{candidate}-02")
};
trusty_common::session_naming::dedupe_by_ordinal(&seed, |c| taken.contains(c))
}