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
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! Real tmux driver adapter bridging [`ManagedTmuxDriver`] to [`TmuxDriver`].
//!
//! Why: the session manager speaks to tmux through the [`ManagedTmuxDriver`]
//! trait seam (so it stays unit-testable), but the daemon already owns a
//! concrete [`crate::daemon::tmux::TmuxDriver`]. This adapter bridges the two so
//! the daemon can hand the session manager a real, tmux-backed driver without
//! duplicating any `tmux` subprocess logic.
//! What: [`RealTmuxDriver`] wraps a `TmuxDriver` and maps each trait method onto
//! the corresponding `TmuxDriver` call, translating its `anyhow`/typed errors
//! into [`ManagedError`].
//! Test: `real_tmux_constructs` (construction is the only side-effect-free path;
//! the actual tmux calls require a live `tmux` binary and are exercised by the
//! `#[ignore]` live integration test in `tests/session_manager_mvp.rs`).
//!
//! [`ManagedTmuxDriver`]: crate::session_manager::manager::ManagedTmuxDriver
//! [`TmuxDriver`]: crate::daemon::tmux::TmuxDriver
//! [`ManagedError`]: crate::session_manager::manager::ManagedError
use crate::core::tmux::TmuxTarget;
use crate::daemon::tmux::{ExclusiveCreate, TmuxDriver};
use super::manager::{ManagedError, ManagedTmuxDriver};
/// Adapter exposing the daemon's [`TmuxDriver`] as a [`ManagedTmuxDriver`].
///
/// Why: the session manager depends on the trait, not the concrete driver, so a
/// thin adapter keeps the manager runtime-agnostic while reusing the daemon's
/// existing tmux subprocess wrapper.
/// What: holds a `TmuxDriver` and forwards every trait method to it, adapting
/// argument and error types.
/// Test: `real_tmux_constructs`.
pub struct RealTmuxDriver {
driver: TmuxDriver,
}
impl RealTmuxDriver {
/// Construct a real tmux driver by discovering the `tmux` binary.
///
/// Why: the daemon needs a ready-to-use driver; discovery resolves the
/// `tmux` path once so subsequent calls avoid repeated PATH lookups.
/// What: calls [`TmuxDriver::discover`], mapping failure into
/// [`ManagedError::TmuxUnavailable`].
/// Test: `real_tmux_constructs` (succeeds only when tmux is installed).
pub fn discover() -> Result<Self, ManagedError> {
let driver =
TmuxDriver::discover().map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
Ok(Self { driver })
}
}
impl ManagedTmuxDriver for RealTmuxDriver {
/// Overrides the trait's no-op default so the production path actually
/// starts the tmux server when it is not yet running (#3823).
fn ensure_server_up(&self) -> Result<(), ManagedError> {
self.driver
.ensure_server_up()
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
fn create_session(&self, name: &str, workdir: &str) -> Result<(), ManagedError> {
self.driver
.create_session(name, Some(workdir))
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Create without `-A`, so tmux itself refuses a name already in use
/// (#3707) instead of attaching the loser to the winner's pane.
///
/// Why: the trait default probes `list-sessions` first, which is a second
/// TOCTOU window in front of the one it is trying to close. tmux's own
/// `new-session` is atomic against the server, so the production driver
/// asks tmux rather than asking about tmux.
/// What: [`crate::daemon::tmux::TmuxDriver::create_session_exclusive`],
/// mapping its [`ExclusiveCreate::NameTaken`] answer to
/// [`ManagedError::NameCollision`] — the variant
/// `create_with_resolved_name` retries on — and every other failure to
/// [`ManagedError::TmuxUnavailable`].
/// Test: `#[ignore]` live tmux coverage; the retry it feeds is covered by
/// `two_creators_of_one_name_never_share_a_pane`.
fn create_session_exclusive(&self, name: &str, workdir: &str) -> Result<(), ManagedError> {
match self.driver.create_session_exclusive(name, Some(workdir)) {
Ok(ExclusiveCreate::Created) => Ok(()),
Ok(ExclusiveCreate::NameTaken) => Err(ManagedError::NameCollision(format!(
"tmux refused `new-session -s {name}`: that name is already live"
))),
Err(e) => Err(ManagedError::TmuxUnavailable(e.to_string())),
}
}
/// Re-assert the tmux server's scrollback/mouse globals (#6469).
fn apply_scrollback_options(&self) {
self.driver.apply_scrollback_options();
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.driver
.kill_session(name)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Rename the live tmux session via `tmux rename-session -t <old> <new>`,
/// routed through the shared `core::tmux::run_tmux` choke point (#2398).
fn rename_session(&self, old: &str, new: &str) -> Result<(), ManagedError> {
let cmd = crate::core::tmux::TmuxCommand::RenameSession {
old: old.to_string(),
new: new.to_string(),
};
let output = crate::core::tmux::run_tmux(&cmd)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Err(ManagedError::TmuxUnavailable(format!(
"tmux rename-session {old} -> {new} failed: {stderr}"
)))
}
}
/// Report every tmux session that currently has a client attached, via the
/// concrete driver's `list-sessions` (`#{session_attached}`).
fn attached_session_names(&self) -> Vec<String> {
self.driver
.list_sessions()
.map(|sessions| {
sessions
.into_iter()
.filter(|s| s.attached)
.map(|s| s.name)
.collect()
})
.unwrap_or_default()
}
fn send_line(&self, name: &str, text: &str) -> Result<(), ManagedError> {
self.driver
.send_line(&TmuxTarget::session(name), text)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Type literal text with NO trailing Enter (overrides the trait default so
/// the production path actually omits the newline; #1461).
fn send_keys_literal(&self, name: &str, text: &str) -> Result<(), ManagedError> {
self.driver
.send_keys_literal(&TmuxTarget::session(name), text)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Overrides the trait default so a resume/restart respawn actually lands
/// in the recorded pane instead of tmux's session-scoped "active pane"
/// resolution (sibling-window hijack, follow-up to #2456).
fn send_line_to_pane(&self, name: &str, pane_id: &str, text: &str) -> Result<(), ManagedError> {
self.driver
.send_line(&TmuxTarget::pane(name, pane_id), text)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Overrides the trait's optimistic `true` default so the production path
/// actually calls `tmux list-panes` and confirms the recorded pane is
/// still among them (sibling-window hijack, follow-up to #2456).
fn pane_exists(&self, name: &str, pane_id: &str) -> bool {
self.driver.pane_exists(name, pane_id)
}
/// Overrides the trait's `pane_exists`-derived default so a genuine
/// `list-panes` query failure reports `None` ("could not determine")
/// rather than `Some(false)` ("confirmed absent") — #3714 review
/// finding 2, consumed by the display-only `reconcile_live_state`.
fn pane_exists_checked(&self, name: &str, pane_id: &str) -> Option<bool> {
self.driver.pane_exists_checked(name, pane_id)
}
/// Type literal text with NO trailing Enter into a SPECIFIC pane
/// (overrides the trait default so `inject`'s `Submit::NoSubmit`
/// dispatch actually lands in the recorded pane; #2468).
fn send_keys_literal_to_pane(
&self,
name: &str,
pane_id: &str,
text: &str,
) -> Result<(), ManagedError> {
self.driver
.send_keys_literal(&TmuxTarget::pane(name, pane_id), text)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Send Ctrl-C to the session (overrides the no-op trait default; #1461).
fn send_interrupt(&self, name: &str) -> Result<(), ManagedError> {
self.driver
.send_interrupt(&TmuxTarget::session(name))
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Send Ctrl-C to a SPECIFIC pane (overrides the trait default so
/// `inject`'s `Submit::Interrupt` dispatch actually targets the recorded
/// pane instead of tmux's session-scoped "active pane" resolution; #2468).
fn send_interrupt_to_pane(&self, name: &str, pane_id: &str) -> Result<(), ManagedError> {
self.driver
.send_interrupt(&TmuxTarget::pane(name, pane_id))
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
fn capture(&self, name: &str, lines: usize) -> Result<String, ManagedError> {
// The tmux `-S -<n>` argv takes a u32; this driver edge is the single
// narrowing point so the rest of the observe path stays `usize`. An
// out-of-range request is saturated to u32::MAX (capturing the whole
// scrollback) rather than erroring — more pane than asked for is benign.
let lines = u32::try_from(lines).unwrap_or(u32::MAX);
self.driver
.capture(&TmuxTarget::session(name), Some(lines))
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// Capture a SPECIFIC pane's output (overrides the trait default so
/// `observe` actually reads the recorded pane instead of tmux's
/// session-scoped "active pane" resolution; #2468).
fn capture_pane(
&self,
name: &str,
pane_id: &str,
lines: usize,
) -> Result<String, ManagedError> {
let lines = u32::try_from(lines).unwrap_or(u32::MAX);
self.driver
.capture(&TmuxTarget::pane(name, pane_id), Some(lines))
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
self.driver
.list_sessions()
.map(|sessions| sessions.into_iter().map(|s| s.name).collect())
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
fn get_pane_cwd(&self, name: &str) -> Option<std::path::PathBuf> {
self.driver.pane_current_path(name)
}
/// Overrides the trait's `None` default so the production path actually
/// calls `tmux display-message -p '#{pane_id}'` (#2453 review finding 1,
/// round 2).
fn get_pane_id(&self, name: &str) -> Option<String> {
self.driver.pane_id(name)
}
/// Report the runtime ready when the `claude` child PID has appeared under
/// the pane shell (overrides the trait's weaker `session_exists` default;
/// issue #1903 / #1299).
///
/// Why: the pane exists the instant `create_session` returns, long before
/// `claude` has exec'd — typing the task then would lose it. The presence
/// of the `claude` child process is the same "runtime is up" signal the
/// PID-capture path relies on, so injection waits for it.
/// What: a SINGLE-shot probe (`max_attempts = 1`, no delay) of
/// [`crate::core::process::find_claude_pid_in_tmux`]; the retry/backoff loop
/// lives in [`crate::session_manager::SessionManager::inject_task_when_ready`],
/// so this stays a cheap yes/no the loop polls.
/// Test: exercised by the `#[ignore]` live integration test (requires a real
/// `tmux` + `claude`); the pure retry loop is unit-tested against the fake.
fn runtime_ready(&self, name: &str) -> bool {
crate::core::process::find_claude_pid_in_tmux(name, 1, std::time::Duration::ZERO).is_some()
}
/// Overrides the no-op trait default so the production path actually calls
/// `tmux set-environment` (#2157 item 1).
fn set_environment(&self, name: &str, key: &str, value: &str) -> Result<(), ManagedError> {
self.driver
.set_environment(name, key, value)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
}
/// A no-op tmux driver used when `tmux` is not installed.
///
/// Why: the managed-session API must still respond (list/get/spawn-record) even
/// on a host without `tmux`; operations that genuinely need a pane return a
/// typed error rather than panicking, and read-only operations keep working.
/// What: every method that mutates tmux state returns
/// [`ManagedError::TmuxUnavailable`], and so does `list_sessions` — see its
/// own doc for why reporting zero sessions was worse than refusing.
/// Test: side-effect-only fallback; covered indirectly by the daemon's
/// `session_manager` accessor when tmux is absent, and directly by
/// `noop_driver_list_sessions_refuses_rather_than_reporting_zero`.
pub struct NoopTmuxDriver;
impl ManagedTmuxDriver for NoopTmuxDriver {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
Err(ManagedError::TmuxUnavailable("tmux not installed".into()))
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Err(ManagedError::TmuxUnavailable("tmux not installed".into()))
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
/// Refuse, rather than report zero live sessions.
///
/// Why: this driver is installed exactly when `RealTmuxDriver::discover()`
/// FAILED — tmux off PATH, or the #5784 host-state gate refusing access on
/// a reassigned `$HOME` — so it has never asked tmux anything. Answering
/// `Ok(vec![])` made "tmux is unreachable" byte-identical to "tmux is
/// running zero sessions", and
/// [`super::SessionManager::dedup_stale_duplicates`] reads an empty live
/// set as proof every candidate record is dead. That decommissioned a
/// live, attached session: the record was tombstoned, its
/// `workspace_path` cleared, and the row hidden from every picker view,
/// while the operator sat in the pane. No amount of error handling at the
/// call site could have caught it, because there was no error.
/// What: returns [`ManagedError::TmuxUnavailable`]. Both liveness callers
/// route through
/// [`super::SessionManager::observed_live_managed_names`] and skip their
/// pass on `Err` rather than inventing an empty set. The one caller that
/// can tolerate an unknown still degrades explicitly, and only there: the
/// trait's `session_exists` maps `Err` to `false` for display and
/// best-effort callers, while every destructive guard routes through
/// `session_exists_checked`, which propagates it (#5859).
/// Test: `noop_driver_list_sessions_refuses_rather_than_reporting_zero`,
/// and end-to-end by
/// `dedup_refuses_on_the_noop_driver_rather_than_reading_zero_as_dead`.
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
// #5856: unknown is not empty.
Err(ManagedError::TmuxUnavailable(
"tmux not installed or inaccessible: live-session state is unknown, not empty".into(),
))
}
/// Fails loudly like every other mutating op on this driver — mirrors
/// `create_session`/`send_line` rather than the trait's silent-no-op
/// default, keeping "tmux is absent" uniformly observable.
fn set_environment(&self, _name: &str, _key: &str, _value: &str) -> Result<(), ManagedError> {
Err(ManagedError::TmuxUnavailable("tmux not installed".into()))
}
}
/// A silent no-op tmux driver used in tests that need to create session records
/// without spawning any real tmux process.
///
/// Why: tests that exercise session-manager business logic (lifecycle transitions,
/// route handlers, prune/decommission) must not spawn real `tmpm-*` tmux
/// sessions. Real sessions created by tests escape into the host and get adopted
/// by the production daemon's `reconcile_on_boot`, polluting `~/.trusty-mpm`
/// with phantom sessions (issue #1790). `NoopTmuxDriver` is the production
/// "tmux unavailable" fallback and intentionally returns `Err` on `create_session`
/// — the wrong behavior for tests that call `create_with_id`. This driver instead
/// accepts every mutating call as a silent no-op so session records can be seeded
/// freely without touching real tmux.
/// What: `create_session` and `kill_session` return `Ok(())` silently; `send_line`
/// returns `Ok(())` silently; `capture` returns an empty string; `list_sessions`
/// returns an empty list so `reconcile_on_boot` sees no live sessions and marks
/// stored records `Stopped` (never adopts real host sessions).
/// Test: used by [`DaemonState::with_root_isolated_managed`] — the sole test
/// constructor that pre-seeds the `managed_sessions` OnceCell so the lazy
/// initialiser never touches the real tmux binary.
///
/// Note: compiled unconditionally under the `daemon` feature (not `#[cfg(test)]`)
/// so integration tests in `tests/` can reference it; never called in production.
#[doc(hidden)]
pub struct FakeNoopTmuxDriver;
impl ManagedTmuxDriver for FakeNoopTmuxDriver {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_keys_literal(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_interrupt(&self, _name: &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())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn real_tmux_constructs() {
// Construction only succeeds when `tmux` is on PATH; in CI it may not be,
// so we only assert the call does not panic and returns a typed result.
let _ = RealTmuxDriver::discover();
}
#[test]
fn fake_noop_driver_accepts_create_session() {
// Why: regression guard that FakeNoopTmuxDriver::create_session returns
// Ok so test code that seeds session records never hits TmuxUnavailable.
let driver = FakeNoopTmuxDriver;
assert!(
driver.create_session("tmpm-test-abc123", "/tmp").is_ok(),
"FakeNoopTmuxDriver::create_session must succeed"
);
}
#[test]
fn noop_driver_set_environment_fails_loudly() {
// #2157: the no-tmux fallback must fail this best-effort mutating op the
// same way it fails create_session/send_line, not silently no-op.
let driver = NoopTmuxDriver;
assert!(driver.set_environment("s", "K", "v").is_err());
}
/// The tmux-absent fallback must report "unknown", never "zero".
///
/// Why: `dedup_stale_duplicates` treats an empty live set as proof every
/// candidate record is dead and decommissions on it. `Ok(vec![])` from a
/// driver that never reached tmux made that proof fraudulent, and no error
/// handling at the call site could have detected it. Asserting the error
/// here — not merely at the dedup call site — keeps the guarantee attached
/// to the driver that has to make it.
/// Test: this function IS the test.
#[test]
fn noop_driver_list_sessions_refuses_rather_than_reporting_zero() {
let driver = NoopTmuxDriver;
let err = driver
.list_sessions()
.expect_err("the tmux-absent fallback must refuse, not report zero sessions");
assert!(
matches!(err, ManagedError::TmuxUnavailable(_)),
"expected TmuxUnavailable, got {err:?}"
);
}
#[test]
fn fake_noop_driver_list_sessions_is_empty() {
// Why: list_sessions must return empty so reconcile_on_boot never adopts
// real host sessions into the test-owned store.
let driver = FakeNoopTmuxDriver;
let sessions = driver.list_sessions().expect("list_sessions ok");
assert!(
sessions.is_empty(),
"FakeNoopTmuxDriver must see no live sessions"
);
}
}